From 464d53c70bf71a5af6e4dfd4069e86f740130984 Mon Sep 17 00:00:00 2001 From: caps Date: Wed, 18 Mar 2026 14:19:29 +0300 Subject: [PATCH] init --- .dockerignore | 14 + .env.example | 14 + .gitignore | 4 + .woodpecker.yml | 22 + Dockerfile | 30 + README.md | 81 ++ deploy.sh | 8 + deploy/remote-deploy.sh | 36 + docker-compose.deploy.yml | 15 + docker-compose.yml | 16 + package.json | 20 + public/css/app.css | 1421 +++++++++++++++++++++++++ public/css/keyboard-additions.css | 82 ++ public/index.html | 310 ++++++ public/index_old.html | 1610 +++++++++++++++++++++++++++++ public/js/api.js | 26 + public/js/auth.js | 233 +++++ public/js/comments.js | 94 ++ public/js/diff.js | 157 +++ public/js/export.js | 118 +++ public/js/keyboard.js | 305 ++++++ public/js/mentions.js | 60 ++ public/js/notes.js | 60 ++ public/js/sse.js | 115 +++ public/js/state.js | 182 ++++ public/js/statistics.js | 642 ++++++++++++ public/js/statuses.js | 208 ++++ public/js/ui.js | 870 ++++++++++++++++ server.js | 597 +++++++++++ start.sh | 14 + 30 files changed, 7364 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .woodpecker.yml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 deploy.sh create mode 100644 deploy/remote-deploy.sh create mode 100644 docker-compose.deploy.yml create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 public/css/app.css create mode 100644 public/css/keyboard-additions.css create mode 100644 public/index.html create mode 100644 public/index_old.html create mode 100644 public/js/api.js create mode 100644 public/js/auth.js create mode 100644 public/js/comments.js create mode 100644 public/js/diff.js create mode 100644 public/js/export.js create mode 100644 public/js/keyboard.js create mode 100644 public/js/mentions.js create mode 100644 public/js/notes.js create mode 100644 public/js/sse.js create mode 100644 public/js/state.js create mode 100644 public/js/statistics.js create mode 100644 public/js/statuses.js create mode 100644 public/js/ui.js create mode 100644 server.js create mode 100644 start.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d29ea4e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.env +.env.runtime +*.db +data/ +node_modules/ +*.zip +.git +.gitignore +.woodpecker.yml +deploy/ +docker-compose*.yml +README.md +start.sh +deploy.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4411ac6 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Секретный ключ для подписи JWT-токенов — обязательно смените! +JWT_SECRET=замените_на_длинную_случайную_строку + +# Секрет для создания новых пользователей через /api/auth/register +ADMIN_SECRET=мой_секрет_для_создания_юзеров + +# Порт публикации контейнера на хосте для docker compose +APP_PORT=3000 + +# Порт сервера (по умолчанию 3000) +PORT=3000 + +# Путь к SQLite базе данных для локального запуска без Docker +DB_PATH=./data/intradesk.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9fb4e02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +.env.runtime +node_modules/ +data/ diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..66e5abc --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,22 @@ +# If you use multiple agents, uncomment labels below and set the same labels on the target agent. +# labels: +# deploy-host: intradesk-monitor + +steps: + - name: deploy + image: docker:27-cli + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /opt/intradesk-monitor:/opt/intradesk-monitor + commands: + - mkdir -p /opt/intradesk-monitor/deploy /opt/intradesk-monitor/data + - cp docker-compose.deploy.yml /opt/intradesk-monitor/docker-compose.deploy.yml + - cp deploy.sh /opt/intradesk-monitor/deploy.sh + - cp deploy/remote-deploy.sh /opt/intradesk-monitor/deploy/remote-deploy.sh + - chmod +x /opt/intradesk-monitor/deploy.sh /opt/intradesk-monitor/deploy/remote-deploy.sh + - test -f /opt/intradesk-monitor/.env || (echo "Missing /opt/intradesk-monitor/.env on deploy host" && exit 1) + - docker build -t intradesk-monitor:$CI_COMMIT_SHA -t intradesk-monitor:latest . + - APP_DIR=/opt/intradesk-monitor IMAGE_REPO=intradesk-monitor IMAGE_TAG=$CI_COMMIT_SHA /opt/intradesk-monitor/deploy/remote-deploy.sh + when: + - event: push + branch: ${CI_REPO_DEFAULT_BRANCH} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..07a08f3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM node:20-alpine + +WORKDIR /app + +ENV NODE_ENV=production +ENV LANG=C.UTF-8 +ENV LC_ALL=C.UTF-8 + +# Зависимости для better-sqlite3 (нужна компиляция) +RUN apk add --no-cache python3 make g++ + +COPY package*.json ./ +RUN npm install --omit=dev --no-audit --no-fund \ + && npm cache clean --force + +COPY server.js ./ +COPY public/ ./public/ + +# Папка для базы данных +RUN mkdir -p /data + +EXPOSE 3000 + +ENV PORT=3000 +ENV DB_PATH=/data/intradesk.db + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "const http=require('http');const req=http.get('http://127.0.0.1:3000/healthz',res=>process.exit(res.statusCode===200?0:1));req.on('error',()=>process.exit(1));" + +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7de5ed2 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# Монитор заявок - CleverParking + +Node/Express приложение со встроенным фронтендом и SQLite. Проект подготовлен для: + +- локального запуска через Docker Compose; +- автоматической сборки Docker-образа в Woodpecker; +- deploy прямо на хост, где установлен Woodpecker agent. + +## Локальный запуск + +```bash +cp .env.example .env +docker compose up -d --build +``` + +Приложение будет доступно на `http://localhost:3000`, а база SQLite сохранится в папке `./data`. + +## Что добавлено для production + +- `Dockerfile` с production `NODE_ENV` и `HEALTHCHECK`; +- `/healthz` endpoint для проверки контейнера; +- fail-fast проверка `JWT_SECRET` и `ADMIN_SECRET` в production; +- `docker-compose.deploy.yml` для запуска заранее собранного локального образа; +- `.woodpecker.yml` для сборки и deploy на хосте агента; +- `deploy/remote-deploy.sh` и `deploy.sh` для повторного запуска того же сценария уже на сервере. + +## Как теперь работает deploy + +1. Woodpecker забирает код из Git. +2. На push в default branch step `deploy` запускается на агенте, который стоит на целевой машине. +3. Step собирает локальный образ `intradesk-monitor:$CI_COMMIT_SHA` и тег `latest`. +4. В `/opt/intradesk-monitor` копируются deploy-файлы из репозитория. +5. Затем выполняется `docker compose up -d` уже с локально собранным образом. + +Registry и SSH для такого сценария не нужны. + +## Что нужно на машине с агентом + +- установленный Docker с `docker compose`; +- Woodpecker agent на этой же машине; +- каталог `/opt/intradesk-monitor`; +- файл `/opt/intradesk-monitor/.env`; +- доверенный (`trusted`) репозиторий в Woodpecker, потому что pipeline использует bind-mount хостовых путей и Docker socket. + +Пример `/opt/intradesk-monitor/.env`: + +```dotenv +JWT_SECRET=long_random_secret +ADMIN_SECRET=another_long_secret +APP_PORT=3000 +``` + +`PORT` и `DB_PATH` в production задаются через compose автоматически. + +## Настройка агента + +Текущий `.woodpecker.yml` ожидает, что на хосте доступны: + +- `/var/run/docker.sock` +- `/opt/intradesk-monitor` + +Если у тебя несколько агентов, добавь на нужный агент собственный label и раскомментируй блок `labels` в `.woodpecker.yml`, чтобы deploy уходил строго на нужную машину. + +## Первый деплой + +1. Создать каталог `/opt/intradesk-monitor`. +2. Положить в него `.env`. +3. Убедиться, что агент Woodpecker может работать с Docker на хосте. +4. Пометить репозиторий как `trusted` в Woodpecker. +5. Запушить изменения в default branch. + +После этого Woodpecker сам соберёт образ на целевой машине и перезапустит контейнер. + +## Полезные команды на сервере + +```bash +cd /opt/intradesk-monitor +docker ps --filter name=intradesk-monitor +docker logs -f intradesk-monitor +./deploy.sh +``` diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..6111c67 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +export APP_DIR="${APP_DIR:-$SCRIPT_DIR}" + +exec "$SCRIPT_DIR/deploy/remote-deploy.sh" diff --git a/deploy/remote-deploy.sh b/deploy/remote-deploy.sh new file mode 100644 index 0000000..3f24cd8 --- /dev/null +++ b/deploy/remote-deploy.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env sh +set -eu + +APP_DIR=${APP_DIR:-/opt/intradesk-monitor} +COMPOSE_FILE=${COMPOSE_FILE:-docker-compose.deploy.yml} + +cd "$APP_DIR" +mkdir -p data deploy + +if [ ! -f .env ]; then + echo ".env is missing in $APP_DIR. Create it from .env.example before the first deploy." >&2 + exit 1 +fi + +if [ -n "${IMAGE_REPO:-}" ] && [ -n "${IMAGE_TAG:-}" ]; then + cat > .env.runtime <&2 + exit 1 +fi + +set -a +. ./.env.runtime +set +a + +if [ "${PULL_IMAGE:-0}" = "1" ]; then + docker compose -f "$COMPOSE_FILE" pull +fi + +docker compose -f "$COMPOSE_FILE" up -d --remove-orphans +docker image prune -f >/dev/null 2>&1 || true diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml new file mode 100644 index 0000000..6dea7bf --- /dev/null +++ b/docker-compose.deploy.yml @@ -0,0 +1,15 @@ +services: + intradesk-monitor: + image: ${IMAGE_REPO}:${IMAGE_TAG} + container_name: intradesk-monitor + restart: unless-stopped + ports: + - "${APP_PORT:-3000}:3000" + volumes: + - ./data:/data + environment: + NODE_ENV: production + JWT_SECRET: ${JWT_SECRET} + ADMIN_SECRET: ${ADMIN_SECRET} + PORT: 3000 + DB_PATH: /data/intradesk.db diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a68a608 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + intradesk-monitor: + container_name: intradesk-monitor + build: + context: . + restart: unless-stopped + ports: + - "${APP_PORT:-3000}:3000" + volumes: + - ./data:/data + environment: + NODE_ENV: production + JWT_SECRET: ${JWT_SECRET} + ADMIN_SECRET: ${ADMIN_SECRET} + PORT: 3000 + DB_PATH: /data/intradesk.db diff --git a/package.json b/package.json new file mode 100644 index 0000000..5720a1c --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "intradesk-monitor", + "version": "1.0.0", + "description": "Монитор заявок CleverParking — серверная версия", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^9.4.3", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", + "node-fetch": "^2.7.0" + }, + "devDependencies": { + "nodemon": "^3.0.0" + } +} diff --git a/public/css/app.css b/public/css/app.css new file mode 100644 index 0000000..3c82c44 --- /dev/null +++ b/public/css/app.css @@ -0,0 +1,1421 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;} +:root{ + --bg:#f0f2f7; + --sidebar-bg:#1e2235; + --surface:#fff; + --surface2:#f7f8fc; + --border:#e8eaf2; + --border2:#d8dce8; + --text:#1a1d2e; + --text2:#5a6080; + --text3:#9098b8; + --accent:#5b7fff; + --accent-light:#eef1ff; + --accent-dark:#3d5fe0; + --red:#ff4d4f; + --red-light:#fff1f0; + --red-border:#ffccc7; + --orange:#fa8c16; + --orange-light:#fff7e6; + --orange-border:#ffd591; + --green:#52c41a; + --green-light:#f6ffed; + --green-border:#b7eb8f; + --blue:#1677ff; + --blue-light:#e6f4ff; + --blue-border:#91caff; + --purple:#722ed1; + --purple-light:#f9f0ff; + --radius:10px; + --radius-sm:6px; + --shadow:0 2px 12px rgba(30,34,53,.08); + --shadow-md:0 6px 28px rgba(30,34,53,.14); +} + +/* ── DARK THEME ─────────────────────────────────────────────────── */ +html.dark { + --bg:#0f1117; + --sidebar-bg:#080a10; + --surface:#161b27; + --surface2:#1e2435; + --border:#252d42; + --border2:#2e3850; + --text:#e2e8f4; + --text2:#8892ab; + --text3:#4e5a72; + --accent:#6b8fff; + --accent-light:#1a2344; + --accent-dark:#4d6fdf; + --red:#ff6b6b; + --red-light:#2a1515; + --red-border:#5c2020; + --orange:#ffa940; + --orange-light:#2a1f0a; + --orange-border:#5c3d0a; + --green:#73d13d; + --green-light:#142a0a; + --green-border:#2a5214; + --blue:#4096ff; + --blue-light:#0f1f3d; + --blue-border:#1a3d6e; + --purple:#b37feb; + --purple-light:#1e0f38; + --shadow:0 2px 12px rgba(0,0,0,.4); + --shadow-md:0 6px 28px rgba(0,0,0,.6); +} +html.dark .tasks-table tbody tr.row-reply{background:#1f0e0e;} +html.dark .tasks-table tbody tr.row-reply:hover{background:#2a1212;} +html.dark .tasks-table tbody tr.row-wait{background:#1f1608;} +html.dark .tasks-table tbody tr.row-wait:hover{background:#2a1e0c;} +html.dark .tasks-table tbody tr.row-resolved{background:#0e1f08;} +html.dark .tasks-table tbody tr.row-resolved:hover{background:#122808;} +html.dark .tasks-table tbody tr.row-vyezdnye{background:#150e2a;} +html.dark .tasks-table tbody tr.row-vyezdnye:hover{background:#1d1238;} +html.dark .tasks-table tbody tr.row-oborudovanie{background:#150e2a;} +html.dark .tasks-table tbody tr.row-oborudovanie-fix{background:#2a1200;} +html.dark .tasks-table tbody tr.row-oborudovanie-fix:hover{background:#381900;} +html.dark .tasks-table tbody tr.row-oborudovanie-in-repair{background:#001f1f;} +html.dark .tasks-table tbody tr.row-oborudovanie-in-repair:hover{background:#002a2a;} +html.dark .tasks-table tbody tr.row-oborudovanie-repair-done{background:#0a1f00;} +html.dark .tasks-table tbody tr.row-oborudovanie-repair-done:hover{background:#0e2800;} +html.dark .tasks-table tbody tr.row-oborudovanie-send-back{background:#001533;} +html.dark .tasks-table tbody tr.row-oborudovanie-send-back:hover{background:#001c44;} +html.dark .tasks-table tbody tr.row-oborudovanie-returned{background:#1a0a33;} +html.dark .tasks-table tbody tr.row-oborudovanie-returned:hover{background:#220d44;} +html.dark .tasks-table tbody tr.row-hidden{background:#111520;opacity:.5;} +html.dark .tasks-table tbody tr.row-unread{background:#0f1c2e;} +html.dark .tasks-table tbody tr.row-unread:hover{background:#14253d;} +html.dark .tasks-table thead th{background:var(--surface2);color:var(--text3);border-color:var(--border);} +html.dark .tasks-table tbody td{border-color:var(--border);} +html.dark .tasks-table tbody tr:hover>td{background:var(--surface2);} +html.dark .sb-reply{background:#2a1515;} +html.dark .sb-wait{background:#2a1f0a;} +html.dark .sb-resolved{background:#142a0a;} +html.dark .sb-none{background:#1e2435;} +html.dark .sb-vyezdnye{background:#1e0f38;border-color:#5c3d8a;} +html.dark .sb-oborudovanie{background:#1e0f38;border-color:#5c3d8a;} +html.dark .sb-hidden{background:#1e2435;border-color:#2e3850;color:#4e5a72;} +html.dark .sb-oborudovanie-fix{background:#2a1200;border-color:#6b2800;} +html.dark .sb-oborudovanie-in-repair{background:#001f1f;border-color:#0a5c5c;} +html.dark .sb-oborudovanie-repair-done{background:#0a1f00;border-color:#1f5c00;} +html.dark .sb-oborudovanie-send-back{background:#001533;border-color:#0a3d80;} +html.dark .sb-oborudovanie-returned{background:#1a0a33;border-color:#5c2d99;} +html.dark .sb-intra-open{background:#0f1f3d;border-color:#1a3d6e;} +html.dark .sb-intra-work{background:#2a1f0a;border-color:#5c3d0a;} +html.dark .sb-intra-done{background:#142a0a;border-color:#2a5214;} +html.dark .sb-intra-hold{background:#1e2435;border-color:#2e3850;} +html.dark .sb-intra-other{background:#1e2435;border-color:#2e3850;} +html.dark .chip-reply.chip-on{background:#2a1515;border-color:#5c2020;color:#ff6b6b;} +html.dark .chip-wait.chip-on{background:#2a1f0a;border-color:#5c3d0a;color:#ffa940;} +html.dark .chip-resolved.chip-on{background:#142a0a;border-color:#2a5214;color:#73d13d;} +html.dark .chip-hidden.chip-on{background:#1e2435;border-color:#2e3850;color:#4e5a72;} +html.dark .chip-unmarked.chip-on{background:#1e2435;border-color:#252d42;color:#8892ab;} +html.dark .chip-vyezdnye.chip-on{background:#1e0f38;border-color:#5c3d8a;color:#b37feb;} +html.dark .chip-oborudovanie.chip-on{background:#1e0f38;border-color:#5c3d8a;color:#b37feb;} +html.dark .chip-oborudovanie-fix.chip-on{background:#2a1200;border-color:#6b2800;color:#ff7a45;} +html.dark .chip-oborudovanie-in-repair.chip-on{background:#001f1f;border-color:#0a5c5c;color:#36cfc9;} +html.dark .chip-oborudovanie-repair-done.chip-on{background:#0a1f00;border-color:#1f5c00;color:#73d13d;} +html.dark .chip-oborudovanie-send-back.chip-on{background:#001533;border-color:#0a3d80;color:#4096ff;} +html.dark .chip-oborudovanie-returned.chip-on{background:#1a0a33;border-color:#5c2d99;color:#b37feb;} +html.dark .chip-all.chip-on{background:var(--accent);} +html.dark .settings-overlay,.dark .confirm-overlay{background:rgba(0,0,0,.7);} +html.dark input,html.dark select,html.dark textarea{background:var(--surface2)!important;color:var(--text)!important;border-color:var(--border)!important;} +html.dark input::placeholder{color:var(--text3)!important;} +html.dark .login-box,html.dark .settings-box,html.dark .confirm-box{background:var(--surface);border:1px solid var(--border);} +html.dark .isp-dropdown-inner{background:var(--surface2);} +html.dark .update-banner{background:#1a2235;border-color:#2e3850;color:var(--text2);} +html.dark .mark-btn{background:var(--surface2)!important;color:var(--text2)!important;border-color:var(--border)!important;} +html.dark .mbtn-active.mbtn-reply{background:#2a1515!important;border-color:#5c2020!important;color:#ff6b6b!important;} +html.dark .mbtn-active.mbtn-wait{background:#2a1f0a!important;border-color:#5c3d0a!important;color:#ffa940!important;} +html.dark .mbtn-active.mbtn-resolve{background:#142a0a!important;border-color:#2a5214!important;color:#73d13d!important;} +html.dark .mbtn-active.mbtn-vyezdnye{background:#1e0f38!important;border-color:#5c3d8a!important;color:#b37feb!important;} +html.dark .mbtn-active.mbtn-oborudovanie{background:#1e0f38!important;border-color:#5c3d8a!important;color:#b37feb!important;} +html.dark .mbtn-active.mbtn-oborudovanie-fix{background:#2a1200!important;border-color:#6b2800!important;color:#ff7a45!important;} +html.dark .mbtn-active.mbtn-oborudovanie-in-repair{background:#001f1f!important;border-color:#0a5c5c!important;color:#36cfc9!important;} +html.dark .mbtn-active.mbtn-oborudovanie-repair-done{background:#0a1f00!important;border-color:#1f5c00!important;color:#73d13d!important;} +html.dark .mbtn-active.mbtn-oborudovanie-send-back{background:#001533!important;border-color:#0a3d80!important;color:#4096ff!important;} +html.dark .mbtn-active.mbtn-oborudovanie-returned{background:#1a0a33!important;border-color:#5c2d99!important;color:#b37feb!important;} +html.dark .stat-card{background:var(--surface);border-color:var(--border);} +html.dark .stat-card.active{background:var(--accent-light);} +html.dark .pg-btn{background:var(--surface);border-color:var(--border);} +html.dark .pg-btn.pg-active{background:var(--accent);} +html.dark .stats-card{background:var(--surface);border-color:var(--border);} +html.dark .stats-kpi-card{background:var(--surface);border-color:var(--border);} +html.dark .stats-reply-client{background:#2a1515;border-color:#5c2020;} +html.dark .stats-reply-staff{background:#142a0a;border-color:#2a5214;} +html.dark .stats-reply-none{background:#2a1f0a;border-color:#5c3d0a;} +html.dark .stats-oldest-item{background:#2a1515;border-color:#5c2020;} +html.dark .stats-oldest-item:hover{background:#381818;} +html.dark .stats-table td,html.dark .stats-table th{border-color:var(--border);} +html.dark .stats-table tr:hover td{background:var(--surface2);} + +body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;font-size:13px;line-height:1.5;} + +/* ── LAYOUT ─────────────────────────────────────────────── */ +.app-layout{display:flex;height:100vh;overflow:hidden;} +.main-area{flex:1;display:flex;flex-direction:column;min-width:0;height:100vh;overflow:hidden;} + +/* ── SIDEBAR ─────────────────────────────────────────────── */ +.sidebar-logo{padding:22px 20px 18px;border-bottom:1px solid rgba(255,255,255,.08);} +.logo-mark{display:flex;align-items:center;gap:10px;} +.logo-icon{width:32px;height:32px;background:var(--accent);border-radius:8px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:14px;font-weight:800;flex-shrink:0;} +.logo-text{font-size:16px;font-weight:700;color:#fff;letter-spacing:-.3px;} +.logo-sub{font-size:11px;color:#8b92b0;margin-top:1px;} +.sidebar-nav{flex:1;padding:12px 10px;display:flex;flex-direction:column;gap:2px;} +.nav-item{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:var(--radius-sm);color:#8b92b0;cursor:pointer;transition:all .15s;font-size:13px;font-weight:500;user-select:none;} +.nav-item:hover{background:rgba(255,255,255,.06);color:#d0d4e8;} +.nav-item.active{background:rgba(255,255,255,.1);color:#fff;} +.nav-badge{margin-left:auto;background:var(--accent);color:#fff;font-size:10px;font-weight:700;border-radius:10px;padding:1px 7px;min-width:20px;text-align:center;line-height:1.6;} +.nav-badge.red{background:var(--red);} +.sidebar-user{padding:14px 14px 14px;border-top:1px solid rgba(255,255,255,.08);position:relative;} +.sidebar-user-inner{display:flex;align-items:center;gap:10px;margin-bottom:10px;} +.user-avatar{width:34px;height:34px;border-radius:50%;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;flex-shrink:0;} +.user-name{font-size:12px;font-weight:600;color:#fff;} +.user-email{font-size:11px;color:#8b92b0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px;} +.sidebar-user-actions{display:flex;gap:6px;} +.sidebar-logout{flex:1;background:rgba(255,255,255,.07);border:none;border-radius:var(--radius-sm);color:#8b92b0;font-family:'Inter',sans-serif;font-size:11px;padding:6px;cursor:pointer;transition:all .15s;} +.sidebar-logout:hover{background:rgba(255,255,255,.12);color:#fff;} +.sidebar-notif-btn{width:30px;height:30px;background:rgba(255,255,255,.07);border:none;border-radius:var(--radius-sm);color:#8b92b0;cursor:pointer;display:flex;align-items:center;justify-content:center;position:relative;transition:all .15s;flex-shrink:0;} +.sidebar-notif-btn:hover{background:rgba(255,255,255,.12);color:#fff;} +.sidebar-notif-badge{position:absolute;top:-4px;right:-4px;background:var(--red);color:#fff;font-size:9px;font-weight:700;border-radius:8px;padding:1px 4px;min-width:14px;text-align:center;line-height:1.4;} + +/* ── TOPBAR ──────────────────────────────────────────────── */ +.topbar{height:52px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 16px;gap:8px;flex-shrink:0;z-index:40;} +.topbar-logo{display:flex;align-items:center;gap:8px;flex-shrink:0;} +.topbar-logo-icon{width:28px;height:28px;background:var(--accent);border-radius:6px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px;font-weight:800;flex-shrink:0;} +.topbar-logo-text{font-size:14px;font-weight:700;color:var(--text);white-space:nowrap;} +.topbar-sep{width:1px;height:20px;background:var(--border);flex-shrink:0;margin:0 2px;} +.topbar-title{font-size:14px;font-weight:700;color:var(--text);white-space:nowrap;} +.topbar-subtitle{font-size:12px;color:var(--text3);white-space:nowrap;} +.topbar-meta{display:flex;align-items:center;gap:14px;font-size:11px;color:var(--text3);white-space:nowrap;flex-shrink:0;} +.topbar-meta span{color:var(--accent);font-weight:600;} +/* Иконки-кнопки в топбаре */ +.topbar-icon-btn{width:32px;height:32px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text2);cursor:pointer;display:flex;align-items:center;justify-content:center;position:relative;transition:all .15s;flex-shrink:0;} +.topbar-icon-btn:hover{border-color:var(--accent);color:var(--accent);} +/* Уведомления */ +.topbar-notif-wrap{position:relative;flex-shrink:0;} +.topbar-notif-badge{position:absolute;top:-4px;right:-4px;background:var(--red);color:#fff;font-size:9px;font-weight:700;border-radius:8px;padding:1px 4px;min-width:14px;text-align:center;line-height:1.4;pointer-events:none;} +/* Пользователь */ +.topbar-user{display:flex;align-items:center;gap:7px;flex-shrink:0;} +.topbar-username{font-size:12px;font-weight:600;color:var(--text2);white-space:nowrap;} +/* Выйти */ +.topbar-logout-btn{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text2);font-family:'Inter',sans-serif;font-size:11px;font-weight:600;padding:5px 12px;cursor:pointer;transition:all .15s;flex-shrink:0;white-space:nowrap;} +.topbar-logout-btn:hover{border-color:var(--red);color:var(--red);} + +/* ── CONTENT ─────────────────────────────────────────────── */ +.content-area{padding:18px 24px;display:flex;flex-direction:column;gap:14px;flex:1;min-height:0;overflow:hidden;} + +/* ── STAT CARDS ──────────────────────────────────────────── */ +.stat-cards{display:flex;gap:12px;flex-wrap:wrap;} +.stat-card{background:var(--surface);border:1.5px solid var(--border);border-radius:var(--radius);padding:14px 18px;flex:1;min-width:150px;display:flex;align-items:center;gap:12px;box-shadow:var(--shadow);cursor:pointer;transition:all .15s;} +.stat-card:hover{box-shadow:var(--shadow-md);border-color:var(--border2);} +.stat-card.active{border-color:var(--accent);background:var(--accent-light);} +.stat-card-icon{width:42px;height:42px;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0;} +.sci-blue{background:#e0e8ff;color:var(--accent);} +.sci-red{background:#ffe8e8;color:var(--red);} +.sci-green{background:#e8f8ef;color:var(--green);} +.sci-orange{background:#fff0dc;color:var(--orange);} +.stat-card-val{font-size:26px;font-weight:700;color:var(--text);line-height:1;margin-bottom:2px;} +.stat-card-lbl{font-size:11px;color:var(--text3);} + +/* ── MAIN PANEL ──────────────────────────────────────────── */ +.tasks-panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);display:flex;flex-direction:column;flex:1;min-width:0;overflow:hidden;} +.tasks-split{display:flex;gap:14px;flex:1;min-height:0;overflow:hidden;} +.detail-col{width:380px;flex-shrink:0;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);display:flex;flex-direction:column;overflow:hidden;} +.detail-col.hidden{display:none;} + +/* ── FILTER AREA ─────────────────────────────────────────── */ +/* filter-area and filter-row defined at bottom */ +.filter-select{appearance:none;background:var(--surface2) url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%239098b8' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E") no-repeat right 9px center;border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text2);font-family:'Inter',sans-serif;font-size:12px;padding:6px 26px 6px 10px;cursor:pointer;outline:none;transition:border-color .15s;} +.filter-select:hover,.filter-select:focus{border-color:var(--accent);} +.filter-search{margin-left:auto;min-width:200px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-family:'Inter',sans-serif;font-size:12px;padding:6px 10px 6px 30px;outline:none;transition:border-color .15s;background-image:url("data:image/svg+xml,%3Csvg width='14' height='14' viewBox='0 0 14 14' fill='none'%3E%3Ccircle cx='6' cy='6' r='4.5' stroke='%239098b8' stroke-width='1.3'/%3E%3Cpath d='M9.5 9.5L12 12' stroke='%239098b8' stroke-width='1.3' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:10px center;} +.filter-search:focus{border-color:var(--accent);} +.filter-search::placeholder{color:var(--text3);} + +/* ── STATUS CHIPS ────────────────────────────────────────── */ +.status-chips-row{display:flex;gap:6px;flex-wrap:wrap;align-items:center;padding-bottom:12px;} +.s-chip{display:inline-flex;align-items:center;gap:5px;padding:5px 11px;border-radius:20px;font-size:12px;font-weight:600;cursor:pointer;user-select:none;transition:all .18s;border:2px solid transparent;white-space:nowrap;position:relative;} +/* Chip OFF — базовый стиль (перекрывается цветными правилами ниже) */ +.chip-off{background:transparent!important;opacity:.5;} +.chip-off .s-dot{opacity:0;} +/* Chip OFF — цветные обводки сохраняются для каждого типа */ +.chip-reply.chip-off{border-color:var(--red)!important;color:var(--red)!important;} +.chip-wait.chip-off{border-color:var(--orange)!important;color:var(--orange)!important;} +.chip-resolved.chip-off{border-color:var(--green)!important;color:var(--green)!important;} +.chip-hidden.chip-off{border-color:#bbb!important;color:#888!important;} +.chip-vyezdnye.chip-off{border-color:var(--purple)!important;color:var(--purple)!important;} +.chip-oborudovanie.chip-off{border-color:var(--purple)!important;color:var(--purple)!important;} +.chip-unmarked.chip-off{border-color:var(--border2)!important;color:var(--text3)!important;} +/* Chip ON — яркий, чёткий */ +.chip-all.chip-on{background:var(--accent);color:#fff;border-color:var(--accent);} +.chip-reply.chip-on{background:var(--red-light);border-color:var(--red);color:var(--red);} +.chip-wait.chip-on{background:var(--orange-light);border-color:var(--orange);color:var(--orange);} +.chip-resolved.chip-on{background:var(--green-light);border-color:var(--green);color:var(--green);} +.chip-hidden.chip-on{background:#f0f0f0;border-color:#bbb;color:#666;} +.chip-unmarked.chip-on{background:var(--surface2);border-color:var(--border2);color:var(--text2);} +.chip-mentioned.chip-on{background:var(--orange-light);border-color:var(--orange-border);color:var(--orange);} +.chip-vyezdnye.chip-on{background:var(--purple-light);border-color:var(--purple);color:var(--purple);} +.chip-oborudovanie.chip-on{background:var(--purple-light);border-color:var(--purple);color:var(--purple);} +.s-chip:hover{opacity:.9;transform:translateY(-1px);} +.s-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;} +.s-count{font-size:10px;font-weight:700;background:rgba(0,0,0,.1);border-radius:8px;padding:0 5px;line-height:1.7;min-width:18px;text-align:center;} +.chip-all.chip-on .s-count{background:rgba(255,255,255,.3);} + +/* ── BULK BAR (see bottom of file) ────────────────── */ +.bulk-check-wrap{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text3);cursor:pointer;} +.bulk-check-wrap input{accent-color:var(--accent);cursor:pointer;} +.bulk-action-btn{display:flex;align-items:center;gap:5px;padding:5px 12px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--surface);color:var(--text2);font-family:'Inter',sans-serif;font-size:12px;cursor:pointer;transition:all .15s;} +.bulk-action-btn:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-light);} +.tf-info{margin-left:auto;font-size:12px;color:var(--text3);} + +/* Column chooser */ +.col-chooser-wrap{position:relative;margin-left:auto;} +.col-chooser-btn{display:flex;align-items:center;gap:5px;padding:5px 12px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--surface);color:var(--text2);font-family:'Inter',sans-serif;font-size:12px;cursor:pointer;transition:all .15s;} +.col-chooser-btn:hover{border-color:var(--accent);color:var(--accent);} +.col-menu{position:absolute;right:0;top:calc(100% + 6px);background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-md);z-index:100;min-width:180px;overflow:hidden;display:none;} +.col-menu-item{display:flex;align-items:center;gap:8px;padding:9px 14px;font-size:12px;color:var(--text2);cursor:pointer;transition:background .1s;} +.col-menu-item:hover{background:var(--surface2);} +.col-menu-item input{accent-color:var(--accent);} + +/* ── TABLE ───────────────────────────────────────────────── */ +.table-wrap{flex:1;overflow-y:auto;min-height:0;overflow-x:auto;} +.tasks-table{width:100%;border-collapse:collapse;} +.tasks-table thead th{background:var(--surface2);padding:9px 12px;text-align:left;font-size:10px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--border);white-space:nowrap;position:sticky;top:0;z-index:1;} +.tasks-table tbody tr{border-bottom:1px solid var(--border);cursor:pointer;transition:filter .1s;} +.tasks-table tbody tr:last-child{border-bottom:none;} +.tasks-table tbody tr:hover{box-shadow: inset 0 0 0 1000px rgba(0,0,0,0.03);} +.tasks-table tbody td{padding:9px 12px;vertical-align:middle;} +.tasks-table tbody tr.active-row { + box-shadow: 0 0 0 1px #667eea, 0 4px 12px rgba(102, 126, 234, 0.2); + border-radius: 6px; + background: white; + position: relative; + z-index: 5; + transition: all 0.2s ease; +} + +/* tank-num в активной строке жирный */ +.tasks-table tbody tr.active-row .task-num { + font-weight: 700; + /* Можете добавить легкое изменение цвета для акцента */ + /* color: #667eea; */ +} + +.col-check{width:36px;} +.col-num{width:72px;white-space:nowrap;} +.col-title{min-width:160px;} +.col-author{width:170px;} +.col-date{width:100px;white-space:nowrap;} +.col-prev{width:160px;} +.col-status{width:180px;} + +/* Unread dot */ +.unread-dot-inline{display:inline-block;width:7px;height:7px;background:var(--accent);border-radius:50%;margin-left:5px;vertical-align:middle;flex-shrink:0;} + +/* Task cells */ +.task-num-link{font-size:13px;font-weight:700;color:var(--text2);text-decoration:none;transition:color .15s;} +.task-num-link:hover{color:var(--accent);} +.task-title-main{font-size:13px;font-weight:500;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:400px;} +.task-title-sub{font-size:11px;color:var(--text3);margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:300px;} +.client-info{display:flex;align-items:center;gap:7px;} +.client-avatar{width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0;} +.client-name{font-size:12px;font-weight:500;color:var(--text);} +.client-org{font-size:11px;color:var(--text3);} +.date-main{font-size:12px;color:var(--text2);} +.date-sub{font-size:10px;color:var(--text3);margin-top:1px;} +.preview-text{font-size:11px;color:var(--text3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px;} +.note-indicator{font-size:11px;color:#389e0d;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px;} +.comment-count-badge{font-size:10px;color:var(--text3);margin-top:3px;} + +/* Status badges */ +.status-badge{display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:12px;font-size:11px;font-weight:600;white-space:nowrap;border:1px solid transparent;} +.status-badge .dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;} +.sb-reply{background:var(--red-light);border-color:var(--red-border);color:var(--red);} +.sb-reply .dot{background:var(--red);} +.sb-wait{background:var(--orange-light);border-color:var(--orange-border);color:var(--orange);} +.sb-wait .dot{background:var(--orange);} +.sb-resolved{background:var(--green-light);border-color:var(--green-border);color:var(--green);} +.sb-resolved .dot{background:var(--green);} +.sb-none{background:var(--surface2);border-color:var(--border2);color:var(--text3);} +.sb-none .dot{background:var(--text3);} +.sb-vyezdnye{background:var(--purple-light);border-color:#d3adf7;color:var(--purple);} +.sb-vyezdnye .dot{background:var(--purple);} +.sb-oborudovanie{background:var(--purple-light);border-color:#d3adf7;color:var(--purple);} +.sb-oborudovanie .dot{background:var(--purple);} +.sb-hidden{background:#f5f5f5;border-color:#d9d9d9;color:#888;} +.sb-hidden .dot{background:#bbb;} +.sb-external{background:var(--red-light);border-color:var(--red-border);color:var(--red);} +.sb-external .dot{background:var(--red);} +.sb-internal{background:var(--green-light);border-color:var(--green-border);color:var(--green);} +.sb-internal .dot{background:var(--green);} + +/* ── MODAL ───────────────────────────────────────────────── */ +.modal-overlay{display:none;position:fixed;inset:0;background:rgba(20,24,40,.5);backdrop-filter:blur(4px);z-index:200;align-items:center;justify-content:center;padding:24px;} +.modal-overlay.open{display:flex;} +.modal{background:var(--surface);border-radius:14px;box-shadow:0 12px 48px rgba(20,24,40,.2);width:680px;max-width:100%;max-height:90vh;display:flex;flex-direction:column;overflow:hidden;} +.modal-header{padding:18px 20px 0;border-bottom:1px solid var(--border);flex-shrink:0;} +.modal-header-top{display:flex;align-items:flex-start;gap:12px;margin-bottom:10px;} +.modal-title{font-size:16px;font-weight:700;color:var(--text);line-height:1.35;} +.modal-subtitle{font-size:12px;color:var(--text3);margin-top:3px;} +.modal-ext-link{font-size:12px;color:var(--accent);text-decoration:none;font-weight:600;padding:5px 10px;border:1px solid var(--blue-border);border-radius:var(--radius-sm);background:var(--blue-light);white-space:nowrap;transition:all .15s;} +.modal-ext-link:hover{background:var(--accent);color:#fff;border-color:var(--accent);} +.modal-close-btn{background:none;border:none;font-size:20px;color:var(--text3);cursor:pointer;line-height:1;padding:3px;transition:color .15s;flex-shrink:0;} +.modal-close-btn:hover{color:var(--text);} +.modal-tabs-row{display:flex;gap:0;} +.detail-tab{padding:9px 14px;font-size:12px;font-weight:500;color:var(--text3);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:all .15s;white-space:nowrap;} +.detail-tab:hover{color:var(--text2);} +.detail-tab.active{color:var(--accent);border-bottom-color:var(--accent);} +.modal-scroll-body{flex:1;overflow-y:auto;min-height:0;} +.modal-footer{border-top:1px solid var(--border);flex-shrink:0;} + +/* Tags in modal header */ +.detail-tag{display:inline-flex;align-items:center;padding:2px 9px;border-radius:12px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;} +.priority-high{background:var(--red-light);border:1px solid var(--red-border);color:var(--red);} +.priority-med{background:var(--orange-light);border:1px solid var(--orange-border);color:var(--orange);} +.tag-green{background:var(--green-light);border:1px solid var(--green-border);color:var(--green);} +.tag-purple{background:var(--purple-light);border:1px solid #d3adf7;color:var(--purple);} +.tag-grey{background:#f5f5f5;border:1px solid #d9d9d9;color:#888;} + +/* ── DETAIL SECTIONS ─────────────────────────────────────── */ +.detail-section{padding:14px 20px;border-bottom:1px solid var(--border);} +.detail-section-title{font-size:10px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px;} +.detail-meta-grid{display:flex;flex-direction:column;gap:7px;} +.detail-meta-row{display:flex;align-items:center;gap:8px;} +.detail-meta-label{font-size:11px;color:var(--text3);min-width:90px;} +.detail-meta-val{font-size:12px;color:var(--text);font-weight:500;} +.client-dot{width:8px;height:8px;border-radius:50%;background:var(--green);display:inline-block;margin-right:4px;} +.detail-description{font-size:12px;color:var(--text2);line-height:1.65;white-space:pre-wrap;word-break:break-word;} +.detail-description blockquote,.intradesk-quote-block{border-left:3px solid var(--border2);padding-left:10px;color:var(--text3);font-style:italic;margin:4px 0;font-size:11px;} +.last-activity-item{display:flex;gap:10px;align-items:flex-start;} +.la-avatar{width:32px;height:32px;border-radius:50%;background:var(--accent);color:#fff;font-size:12px;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0;} +.la-name{font-size:12px;font-weight:600;color:var(--text);} +.la-time{font-size:10px;color:var(--text3);margin-left:6px;} +.la-text{font-size:11px;color:var(--text2);margin-top:3px;line-height:1.5;} + +/* Mark buttons */ +.detail-marks{display:flex;gap:6px;flex-wrap:wrap;padding:10px 20px;border-bottom:1px solid var(--border);} +.mark-btn{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:var(--radius-sm);font-size:12px;font-weight:500;border:1.5px solid var(--border);background:var(--surface2);color:var(--text2);cursor:pointer;transition:all .15s;position:relative;} +.mark-btn:hover{border-color:var(--border2);}/* Ховер с брендовым фоном для цветных кнопок */ +.mark-btn.mbtn-reply:hover {} +.mark-btn.mbtn-wait:hover {} +.mark-btn.mbtn-resolve:hover {} +.mark-btn.mbtn-vyezdnye:hover{} +.mark-btn.mbtn-oborudovanie:hover{} +.mark-btn.mbtn-hidden:hover {} +.mark-btn.mbtn-oborudovanie-fix:hover{} +.mark-btn.mbtn-oborudovanie-buy:hover{} +/* old class names */ +.active-reply {background:var(--red-light)!important;border-color:var(--red)!important;color:var(--red)!important;font-weight:700;} +.active-wait {background:var(--orange-light)!important;border-color:var(--orange)!important;color:var(--orange)!important;font-weight:700;} +.active-resolve {background:var(--green-light)!important;border-color:var(--green)!important;color:var(--green)!important;font-weight:700;} +.active-vyezdnye{background:var(--purple-light)!important;border-color:#d3adf7!important;color:var(--purple)!important;font-weight:700;} +.active-oborudovanie{background:var(--purple-light)!important;border-color:#d3adf7!important;color:var(--purple)!important;font-weight:700;} +.active-hide {background:#f5f5f5!important;border-color:#bbb!important;color:#666!important;font-weight:700;} +/* Mark buttons — неактивные всегда серые, только активная получает цвет */ +.mbtn-reply, .mbtn-wait, .mbtn-resolve, .mbtn-vyezdnye, +.mbtn-oborudovanie, .mbtn-hidden, .mbtn-oborudovanie-fix, +.mbtn-oborudovanie-buy, .mbtn-oborudovanie-client-sent, +.mbtn-oborudovanie-in-repair, .mbtn-oborudovanie-repair-done, +.mbtn-oborudovanie-send-back, .mbtn-oborudovanie-returned { + border-color: var(--border2) !important; + color: var(--text2) !important; + background: var(--surface2) !important; +} +/* Hover: показываем цвет кнопки */ +.mark-btn.mbtn-reply:hover { border-color:var(--red)!important; color:var(--red)!important; background:var(--red-light)!important; } +.mark-btn.mbtn-wait:hover { border-color:var(--orange)!important; color:var(--orange)!important; background:var(--orange-light)!important; } +.mark-btn.mbtn-resolve:hover { border-color:var(--green)!important; color:var(--green)!important; background:var(--green-light)!important; } +.mark-btn.mbtn-vyezdnye:hover { border-color:var(--purple)!important; color:var(--purple)!important; background:var(--purple-light)!important; } +.mark-btn.mbtn-oborudovanie:hover { border-color:var(--purple)!important; color:var(--purple)!important; background:var(--purple-light)!important; } +.mark-btn.mbtn-hidden:hover { border-color:#bbb!important; color:#666!important; background:#f5f5f5!important; } +.mark-btn.mbtn-oborudovanie-fix:hover { border-color:#13c2c2!important; color:#08979c!important; background:#e6fffb!important; } +.mark-btn.mbtn-oborudovanie-buy:hover { border-color:#eb2f96!important; color:#c41d7f!important; background:#fff0f6!important; } +.mark-btn.mbtn-oborudovanie-client-sent:hover { border-color:#fa541c!important; color:#d4380d!important; background:#fff2e8!important; } +.mark-btn.mbtn-oborudovanie-in-repair:hover { border-color:#13c2c2!important; color:#08979c!important; background:#e6fffb!important; } +.mark-btn.mbtn-oborudovanie-repair-done:hover { border-color:#52c41a!important; color:#389e0d!important; background:#f6ffed!important; } +.mark-btn.mbtn-oborudovanie-send-back:hover { border-color:#1677ff!important; color:#0958d9!important; background:#e6f4ff!important; } +.mark-btn.mbtn-oborudovanie-returned:hover { border-color:#722ed1!important; color:#531dab!important; background:#f9f0ff!important; } +/* Активная кнопка: полный цвет + жирный */ +.mbtn-active { font-weight:700; } +.mbtn-active.mbtn-reply { background:var(--red-light)!important; border-color:var(--red)!important; color:var(--red)!important; } +.mbtn-active.mbtn-wait { background:var(--orange-light)!important; border-color:var(--orange)!important; color:var(--orange)!important; } +.mbtn-active.mbtn-resolve { background:var(--green-light)!important; border-color:var(--green)!important; color:var(--green)!important; } +.mbtn-active.mbtn-vyezdnye { background:var(--purple-light)!important; border-color:var(--purple)!important; color:var(--purple)!important; } +.mbtn-active.mbtn-oborudovanie { background:var(--purple-light)!important; border-color:var(--purple)!important; color:var(--purple)!important; } +.mbtn-active.mbtn-hidden { background:#f5f5f5!important; border-color:#bbb!important; color:#666!important; } +.mbtn-active.mbtn-oborudovanie-fix { background:#e6fffb!important; border-color:#13c2c2!important; color:#08979c!important; } +.mbtn-active.mbtn-oborudovanie-buy { background:#fff0f6!important; border-color:#eb2f96!important; color:#c41d7f!important; } +.mbtn-active.mbtn-oborudovanie-client-sent { background:#fff2e8!important; border-color:#fa541c!important; color:#d4380d!important; } +.mbtn-active.mbtn-oborudovanie-in-repair { background:#e6fffb!important; border-color:#13c2c2!important; color:#08979c!important; } +.mbtn-active.mbtn-oborudovanie-repair-done { background:#f6ffed!important; border-color:#52c41a!important; color:#389e0d!important; } +.mbtn-active.mbtn-oborudovanie-send-back { background:#e6f4ff!important; border-color:#1677ff!important; color:#0958d9!important; } +.mbtn-active.mbtn-oborudovanie-returned { background:#f9f0ff!important; border-color:#722ed1!important; color:#531dab!important; } +.mbtn-active.mbtn-active/* Who set a mark */ +.sp-mark-setby{display:flex;flex-wrap:wrap;gap:4px;padding:4px 16px 8px;} +.mset-item{font-size:10px;color:var(--text3);background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:2px 8px;} + +/* Note */ +.detail-note-section{padding:10px 20px;border-bottom:1px solid var(--border);} +.detail-note-empty{font-size:12px;color:var(--text3);cursor:pointer;padding:7px 12px;border:1px dashed var(--border2);border-radius:var(--radius-sm);text-align:center;transition:all .15s;} +.detail-note-empty:hover{border-color:var(--accent);color:var(--accent);} +.detail-note-text{font-size:12px;color:#389e0d;background:#f6ffed;border:1px solid #b7eb8f;border-radius:var(--radius-sm);padding:8px 12px;cursor:pointer;line-height:1.5;} +.detail-note-editor{display:flex;flex-direction:column;gap:6px;} +.detail-note-editor textarea{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-family:'Inter',sans-serif;font-size:12px;padding:8px;resize:vertical;min-height:70px;outline:none;transition:border-color .15s;width:100%;} +.detail-note-editor textarea:focus{border-color:var(--accent);} +.detail-note-actions{display:flex;gap:6px;} + +/* Composer */ +.detail-composer{padding:12px 20px 16px;} +.composer-textarea{width:100%;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-family:'Inter',sans-serif;font-size:12px;padding:10px 12px;resize:none;outline:none;min-height:68px;transition:border-color .15s;line-height:1.5;} +.composer-textarea:focus{border-color:var(--accent);background:#fff;} +.composer-textarea::placeholder{color:var(--text3);} +.composer-footer{display:flex;align-items:center;gap:8px;margin-top:8px;} +.composer-tools{display:flex;gap:4px;} +.composer-tool{width:28px;height:28px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--surface2);color:var(--text3);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s;font-size:12px;font-weight:700;} +.composer-tool:hover{border-color:var(--accent);color:var(--accent);} +.btn-send{margin-left:auto;background:var(--accent);border:none;border-radius:var(--radius-sm);color:#fff;font-family:'Inter',sans-serif;font-size:12px;font-weight:700;padding:7px 20px;cursor:pointer;transition:background .15s;} +.btn-send:hover{background:var(--accent-dark);} +.btn-send:disabled{opacity:.5;cursor:not-allowed;} +.autocomplete-popup{position:absolute;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);min-width:160px;z-index:300;box-shadow:var(--shadow-md);overflow:hidden;} +.autocomplete-item{padding:7px 12px;font-size:12px;cursor:pointer;color:var(--text2);} +.autocomplete-item:hover,.autocomplete-item.selected{background:var(--accent-light);color:var(--accent);} +.mention-tag{color:var(--accent);font-weight:600;} + +/* Comments */ +.detail-comments-list{padding:10px 20px;display:flex;flex-direction:column;gap:10px;} +.detail-comment{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px 12px;} +.detail-comment.own{border-color:#b7eb8f;background:#f6ffed;} +.dc-header{display:flex;align-items:center;gap:6px;margin-bottom:6px;} +.dc-author{font-size:12px;font-weight:600;color:var(--text);} +.dc-time{font-size:10px;color:var(--text3);} +.dc-edited{font-size:10px;color:var(--text3);font-style:italic;} +.dc-text{font-size:12px;color:var(--text2);line-height:1.55;white-space:pre-wrap;word-break:break-word;} +.dc-actions{display:flex;gap:6px;margin-top:6px;} +.dc-btn{font-size:10px;color:var(--text3);cursor:pointer;padding:2px 6px;border:1px solid var(--border);border-radius:4px;background:var(--surface);transition:all .15s;} +.dc-btn:hover{border-color:var(--accent);color:var(--accent);} +.dc-btn.del:hover{border-color:var(--red);color:var(--red);} + +/* History */ +.history-entry{padding:10px 20px;border-bottom:1px solid var(--border);} +.he-header{display:flex;align-items:center;gap:8px;margin-bottom:6px;} +.he-author{font-size:12px;font-weight:600;} +.he-author.staff{color:var(--accent);} +.he-author.client{color:var(--red);} +.he-time{font-size:10px;color:var(--text3);margin-left:auto;} +.he-type{display:inline-block;font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--text3);background:var(--surface2);border:1px solid var(--border);border-radius:3px;padding:1px 6px;margin-bottom:4px;} +.he-type.comment{color:var(--accent);background:var(--accent-light);border-color:var(--blue-border);} +.he-text{font-size:12px;color:var(--text2);line-height:1.6;white-space:pre-wrap;word-break:break-word;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:8px 10px;} +.he-pill{font-size:12px;color:var(--text2);} + +/* ── SETTINGS MODAL ──────────────────────────────────────── */ +.settings-overlay{display:none;position:fixed;inset:0;background:rgba(20,24,40,.5);backdrop-filter:blur(3px);z-index:200;align-items:center;justify-content:center;padding:24px;} +.settings-overlay.open{display:flex;} +.settings-box{background:var(--surface);border-radius:14px;box-shadow:var(--shadow-md);max-width:700px;width:100%;max-height:88vh;overflow-y:auto;} +.settings-header{display:flex;align-items:center;justify-content:space-between;padding:18px 24px;border-bottom:1px solid var(--border);} +.settings-title{font-size:15px;font-weight:700;color:var(--text);} +.settings-body{padding:20px 24px 24px;} +.settings-section-title{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text3);margin-bottom:12px;} +.form-row{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:12px;} +.form-field{display:flex;flex-direction:column;gap:5px;flex:1;min-width:160px;} +.form-field label{font-size:10px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:.05em;} +.form-field input{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-family:'Inter',sans-serif;font-size:12px;padding:8px 11px;outline:none;transition:border-color .2s;} +.form-field input:focus{border-color:var(--accent);} +.users-table{width:100%;border-collapse:collapse;font-size:12px;margin-top:10px;} +.users-table th{text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--text3);padding:6px 10px;border-bottom:1px solid var(--border);} +.users-table td{padding:8px 10px;border-bottom:1px solid var(--border);color:var(--text2);vertical-align:middle;} +.users-table tr:last-child td{border-bottom:none;} +.inline-pass{background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:'Inter',sans-serif;font-size:11px;padding:4px 8px;width:130px;outline:none;} +.inline-pass:focus{border-color:var(--accent);} + +/* ── CONFIRM MODAL ───────────────────────────────────────── */ +.confirm-overlay{display:none;position:fixed;inset:0;background:rgba(20,24,40,.5);z-index:300;align-items:center;justify-content:center;} +.confirm-overlay.open{display:flex;} +.confirm-box{background:var(--surface);border-radius:12px;padding:24px 28px;max-width:360px;width:100%;box-shadow:var(--shadow-md);} +.confirm-title{font-size:15px;font-weight:700;color:var(--text);margin-bottom:8px;} +.confirm-msg{font-size:13px;color:var(--text2);margin-bottom:16px;} +.confirm-actions{display:flex;gap:8px;} + +/* ── MENTIONS ────────────────────────────────────────────── */ +.mentions-dropdown{position:absolute;top:calc(100% + 8px);right:0;min-width:280px;background:var(--sidebar-bg);border:1px solid rgba(255,255,255,.12);border-radius:var(--radius);box-shadow:0 8px 32px rgba(0,0,0,.4);z-index:100;display:none;overflow:hidden;max-height:300px;overflow-y:auto;} +.mentions-dropdown.open{display:block;} +.mentions-dd-header{padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.08);font-size:12px;font-weight:600;color:#fff;} +.mention-item{padding:9px 14px;display:flex;align-items:center;gap:8px;cursor:pointer;transition:background .1s;border-bottom:1px solid rgba(255,255,255,.06);} +.mention-item:hover{background:rgba(255,255,255,.07);} +.mention-item.unread{background:rgba(250,140,22,.1);} +.m-dot{width:6px;height:6px;border-radius:50%;background:var(--orange);flex-shrink:0;} +.m-dot.read{background:rgba(255,255,255,.2);} +.m-task{font-size:12px;font-weight:700;color:var(--accent);} +.m-by{font-size:11px;color:rgba(255,255,255,.6);} +.m-time{font-size:10px;color:rgba(255,255,255,.35);margin-left:auto;white-space:nowrap;} +.mentions-empty{padding:16px 14px;font-size:12px;color:rgba(255,255,255,.35);text-align:center;} + +/* ── UPDATE BANNER ───────────────────────────────────────── */ +.update-banner{display:none;align-items:center;gap:10px;background:var(--accent-light);border:1px solid #c7d4ff;border-radius:var(--radius);padding:10px 16px;font-size:12px;color:var(--accent);font-weight:500;} +.update-banner.visible{display:flex;} +.update-banner-btn{background:var(--accent);border:none;border-radius:var(--radius-sm);color:#fff;font-family:'Inter',sans-serif;font-size:11px;font-weight:700;padding:5px 14px;cursor:pointer;margin-left:auto;} + +/* ── BUTTONS ─────────────────────────────────────────────── */ +.btn{display:inline-flex;align-items:center;gap:5px;padding:7px 16px;border-radius:var(--radius-sm);font-family:'Inter',sans-serif;font-size:12px;font-weight:700;cursor:pointer;transition:all .15s;white-space:nowrap;border:none;} +.btn-primary{background:var(--accent);color:#fff;} +.btn-primary:hover{background:var(--accent-dark);} +.btn-default{background:var(--surface2);border:1px solid var(--border);color:var(--text2);} +.btn-default:hover{border-color:var(--accent);color:var(--accent);} +.btn-danger{background:var(--red-light);border:1px solid var(--red-border);color:var(--red);} +.btn-danger:hover{background:#ffe0de;} +.btn:disabled{opacity:.45;cursor:not-allowed;} + +/* ── LOGIN ───────────────────────────────────────────────── */ +.login-overlay{position:fixed;inset:0;background:linear-gradient(135deg,#e8ecf7,#f5f7ff);z-index:300;display:flex;align-items:center;justify-content:center;} +.login-overlay.hidden{display:none;} +.login-box{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:36px 40px;width:360px;box-shadow:0 8px 40px rgba(30,34,53,.12);} +.login-logo{display:flex;align-items:center;gap:10px;margin-bottom:6px;} +.login-logo-icon{width:36px;height:36px;background:var(--accent);border-radius:8px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:16px;font-weight:800;} +.login-logo-text{font-size:18px;font-weight:800;color:var(--text);} +.login-sub{font-size:12px;color:var(--text3);margin-bottom:24px;} +.login-field{margin-bottom:14px;} +.login-field label{display:block;font-size:10px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:.06em;margin-bottom:5px;} +.login-field input{width:100%;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-family:'Inter',sans-serif;font-size:13px;padding:10px 13px;outline:none;transition:border-color .2s;} +.login-field input:focus{border-color:var(--accent);background:#fff;} +.login-btn{width:100%;margin-top:6px;background:var(--accent);border:none;border-radius:var(--radius-sm);color:#fff;font-family:'Inter',sans-serif;font-size:13px;font-weight:700;padding:11px;cursor:pointer;transition:background .15s;} +.login-btn:hover{background:var(--accent-dark);} +.login-error{font-size:12px;color:var(--red);min-height:18px;margin-top:8px;text-align:center;} + +/* ── STATE BOX / SPINNER ─────────────────────────────────── */ +.state-box{padding:48px 24px;text-align:center;color:var(--text3);} +.state-box .icon{font-size:36px;margin-bottom:12px;display:block;} +.state-box .msg{font-size:14px;margin-bottom:6px;color:var(--text2);} +.state-box .sub{font-size:12px;} +.spinner{display:inline-block;width:28px;height:28px;border:2px solid var(--border2);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite;margin-bottom:12px;} +@keyframes spin{to{transform:rotate(360deg)}} + +/* ── TOAST ───────────────────────────────────────────────── */ +.toast-wrap{position:fixed;bottom:20px;right:20px;z-index:400;display:flex;flex-direction:column;gap:6px;pointer-events:none;} +.toast{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:10px 16px;font-size:12px;color:var(--text2);max-width:320px;box-shadow:var(--shadow-md);animation:toastIn .2s ease;} +.toast.success{border-color:var(--green-border);background:var(--green-light);color:#389e0d;} +.toast.warn{border-color:var(--red-border);background:var(--red-light);color:var(--red);} +.toast.info{border-color:#91caff;background:var(--blue-light);color:var(--blue);} +.toast.mention{border-color:var(--orange-border);background:var(--orange-light);color:var(--orange);} +@keyframes toastIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}} + +/* ── MISC ────────────────────────────────────────────────── */ +.divider{border:none;border-top:1px solid var(--border);margin:14px 0;} +::-webkit-scrollbar{width:5px;height:5px;} +::-webkit-scrollbar-track{background:transparent;} +::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px;} +::-webkit-scrollbar-thumb:hover{background:#b8bdd4;} + +/* ── ROW COLORS BY STATUS ────────────────────────────────── */ +.tasks-table tbody tr.row-reply { background: #fff5f5; } +.tasks-table tbody tr.row-reply:hover { background: #ffeeee; } +.tasks-table tbody tr.row-wait { background: #fffbf0; } +.tasks-table tbody tr.row-wait:hover { background: #fff4d6; } +.tasks-table tbody tr.row-resolved { background: #f6ffed; } +.tasks-table tbody tr.row-resolved:hover { background: #effde3; } +.tasks-table tbody tr.row-vyezdnye { background: #f9f0ff; } +.tasks-table tbody tr.row-vyezdnye:hover { background: #f3e6ff; } +.tasks-table tbody tr.row-oborudovanie { background: #f9f0ff; } +.tasks-table tbody tr.row-oborudovanie:hover { background: #f3e6ff; } +.tasks-table tbody tr.row-hidden { background: #fafafa; opacity:.6; } +.tasks-table tbody tr.row-external { background: #fff9f0; } +.tasks-table tbody tr.row-external:hover { background: #fff3e0; } +.tasks-table tbody tr.row-internal { background: #f0fff4; } +.tasks-table tbody tr.row-internal:hover { background: #e4ffe9; } +.tasks-table tbody tr.row-reply td:first-child { box-shadow: inset 3px 0 0 var(--red); } +.tasks-table tbody tr.row-wait td:first-child { box-shadow: inset 3px 0 0 var(--orange); } +.tasks-table tbody tr.row-resolved td:first-child { box-shadow: inset 3px 0 0 var(--green); } +.tasks-table tbody tr.row-vyezdnye td:first-child { box-shadow: inset 3px 0 0 var(--purple); } +.tasks-table tbody tr.row-oborudovanie td:first-child { box-shadow: inset 3px 0 0 var(--purple); } +.tasks-table tbody tr.row-hidden td:first-child { box-shadow: inset 3px 0 0 #ccc; } +.tasks-table tbody tr.row-external td:first-child { box-shadow: inset 3px 0 0 var(--orange); } +.tasks-table tbody tr.row-internal td:first-child { box-shadow: inset 3px 0 0 var(--green); } + +/* ── CHIP ACTIVE/INACTIVE ────────────────────────────────── */ +.s-chip.chip-active { + opacity: 1; + box-shadow: 0 0 0 2px currentColor; + filter: none; +} +.s-chip.chip-inactive { + opacity: .45; + filter: grayscale(.5); + box-shadow: none; +} +.s-chip.chip-active.s-chip-all { background: var(--accent); color: #fff; box-shadow: 0 0 0 2px var(--accent-dark); } +.s-chip.chip-inactive.s-chip-all { background: var(--surface2); border-color: var(--border); color: var(--text3); box-shadow: none; } + +/* ── TASK MODAL BOX ──────────────────────────────────────── */ +.task-modal-box { + max-width: 780px; + width: 100%; + max-height: 90vh; + min-height: 60vh; + display: flex; + flex-direction: column; + overflow: hidden; +} +.task-modal-box .modal-header { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 18px 20px 14px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.modal-open-link { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--accent); + text-decoration: none; + border: 1px solid var(--blue-border); + border-radius: var(--radius-sm); + padding: 5px 12px; + background: var(--accent-light); + transition: all .15s; + white-space: nowrap; +} +.modal-open-link:hover { background: #d6e0ff; } + +/* ── SETTINGS MODAL BODY ─────────────────────────────────── */ +.modal-body-pad { padding: 20px 24px 24px; } + +/* ── UNREAD DOT INLINE ───────────────────────────────────── */ +.unread-dot-inline { + display: inline-block; + width: 7px; height: 7px; + border-radius: 50%; + background: var(--accent); + margin-left: 5px; + vertical-align: middle; + flex-shrink: 0; +} + +/* ── COLUMN PICKER MENU ──────────────────────────────────── */ +.col-menu { + display: none; + position: absolute; + top: calc(100% + 4px); + right: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-md); + min-width: 190px; + z-index: 100; + padding: 6px 0; + overflow: hidden; +} +.col-menu.open { display: block; } +.col-menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 14px; + font-size: 12px; + color: var(--text2); + cursor: pointer; + transition: background .1s; + user-select: none; +} +.col-menu-item:hover { background: var(--surface2); } +.col-menu-item input[type=checkbox] { accent-color: var(--accent); width: 14px; height: 14px; cursor: pointer; } + +/* ── NO SEPARATE DETAIL COLUMN IN THIS VERSION ───────────── */ +.tasks-list-col { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); display: flex; flex-direction: column; overflow: hidden; } + +/* ── SIDE PANEL HEADER OVERRIDE ─────────────────────────── */ +.detail-col .modal-header { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.detail-col .detail-marks { + padding: 8px 16px; + flex-wrap: wrap; +} +.detail-col .detail-note-section { + padding: 8px 16px; +} +.detail-col #modal-body { + flex: 1; + overflow-y: auto; + min-height: 0; + padding: 14px 16px; +} +.detail-col .detail-composer { + padding: 10px 16px; + border-top: 1px solid var(--border); + flex-shrink: 0; +} +.detail-col .detail-tabs { + padding: 0 16px; + flex-shrink: 0; +} + +/* ── SIDE PANEL (sp-*) ───────────────────────────────────── */ +.sp-header { display:flex; align-items:flex-start; gap:10px; padding:14px 16px; border-bottom:1px solid var(--border); flex-shrink:0; } +.sp-badges { display:flex; gap:5px; flex-wrap:wrap; margin-bottom:5px; } +.sp-header-actions { display:flex; gap:6px; align-items:center; flex-shrink:0; } +.sp-ext-link { display:flex; align-items:center; justify-content:center; gap:6px; width:100%; padding:8px 12px; border:1px solid var(--accent-border,rgba(91,127,255,.35)); border-radius:var(--radius-sm); background:var(--accent-light); color:var(--accent); text-decoration:none; font-size:12px; font-weight:600; letter-spacing:.04em; transition:all .15s; box-sizing:border-box; } +.sp-ext-link:hover { background:var(--accent); color:#fff; border-color:var(--accent); } +.sp-close-btn { background:none; border:none; font-size:20px; color:var(--text3); cursor:pointer; line-height:1; padding:3px; transition:color .15s; } +.sp-close-btn:hover { color:var(--text); } +.sp-marks { display:flex; flex-wrap:wrap; gap:5px; padding:8px 16px; border-bottom:1px solid var(--border); flex-shrink:0;justify-content: center; } +.sp-note { padding:8px 16px; border-bottom:1px solid var(--border); flex-shrink:0; } +.sp-tabs { display:flex; padding:0 16px; border-bottom:1px solid var(--border); flex-shrink:0; } +.sp-body { flex:1; overflow-y:auto; min-height:0; padding:12px 16px; } +.sp-composer { padding:10px 16px; border-top:1px solid var(--border); flex-shrink:0; } +.sp-section { margin-bottom:12px; } +.sp-section-title { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--text3); margin-bottom:6px; } + +/* ── WHO-ANSWERED LABEL (inside client cell) ─────────────── */ +.who-label { display:block; font-size:10px; font-weight:600; margin-top:2px; letter-spacing:.02em; } +.who-internal { color: var(--green); } +.who-external { color: var(--red); } +.who-none { color: var(--text3); font-weight:400; } + +/* ── AVATAR RING (who answered) ──────────────────────────── */ +.client-avatar.ring-internal { + box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--green); +} +.client-avatar.ring-external { + box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--red); +} +.client-avatar.ring-none { + box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--border2); +} + +/* ── UNREAD ROW — more visible ───────────────────────────── */ +.tasks-table tbody tr.row-unread { + background: #eef2ff !important; +} +.tasks-table tbody tr.row-unread:hover { + background: #e4eaff !important; +} +.tasks-table tbody tr.row-unread td:first-child { + box-shadow: inset 3px 0 0 var(--accent); +} +.tasks-table tbody tr.row-unread .task-num { + color: var(--accent); + font-weight: 700; +} +.unread-dot-inline { + display: inline-block; + width: 7px; height: 7px; + border-radius: 50%; + background: var(--accent); + margin-left: 5px; + vertical-align: middle; + box-shadow: 0 0 0 2px rgba(91,127,255,.25); +} + +/* ── INLINE STATUS PICKER ─────────────────────────────────── */ +.inline-status-picker { + position: relative; + display: inline-block; + cursor: pointer; +} +.inline-status-picker .isp-dropdown { + display: none; + position: fixed; /* fixed so it escapes any overflow:hidden parent */ + z-index: 500; +} +.inline-status-picker:hover .isp-dropdown, +.inline-status-picker.isp-open .isp-dropdown { + display: block; +} +.isp-dropdown-inner { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-md); + min-width: 160px; + padding: 4px 0; + white-space: nowrap; +} +.isp-item { + display: flex; + align-items: center; + gap: 7px; + padding: 7px 12px; + font-size: 12px; + color: var(--text2); + cursor: pointer; + transition: background .1s; +} +.isp-item:hover { background: var(--surface2); color: var(--text); } +.isp-item.isp-active { font-weight: 600; background: var(--accent-light); color: var(--accent); } +.isp-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } + +/* Hide update banner — auto-reload is on */ +.update-banner { display: none !important; } + +/* ── DATE AGO ────────────────────────────────────────────── */ +.date-ago { + font-size: 10px; + color: var(--accent); + margin-top: 1px; + font-weight: 500; +} + +/* ══════════════════════════════════════════════════════ + STATISTICS PAGE + ══════════════════════════════════════════════════════ */ +#stats-page { + flex-direction: column; + overflow-y: auto; + background: var(--bg); + padding: 20px 24px; + gap: 16px; +} +.stats-btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 14px; border-radius: var(--radius-sm); + background: var(--surface2); border: 1px solid var(--border); + color: var(--text2); font-size: 12px; font-weight: 600; + cursor: pointer; transition: all .15s; white-space: nowrap; +} +.stats-btn:hover { border-color: var(--accent); color: var(--accent); } +.stats-btn.active { background: var(--accent-light); border-color: var(--accent); color: var(--accent); } + +/* KPI row */ +.stats-inner { + max-width: 1320px; + margin: 0 auto; + padding: 28px 24px 60px; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* ── KPI cards ──────────────────────────────────────────────── */ +.stats-kpi-row { display:flex; gap:12px; flex-wrap:wrap; } +.stats-kpi-card { + flex: 1; min-width: 140px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 18px 20px 16px; + display: flex; + flex-direction: column; + gap: 6px; + transition: box-shadow .15s; +} +.stats-kpi-card:hover { box-shadow: 0 4px 18px rgba(0,0,0,.09); } +.stats-kpi-icon { opacity: .75; margin-bottom: 2px; } +.stats-kpi-val { font-size: 30px; font-weight: 800; line-height: 1; } +.stats-kpi-lbl { font-size: 12px; color: var(--text3); font-weight: 500; } + +.stats-kpi-blue { border-top: 3px solid var(--accent); } +.stats-kpi-blue .stats-kpi-val, .stats-kpi-blue .stats-kpi-icon { color: var(--accent); } +.stats-kpi-accent { border-top: 3px solid var(--s-indigo); } +.stats-kpi-accent .stats-kpi-val, .stats-kpi-accent .stats-kpi-icon { color: var(--s-indigo); } +.stats-kpi-green { border-top: 3px solid var(--s-teal); } +.stats-kpi-green .stats-kpi-val, .stats-kpi-green .stats-kpi-icon { color: var(--s-teal); } +.stats-kpi-orange { border-top: 3px solid var(--orange); } +.stats-kpi-orange .stats-kpi-val, .stats-kpi-orange .stats-kpi-icon { color: var(--orange); } +.stats-kpi-red { border-top: 3px solid var(--red); } +.stats-kpi-red .stats-kpi-val, .stats-kpi-red .stats-kpi-icon { color: var(--red); } +.stats-kpi-teal { border-top: 3px solid #0d9488; } +.stats-kpi-teal .stats-kpi-val, .stats-kpi-teal .stats-kpi-icon { color: #0d9488; } +.stats-kpi-indigo { border-top: 3px solid #7c3aed; } +.stats-kpi-indigo .stats-kpi-val, .stats-kpi-indigo .stats-kpi-icon { color: #7c3aed; } + +/* ── Shared card ────────────────────────────────────────────── */ +.stats-charts-row { display:flex; gap:16px; flex-wrap:wrap; } +.stats-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 18px 20px; +} +.stats-card-title { + font-size: 13px; + font-weight: 700; + color: var(--text2); + letter-spacing: .02em; + text-transform: uppercase; + margin-bottom: 14px; +} + +/* ── Timing ─────────────────────────────────────────────────── */ +.stats-timing-grid { display:flex; gap:10px; } +.stats-timing-cell { + flex: 1; text-align: center; + padding: 12px 8px; + background: var(--surface2); + border-radius: 10px; +} +.stats-timing-val { font-size: 20px; font-weight: 800; color: var(--accent); } +.stats-timing-muted .stats-timing-val { color: var(--text2); font-size: 17px; } +.stats-timing-lbl { font-size: 11px; color: var(--text3); margin-top: 3px; } +.stats-timing-note { font-size: 11px; color: var(--text3); margin-top: 8px; } + +/* ── Heatmap ─────────────────────────────────────────────────── */ +.stats-heatmap-wrap { display:flex; flex-direction:column; gap:2px; overflow-x:auto; } +.stats-heatmap-hours { display:flex; align-items:center; gap:2px; padding-left:34px; } +.stats-heatmap-corner{ width:34px; flex-shrink:0; } +.stats-hm-hlbl { width:22px; flex-shrink:0; font-size:9px; color:var(--text3); text-align:center; } +.stats-hm-row { display:flex; align-items:center; gap:2px; } +.stats-hm-dlbl { width:32px; flex-shrink:0; font-size:10px; color:var(--text2); font-weight:700; text-align:right; padding-right:5px; } +.stats-hm-cell { + width: 22px; height: 22px; + border-radius: 4px; + flex-shrink: 0; + display: flex; align-items: center; justify-content: center; + font-size: 8px; font-weight: 700; + cursor: default; + transition: transform .1s; + /* colour: interpolate from --surface2 → accent using --hi (0–1) */ + background: color-mix(in srgb, var(--accent) calc(var(--hi) * 100%), var(--surface2)); + color: transparent; /* hide number by default, show on hover via title */ +} +.stats-hm-cell[style*="--hi:0"] { background: var(--surface2); } +.stats-hm-cell:hover { transform: scale(1.35); z-index: 5; position: relative; } +/* show number only when there's activity */ +.stats-hm-cell span { pointer-events: none; color: rgba(255,255,255,.0); } +.stats-hm-cell:hover span { color: white; } + +.stats-hm-legend { + display:flex; align-items:center; gap:3px; + margin-top:8px; padding-left:34px; + font-size:10px; color:var(--text3); +} +.stats-hm-leg-cell { + width: 20px; height: 13px; border-radius: 3px; + background: color-mix(in srgb, var(--accent) calc(var(--hi) * 100%), var(--surface2)); +} +.stats-hm-leg-lbl { margin: 0 5px; } + +/* dark mode: text on hot cells */ +html.dark .stats-hm-cell:hover span { color: rgba(255,255,255,.95); } + +/* ── Line chart ─────────────────────────────────────────────── */ +.stats-line-chart { display:flex; flex-direction:column; gap:4px; position:relative; } +.stats-svg { width:100%; height:130px; display:block; overflow:visible; } +.stats-line-xlabels { + display:flex; justify-content:space-between; + font-size:10px; color:var(--text3); padding:0 2px; +} +.stats-line-xlabels span { flex:1; text-align:center; } +.line-dot { cursor:pointer; transition:r .12s; } +.line-dot:hover { r:7; } +.stats-line-tooltip { + position: absolute; + background: var(--text); + color: var(--surface); + font-size: 12px; font-weight: 600; + padding: 5px 11px; + border-radius: 7px; + pointer-events: none; + white-space: nowrap; + z-index: 20; + box-shadow: 0 3px 10px rgba(0,0,0,.2); +} + +/* ── Bar chart (generic) ────────────────────────────────────── */ +.stats-bar-chart { display:flex; flex-direction:column; gap:9px; } +.stats-bar-row { display:flex; align-items:center; gap:10px; } +.stats-bar-label { + font-size: 12px; color: var(--text2); + width: 88px; flex-shrink: 0; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.stats-bar-track { + flex: 1; height: 10px; + background: var(--surface2); border-radius: 5px; overflow: hidden; + position: relative; +} +.stats-bar-fill { height:10px; border-radius:5px; transition:width .5s cubic-bezier(.4,0,.2,1); } +.stats-bar-count { font-size:12px; font-weight:700; color:var(--text2); width:28px; text-align:right; flex-shrink:0; } +.stats-bar-pct { font-size:11px; color:var(--text3); width:34px; text-align:right; flex-shrink:0; } + +/* ── Executor bars ──────────────────────────────────────────── */ +.stats-exec-bar-done { + position: absolute; left:0; top:0; height:100%; + background: color-mix(in srgb, var(--s-teal) 40%, var(--surface2)); + border-radius: 5px; transition: width .5s; +} +.stats-exec-bar-open { + position: absolute; left:0; top:0; height:100%; + background: var(--s-indigo); border-radius: 5px; + transition: width .5s; +} +.stats-exec-open-badge { + font-size: 11px; color: var(--s-indigo); + background: color-mix(in srgb, var(--s-indigo) 12%, transparent); + padding: 1px 7px; border-radius: 10px; + white-space: nowrap; flex-shrink: 0; +} +.stats-exec-legend { + display: flex; gap: 16px; + margin-top: 6px; padding-top: 8px; + border-top: 1px solid var(--border); + font-size: 11px; color: var(--text3); +} +.stats-exec-leg-item { display:flex; align-items:center; gap:5px; } +.stats-exec-leg-dot { width:10px; height:10px; border-radius:2px; flex-shrink:0; } + +/* ── Repeat clients ─────────────────────────────────────────── */ +.stats-repeat-grid { display:flex; flex-direction:column; gap:9px; } +.stats-repeat-row { display:flex; align-items:center; gap:10px; } +.stats-repeat-name { + font-size: 12px; color: var(--text2); + width: 160px; flex-shrink: 0; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.stats-repeat-track { + flex: 1; height: 12px; + background: var(--surface2); border-radius: 6px; + position: relative; overflow: hidden; +} +.stats-repeat-bar-total { + position: absolute; left:0; top:0; height:100%; + background: color-mix(in srgb, var(--accent) 25%, var(--surface2)); + border-radius: 6px; transition: width .5s; +} +.stats-repeat-bar-open { + position: absolute; left:0; top:0; height:100%; + background: var(--accent); border-radius: 6px; transition: width .5s; +} +.stats-repeat-counts { display:flex; align-items:center; gap:6px; flex-shrink:0; } +.stats-repeat-total { font-size:13px; font-weight:700; color:var(--text); min-width:20px; text-align:right; } +.stats-repeat-open { + font-size: 11px; color: var(--accent); + background: var(--accent-light); + padding: 1px 7px; border-radius: 10px; +} + +/* ── Donut + legend ─────────────────────────────────────────── */ +.stats-donut-wrap { display:flex; justify-content:center; margin-bottom:14px; } +.stats-donut-svg { width:140px; height:140px; transform:rotate(-90deg); } +.stats-donut-svg text { transform:rotate(90deg); transform-origin:70px 70px; } +.stats-legend { display:flex; flex-direction:column; gap:7px; } +.stats-legend-item { display:flex; align-items:center; gap:8px; font-size:12px; } +.stats-legend-dot { width:9px; height:9px; border-radius:50%; flex-shrink:0; } +.stats-legend-lbl { flex:1; color:var(--text2); } +.stats-legend-val { font-weight:700; color:var(--text); min-width:24px; text-align:right; } +.stats-legend-pct { color:var(--text3); min-width:36px; text-align:right; } + +/* ── Empty state ────────────────────────────────────────────── */ +.stats-empty { color:var(--text3); font-size:13px; text-align:center; padding:20px 0; } + +/* ── Dark tweaks ────────────────────────────────────────────── */ +html.dark .stats-kpi-card:hover { box-shadow: 0 4px 22px rgba(0,0,0,.4); } +html.dark .stats-card { background: var(--surface); } + +/* ── Topbar nav tabs ──────────────────────────────────── */ +.topbar-nav-tabs { display:flex; gap:2px; background:var(--surface2); border-radius:8px; padding:3px; } +.nav-tab { + display:inline-flex; align-items:center; gap:6px; + padding:5px 14px; border:none; background:transparent; + color:var(--text3); font-size:12px; font-weight:500; + font-family:inherit; cursor:pointer; border-radius:6px; + transition:all .15s; white-space:nowrap; +} +.nav-tab:hover { background:var(--surface); color:var(--text2); } +.nav-tab-active { background:var(--surface)!important; color:var(--accent)!important; font-weight:700; box-shadow:0 1px 4px rgba(0,0,0,.1); } + +/* ── Activity chart (16 weeks bars) ──────────────────── */ +.stats-activity-chart { display:flex; gap:6px; align-items:flex-end; height:130px; } +.stats-activity-chart-14 { gap:3px; } +.stats-activity-col { flex:1; display:flex; flex-direction:column; align-items:center; gap:3px; height:100%; justify-content:flex-end; } +.stats-activity-bar-wrap { flex:1; width:100%; display:flex; flex-direction:column; justify-content:flex-end; position:relative; } +.stats-activity-bar { width:100%; background:var(--accent); border-radius:3px 3px 0 0; transition:height .4s; position:relative; z-index:2; } +.stats-activity-bar-green { background:var(--green); } +.stats-activity-bar-bg { background:var(--border2); position:absolute; bottom:0; left:0; z-index:1; width:100%; } +.stats-activity-count { font-size:9px; font-weight:700; color:var(--text3); min-height:13px; } +.stats-activity-label { font-size:9px; color:var(--text3); white-space:nowrap; transform:rotate(-35deg); transform-origin:center; margin-top:3px; } + +/* ── Oldest open list ────────────────────────────────── */ +.stats-oldest-list { display:flex; flex-direction:column; gap:6px; } +.stats-oldest-item { + display:flex; align-items:center; gap:10px; + padding:8px 10px; background:var(--surface2); + border-radius:8px; cursor:pointer; transition:background .12s; font-size:12px; +} +.stats-oldest-item:hover { background:var(--accent-light); } +.stats-oldest-num { font-weight:700; color:var(--accent); flex-shrink:0; min-width:48px; } +.stats-oldest-name { flex:1; color:var(--text2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.stats-oldest-age { font-weight:700; flex-shrink:0; } + +/* ── Timing periods table ────────────────────────────── */ +.stats-timing-periods { display:flex; flex-direction:column; gap:0; } +.stats-timing-period-row { + display:grid; grid-template-columns:80px 1fr 1fr 60px; + gap:8px; align-items:center; + padding:7px 10px; border-radius:8px; + font-size:12px; + transition:background .1s; +} +.stats-timing-period-row:hover { background:var(--surface2); } +.stats-timing-period-hdr { + display:grid; grid-template-columns:80px 1fr 1fr 60px; + gap:8px; padding:0 10px 6px; + font-size:10px; font-weight:700; color:var(--text3); + text-transform:uppercase; letter-spacing:.05em; + border-bottom:1px solid var(--border); margin-bottom:4px; +} +.stats-tpr-period { color:var(--text2); font-weight:600; } +.stats-tpr-val { font-weight:700; color:var(--accent); } +.stats-tpr-med { font-weight:700; color:var(--s-teal,#0d9488); } +.stats-tpr-cnt { color:var(--text3); font-size:11px; text-align:right; } +.stats-timing-note { font-size:11px; color:var(--text3); padding:0 10px; } + +/* ── Clients expandable list ─────────────────────────── */ +.stats-clients-card { } +.stats-clients-header { + display:flex; align-items:center; gap:8px; cursor:pointer; + user-select:none; +} +.stats-clients-toggle { + margin-left:auto; font-size:11px; color:var(--text3); + background:var(--surface2); border:1px solid var(--border); + border-radius:6px; padding:2px 10px; cursor:pointer; + transition:all .15s; flex-shrink:0; +} +.stats-clients-toggle:hover { border-color:var(--accent); color:var(--accent); } +.stats-clients-filter { + display:flex; align-items:center; gap:8px; flex-wrap:wrap; +} +.stats-clients-check-wrap { + display:flex; align-items:center; gap:6px; + font-size:12px; color:var(--text2); cursor:pointer; user-select:none; + background:var(--surface2); border:1px solid var(--border); + border-radius:6px; padding:4px 10px; transition:all .15s; +} +.stats-clients-check-wrap:hover { border-color:var(--accent); } +.stats-clients-check-wrap input { cursor:pointer; accent-color:var(--accent); } +.stats-clients-list { display:flex; flex-direction:column; gap:7px; } +.stats-clients-row { display:flex; align-items:center; gap:10px; } +.stats-clients-name { + font-size:12px; color:var(--text2); + width:200px; flex-shrink:0; + overflow:hidden; text-overflow:ellipsis; white-space:nowrap; +} +.stats-clients-track { + flex:1; height:12px; background:var(--surface2); border-radius:6px; + position:relative; overflow:hidden; +} +.stats-clients-bar-total { + position:absolute; left:0; top:0; height:100%; + background:var(--accent); opacity:.25; border-radius:6px; transition:width .4s; +} +.stats-clients-bar-open { + position:absolute; left:0; top:0; height:100%; + background:var(--accent); border-radius:6px; transition:width .4s; +} +.stats-clients-counts { display:flex; align-items:center; gap:6px; flex-shrink:0; } +.stats-clients-total { font-size:13px; font-weight:700; color:var(--text); min-width:20px; text-align:right; } +.stats-clients-open-badge { + font-size:11px; color:var(--accent); + background:var(--accent-light); padding:1px 7px; border-radius:10px; + white-space:nowrap; +} + +/* ── Exec load bars ──────────────────────────────────── */ +.stats-exec-open-badge { + font-size:11px; color:var(--s-indigo,#4f46e5); + background:color-mix(in srgb, var(--s-indigo,#4f46e5) 10%, transparent); + padding:1px 7px; border-radius:10px; white-space:nowrap; flex-shrink:0; +} +.stats-exec-legend { display:flex; gap:16px; margin-top:4px; padding-top:8px; border-top:1px solid var(--border); font-size:11px; color:var(--text3); } +.stats-exec-leg-item { display:flex; align-items:center; gap:5px; } +.stats-exec-leg-dot { width:10px; height:10px; border-radius:2px; flex-shrink:0; } +.stats-exec-bar-done { position:absolute; left:0; top:0; height:100%; background:color-mix(in srgb, var(--green) 35%, var(--surface2)); border-radius:4px; transition:width .5s; } +.stats-exec-bar-open { position:absolute; left:0; top:0; height:100%; background:var(--accent); border-radius:4px; transition:width .5s; } + +/* ── Bar pct ─────────────────────────────────────────── */ +.stats-bar-pct { font-size:11px; color:var(--text3); width:34px; text-align:right; flex-shrink:0; } + +/* ── Dark tweaks ─────────────────────────────────────── */ +html.dark .stats-kpi-card:hover { box-shadow:0 4px 20px rgba(0,0,0,.4); } +html.dark .stats-card { background:var(--surface); } + +/* ══════════════════════════════════════════════════════════════ + FILTER AREA — tab-bar, chip-dropdown, pagination, bulk + ══════════════════════════════════════════════════════════════ */ + +/* ── filter-area wrapper ──────────────────────────────── */ +.filter-area { padding: 12px 16px 0; border-bottom: 1px solid var(--border); } + +/* ── Tab bar ──────────────────────────────────────────── */ +.tab-bar { + display: flex; + gap: 2px; + margin-bottom: 0; + border-bottom: 2px solid var(--border); +} +.tab-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border: none; + background: transparent; + color: var(--text3); + font-size: 13px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + border-radius: 4px 4px 0 0; + transition: color .15s, border-color .15s, background .15s; + white-space: nowrap; +} +.tab-btn:hover { background: var(--surface2); color: var(--text1); } +.tab-btn-active { color: var(--accent) !important; border-bottom-color: var(--accent) !important; font-weight: 700; } +.tab-cnt { + display: inline-flex; align-items: center; justify-content: center; + min-width: 20px; height: 18px; padding: 0 5px; + border-radius: 10px; background: var(--surface2); + color: var(--text3); font-size: 11px; font-weight: 700; + transition: background .15s, color .15s; +} +.tab-btn-active .tab-cnt { background: var(--accent-light); color: var(--accent); } + +/* ── Filter row ───────────────────────────────────────── */ +.filter-row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + padding: 10px 0 12px; +} + +/* ── Chip Dropdown ────────────────────────────────────── */ +.chip-dropdown-wrap { position: relative; flex-shrink: 0; } +.chip-dropdown-btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 12px; + border: 1px solid var(--border); border-radius: var(--radius-sm); + background: var(--surface2); color: var(--text2); + font-size: 12px; font-weight: 500; font-family: inherit; + cursor: pointer; white-space: nowrap; + transition: border-color .15s, color .15s; +} +.chip-dropdown-btn:hover { border-color: var(--accent); color: var(--accent); } +.chip-dropdown-panel { + display: none; + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 300; + min-width: 210px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 8px 24px rgba(0,0,0,.13); + padding: 6px 0; + overflow: hidden; +} +/* JS toggles chip-dd-open */ +.chip-dropdown-panel.chip-dd-open { display: block; } +.chip-dd-item { + display: flex; align-items: center; gap: 8px; + padding: 7px 14px; + cursor: pointer; font-size: 13px; color: var(--text2); + transition: background .1s; user-select: none; +} +.chip-dd-item:hover { background: var(--surface2); } +.chip-dd-item.chip-dd-active { color: var(--accent); background: var(--accent-light); font-weight: 600; } +.chip-dd-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; } +.chip-dd-lbl { flex: 1; } +.chip-dd-cnt { + font-size: 11px; font-weight: 700; color: var(--text3); + background: var(--surface2); border-radius: 10px; + padding: 1px 6px; min-width: 22px; text-align: center; +} +.chip-dd-item.chip-dd-active .chip-dd-cnt { color: var(--accent); background: var(--accent-light); } +html.dark .chip-dropdown-panel { box-shadow: 0 8px 24px rgba(0,0,0,.45); } + +/* ── Pagination ───────────────────────────────────────── */ +.pagination-bar { + display: flex; align-items: center; gap: 4px; + padding: 10px 16px; border-top: 1px solid var(--border); + flex-shrink: 0; flex-wrap: wrap; +} +.pg-btn { + min-width: 30px; height: 28px; padding: 0 7px; + border: 1px solid var(--border); border-radius: var(--radius-sm); + background: var(--surface2); color: var(--text2); + font-family: inherit; font-size: 12px; font-weight: 500; + cursor: pointer; display: flex; align-items: center; justify-content: center; + transition: all .12s; +} +.pg-btn:hover { border-color: var(--accent); color: var(--accent); } +.pg-btn.pg-active { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 700; } +.pg-info { font-size: 11px; color: var(--text3); margin-left: 6px; white-space: nowrap; } + +/* ── Bulk bar ─────────────────────────────────────────── */ +.bulk-bar { + display: flex; align-items: center; gap: 10px; + padding: 7px 16px; background: var(--accent-light); + border-bottom: 1px solid var(--border); + font-size: 12px; flex-shrink: 0; flex-wrap: wrap; +} +.bulk-check-wrap { display: flex; align-items: center; gap: 6px; color: var(--text2); cursor: pointer; } +.bulk-sel-info { color: var(--text3); } +.bulk-sel-count { font-weight: 700; color: var(--accent); } +.bulk-sep { width: 1px; height: 16px; background: var(--border2); flex-shrink: 0; } +.bulk-status-label{ color: var(--text3); white-space: nowrap; } +.bulk-status-sel { + padding: 3px 8px; border: 1px solid var(--border); + border-radius: var(--radius-sm); background: var(--surface); + font-family: inherit; font-size: 11px; color: var(--text2); cursor: pointer; +} + +/* ══════════════════════════════════════════════════════════════ + STATISTICS EXTRAS + ══════════════════════════════════════════════════════════════ */ + +/* ── Period tab switcher ──────────────────────────────── */ +.stats-period-tabs { + display: flex; gap: 2px; + background: var(--surface2); border-radius: 7px; padding: 2px; + margin-left: auto; flex-shrink: 0; +} +.stats-period-tab { + font-size: 11px; font-weight: 500; font-family: inherit; + padding: 3px 9px; border: none; + background: transparent; color: var(--text3); + border-radius: 5px; cursor: pointer; + transition: all .12s; white-space: nowrap; +} +.stats-period-tab:hover { color: var(--text2); background: var(--surface); } +.stats-period-tab.active { background: var(--surface); color: var(--accent); font-weight: 700; box-shadow: 0 1px 3px rgba(0,0,0,.08); } + +/* ── Timing rows ──────────────────────────────────────── */ +.stats-timing-rows { display: flex; flex-direction: column; gap: 0; } +.stats-timing-row-label { + font-size: 11px; font-weight: 700; color: var(--text3); + text-transform: uppercase; letter-spacing: .05em; + padding: 0 2px 5px; +} +.stats-timing-grid { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 0; } +.stats-timing-cell { + flex: 1; min-width: 80px; text-align: center; + padding: 11px 8px; background: var(--surface2); border-radius: 10px; +} +.stats-timing-val { font-size: 20px; font-weight: 800; color: var(--accent); line-height: 1.1; } +.stats-timing-muted .stats-timing-val { color: var(--text2); font-size: 17px; } +.stats-timing-lbl { font-size: 10px; color: var(--text3); margin-top: 4px; } +.stats-timing-note { font-size: 11px; color: var(--text3); } + +/* ── Exec bar colours ─────────────────────────────────── */ +.stats-exec-bar-done { + position: absolute; left: 0; top: 0; height: 100%; + background: color-mix(in srgb, var(--green) 30%, var(--surface2)); + border-radius: 4px; transition: width .5s; +} +.stats-exec-bar-open { + position: absolute; left: 0; top: 0; height: 100%; + background: var(--accent); border-radius: 4px; transition: width .5s; +} + diff --git a/public/css/keyboard-additions.css b/public/css/keyboard-additions.css new file mode 100644 index 0000000..c8aecc1 --- /dev/null +++ b/public/css/keyboard-additions.css @@ -0,0 +1,82 @@ +/* ═══════════════════════════════════════════════════════════════════ + keyboard-additions.css + Добавить в конец основного CSS файла или подключить отдельно: + + ═══════════════════════════════════════════════════════════════════ */ + +/* ── Курсор клавиатурной навигации ────────────────────────────────── */ +.tasks-table tbody tr.kb-cursor { + outline: 2px solid var(--accent, #5b7fff); + outline-offset: -2px; + background-color: color-mix(in srgb, var(--accent, #5b7fff) 8%, transparent); + /* Мягкий переход при перемещении стрелками */ + transition: outline 0.1s ease, background-color 0.1s ease; +} + +/* Если строка одновременно является active-row (открытое меню) — усиливаем */ +.tasks-table tbody tr.kb-cursor.active-row { + outline: 2px solid var(--accent, #5b7fff); + outline-offset: -2px; +} + +/* ── Мигание кнопки статуса при нажатии 1–6 ──────────────────────── */ +@keyframes kb-flash-anim { + 0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.8); transform: scale(1); } + 40% { box-shadow: 0 0 0 6px rgba(255, 255, 255, 0); transform: scale(1.06); } + 100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); transform: scale(1); } +} + +.mark-btn.kb-flash { + animation: kb-flash-anim 0.4s ease forwards; +} + +/* ── Подсказка по шорткатам (опционально, показывать по ? или в футере) ── */ +.kb-help-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.kb-help-box { + background: var(--bg2, #1e1e2e); + border: 1px solid var(--border, #2d2d3e); + border-radius: 12px; + padding: 28px 36px; + min-width: 380px; + color: var(--text, #e0e0e0); +} + +.kb-help-box h3 { + margin: 0 0 18px; + font-size: 1rem; + color: var(--accent, #5b7fff); + letter-spacing: 0.05em; +} + +.kb-help-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 5px 0; + border-bottom: 1px solid rgba(255,255,255,0.05); + font-size: 0.85rem; + gap: 20px; +} + +.kb-help-row:last-child { border-bottom: none; } + +.kb-key { + display: inline-block; + background: var(--bg3, #2a2a3d); + border: 1px solid var(--border, #3a3a4d); + border-radius: 5px; + padding: 2px 8px; + font-family: monospace; + font-size: 0.8rem; + color: var(--accent, #5b7fff); + white-space: nowrap; +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..eeeb97b --- /dev/null +++ b/public/index.html @@ -0,0 +1,310 @@ + + + + + +Заявки — CleverParking + + + + + +
+ + + + + +
+
+
+
⚙ Настройки
+ +
+
+
Подключение к IntraDesk API
+
+
+ + +
+
+
+
+
+
+
+ + + +
+
Управление пользователями
+
+
+
+
+
+
+ + + +
ЛогинСозданНовый пароль
+
+
+
+ + +
+
+
Подтверждение
+
Отметить 0 заявок как прочитанных?
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + diff --git a/public/index_old.html b/public/index_old.html new file mode 100644 index 0000000..3d8ec50 --- /dev/null +++ b/public/index_old.html @@ -0,0 +1,1610 @@ + + + + + +Монитор заявок — CleverParking + + + + +
+ + + + + + + + + + + + + + + diff --git a/public/js/api.js b/public/js/api.js new file mode 100644 index 0000000..24f1d8d --- /dev/null +++ b/public/js/api.js @@ -0,0 +1,26 @@ +// ── API helpers ─────────────────────────────────────────────────── +async function apiGet(path) { + try { + const r = await fetch(path, { headers:{'Authorization':`Bearer ${authToken}`} }); + if (r.status===401) { doLogout(); return null; } + return r.json(); + } catch { return null; } +} +async function apiPost(path, body, method='POST') { + try { + const r = await fetch(path, { + method, + headers:{'Authorization':`Bearer ${authToken}`,'Content-Type':'application/json'}, + body:JSON.stringify(body) + }); + if (r.status===401) { doLogout(); return null; } + return r.json(); + } catch { return null; } +} +async function apiDelete(path) { + try { + const r = await fetch(path, { method:'DELETE', headers:{'Authorization':`Bearer ${authToken}`} }); + if (r.status===401) { doLogout(); return null; } + return r.json(); + } catch { return null; } +} diff --git a/public/js/auth.js b/public/js/auth.js new file mode 100644 index 0000000..c784aa7 --- /dev/null +++ b/public/js/auth.js @@ -0,0 +1,233 @@ +async function doLogin() { + const username=document.getElementById('login-username').value.trim(); + const password=document.getElementById('login-password').value; + const errEl =document.getElementById('login-error'); + errEl.textContent=''; + if(!username||!password){errEl.textContent='Заполните все поля';return;} + const r=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username,password})}); + const data=await r.json(); + if(!r.ok){errEl.textContent=data.error||'Ошибка входа';return;} + authToken =data.token; + currentUser={username:data.username,is_admin:data.is_admin}; + localStorage.setItem('intradesk_token',authToken); + showApp(); init(); +} +document.addEventListener('keydown',e=>{ + if(e.key==='Enter'&&!document.getElementById('login-overlay').classList.contains('hidden')) doLogin(); +}); + +function doLogout() { + localStorage.removeItem('intradesk_token'); + authToken=null; currentUser=null; + if(statusPollTimer) clearInterval(statusPollTimer); + if(clientCountdownTimer) clearInterval(clientCountdownTimer); + stopSSE(); + document.getElementById('app-shell').style.display='none'; + document.getElementById('login-overlay').classList.remove('hidden'); + document.getElementById('login-password').value=''; +} + +function showApp() { + document.getElementById('login-overlay').classList.add('hidden'); + document.getElementById('app-shell').style.display='flex'; + const u=currentUser.username; + const initials=avatarInitials(u); + const color=avatarColor(u); + const av=document.getElementById('sb-avatar'); + if(av){av.textContent=initials;av.style.background=color;} + if(document.getElementById('sb-username')) document.getElementById('sb-username').textContent=u; + if(document.getElementById('sb-email')) document.getElementById('sb-email').textContent=u+'@cleverparks.io'; + const ns=document.getElementById('nav-settings'); + if(ns) ns.style.display=currentUser.is_admin?'flex':'none'; // flex = topbar-icon-btn + +} + +async function init() { + await Promise.all([loadMarks(),loadNotes(),loadMentions(),loadUserList(),loadCommentCounts()]); + await loadTasksFromServer(); + if(currentUser.is_admin){ + const s=await apiGet('/api/settings'); + if(s) applySettings(s); + loadUsersTable(); + } + const status=await apiGet('/api/status'); + if(status){serverRefreshInterval=status.auto_refresh_interval||300;lastKnownUpdatedAt=status.last_updated;} + startSSE(); + startStatusPolling(); +} + +async function loadMarks() {const d=await apiGet('/api/marks'); if(d) marksCache=d;} +async function loadNotes() {const d=await apiGet('/api/notes'); if(d) notesCache=d;} +async function loadUserList() {const d=await apiGet('/api/users/list'); if(d) knownUsers=d.map(u=>u.toLowerCase());} +async function loadCommentCounts(){const d=await apiGet('/api/comments/counts');if(d&&typeof d==='object')commentCounts=d;} + +async function loadTasksFromServer(silent=false) { + if(!silent&&!allData.length) showLoading(); + const data=await apiGet('/api/tasks'); + if(!data) return; + allData=(data.tasks||[]).map(task=>{ + const lc=extractLastComment(task); + // Восстанавливаем форсированный комментарий из кеша, если updatedat не изменился + if(!lc && forceComment) { + const tid=String(task.id); + const cached=forcedCommentCache.get(tid); + if(cached && cached.updatedat===task.updatedat) return {task,lastComment:cached.lastComment}; + } + return {task,lastComment:lc}; + }); + lastKnownUpdatedAt=data.updated_at; + const el=id=>document.getElementById(id); + if(el('total-loaded')) el('total-loaded').textContent=allData.length; + if(el('total-count')) el('total-count').textContent=allData.length; + if(el('last-update')) el('last-update').textContent=data.updated_at?new Date(data.updated_at).toLocaleTimeString('ru-RU'):'—'; + document.getElementById('update-banner')?.classList.remove('visible'); + onTasksLoaded(); + renderTable(); + if(forceComment) fetchMissingComments(); +} + +async function fetchMissingComments() { + // Только задачи без комментария, для которых кеш отсутствует или устарел + const toFetch=allData.filter(i=>{ + if(i.lastComment) return false; + const tid=String(i.task.id); + const cached=forcedCommentCache.get(tid); + return !cached || cached.updatedat!==i.task.updatedat; + }); + if(!toFetch.length) return; + for(const item of toFetch) { + const tid=String(item.task.id); + const upd=encodeURIComponent(item.task.updatedat||''); + let lc=null; + try { + const res=await apiGet(`/api/tasks/lastcomment/${tid}?upd=${upd}`); + if(res?.entries?.length) { + const entry=res.entries[0]; + const ev=(entry.events?.data||[]).find(e=>COMMENT_TYPES.has(e.type)); + if(ev) lc={ + username:entry.username, userid:entry.userid, + usertype:entry.usertype, eventat:entry.eventat, + text:ev.stringvalue||'', isPrivate:ev.type===55, eventType:ev.type + }; + } + } catch {} + item.lastComment=lc; + // Кешируем результат (даже null) — повторный запрос только при изменении updatedat + forcedCommentCache.set(tid,{updatedat:item.task.updatedat,lastComment:lc}); + } + renderTable(); +} + + +async function loadTasks() { + const btn=document.getElementById('load-btn'); + btn.disabled=true;btn.textContent='⏳ Загрузка...'; + try { + const r=await apiPost('/api/tasks/refresh',{}); + if(r?.error){showToast('❌ '+r.error,'warn');return;} + await Promise.all([loadMarks(),loadNotes()]); + await loadTasksFromServer(true); + } finally {btn.disabled=false;btn.textContent='↻ Обновить';} +} + +async function loadAllTasks() { + const btn=document.getElementById('refresh-all-btn'); + const status=document.getElementById('refresh-all-status'); + if(btn){btn.disabled=true;btn.textContent='⏳ Загружаю все...';} + if(status) status.textContent='Может занять ~30 секунд...'; + try { + const r=await apiPost('/api/tasks/refresh/full',{}); + if(r?.error){showToast('❌ '+r.error,'warn');if(status)status.textContent='Ошибка: '+r.error;return;} + await Promise.all([loadMarks(),loadNotes()]); + await loadTasksFromServer(true); + if(status) status.textContent=`✓ Загружено ${r.count} заявок`; + showToast(`✓ Загружено ${r.count} заявок`,'success'); + } finally {if(btn){btn.disabled=false;btn.textContent='🔄 Обновить ВСЕ заявки';}} +} + +async function reloadData() { + await Promise.all([loadMarks(),loadNotes()]); + await loadTasksFromServer(true); +} + +function startStatusPolling() { + if(statusPollTimer) clearInterval(statusPollTimer); + statusPollTimer=setInterval(async()=>{ + const s=await apiGet('/api/status'); + if(s?.last_updated && s.last_updated!==lastKnownUpdatedAt) { + lastKnownUpdatedAt=s.last_updated; + await Promise.all([loadMarks(),loadNotes()]); + await loadTasksFromServer(true); + } + },30000); +} + +function applySettings(s) { + const f=id=>document.getElementById(id); + if(f('status-ids')) f('status-ids').value=s.status_ids||''; + if(f('event-types')) f('event-types').value=s.event_types||''; + if(f('server-refresh-interval'))f('server-refresh-interval').value=s.auto_refresh_interval||'300'; + if(f('apikey-status')) f('apikey-status').textContent=s.api_key_set?'(ключ задан ✓)':'(не задан!)'; +} +async function saveSettings() { + const body={status_ids:document.getElementById('status-ids').value.trim(),event_types:document.getElementById('event-types').value.trim(),auto_refresh_interval:document.getElementById('server-refresh-interval').value}; + const apiKey=document.getElementById('api-key').value.trim(); + if(apiKey) body.api_key=apiKey; + const r=await apiPost('/api/settings',body); + if(r?.ok){applySettings(body);document.getElementById('api-key').value='';showToast('✓ Настройки сохранены','success');} + else showToast('❌ '+(r?.error||'Ошибка'),'warn'); +} + +async function createUser() { + const username=document.getElementById('new-user-login').value.trim(); + const password=document.getElementById('new-user-pass').value; + const msgEl =document.getElementById('user-msg'); + const r=await apiPost('/api/auth/register',{username,password}); + if(r?.ok){ + msgEl.style.color='var(--green)';msgEl.textContent=`✓ Пользователь "${username}" создан`; + document.getElementById('new-user-login').value=''; + document.getElementById('new-user-pass').value=''; + loadUsersTable();loadUserList(); + } else {msgEl.style.color='var(--red)';msgEl.textContent=r?.error||'Ошибка';} +} +async function loadUsersTable() { + const users=await apiGet('/api/users'); + if(!users) return; + const tbody=document.getElementById('users-tbody'); + if(!tbody) return; + tbody.innerHTML=users.map(u=>` + + ${esc(u.username)} + ${u.created_at?new Date(u.created_at).toLocaleDateString('ru-RU'):'—'} + + + ${u.username!=='admin'?``:''} + `).join(''); +} +async function changePassword(id) { + const inp=document.getElementById(`pass-${id}`); + if(!inp||inp.value.length<6){showToast('Пароль минимум 6 символов','warn');return;} + const r=await apiPost(`/api/users/${id}/password`,{password:inp.value},'PUT'); + if(r?.ok){inp.value='';showToast('✓ Пароль изменён','success');} + else showToast(r?.error||'Ошибка','warn'); +} +async function deleteUser(id,username) { + if(!confirm(`Удалить пользователя "${username}"?`)) return; + const r=await apiDelete(`/api/users/${id}`); + if(r?.ok){loadUsersTable();loadUserList();} + else showToast(r?.error||'Ошибка','warn'); +} + +window.addEventListener('DOMContentLoaded',()=>{ + if(authToken){ + try{ + const p=JSON.parse(atob(authToken.split('.')[1])); + if(p.exp*1000>Date.now()){ + currentUser={username:p.username,is_admin:p.username==='admin'}; + showApp();init();return; + } + }catch{} + localStorage.removeItem('intradesk_token');authToken=null; + } + document.getElementById('login-overlay').classList.remove('hidden'); +}); diff --git a/public/js/comments.js b/public/js/comments.js new file mode 100644 index 0000000..2267658 --- /dev/null +++ b/public/js/comments.js @@ -0,0 +1,94 @@ +async function loadCommentsForTask(taskId) { + const rows=await apiGet(`/api/comments/${taskId}`); + if(!rows) return; + commentsCache[taskId]=rows; + if(typeof commentCounts==='object') commentCounts[taskId]=rows.length; + if(currentModalIdx!==null&&String(allData[currentModalIdx].task.id)===taskId){ + const dbody=document.getElementById('detail-body'); + if(dbody&¤tTab==='comments') dbody.innerHTML=renderCommentsHtml(taskId,rows); + // Update tab label + const tab=document.getElementById('dtab-comments'); + if(tab) tab.textContent=`Наши комментарии${rows.length?` (${rows.length})`:''}`; + } +} + +function renderCommentsTab(){ + if(currentModalIdx===null) return; + const taskId=String(allData[currentModalIdx].task.id); + const comments=commentsCache[taskId]||[]; + const dbody=document.getElementById('detail-body'); + if(dbody) dbody.innerHTML=renderCommentsHtml(taskId,comments); +} + +function startEditComment(id, text) { + const ea=document.getElementById(`cmt-edit-${id}`); + const ta=document.getElementById(`cmt-edit-ta-${id}`); + const td=document.getElementById(`cmt-text-${id}`); + if(!ea||!ta) return; + ta.value=text.replace(/\\n/g,'\n'); + ea.style.display='flex';td.style.display='none';ta.focus(); +} +function cancelEditComment(id) { + const ea=document.getElementById(`cmt-edit-${id}`); + const td=document.getElementById(`cmt-text-${id}`); + if(ea) ea.style.display='none'; + if(td) td.style.display=''; +} +async function saveEditComment(id, taskId) { + const ta=document.getElementById(`cmt-edit-ta-${id}`); + const text=ta?.value?.trim(); + if(!text) return; + const r=await apiPost(`/api/comments/${id}`,{text},'PUT'); + if(r?.id){ + commentsCache[taskId]=(commentsCache[taskId]||[]).map(c=>c.id===id?r:c); + renderCommentsTab(); + } else showToast('❌ '+(r?.error||'Ошибка'),'warn'); +} +async function deleteComment(id, taskId) { + if(!confirm('Удалить комментарий?')) return; + const r=await apiDelete(`/api/comments/${id}`); + if(r?.ok){ + commentsCache[taskId]=(commentsCache[taskId]||[]).filter(c=>c.id!==id); + if(typeof commentCounts==='object') commentCounts[taskId]=(commentsCache[taskId]||[]).length; + renderCommentsTab(); + renderTable(); + } else showToast('❌ '+(r?.error||'Ошибка'),'warn'); +} + +// @autocomplete +function onCommentInput(ta) { + const val=ta.value,pos=ta.selectionStart; + const before=val.slice(0,pos); + const match=before.match(/@([\w\u0400-\u04ff]*)$/); + if(match){ + acState={active:true,start:before.length-match[0].length,query:match[1].toLowerCase()}; + showAutocomplete(ta,acState.query); + } else {acState.active=false;hideAutocomplete();} +} +function showAutocomplete(ta,query) { + const popup=document.getElementById('autocomplete-popup'); + const matches=knownUsers.filter(u=>u.startsWith(query)&&u!==currentUser.username.toLowerCase()); + if(!matches.length){hideAutocomplete();return;} + popup.innerHTML=matches.slice(0,6).map(u=>`
${esc(u)}
`).join(''); + popup.style.display='block'; +} +function hideAutocomplete(){const p=document.getElementById('autocomplete-popup');if(p)p.style.display='none';acState.active=false;} +function handleAutocompleteKey(e){ + if(!acState.active) return; + const popup=document.getElementById('autocomplete-popup'); + if(!popup||popup.style.display==='none') return; + const items=popup.querySelectorAll('.autocomplete-item'); + let sel=[...items].findIndex(i=>i.classList.contains('selected')); + if(e.key==='ArrowDown'){e.preventDefault();if(sel=0)items[sel].classList.remove('selected');items[sel+1].classList.add('selected');}} + else if(e.key==='ArrowUp'){e.preventDefault();if(sel>0){items[sel].classList.remove('selected');items[sel-1].classList.add('selected');}} + else if(e.key==='Tab'||e.key==='Enter'){const ch=popup.querySelector('.selected')||items[0];if(ch){e.preventDefault();insertMention(ch.dataset.user);}} + else if(e.key==='Escape') hideAutocomplete(); +} +function insertMention(username) { + const ta=document.getElementById('composer-ta'); + if(!ta) return; + const val=ta.value; + ta.value=val.slice(0,acState.start)+'@'+username+' '+val.slice(ta.selectionStart); + ta.selectionStart=ta.selectionEnd=acState.start+username.length+2; + hideAutocomplete();ta.focus(); +} diff --git a/public/js/diff.js b/public/js/diff.js new file mode 100644 index 0000000..5ae96b1 --- /dev/null +++ b/public/js/diff.js @@ -0,0 +1,157 @@ +// ═══════════════════════════════════════════════════════════════════ +// diff.js — Безопасное diff-обновление списка заявок +// +// Подключить в index.html ПОСЛЕ state.js и keyboard.js: +// +// +// Заменяет прямое присваивание allData = [...] в auth.js. +// Вместо loadTasksFromServer присваивает данные через applyTasksDiff(). +// ═══════════════════════════════════════════════════════════════════ + +// ── Утилита: Map задач по id ─────────────────────────────────────── +function buildTaskMap(dataArr) { + const map = new Map(); + for (const item of dataArr) { + map.set(String(item.task.id), item); + } + return map; +} + +/** + * Применяет новые данные к allData без потери состояния интерфейса. + * + * @param {Array} newRawTasks — массив tasks с сервера (до map/extractLastComment) + * @param {boolean} silent — если true, тост об изменениях не показывается + * @returns {{ added: number, changed: number, removed: number }} + */ +function applyTasksDiff(newRawTasks, silent = false) { + // Собираем новый массив в том же формате, что и allData + const newData = (newRawTasks || []).map(task => { + const lc = extractLastComment(task); + if (!lc && typeof forceComment !== 'undefined' && forceComment) { + const tid = String(task.id); + const cached = forcedCommentCache.get(tid); + if (cached && cached.updatedat === task.updatedat) { + return { task, lastComment: cached.lastComment }; + } + } + return { task, lastComment: lc }; + }); + + // ── Diff ─────────────────────────────────────────────────────────── + const oldMap = buildTaskMap(allData); + const newMap = buildTaskMap(newData); + + let added = 0, changed = 0, removed = 0; + + for (const [id, newItem] of newMap) { + if (!oldMap.has(id)) { + added++; + } else { + const old = oldMap.get(id); + if (old.task.updatedat !== newItem.task.updatedat) { + changed++; + } + } + } + for (const id of oldMap.keys()) { + if (!newMap.has(id)) removed++; + } + + // ── Сохраняем позицию активной строки ───────────────────────────── + // activeRowIdx указывает на позицию в отфильтрованном/отображаемом списке. + // После обновления allData порядок может измениться → ищем задачу по id. + let preservedTaskId = null; + if (typeof activeRowIdx !== 'undefined' && activeRowIdx !== null && allData[activeRowIdx]) { + preservedTaskId = String(allData[activeRowIdx].task.id); + } + + // Аналогично для открытой боковой панели + let preservedModalTaskId = null; + if (typeof currentModalIdx !== 'undefined' && currentModalIdx !== null && allData[currentModalIdx]) { + preservedModalTaskId = String(allData[currentModalIdx].task.id); + } + + // ── Применяем новый массив ───────────────────────────────────────── + allData = newData; + + // ── Восстанавливаем activeRowIdx ─────────────────────────────────── + if (preservedTaskId !== null) { + const newIdx = allData.findIndex(i => String(i.task.id) === preservedTaskId); + if (newIdx !== -1) { + activeRowIdx = newIdx; + } else { + // Задача удалена — сдвигаем курсор к ближайшей + activeRowIdx = Math.min(activeRowIdx, allData.length - 1); + if (activeRowIdx < 0) activeRowIdx = null; + } + } + + // ── Восстанавливаем currentModalIdx ─────────────────────────────── + if (preservedModalTaskId !== null) { + const newModalIdx = allData.findIndex(i => String(i.task.id) === preservedModalTaskId); + if (newModalIdx !== -1) { + currentModalIdx = newModalIdx; + } else { + // Задача из открытого меню исчезла — закрываем меню + currentModalIdx = null; + const dc = document.getElementById('detail-col'); + if (dc) dc.classList.add('hidden'); + } + } + + // ── Показать уведомление об изменениях ──────────────────────────── + if (!silent && (added || changed || removed)) { + const parts = []; + if (added) parts.push(`+${added} новых`); + if (changed) parts.push(`~${changed} изм.`); + if (removed) parts.push(`−${removed} удалено`); + if (typeof showToast === 'function') { + showToast(`📋 Обновление: ${parts.join(', ')}`, 'info', 3500); + } + } + + return { added, changed, removed }; +} + +// ═══════════════════════════════════════════════════════════════════ +// Патч loadTasksFromServer: вместо прямого allData = [...] — applyTasksDiff +// ═══════════════════════════════════════════════════════════════════ +(function patchLoadTasksFromServer() { + const _orig = window.loadTasksFromServer; + if (typeof _orig !== 'function') { + document.addEventListener('DOMContentLoaded', patchLoadTasksFromServer); + return; + } + + window.loadTasksFromServer = async function (silent = false) { + if (!silent && !allData.length) { + if (typeof showLoading === 'function') showLoading(); + } + + const data = await apiGet('/api/tasks'); + if (!data) return; + + const { added, changed, removed } = applyTasksDiff(data.tasks || [], silent); + + // Обновляем мета-данные (те же строки, что были в оригинале) + lastKnownUpdatedAt = data.updated_at; + const el = id => document.getElementById(id); + if (el('total-loaded')) el('total-loaded').textContent = allData.length; + if (el('total-count')) el('total-count').textContent = allData.length; + if (el('last-update')) el('last-update').textContent = + data.updated_at ? new Date(data.updated_at).toLocaleTimeString('ru-RU') : '—'; + document.getElementById('update-banner')?.classList.remove('visible'); + + if (typeof onTasksLoaded === 'function') onTasksLoaded(); + if (typeof renderTable === 'function') renderTable(); + if (typeof forceComment !== 'undefined' && forceComment) { + if (typeof fetchMissingComments === 'function') fetchMissingComments(); + } + + // Если открыта боковая панель — обновить её содержимое (данные задачи могли измениться) + if (currentModalIdx !== null && typeof renderSidePanel === 'function') { + renderSidePanel(currentModalIdx); + } + }; +})(); diff --git a/public/js/export.js b/public/js/export.js new file mode 100644 index 0000000..a53da68 --- /dev/null +++ b/public/js/export.js @@ -0,0 +1,118 @@ +// ── Export ──────────────────────────────────────────────────────── +function buildExportData() { + const source=selectedRows.size>0?[...selectedRows].map(idx=>allData[idx]).filter(Boolean):allData; + return source.map(({task})=>{ + const id=String(task.id), n=notesCache[id]; + const markFields={}; + MARKS_CONFIG.forEach(m=>{ + markFields[m.key]=isMark(id,m.key); + markFields[`${m.key}_by`]=getMarkBy(id,m.key)||''; + }); + return { + tasknumber:task.tasknumber||task.id, name:task.name||'', + note:n?.text||'', note_by:n?.by||'', note_updated:n?.at?fmtDate(new Date(n.at)):'', + ...markFields, + comments_count:getCommentCount(id), + }; + }); +} + +function getExportConfig() { + const groups = (exportFieldGroups instanceof Set && exportFieldGroups.size) + ? exportFieldGroups + : new Set(['note','status','comments']); + const mode = exportStatusMode === 'B' ? 'B' : 'A'; + return { groups, mode }; +} + +function pickStatus(r) { + const m=MARKS_CONFIG.find(m=>r[m.key]); + return m?{label:m.exportLabel,by:r[`${m.key}_by`]}:{label:'Без статуса',by:''}; +} + +function dl(content, filename, mime){ + const a=document.createElement('a'); + a.href=URL.createObjectURL(new Blob([content],{type:mime})); + a.download=filename; a.click(); +} + +function exportCSV(){ + const d=buildExportData(); + const { groups, mode } = getExportConfig(); + + const h=['Номер','Название']; + if (groups.has('note')) { + h.push('Заметка','Заметка — кто','Заметка — когда'); + } + if (groups.has('status')) { + if (mode==='A') { + MARKS_CONFIG.forEach(m=>h.push(m.exportLabel,`${m.exportLabel} — кто`)); + } else { + h.push('Статус','Статус — кто'); + } + } + if (groups.has('comments')) { + h.push('Комментариев'); + } + + const rows=d.map(r=>{ + const row=[ + r.tasknumber, + r.id, + `"${r.name.replace(/"/g,'""')}"` + ]; + if (groups.has('note')) { + row.push( + `"${r.note.replace(/"/g,'""')}"`, + r.note_by, + r.note_updated + ); + } + if (groups.has('status')) { + if (mode==='A') { + MARKS_CONFIG.forEach(m=>row.push(r[m.key]?'Да':'Нет', r[`${m.key}_by`])); + } else { + const s = pickStatus(r); + row.push(s.label, s.by); + } + } + if (groups.has('comments')) { + row.push(String(r.comments_count)); + } + return row.join(';'); + }); + dl('\uFEFF'+[h.join(';'),...rows].join('\n'),`intradesk_${datestamp()}.csv`,'text/csv;charset=utf-8'); +} + +function exportJSON(){ + dl(JSON.stringify(buildExportData(),null,2),`intradesk_${datestamp()}.json`,'application/json'); +} + +function exportTXT(){ + const { groups, mode } = getExportConfig(); + const lines=buildExportData().map(r=>{ + const parts=[`#${r.tasknumber} — ${r.name}`]; + + if (groups.has('status')) { + if (mode==='A') { + const flags=MARKS_CONFIG + .filter(m=>r[m.key]) + .map(m=>`${m.exportLabel.toUpperCase()} (${r[`${m.key}_by`]})`) + .join(', '); + if (flags) parts.push(` Статус: ${flags}`); + } else { + const s = pickStatus(r); + parts.push(` Статус: ${s.label}${s.by?` (${s.by})`:''}`); + } + } + + if (groups.has('note') && r.note) { + parts.push(` Заметка (${r.note_by}): ${r.note}`); + } + if (groups.has('comments') && r.comments_count) { + parts.push(` Наших комментариев: ${r.comments_count}`); + } + return parts.join('\n'); + }); + dl(lines.join('\n\n'),`intradesk_${datestamp()}.txt`,'text/plain;charset=utf-8'); +} diff --git a/public/js/keyboard.js b/public/js/keyboard.js new file mode 100644 index 0000000..23cface --- /dev/null +++ b/public/js/keyboard.js @@ -0,0 +1,305 @@ +// ═══════════════════════════════════════════════════════════════════ +// keyboard.js — Централизованный менеджер клавиатурных шорткатов +// Подключить в index.html ПОСЛЕ всех остальных скриптов: +// +// ═══════════════════════════════════════════════════════════════════ + +// ── Константы навигации ──────────────────────────────────────────── +const KB_PAGE_SIZE = 10; // строк на PageUp/PageDown + +// ── Индекс «активной» строки (курсор списка, отдельно от открытого меню) ── +// activeRowIdx — выделенная строка стрелками; currentModalIdx — открытое боковое меню. +// Они могут совпадать или расходиться: курсор остаётся на месте при закрытии меню. +let activeRowIdx = null; // null = курсор не установлен + +// ═══════════════════════════════════════════════════════════════════ +// РАЗДЕЛ 1: Проверка «безопасности» фокуса +// Цель: не перехватывать клавиши, когда юзер что-то вводит в поля. +// ═══════════════════════════════════════════════════════════════════ +function kbIsInputFocused() { + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName.toLowerCase(); + // Любой редактируемый элемент → шорткаты отключены + if (tag === 'input' || tag === 'textarea' || tag === 'select') return true; + if (el.isContentEditable) return true; + // ace/codemirror или любой кастомный редактор с role="textbox" + if (el.getAttribute('role') === 'textbox') return true; + return false; +} + +// ═══════════════════════════════════════════════════════════════════ +// РАЗДЕЛ 2: Навигация по списку +// ═══════════════════════════════════════════════════════════════════ + +/** Возвращает число строк в текущем отфильтрованном виде */ +function kbListLength() { + return (typeof filteredData !== 'undefined' ? filteredData : allData).length; +} + +/** + * activeRowIdx — позиция в filteredData (0-based). + * Конвертируем в allData-индекс для openModal. + */ +function kbFilteredToAllIdx(filteredIdx) { + const fd = typeof filteredData !== 'undefined' ? filteredData : allData; + const item = fd[filteredIdx]; + if (!item) return null; + return allData.indexOf(item); +} + +/** Установить активный индекс (в filteredData) и прокрутить строку */ +function kbSetActive(idx) { + const len = kbListLength(); + if (len === 0) return; + idx = Math.max(0, Math.min(idx, len - 1)); + activeRowIdx = idx; + const allIdx = kbFilteredToAllIdx(idx); + kbHighlightRow(allIdx); + kbScrollRowIntoView(allIdx); + + // Если боковая панель открыта — переключаем её на новую заявку + if (currentModalIdx !== null && allIdx !== null) { + openModal(allIdx); + } +} + +/** Визуальное выделение строки по allData-индексу (data-idx в DOM) */ +function kbHighlightRow(allIdx) { + document.querySelectorAll('.tasks-table tbody tr').forEach(tr => { + tr.classList.remove('kb-cursor'); + }); + const row = document.querySelector(`.tasks-table tbody tr[data-idx="${allIdx}"]`); + if (row) row.classList.add('kb-cursor'); +} + +/** Прокрутить строку (по allData-индексу) в видимую область */ +function kbScrollRowIntoView(allIdx) { + const row = document.querySelector(`.tasks-table tbody tr[data-idx="${allIdx}"]`); + if (!row) return; + row.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); +} + +// ═══════════════════════════════════════════════════════════════════ +// РАЗДЕЛ 3: Управление боковым меню (ArrowRight / ArrowLeft) +// ═══════════════════════════════════════════════════════════════════ + +function kbHandleArrowRight() { + if (currentModalIdx === null) { + // Меню не открыто — открываем для активной строки + const filteredIdx = activeRowIdx ?? 0; + kbSetActive(filteredIdx); // убедимся что строка выделена + const allIdx = kbFilteredToAllIdx(filteredIdx); + if (allIdx !== null) openModal(allIdx); + } else { + // Меню уже открыто — переключаем на вкладку "Загрузить заявку" + if (currentTab !== 'history') { + switchTab('history'); + } + } +} + +function kbHandleArrowLeft() { + if (currentModalIdx === null) return; // меню не открыто — нечего делать + + if (currentTab === 'history') { + // Открыта дополнительная вкладка → вернуть на основную + switchTab('last'); + } else { + // Основная информация → закрыть меню + closeModal(); + } +} + +// ═══════════════════════════════════════════════════════════════════ +// РАЗДЕЛ 4: Быстрая смена статуса клавишами 1–6 +// ═══════════════════════════════════════════════════════════════════ + +// Порядок статусов определяется полем kbShortcut в MARKS_CONFIG (statuses.js) +const KB_STATUS_MAP = Object.fromEntries( + MARKS_CONFIG.filter(m => m.kbShortcut).map(m => [m.kbShortcut, m.key]) +); + +async function kbSetStatus(digit) { + if (currentModalIdx === null) return; // меню должно быть открыто + const markType = KB_STATUS_MAP[digit]; + if (!markType) return; + + const item = allData[currentModalIdx]; + if (!item) return; + const tid = String(item.task.id); + + // Показать мгновенную визуальную индикацию + kbShowStatusFlash(digit, markType); + + // Вызываем существующий setMark (optimistic update + запрос к серверу) + try { + await setMark(tid, markType, null); + } catch (err) { + // setMark делает optimistic update сам; ошибки сервера — через showToast внутри setMark + console.warn('[KB] setMark error:', err); + } +} + +/** Мигающий тост-индикатор при смене статуса клавишей */ +function kbShowStatusFlash(digit, markType) { + const cfg = MARKS_CONFIG.find(m => m.key === markType); + + // Подсветить кнопку в боковом меню + const btn = document.querySelector(`.sp-marks .mark-btn[data-mark="${markType}"]`); + if (btn) { + btn.classList.add('kb-flash'); + setTimeout(() => btn.classList.remove('kb-flash'), 600); + } + + // Тост (если showToast уже есть в проекте — через sse.js) + if (typeof showToast === 'function') { + const label = cfg ? `${cfg.kbShortcut} · ${cfg.label}` : markType; + showToast(`⌨ ${label}`, 'mark', 2000); + } +} + +// ═══════════════════════════════════════════════════════════════════ +// РАЗДЕЛ 5: Центральный обработчик клавиатуры +// ═══════════════════════════════════════════════════════════════════ + +function kbHandleKeydown(e) { + // ── Игнорируем, если фокус в поле ввода ────────────────────────── + if (kbIsInputFocused()) return; + + // ── Игнорируем с модификаторами Ctrl/Cmd/Alt (не ломаем браузерные шорткаты) ── + if (e.ctrlKey || e.metaKey || e.altKey) return; + + const len = kbListLength(); + if (len === 0) return; + + // Если activeRowIdx не установлен — начинаем с 0 + const cur = activeRowIdx ?? -1; + + switch (e.key) { + // ── Навигация ─────────────────────────────────────────────────── + case 'ArrowUp': { + e.preventDefault(); + kbSetActive(cur <= 0 ? 0 : cur - 1); + break; + } + case 'ArrowDown': { + e.preventDefault(); + kbSetActive(cur < 0 ? 0 : cur + 1); + break; + } + case 'PageUp': { + e.preventDefault(); + kbSetActive(Math.max(0, cur - KB_PAGE_SIZE)); + break; + } + case 'PageDown': { + e.preventDefault(); + kbSetActive(cur < 0 ? KB_PAGE_SIZE : cur + KB_PAGE_SIZE); + break; + } + case 'Home': { + e.preventDefault(); + kbSetActive(0); + break; + } + case 'End': { + e.preventDefault(); + kbSetActive(len - 1); + break; + } + case 'Enter': { + // Enter открывает/закрывает боковое меню для текущей строки + if (cur >= 0) { + e.preventDefault(); + const allIdx = kbFilteredToAllIdx(cur); + if (currentModalIdx === allIdx) { + closeModal(); + } else if (allIdx !== null) { + openModal(allIdx); + } + } + break; + } + + // ── Боковое меню ──────────────────────────────────────────────── + case 'ArrowRight': { + e.preventDefault(); + kbHandleArrowRight(); + break; + } + case 'ArrowLeft': { + e.preventDefault(); + kbHandleArrowLeft(); + break; + } + + // ── Быстрая смена статуса (только если меню открыто) ─────────── + case '1': case '2': case '3': + case '4': case '5': case '6': { + if (currentModalIdx !== null) { + e.preventDefault(); + kbSetStatus(e.key); + } + break; + } + + // ── Escape: закрыть меню, снять курсор ───────────────────────── + // (Escape уже обрабатывается в ui.js через closeModal, но добавим снятие курсора) + case 'Escape': { + if (currentModalIdx !== null) { + // Меню закроет ui.js сам, но сбрасываем курсор + // activeRowIdx оставляем — юзер видит, где был + } + break; + } + + default: + return; // не наша клавиша — не вызываем preventDefault + } +} + +// ── Единственный глобальный слушатель ───────────────────────────── +// Capture: false (bubble) — достаточно, т.к. у нас нет stopPropagation в строках таблицы +document.addEventListener('keydown', kbHandleKeydown, false); + +// ═══════════════════════════════════════════════════════════════════ +// РАЗДЕЛ 6: Синхронизация cursor при рендере таблицы +// Патчим renderTable() так, чтобы после рендера курсор восстанавливался. +// ═══════════════════════════════════════════════════════════════════ +(function patchRenderTable() { + const _orig = window.renderTable; + if (typeof _orig !== 'function') { + // renderTable ещё не загружена — повторим при DOMContentLoaded + document.addEventListener('DOMContentLoaded', patchRenderTable); + return; + } + window.renderTable = function (...args) { + _orig.apply(this, args); + // После рендера восстановить подсветку курсора + if (activeRowIdx !== null) { + const allIdx = kbFilteredToAllIdx(activeRowIdx); + if (allIdx !== null) kbHighlightRow(allIdx); + } + }; +})(); + +// ═══════════════════════════════════════════════════════════════════ +// РАЗДЕЛ 7: Сброс activeRowIdx при клике мышью на строку таблицы +// Чтобы курсор и мышь не расходились. +// ═══════════════════════════════════════════════════════════════════ +document.addEventListener('click', e => { + const row = e.target.closest('.tasks-table tbody tr[data-idx]'); + if (!row) return; + const allIdx = parseInt(row.dataset.idx, 10); + if (!isNaN(allIdx)) { + // Find position in filteredData + const fd = typeof filteredData !== 'undefined' ? filteredData : allData; + const item = allData[allIdx]; + const filteredIdx = item ? fd.indexOf(item) : -1; + if (filteredIdx >= 0) { + activeRowIdx = filteredIdx; + } + kbHighlightRow(allIdx); + } +}, false); diff --git a/public/js/mentions.js b/public/js/mentions.js new file mode 100644 index 0000000..40a876e --- /dev/null +++ b/public/js/mentions.js @@ -0,0 +1,60 @@ +async function loadMentions() { + const d=await apiGet('/api/mentions'); + if(!d) return; + mentionsData = d.mentions||[]; + mentionUnread = d.unread||0; + renderMentionsBell(); +} + +function renderMentionsBell() { + const badge=document.getElementById('notif-badge'); + const btn =document.getElementById('notif-btn'); + if(!badge||!btn) return; + if(mentionUnread>0){badge.textContent=mentionUnread;badge.style.display='block';} + else badge.style.display='none'; +} + +function toggleMentionsDropdown() { + const dd=document.getElementById('mentions-dropdown'); + if(!dd) return; + const isOpen=dd.classList.contains('open'); + dd.classList.toggle('open',!isOpen); + if(!isOpen) renderMentionsList(); +} +document.addEventListener('click',e=>{ + const dd=document.getElementById('mentions-dropdown'); + if(dd&&!document.getElementById('notif-btn')?.contains(e.target)&&!dd.contains(e.target)) dd.classList.remove('open'); +}); + +function renderMentionsList() { + const el=document.getElementById('mentions-list'); + if(!el) return; + if(!mentionsData.length){el.innerHTML='
Нет упоминаний
';return;} + el.innerHTML=mentionsData.slice(0,10).map(m=>{ + const unread=!m.read_at; + const task=allData.find(d=>String(d.task.id)===String(m.task_id)); + const num=task?(task.task.tasknumber||task.task.id):m.task_id; + return`
+ + #${num} + ↩ ${esc(m.mentioned_by)} + ${timeAgo(new Date(m.created_at))} +
`; + }).join(''); +} + +async function jumpToMention(taskId) { + document.getElementById('mentions-dropdown')?.classList.remove('open'); + STATE_CHIP_KEYS.forEach(k=>stateChips[k]=true); + ['chip-reply','chip-wait','chip-resolved','chip-hidden','chip-unmarked'].forEach(id=>document.getElementById(id)?.classList.remove('inactive')); + document.getElementById('chip-all')?.classList.remove('off'); + const idx=allData.findIndex(d=>String(d.task.id)===String(taskId)); + if(idx!==-1){ + currentPage=1; renderTable(); + setTimeout(()=>{ + const row=document.querySelector(`tbody tr[data-idx="${idx}"]`); + if(row) row.scrollIntoView({behavior:'smooth',block:'center'}); + openDetail(idx); + },100); + } +} diff --git a/public/js/notes.js b/public/js/notes.js new file mode 100644 index 0000000..297b5b2 --- /dev/null +++ b/public/js/notes.js @@ -0,0 +1,60 @@ +function getNote(tid) {return notesCache[tid]?.text||'';} +function getNoteBy(tid) {return notesCache[tid]?.by||null;} +function getNoteAt(tid) {return notesCache[tid]?.at||null;} + +// Status marks are mutually exclusive — only one at a time +const STATUS_MARKS = MARKS_CONFIG.map(m => m.key); + +async function setMark(taskId, markType, e) { + e&&e.stopPropagation(); + const tid = String(taskId); + if(!marksCache[tid]) marksCache[tid]={}; + const now = new Date().toISOString(); + const wasActive = marksCache[tid][markType]?.active; + + // Clear all status marks first + STATUS_MARKS.forEach(m=>{ + marksCache[tid][m]={...(marksCache[tid][m]||{}),active:false}; + }); + + // Toggle: if was active → unset; else → set this one + if(!wasActive){ + marksCache[tid][markType]={active:true,by:currentUser.username,at:now}; + } + + renderTable(); + // Patch side panel mark buttons in-place without full re-render + if(currentModalIdx!==null){ + const btns=document.querySelectorAll('.sp-marks .mark-btn[data-mark]'); + if(btns.length){ + const tid2=String(allData[currentModalIdx].task.id); + const states = Object.fromEntries(MARKS_CONFIG.map(m => [m.key, isMark(tid2, m.key)])); + btns.forEach(btn=>{ + btn.classList.toggle('mbtn-active', !!states[btn.dataset.mark]); + }); + // Also update sp-header badge + const badge=document.querySelector('.sp-header .sp-status-badge'); + if(badge) badge.outerHTML=internalStatusBadge(tid2); + } else { + renderSidePanel(currentModalIdx); + } + } + + // Один запрос — сервер сам сбрасывает остальные статусы и шлёт одно SSE + const newVal = !wasActive; + apiPost('/api/marks',{task_id:tid,mark_type:markType,value:newVal,clear_others:newVal}); +} + +function toggleMark(taskId, markType, e){ return setMark(taskId, markType, e); } +// Именованные алиасы — генерируются из MARKS_CONFIG, плюс ручные для совместимости +MARKS_CONFIG.forEach(m => { + const name = 'toggle' + m.key.replace(/_([a-z])/g, (_, c) => c.toUpperCase()).replace(/^(.)/, c => c.toUpperCase()); + window[name] = (id, e) => setMark(id, m.key, e); +}); +// Явные алиасы для обратной совместимости с inline-атрибутами HTML +function toggleResolved(id,e) { return setMark(id,'resolved',e); } +function toggleNeedsReply(id,e) { return setMark(id,'needs_reply',e); } +function toggleWaitClient(id,e) { return setMark(id,'wait_client',e); } +function toggleVyezdnye(id,e) { return setMark(id,'vyezdnye',e); } +function toggleOborudovanie(id,e) { return setMark(id,'oborudovanie',e); } +function toggleHidden(id,e) { return setMark(id,'hidden',e); } diff --git a/public/js/sse.js b/public/js/sse.js new file mode 100644 index 0000000..e14af21 --- /dev/null +++ b/public/js/sse.js @@ -0,0 +1,115 @@ +function showToast(text, type='info', duration=4000) { + const wrap=document.getElementById('toast-wrap'); + const el=document.createElement('div'); + const cls={mark:'',note:'success',warn:'warn',reload:'info','mention-t':'mention',success:'success',info:'info',warn:'warn',mention:'mention'}[type]||''; + el.className=`toast ${cls}`; el.textContent=text; + wrap.appendChild(el); + setTimeout(()=>el.remove(),duration); +} + +function playMentionSound() { + try { + const ctx=new(window.AudioContext||window.webkitAudioContext)(); + const o=ctx.createOscillator(),g=ctx.createGain(); + o.connect(g);g.connect(ctx.destination); + o.type='sine';o.frequency.value=880; + g.gain.setValueAtTime(0.25,ctx.currentTime); + g.gain.exponentialRampToValueAtTime(0.001,ctx.currentTime+0.35); + o.start(ctx.currentTime);o.stop(ctx.currentTime+0.35); + } catch {} +} + +let sseSource=null; +let sseReconnectTimer=null; +function startSSE() { + if(sseReconnectTimer){clearTimeout(sseReconnectTimer);sseReconnectTimer=null;} + if(sseSource){sseSource.close();sseSource=null;} + if(!authToken) return; + sseSource=new EventSource(`/api/events?token=${encodeURIComponent(authToken)}`); + + sseSource.addEventListener('mark',e=>{ + const d=JSON.parse(e.data); + if(!marksCache[d.task_id]) marksCache[d.task_id]={}; + marksCache[d.task_id][d.mark_type]={active:d.value,by:d.by,at:d.at}; + // Сбрасываем все метки, которые сервер очистил в том же запросе + (d.cleared_marks||[]).forEach(m=>{ + marksCache[d.task_id][m]={...(marksCache[d.task_id][m]||{}),active:false}; + }); + // Тост только для действий других пользователей + if(d.by!==currentUser?.username){ + const markCfg=MARKS_CONFIG.find(m=>m.key===d.mark_type); + const label=markCfg?markCfg.sseLabel:d.mark_type; + showToast(`👤 ${d.by}: ${d.value?`✓ «${label}»`:`снял «${label}»`} #${taskNumFromId(d.task_id)}`,'info'); + } + renderTable(); + refreshDetailIfOpen(d.task_id); + }); + + sseSource.addEventListener('note',e=>{ + const d=JSON.parse(e.data); + if(d.text) notesCache[d.task_id]={text:d.text,by:d.by,at:d.at}; + else delete notesCache[d.task_id]; + // Тост только для действий других пользователей + if(d.by!==currentUser?.username){ + showToast(`📝 ${d.by}: ${d.text?'обновил заметку':'удалил заметку'} #${taskNumFromId(d.task_id)}`,'success'); + } + renderTable(); + refreshDetailIfOpen(d.task_id); + }); + + sseSource.addEventListener('comment_new',e=>{ + const d=JSON.parse(e.data); + const tid=String(d.comment.task_id); + if(!commentsCache[tid]) commentsCache[tid]=[]; + commentsCache[tid].push(d.comment); + if(typeof commentCounts==='object') commentCounts[tid]=(commentsCache[tid]||[]).length; + showToast(`💬 ${d.comment.author}: комментарий к #${d.task_number}`,'success'); + renderTable(); + refreshDetailIfOpen(tid); + }); + + sseSource.addEventListener('comment_edit',e=>{ + const d=JSON.parse(e.data); + const tid=String(d.comment.task_id); + if(commentsCache[tid]) commentsCache[tid]=commentsCache[tid].map(c=>c.id===d.comment.id?d.comment:c); + refreshDetailIfOpen(tid); + }); + + sseSource.addEventListener('comment_delete',e=>{ + const d=JSON.parse(e.data); + const tid=String(d.task_id); + if(commentsCache[tid]) commentsCache[tid]=commentsCache[tid].filter(c=>c.id!==d.comment_id); + if(typeof commentCounts==='object') commentCounts[tid]=(commentsCache[tid]||[]).length; + renderTable(); + refreshDetailIfOpen(tid); + }); + + sseSource.addEventListener('tasks_updated',e=>{ + const d=JSON.parse(e.data); + showToast(`🔄 Обновление: ${d.count} заявок`,'info',4000); + reloadData(); + }); + + sseSource.addEventListener('mention',e=>{ + const d=JSON.parse(e.data); + mentionsData.unshift({task_id:d.task_id,mentioned_user:currentUser.username,mentioned_by:d.by,created_at:d.at,read_at:null}); + mentionUnread=d.unread_count; + renderMentionsBell(); + playMentionSound(); + showToast(`🔔 ${d.by} упомянул вас в заявке #${d.task_number}`,'mention',8000); + }); + + sseSource.onerror=()=>{ + // Браузер сам переподключается при CONNECTING (0/1), нам нужно только при CLOSED (2) + if(!sseSource || sseSource.readyState !== EventSource.CLOSED) return; + if(sseReconnectTimer) return; + sseReconnectTimer=setTimeout(()=>{ + sseReconnectTimer=null; + if(authToken) startSSE(); + },5000); + }; +} +function stopSSE(){ + if(sseReconnectTimer){clearTimeout(sseReconnectTimer);sseReconnectTimer=null;} + if(sseSource){sseSource.close();sseSource=null;} +} diff --git a/public/js/state.js b/public/js/state.js new file mode 100644 index 0000000..7e358c3 --- /dev/null +++ b/public/js/state.js @@ -0,0 +1,182 @@ +// ── Constants ───────────────────────────────────────────────────── +const TASK_URL = 'https://cleverparking.intradesk.ru/task/list/'; +const COMMENT_TYPES = new Set([50, 55]); +const EVENT_TYPE_NAMES = { + 10:'Смена статуса',20:'Смена приоритета',25:'Смена рейтинга',30:'Смена исполнителя', + 40:'Смена группы',45:'Изм. доп. данных',50:'Комментарий',55:'Приватный комментарий', + 60:'Добавлен наблюдатель',61:'Добавлен координатор',65:'Добавлена группа наблюдателей', + 70:'Удалён наблюдатель',80:'Добавлен файл',85:'Добавлен прив. файл',90:'Удалён файл', + 100:'Добавлен тег',110:'Удалён тег',120:'Изм. плановая дата',130:'Изм. дата реакции', + 140:'Изменён сервис',150:'Изменён тип',160:'Изменён заявитель',165:'Изм. группа заявителя', + 170:'Изм. доп. поле',200:'Изменено название',205:'Изменено описание',210:'Изм. бизнес-процесс', + 280:'Архивировано',300:'Выполнен макрос',400:'Эскалация' +}; +const STATE_CHIP_KEYS = [...MARKS_CONFIG.map(m => m.key), 'unmarked']; +const SEEN_KEY = 'intradesk_seen'; + +// ── App State ───────────────────────────────────────────────────── +let authToken = localStorage.getItem('intradesk_token'); +let currentUser = null; +let allData = []; +let marksCache = {}; +let notesCache = {}; +let commentsCache = {}; +let mentionsData = []; +let mentionUnread = 0; +let knownUsers = []; +let lastKnownUpdatedAt = null; +let statusPollTimer = null, clientCountdownTimer = null, clientCountdownSec = 0; +let serverRefreshInterval = 300; +let commentCounts = {}; +let currentFilter = 'all'; +let selectedTags = new Set(); +let openNoteIdx = null; +let currentModalIdx = null, currentTab = 'last'; +let mentionedFilterOn = false; +let acState = { active:false, start:0, query:'' }; +const stateChips = Object.fromEntries([ + ...MARKS_CONFIG.map(m => [m.key, true]), + ['unmarked', true], +]); +let unreadMode = 'highlight'; +let exportFieldGroups = new Set(['note','status','comments']); +let exportStatusMode = 'A'; +const forceComment = true; // всегда включено — для задач без комментария подгружаем историю +// taskId -> { updatedat, lastComment } — не перезапрашиваем если updatedat не изменился +const forcedCommentCache = new Map(); + +// ── Seen ────────────────────────────────────────────────────────── +function loadSeen() { try { return JSON.parse(localStorage.getItem(SEEN_KEY)||'{}'); } catch { return {}; } } +function saveSeen(s) { try { localStorage.setItem(SEEN_KEY, JSON.stringify(s)); } catch {} } +function markSeen(id,upd) { const s=loadSeen(); s[id]=upd; saveSeen(s); } +function markAllRead(){ + const s=loadSeen(); + const targets=selectedRows.size>0?[...selectedRows].map(idx=>allData[idx]).filter(Boolean):allData; + targets.forEach(({task})=>{s[task.id]=task.updatedat;}); + saveSeen(s); renderTable(); +} +function isUnread(task) { const s=loadSeen(); return !s[task.id]||s[task.id]!==task.updatedat; } + +function setUnreadMode(mode) { + unreadMode = mode === 'reset' ? 'reset' : 'highlight'; +} + +// ── Marks helpers ───────────────────────────────────────────────── +function isMark(tid, key) { return !!(marksCache[tid]?.[key]?.active); } +// Именованные алиасы (для совместимости с существующим кодом) +function isResolved(tid) { return isMark(tid, 'resolved'); } +function isNeedsReply(tid) { return isMark(tid, 'needs_reply'); } +function isWaitClient(tid) { return isMark(tid, 'wait_client'); } +function isVyezdnye(tid) { return isMark(tid, 'vyezdnye'); } +function isOborudovanie(tid) { return isMark(tid, 'oborudovanie'); } +function isHidden(tid) { return isMark(tid, 'hidden'); } +function getMarkBy(tid, type) { const m=marksCache[tid]?.[type]; return (m&&m.active)?m.by:null; } +function taskNumFromId(taskId) { + const item=allData.find(d=>String(d.task.id)===String(taskId)); + return item?(item.task.tasknumber||item.task.id):taskId; +} + +// ── Utility ─────────────────────────────────────────────────────── +function esc(s){if(!s)return'';return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');} +function fmtDate(d){return`${d.toLocaleDateString('ru-RU',{day:'2-digit',month:'2-digit',year:'2-digit'})} ${d.toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'})}`;} +function timeAgo(d){const m=Math.floor((Date.now()-d)/60000);if(m<1)return'только что';if(m<60)return`${m} мин назад`;const h=Math.floor(m/60);if(h<24)return`${h} ч назад`;return`${Math.floor(h/24)} д назад`;} +function datestamp(){const d=new Date();return`${d.getFullYear()}${String(d.getMonth()+1).padStart(2,'0')}${String(d.getDate()).padStart(2,'0')}`;} +function stripHtml(s){const t=document.createElement('div');t.innerHTML=s;return(t.textContent||t.innerText||'').replace(/\s+/g,' ').trim();} +function sanitizeHtml(raw){ + if(!raw)return''; + if(!/<[a-z]/i.test(raw))return esc(raw); + const t=document.createElement('div');t.innerHTML=raw; + t.querySelectorAll('script,style,intradesk-initiator-notification-signature').forEach(e=>e.remove()); + t.querySelectorAll('intradesk-quote').forEach(e=>{const q=document.createElement('blockquote');q.className='intradesk-quote-block';q.innerHTML=e.innerHTML;e.replaceWith(q);}); + t.querySelectorAll('*').forEach(e=>{['style','class','color','face','size','bgcolor','align','contenteditable'].forEach(a=>e.removeAttribute(a));if(e.tagName==='FONT'){const s=document.createElement('span');s.innerHTML=e.innerHTML;e.replaceWith(s);}}); + return t.innerHTML; +} +function renderMentions(text) { + return esc(text).replace(/@([\w\u0400-\u04ff]+)/g,'@$1'); +} +function showLoading() { + document.getElementById('table-wrap').innerHTML=`
Загружаю заявки...
`; +} +function extractLastComment(task) { + for (const entry of task.lifetime?.data||[]) { + const ev=(entry.events?.data||[]).find(e=>COMMENT_TYPES.has(e.type)); + if(ev) return { username:entry.username, userid:entry.userid, usertype:entry.usertype, eventat:entry.eventat, text:ev.stringvalue||'', isPrivate:ev.type===55, eventType:ev.type }; + } + return null; +} +function getAuthorType(item) { + if(!item.lastComment) return 'nocomment'; + const {usertype}=item.lastComment; + if(usertype===10) return 'internal'; + if(usertype===20) return 'external'; + return 'unknown'; +} +function promptMarkAllRead() { + const targets=selectedRows.size>0?[...selectedRows].map(idx=>allData[idx]).filter(Boolean):allData; + const n=targets.filter(i=>isUnread(i.task)).length; + if(!n) return; + document.getElementById('confirm-count').textContent=n; + document.getElementById('confirm-modal').classList.add('open'); +} + +// Количество наших комментариев по задаче +function getCommentCount(taskId) { + const tid=String(taskId); + if (commentCounts && Object.prototype.hasOwnProperty.call(commentCounts, tid)) { + return commentCounts[tid]||0; + } + return (commentsCache[tid]||[]).length; +} + +// Автодействия после загрузки задач (режим сброса статусов при обновлении) +function onTasksLoaded() { + if (typeof onDataLoaded === 'function') onDataLoaded(); + if (unreadMode !== 'reset') return; + const seen = loadSeen(); + const toClear = []; + + allData.forEach(({ task }) => { + const id = String(task.id); + const unread = !seen[id] || seen[id] !== task.updatedat; + if (!unread) return; + const m = marksCache[id] || {}; + MARKS_CONFIG.filter(m => m.clearOnUpdate).map(m => m.key).forEach(type => { + if (m[type]?.active) { + m[type] = { ...(m[type] || {}), active: false }; + toClear.push({ taskId: id, type }); + } + }); + marksCache[id] = m; + }); + + if (toClear.length && typeof apiPost === 'function') { + toClear.forEach(({ taskId, type }) => { + apiPost('/api/marks', { task_id: String(taskId), mark_type: type, value: false }); + }); + } +} + +// ── Column visibility ───────────────────────────────────────────── +const ALL_COLUMNS = [ + { key:'num', label:'#Номер', default:true }, + { key:'title', label:'Тема', default:true }, + { key:'author', label:'Кто ответил', default:true }, + { key:'date', label:'Дата', default:true }, + { key:'preview',label:'Превью', default:true }, + { key:'status', label:'Статус', default:true }, +]; +let visibleColumns = new Set(ALL_COLUMNS.filter(c=>c.default).map(c=>c.key)); +try { + const saved = JSON.parse(localStorage.getItem('intradesk_cols')||'null'); + if(saved && Array.isArray(saved)) visibleColumns = new Set(saved); +} catch {} +function saveColumnPrefs() { try { localStorage.setItem('intradesk_cols', JSON.stringify([...visibleColumns])); } catch {} } + +// ── Short name helper ───────────────────────────────────────────── +function shortName(fullName) { + if(!fullName) return '—'; + const parts = fullName.trim().split(/\s+/); + if(parts.length === 1) return parts[0]; + // Фамилия + инициалы + return parts[0] + ' ' + parts.slice(1).map(p => p[0] ? p[0].toUpperCase()+'.' : '').join(''); +} diff --git a/public/js/statistics.js b/public/js/statistics.js new file mode 100644 index 0000000..bfd162c --- /dev/null +++ b/public/js/statistics.js @@ -0,0 +1,642 @@ +// ══════════════════════════════════════════════════════════════════ +// СТРАНИЦА СТАТИСТИКИ +// ══════════════════════════════════════════════════════════════════ + +let statsPageOpen = false; +let clientsExpanded = false; +let clientsOnlyOpen = false; +let timingPeriodIdx = 4; + +const STATS_CACHE_KEY = 'intradesk_stats_html_v2'; +function saveStatsCache() { + try { sessionStorage.setItem(STATS_CACHE_KEY, JSON.stringify({ ts: Date.now(), html: document.getElementById('stats-content')?.innerHTML||'' })); } catch(e){} +} +function tryLoadStatsCache() { + try { + const d = JSON.parse(sessionStorage.getItem(STATS_CACHE_KEY)||'null'); + if (d && Date.now()-d.ts < 30*60*1000 && d.html) { + document.getElementById('stats-content').innerHTML = d.html; + return true; + } + } catch(e){} + return false; +} + +function openStatsPage() { + statsPageOpen = true; + document.getElementById('stats-page').style.display = 'flex'; + document.getElementById('nav-btn-tasks')?.classList.remove('nav-tab-active'); + document.getElementById('nav-btn-stats')?.classList.add('nav-tab-active'); + if (allData.length === 0) { tryLoadStatsCache(); return; } + renderStatsPage(); +} +function closeStatsPage() { + statsPageOpen = false; + document.getElementById('stats-page').style.display = 'none'; + document.getElementById('nav-btn-stats')?.classList.remove('nav-tab-active'); + document.getElementById('nav-btn-tasks')?.classList.add('nav-tab-active'); +} +function toggleStatsPage() { statsPageOpen ? closeStatsPage() : openStatsPage(); } + +function renderStatsPage() { + if (!statsPageOpen) return; + const data = allData; + const now = Date.now(); + const total = data.length; + const openCount = data.filter(i => isTaskOpen(i)).length; + const doneCount = data.filter(i => isTaskDone(i)).length; + + // Метки + const sc = {}, scOpen = {}; + MARKS_CONFIG.forEach(m => { sc[m.key] = 0; scOpen[m.key] = 0; }); + let uc = 0, ucOpen = 0; + data.forEach(item => { + const m = MARKS_CONFIG.find(m => marksCache[String(item.task.id)]?.[m.key]?.active); + if (m) sc[m.key]++; else uc++; + if (isTaskOpen(item)) { if (m) scOpen[m.key]++; else ucOpen++; } + }); + + // Тайминги + const PERIODS = [ + { label:'30 дней', days:30 }, + { label:'90 дней', days:90 }, + { label:'180 дней', days:180 }, + { label:'1 год', days:365 }, + { label:'Всё время',days:Infinity }, + ]; + const periodRows = PERIODS.map(p => { + const cutoff = p.days === Infinity ? 0 : now - p.days*86400000; + const reaction = [], resolution = []; + data.forEach(({ task: t }) => { + if (new Date(t.createdat).getTime() < cutoff) return; + if (t.reactiondatefact && t.createdat) { + const m = (new Date(t.reactiondatefact)-new Date(t.createdat))/60000; + if (m>0 && m<43200) reaction.push(m); + } + if (t.resolutiondatefact && t.createdat) { + const m = (new Date(t.resolutiondatefact)-new Date(t.createdat))/60000; + if (m>0) resolution.push(m); + } + }); + return { ...p, reaction, resolution }; + }); + const AP = periodRows[timingPeriodIdx]; + + // 16 недель + const WEEKS = 16; + const wLabels = [], wOpen = [], wCreated = [], wClosed = []; + for (let i = WEEKS-1; i>=0; i--) { + const wEnd = new Date(now - i *7*86400000); + const wStart = new Date(now-(i+1)*7*86400000); + wLabels.push(wStart.toLocaleDateString('ru-RU',{day:'numeric',month:'short'})); + wOpen.push(data.filter(({task:t})=>{ const cr=new Date(t.createdat); if(cr>=wEnd)return false; if(!isTaskDone({task:t}))return true; return new Date(t.resolutiondatefact||t.closedat||0)>wEnd; }).length); + wCreated.push(data.filter(({task:t})=>{const c=new Date(t.createdat);return c>=wStart&&c{const c=new Date(t.resolutiondatefact||t.closedat||0);return c>=wStart&&cb.count=0); let fTotal=0; + data.forEach(({task:t})=>{ + if(t.resolutiondatefact&&t.createdat){ + const m=(new Date(t.resolutiondatefact)-new Date(t.createdat))/60000; + if(m>0){fTotal++;for(const b of FB){if(m<=b.max){b.count++;break;}}} + } + }); + const maxF=Math.max(...FB.map(b=>b.count),1); + + // Возраст + const AB = [ + {label:'До недели', max:7, color:'#0d9488'}, + {label:'До месяца', max:30, color:'var(--accent)'}, + {label:'2 месяца', max:61, color:'#0284c7'}, + {label:'3 месяца', max:92, color:'#d97706'}, + {label:'6 месяцев', max:183, color:'var(--orange)'}, + {label:'12 месяцев',max:365, color:'var(--red)'}, + {label:'> 12 мес.', max:Infinity,color:'#820014'}, + ]; + AB.forEach(b=>b.count=0); + data.forEach(item=>{const d=(now-new Date(item.task.createdat||item.task.updatedat))/86400000;for(const b of AB){if(d<=b.max){b.count++;break;}}}); + const maxAB=Math.max(...AB.map(b=>b.count),1); + + // Клиенты + const cMap = {}; + data.forEach(({task:t})=>{ + const n=t._clientname||t.clientname||'Неизвестно'; + if(!cMap[n])cMap[n]={count:0,open:0}; + cMap[n].count++; + if(isTaskOpen({task:t}))cMap[n].open++; + }); + window._clientMap = cMap; + + // Исполнители + const EXEC_KEYS=['высоких','абрамов','железняков','коваленко']; + const eMap={}; + data.forEach(({task:t})=>{ + if(!t.executor)return; + const raw=t._executorname||`#${t.executor}`; + if(!EXEC_KEYS.some(k=>raw.toLowerCase().includes(k)))return; + if(!eMap[raw])eMap[raw]={open:0,done:0}; + if(isTaskDone({task:t}))eMap[raw].done++;else eMap[raw].open++; + }); + const eList=Object.entries(eMap).sort((a,b)=>(b[1].open+b[1].done)-(a[1].open+a[1].done)); + const maxE=eList[0]?eList[0][1].open+eList[0][1].done:1; + + // ── НОВЫЕ ВЫЧИСЛЕНИЯ ──────────────────────────────────────────── + + // 1. Тепловая карта (час x день недели) + const HM = Array.from({length:7}, () => new Array(24).fill(0)); + data.forEach(({task:t}) => { + if (!t.createdat) return; + const d = new Date(t.createdat); + HM[d.getDay()][d.getHours()]++; + }); + const hmMax = Math.max(...HM.flat(), 1); + const HM_ORDER = [1,2,3,4,5,6,0]; + const HM_LABELS = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс']; + + // 4. Без исполнителя + const noExec = data.filter(({task:t}) => !t.executor).length; + const noExecPct = total ? Math.round(noExec/total*100) : 0; + + // 5 + 11. Тип проблемы + время жизни по типу + const PROB_CATS = [ + {re:/шлагбаум/, label:'Шлагбаумы', color:'#0284c7', count:0, times:[]}, + {re:/принтер|билет|талон/, label:'Принтер / Билеты', color:'#0d9488', count:0, times:[]}, + {re:/камер|распознав|грз/, label:'Камеры / Распознавание', color:'var(--accent)', count:0, times:[]}, + {re:/оплат|qr|банк|терминал/, label:'Оплата / QR', color:'#d97706', count:0, times:[]}, + {re:/тариф|настройк/, label:'Тарифы / Настройки', color:'#7c3aed', count:0, times:[]}, + {re:/сет|связь|сервер/, label:'Сеть / Связь', color:'#db2777', count:0, times:[]}, + ]; + let probOther = 0; + const probOtherTimes = []; + data.forEach(({task:t}) => { + const txt = ((t.name||'')+(t.description||'')).toLowerCase(); + let hit = false; + for (const c of PROB_CATS) { + if (c.re.test(txt)) { + c.count++; + if (t.resolutiondatefact && t.createdat) { + const m = (new Date(t.resolutiondatefact)-new Date(t.createdat))/60000; + if (m>0) c.times.push(m); + } + hit = true; break; + } + } + if (!hit) { + probOther++; + if (t.resolutiondatefact && t.createdat) { + const m = (new Date(t.resolutiondatefact)-new Date(t.createdat))/60000; + if (m>0) probOtherTimes.push(m); + } + } + }); + const allCats = [...PROB_CATS, {label:'Прочее', color:'var(--border2)', count:probOther, times:probOtherTimes}]; + const maxCat = Math.max(...allCats.map(c=>c.count), 1); + const maxCatMed = Math.max(...allCats.map(c=>med(c.times)||0), 1); + + // 8. Эмоциональная оценка + const emoMap = {}; + data.forEach(({task:t}) => { + if (t.aiemotionalassessment) emoMap[t.aiemotionalassessment] = (emoMap[t.aiemotionalassessment]||0)+1; + }); + const emoList = Object.entries(emoMap).sort((a,b)=>b[1]-a[1]); + const emoTotal = emoList.reduce((s,[,v])=>s+v, 0); + const maxEmo = Math.max(...emoList.map(([,v])=>v), 1); + const EMO_COLORS = {'Отлично':'#0d9488','Хорошо':'#0284c7','Нормально':'#d97706','Плохо':'var(--red)','Ужасно':'#820014'}; + + // 9. Конверсия в оценку + const evalCount = data.filter(({task:t}) => t.evaluation && t.evaluation>0).length; + const evalPct = doneCount ? Math.round(evalCount/doneCount*100) : 0; + const evalSum = data.reduce((s,{task:t}) => s+(t.evaluation&&t.evaluation>0?t.evaluation:0), 0); + const evalAvg = evalCount ? (evalSum/evalCount).toFixed(1) : null; + + // 12. Закрытые в тот же день + let sameDayClose=0, sameDayBase=0; + data.forEach(({task:t}) => { + const cl = t.resolutiondatefact||t.closedat; + if (!cl) return; + sameDayBase++; + const ms = new Date(cl)-new Date(t.createdat); + if (ms>0 && ms<86400000) sameDayClose++; + }); + const sameDayPct = sameDayBase ? Math.round(sameDayClose/sameDayBase*100) : 0; + + // 15. Удалённая vs Выездная + const svcRemote = data.filter(({task:t}) => t.service===84683).length; + const svcField = data.filter(({task:t}) => t.service===84682).length; + const svcOther = total - svcRemote - svcField; + const svcMax = Math.max(svcRemote, svcField, svcOther, 1); + + // 17. Просроченные дедлайны + const expiredTotal = data.filter(({task:t}) => t.isresolutionexpired===true).length; + const expiredPct = total ? Math.round(expiredTotal/total*100) : 0; + const EXP_WEEKS = 12; + const expWLabels=[], expWCounts=[], expWBase=[]; + for (let i=EXP_WEEKS-1; i>=0; i--) { + const wEnd = new Date(now - i *7*86400000); + const wStart = new Date(now-(i+1)*7*86400000); + expWLabels.push(wStart.toLocaleDateString('ru-RU',{day:'numeric',month:'short'})); + const inWk = data.filter(({task:t})=>{const c=new Date(t.createdat);return c>=wStart&&ct.isresolutionexpired===true).length); + } + const maxExpW = Math.max(...expWCounts, 1); + + // 18. Приоритетные клиенты (любой ненулевой priority) + const prioMap = {}; + data.forEach(({task:t}) => { + if (!t.priority) return; + const n = t._clientname||t.clientname||'Неизвестно'; + if (!prioMap[n]) prioMap[n]={count:0,open:0}; + prioMap[n].count++; + if (isTaskOpen({task:t})) prioMap[n].open++; + }); + const prioList = Object.entries(prioMap).sort((a,b)=>b[1].count-a[1].count).slice(0,10); + const maxPrio = Math.max(...prioList.map(([,v])=>v.count), 1); + + // 19. Нерабочее время + let nightCount=0, weekendCount=0; + data.forEach(({task:t}) => { + if (!t.createdat) return; + const d=new Date(t.createdat), h=d.getHours(), day=d.getDay(); + if (h>=22||h<7) nightCount++; + if (day===0||day===6) weekendCount++; + }); + const nightPct = total ? Math.round(nightCount/total*100) : 0; + const weekendPct = total ? Math.round(weekendCount/total*100) : 0; + + // 20. Вложения + const ATT_B = [ + {label:'0', max:0, count:0, color:'var(--surface2)'}, + {label:'1', max:1, count:0, color:'#0d9488'}, + {label:'2–3', max:3, count:0, color:'var(--accent)'}, + {label:'4–6', max:6, count:0, color:'#d97706'}, + {label:'7+', max:Infinity, count:0, color:'var(--red)'}, + ]; + data.forEach(({task:t}) => { + let cnt=0; + try { cnt=(t.attachments?JSON.parse(t.attachments):[]).length; } catch(e){} + for (const b of ATT_B) { if (cnt<=b.max) { b.count++; break; } } + }); + const maxAtt = Math.max(...ATT_B.map(b=>b.count), 1); + + // 3. Время первого ответа по исполнителю + const execRxMap = {}; + data.forEach(({task:t}) => { + if (!t.reactiondatefact||!t.createdat||!t.executor) return; + const m=(new Date(t.reactiondatefact)-new Date(t.createdat))/60000; + if (m<=0||m>43200) return; + const name = t._executorname||`#${t.executor}`; + if (!execRxMap[name]) execRxMap[name]=[]; + execRxMap[name].push(m); + }); + const execRxList = Object.entries(execRxMap) + .filter(([,v])=>v.length>=1) + .map(([name,times])=>({name, count:times.length, med:med(times)})) + .sort((a,b)=>a.med-b.med) + .slice(0,8); + const maxRxMed = Math.max(...execRxList.map(e=>e.med), 1); + + // 7. Повторные клиенты + const repeatClients = Object.values(cMap).filter(v=>v.count>1).length; + const totalClients = Object.keys(cMap).length; + const repeatPct = totalClients ? Math.round(repeatClients/totalClients*100) : 0; + + // ── HTML ──────────────────────────────────────────────────────── + document.getElementById('stats-content').innerHTML = ` +
+ + +
+ ${kpi('blue', iconGrid(), total, 'Всего заявок')} + ${kpi('accent',iconOpen(), openCount, 'Открытых')} + ${kpi('green', iconDone(), doneCount, 'Выполненных')} + ${kpi('orange',``, + `${noExecPct}%`, 'Без исполнителя')} + ${kpi('red',``, + `${expiredPct}%`, 'Просрочено')} + ${kpi('teal',``, + `${sameDayPct}%`, 'Закрыто в день')} + ${kpi('indigo',``, + `${repeatPct}%`, 'Повторных клиентов')} +
+ + +
+
+
Метки — все заявки
+
${renderDonut(sc,uc,total)}
+
${donutLegend(sc,uc,total)}
+
+
+
Метки — только открытые
+
${renderDonut(scOpen,ucOpen,openCount)}
+
${donutLegend(scOpen,ucOpen,openCount)}
+
+
+
+ ⏱ Время реагирования и закрытия +
+ ${PERIODS.map((p,i)=>``).join('')} +
+
+
${renderTimingBody(AP)}
+
+
+ + +
+
📈 Открытых заявок на конец недели
+
+ + + + ${(()=>{ if(maxOpen===0)return''; const pts=wOpen.map((v,i)=>[i*60+30,115-v/maxOpen*95]); const area='M'+pts[0]+' '+pts.slice(1).map(p=>'L'+p).join(' ')+' L'+pts[pts.length-1][0]+',115 L'+pts[0][0]+',115 Z'; const line='M'+pts[0]+' '+pts.slice(1).map(p=>'L'+p).join(' '); return''+pts.map(([x,y],i)=>'').join(''); })()} + +
${wLabels.map((l,i)=>i%2===0?`${l}`:'').join('')}
+
+
+ + +
+
+ 📊 Создано / Закрыто по неделям + создано + закрыто +
+
+ ${wCreated.map((cr,i)=>{ const cl=wClosed[i],hCr=Math.round(cr/maxDual*100),hCl=Math.round(cl/maxDual*100); return'
'+wLabels[i]+'
'; }).join('')} +
+
+ + +
+
+
👤 Нагрузка исполнителей
+ ${eList.length===0?`
Нет данных
`:`
${eList.map(([name,v])=>{ const tot=v.open+v.done,pct=tot/maxE*100,opPct=tot?v.open/tot*100:0; return'
'+escHtml(name)+'
'+v.open+' откр.'+v.done+' завер.
'; }).join('')}
ОткрытыеЗавершённые
`} +
+
+
⏳ Воронка времени жизни
+ ${fTotal===0?`
Нет закрытых заявок
`:`
${FB.map(b=>{const pct=b.count/maxF*100,share=fTotal?Math.round(b.count/fTotal*100):0;return'
'+b.label+'
'+b.count+'
'+share+'%
';}).join('')}
`} +
+
+
📅 Возраст заявок
+
${AB.map(b=>{const share=total?Math.round(b.count/total*100):0;return'
'+b.label+'
'+b.count+'
'+share+'%
';}).join('')}
+
+
+ + +
+
+ 🔁 Клиенты + + +
+
+
+ + + + + + +
+
🌡️ Тепловая карта активности (час × день недели)
+
+
+ ${Array.from({length:24},(_,h)=>`
${h}
`).join('')} +
+ ${HM_ORDER.map((dayIdx,i)=>{ + const row=HM[dayIdx]; + return'
'+HM_LABELS[i]+'
'+row.map((v,h)=>'
'+(v||'')+'
').join('')+'
'; + }).join('')} +
+ меньше + ${[0,.2,.4,.6,.8,1].map(hi=>`
`).join('')} + больше +
+
+
+ + +
+
+
🏷️ Тип проблемы
+
+ ${allCats.map(c=>{ const pct=c.count/maxCat*100, share=total?Math.round(c.count/total*100):0; return'
'+c.label+'
'+c.count+'
'+share+'%
'; }).join('')} +
+
+
+
⏱️ Время закрытия по типу (медиана)
+ ${allCats.filter(c=>c.times&&c.times.length>0).length===0 + ?`
Нет закрытых заявок
` + :`
${allCats.map(c=>{ const m=med(c.times); if(!m)return''; const pct=m/maxCatMed*100; return'
'+c.label+'
'+fmtDur(m)+'
'; }).join('')}
Медиана по закрытым заявкам каждого типа
`} +
+
+
🔧 Тип поддержки
+
+
Удалённая
${svcRemote}
${total?Math.round(svcRemote/total*100):0}%
+
Выездная
${svcField}
${total?Math.round(svcField/total*100):0}%
+ ${svcOther>0?`
Прочие
${svcOther}
${total?Math.round(svcOther/total*100):0}%
`:''} +
+
84683 — удалённая · 84682 — выездная
+
+
+ + +
+
+
😊 Эмоциональная оценка ИИ
+ ${emoList.length===0 + ?`
Нет данных aiemotionalassessment
` + :`
${emoList.map(([label,cnt])=>{ const color=EMO_COLORS[label]||'var(--accent)'; const pct=cnt/maxEmo*100, share=emoTotal?Math.round(cnt/emoTotal*100):0; return'
'+escHtml(label)+'
'+cnt+'
'+share+'%
'; }).join('')}
${emoTotal} из ${total} заявок имеют оценку
`} +
+
+
⭐ Оценки от клиентов (evaluation)
+
+
+
Конверсия закрытых → оценка
+
+
+
+
+
${evalPct}%
+
+
${evalCount} из ${doneCount} закрытых
+
+ ${evalAvg?`
Средний балл
${evalAvg}
по ${evalCount} оценкам
`:`
Оценок пока нет
`} +
+
+
+
📎 Вложения на заявку
+
+ ${ATT_B.map(b=>{ const pct=b.count/maxAtt*100, share=total?Math.round(b.count/total*100):0; return'
'+b.label+'
'+b.count+'
'+share+'%
'; }).join('')} +
+
Косвенный показатель сложности заявки
+
+
+ + +
+
+
⚡ Время первого ответа по исполнителю
+ ${execRxList.length===0 + ?`
Нет данных по реакции
` + :`
${execRxList.map(e=>{ const pct=e.med/maxRxMed*100; return'
'+escHtml(e.name)+'
'+fmtDur(e.med)+'
'+e.count+'шт
'; }).join('')}
Медиана времени реакции · сортировка от быстрых к медленным
`} +
+
+
+ 🚨 Просроченные дедлайны по неделям + ${expiredTotal} всего (${expiredPct}%) +
+
+ ${expWCounts.map((cnt,i)=>{ const base=expWBase[i]||0, pct=base?Math.round(cnt/base*100):0; const h=Math.max(cnt/maxExpW*100,cnt?4:0); const color=pct>30?'var(--red)':pct>15?'var(--orange)':'var(--accent)'; return'
'+cnt+'
'+expWLabels[i]+'
'; }).join('')} +
+
isresolutionexpired = true · по дате создания заявки
+
+
+ + +
+
+
🔥 Клиенты с приоритетными заявками
+ ${prioList.length===0 + ?`
Нет заявок с полем priority
` + :`
${prioList.map(([name,v])=>{ const pct=v.count/maxPrio*100, opPct=v.count?v.open/v.count*100:0; return'
'+escHtml(name)+'
'+v.count+'
'+(v.open?''+v.open+' откр.':'')+'
'; }).join('')}
Заявки с ненулевым полем priority
`} +
+
+
🌙 Активность в нерабочее время
+
+
+
Ночные заявки (22:00 — 07:00)
+
+
+
+
+
${nightPct}%
+
+
${nightCount} заявок из ${total}
+
+
+
Выходные дни (сб / вс)
+
+
+
+
+
${weekendPct}%
+
+
${weekendCount} заявок из ${total}
+
+
+
Аргумент для обоснования дежурства / доп. тарификации
+
+
+ +
`; + + renderClientsList(); + saveStatsCache(); +} + +// Тайминги — body +function renderTimingBody(p) { + if (!p.reaction.length && !p.resolution.length) + return `
Нет данных за этот период
`; + return ` +
+
⚡ Время реакции
+
+
${fmtDur(avg(p.reaction))}
Среднее
+
${fmtDur(med(p.reaction))}
Медиана
+
${p.reaction.length}
Заявок
+
+
🏁 Время до закрытия
+
+
${fmtDur(avg(p.resolution))}
Среднее
+
${fmtDur(med(p.resolution))}
Медиана
+
${p.resolution.length}
Закрыто
+
+
+
По полям «Дата реакции» и «Дата решения» · период: ${p.label}
`; +} + +function renderTimingCard() { + const PERIODS=[{days:30},{days:90},{days:180},{days:365},{days:Infinity}]; + const PLABELS=['30 дней','90 дней','180 дней','1 год','Всё время']; + const p_days = PERIODS[timingPeriodIdx].days; + const now = Date.now(); + const cutoff = p_days===Infinity ? 0 : now - p_days*86400000; + const reaction=[], resolution=[]; + allData.forEach(({task:t})=>{ + if(new Date(t.createdat).getTime()0&&m<43200)reaction.push(m);} + if(t.resolutiondatefact&&t.createdat){const m=(new Date(t.resolutiondatefact)-new Date(t.createdat))/60000;if(m>0)resolution.push(m);} + }); + const AP={label:PLABELS[timingPeriodIdx],reaction,resolution}; + document.getElementById('stats-timing-body').innerHTML = renderTimingBody(AP); + document.querySelectorAll('.stats-period-tab').forEach((b,i)=>{ + b.classList.toggle('active', i===timingPeriodIdx); + }); +} + +function renderClientsList() { + const wrap = document.getElementById('stats-clients-list-wrap'); + if (!wrap || !window._clientMap) return; + let entries = Object.entries(window._clientMap); + if (clientsOnlyOpen) entries=entries.filter(([,v])=>v.open>0).sort((a,b)=>b[1].open-a[1].open); + else entries=entries.filter(([,v])=>v.count>1).sort((a,b)=>b[1].count-a[1].count); + const showN=clientsExpanded?entries.length:Math.min(12,entries.length); + const visible=entries.slice(0,showN); + const hidden=entries.length-showN; + const maxV=visible[0]?(clientsOnlyOpen?visible[0][1].open:visible[0][1].count):1; + if(visible.length===0){wrap.innerHTML=`
${clientsOnlyOpen?'Нет клиентов с открытыми заявками':'Нет повторных обращений'}
`;return;} + wrap.innerHTML=` +
+ ${visible.map(([name,v])=>{ const dv=clientsOnlyOpen?v.open:v.count,pct=dv/maxV*100,opPct=v.count?v.open/v.count*100:0; return'
'+escHtml(name)+'
'+v.count+''+(v.open?''+v.open+' откр.':'')+'
'; }).join('')} +
+ ${!clientsExpanded&&hidden>0?`
+ ещё ${hidden} — показать все
`:''}`; +} + +// Helpers +function avg(a){return a.length?a.reduce((x,y)=>x+y,0)/a.length:null;} +function med(a){if(!a.length)return null;const s=[...a].sort((x,y)=>x-y),m=Math.floor(s.length/2);return s.length%2===0?(s[m-1]+s[m])/2:s[m];} +function fmtDur(m){if(m===null)return'—';if(m<60)return`${Math.round(m)} мин`;if(m<1440)return`${(m/60).toFixed(1)} ч`;return`${(m/1440).toFixed(1)} дн`;} + +function kpi(c,icon,val,lbl){return`
${icon}
${val}
${lbl}
`;} +function iconGrid(){return``;} +function iconOpen(){return``;} +function iconDone(){return``;} + +function renderDonut(sc,uc,tot){ + if(!tot)return'
Нет данных
'; + const segs=[...MARKS_CONFIG.map(m=>({color:m.ispColor,val:sc[m.key]||0})),{color:'var(--border2)',val:uc}].filter(s=>s.val>0); + const r=54,cx=70,cy=70,sw=22,circ=2*Math.PI*r;let off=0; + const paths=segs.map(s=>{const dash=s.val/tot*circ,gap=circ-dash,el=``;off+=dash;return el;}); + return`${paths.join('')}${tot}заявок`; +} +function donutLegend(sc,uc,tot){ + return MARKS_CONFIG.map(m=>{const c=sc[m.key]||0,p=tot?Math.round(c/tot*100):0;return`
${m.chipLabel}${c}${p}%
`;}).join('')+ + `
Без статуса${uc}${tot?Math.round(uc/tot*100):0}%
`; +} + +function escHtml(s){return String(s).replace(/&/g,'&').replace(//g,'>');} + +function showLineTip(event,el){const tip=document.getElementById('line-tooltip');if(!tip)return;tip.textContent=`${el.dataset.lbl}: ${el.dataset.val} откр.`;tip.style.display='block';const wr=document.getElementById('stats-line-chart').getBoundingClientRect();tip.style.left=Math.min(event.clientX-wr.left+12,wr.width-130)+'px';tip.style.top=Math.max(event.clientY-wr.top-36,4)+'px';} +function hideLineTip(){const t=document.getElementById('line-tooltip');if(t)t.style.display='none';} + +function openTaskById(id){const i=allData.findIndex(x=>x.task.id===id);if(i!==-1){closeStatsPage();setTimeout(()=>openDetail(i),100);}} +function onDataLoaded(){if(statsPageOpen)renderStatsPage();} diff --git a/public/js/statuses.js b/public/js/statuses.js new file mode 100644 index 0000000..3282d04 --- /dev/null +++ b/public/js/statuses.js @@ -0,0 +1,208 @@ +// ══════════════════════════════════════════════════════════════════ +// ЕДИНЫЙ РЕЕСТР СТАТУСОВ +// Добавьте новый статус сюда — больше ничего менять не нужно. +// ══════════════════════════════════════════════════════════════════ +// +// Поля объекта статуса: +// key — уникальный ключ (используется в БД, API, marksCache) +// chipClass — CSS-класс чипа фильтра (для цвета) +// chipLabel — текст на чипе фильтра +// chipDotColor — цвет точки на чипе (CSS-значение) +// badgeClass — CSS-класс бейджа статуса в таблице +// label — текст бейджа и тултипов +// sseLabel — текст в SSE-тосте +// exportLabel — текст в экспорте (CSV/TXT) +// btnClass — CSS-класс активной кнопки в боковой панели +// rowClass — CSS-класс строки таблицы +// btnIcon — иконка кнопки в боковой панели +// btnLabel — текст кнопки в боковой панели +// ispColor — цвет точки в inline-пикере статуса +// kbColor — цвет вспышки при нажатии клавиши +// kbShortcut — цифра-клавиша быстрого выбора (строка) +// clearOnUpdate — true: статус сбрасывается при обновлении задачи (режим сброса) + +const MARKS_CONFIG = [ + { + key: 'needs_reply', + chipClass: 'chip-reply', + chipLabel: 'Нужен ответ', + chipDotColor: 'var(--red)', + badgeClass: 'sb-reply', + label: 'Нужен ответ', + sseLabel: 'Нужно ответить', + exportLabel: 'Клиент ждет ответа', + btnClass: 'mbtn-reply', + rowClass: 'row-reply', + btnIcon: '✉', + btnLabel: 'Нужно ответить', + ispColor: 'var(--red)', + kbColor: '#ff4d4f', + kbShortcut: '1', + clearOnUpdate: true, + }, + { + key: 'wait_client', + chipClass: 'chip-wait', + chipLabel: 'Ждём клиента', + chipDotColor: 'var(--orange)', + badgeClass: 'sb-wait', + label: 'Ждём клиента', + sseLabel: 'Ждём клиента', + exportLabel: 'Ждем клиента', + btnClass: 'mbtn-wait', + rowClass: 'row-wait', + btnIcon: '⏳', + btnLabel: 'Ждём клиента', + ispColor: 'var(--orange)', + kbColor: '#fa8c16', + kbShortcut: '2', + clearOnUpdate: false, + }, + { + key: 'resolved', + chipClass: 'chip-resolved', + chipLabel: 'Можно закрыть', + chipDotColor: 'var(--green)', + badgeClass: 'sb-resolved', + label: 'Можно закрыть', + sseLabel: 'Можно закрыть', + exportLabel: 'Решено', + btnClass: 'mbtn-resolve', + rowClass: 'row-resolved', + btnIcon: '✓', + btnLabel: 'Можно Закрыть', + ispColor: 'var(--green)', + kbColor: '#52c41a', + kbShortcut: '3', + clearOnUpdate: false, + }, + { + key: 'vyezdnye', + chipClass: 'chip-vyezdnye', + chipLabel: 'Не наша', + chipDotColor: 'var(--purple)', + badgeClass: 'sb-vyezdnye', + label: 'Не наша', + sseLabel: 'Не наша', + exportLabel: 'Не наша', + btnClass: 'mbtn-vyezdnye', + rowClass: 'row-vyezdnye', + btnIcon: '🚗', + btnLabel: 'Не наша', + ispColor: 'var(--purple)', + kbColor: '#722ed1', + kbShortcut: '4', + clearOnUpdate: false, + }, + { + key: 'hidden', + chipClass: 'chip-hidden', + chipLabel: 'Скрытые', + chipDotColor: 'var(--text2)', + badgeClass: 'sb-hidden', + label: 'Скрыто', + sseLabel: 'Скрыто', + exportLabel: 'Скрыто', + btnClass: 'mbtn-hidden', + rowClass: 'row-hidden', + btnIcon: '⊘', + btnLabel: 'Скрыть', + ispColor: '#bbb', + kbColor: '#bbb', + kbShortcut: '6', + clearOnUpdate: false, + }, + { + key: 'oborudovanie-fix', + chipClass: 'chip-oborudovanie-fix', + chipLabel: 'Клиент отправил', + chipDotColor: '#fa541c', + badgeClass: 'sb-oborudovanie-fix', + label: 'Клиент отправил оборудование', + sseLabel: 'Клиент отправил оборудование', + exportLabel: 'Клиент отправил оборудование', + btnClass: 'mbtn-oborudovanie-fix', + rowClass: 'row-oborudovanie-fix', + btnIcon: '📤', + btnLabel: 'Клиент отправил', + ispColor: '#fa541c', + kbColor: '#fa541c', + kbShortcut: '7', + clearOnUpdate: false, + }, + { + key: 'oborudovanie-in-repair', + chipClass: 'chip-oborudovanie-in-repair', + chipLabel: 'В ремонте', + chipDotColor: '#13c2c2', + badgeClass: 'sb-oborudovanie-in-repair', + label: 'Оборудование в ремонте', + sseLabel: 'Оборудование в ремонте', + exportLabel: 'Оборудование в ремонте', + btnClass: 'mbtn-oborudovanie-in-repair', + rowClass: 'row-oborudovanie-in-repair', + btnIcon: '🔧', + btnLabel: 'В ремонте', + ispColor: '#13c2c2', + kbColor: '#13c2c2', + kbShortcut: '8', + clearOnUpdate: false, + }, + { + key: 'oborudovanie-repair-done', + chipClass: 'chip-oborudovanie-repair-done', + chipLabel: 'Ремонт окончен', + chipDotColor: '#52c41a', + badgeClass: 'sb-oborudovanie-repair-done', + label: 'Ремонт окончен — ждёт отправки', + sseLabel: 'Ремонт окончен — ждёт отправки', + exportLabel: 'Ремонт окончен — ждёт отправки', + btnClass: 'mbtn-oborudovanie-repair-done', + rowClass: 'row-oborudovanie-repair-done', + btnIcon: '✅', + btnLabel: 'Ремонт окончен', + ispColor: '#52c41a', + kbColor: '#52c41a', + kbShortcut: '9', + clearOnUpdate: false, + }, + { + key: 'oborudovanie-send-back', + chipClass: 'chip-oborudovanie-send-back', + chipLabel: 'Отправлено клиенту', + chipDotColor: '#1677ff', + badgeClass: 'sb-oborudovanie-send-back', + label: 'Оборудование отправлено клиенту', + sseLabel: 'Оборудование отправлено клиенту', + exportLabel: 'Оборудование отправлено клиенту', + btnClass: 'mbtn-oborudovanie-send-back', + rowClass: 'row-oborudovanie-send-back', + btnIcon: '🚚', + btnLabel: 'Отправлено клиенту', + ispColor: '#1677ff', + kbColor: '#1677ff', + kbShortcut: '0', + clearOnUpdate: false, + }, + { + key: 'oborudovanie-returned', + chipClass: 'chip-oborudovanie-returned', + chipLabel: 'Клиент получил', + chipDotColor: '#722ed1', + badgeClass: 'sb-oborudovanie-returned', + label: 'Клиент получил оборудование', + sseLabel: 'Клиент получил оборудование', + exportLabel: 'Клиент получил оборудование', + btnClass: 'mbtn-oborudovanie-returned', + rowClass: 'row-oborudovanie-returned', + btnIcon: '🏠', + btnLabel: 'Клиент получил', + ispColor: '#722ed1', + kbColor: '#722ed1', + kbShortcut: '', + clearOnUpdate: false, + }, +]; + +// Совместимость с Node.js (server.js использует require) +if (typeof module !== 'undefined') module.exports = MARKS_CONFIG; diff --git a/public/js/ui.js b/public/js/ui.js new file mode 100644 index 0000000..87339b4 --- /dev/null +++ b/public/js/ui.js @@ -0,0 +1,870 @@ +// ── Column visibility ───────────────────────────────────────────── +const ALL_COLS = [ + { key:'num', label:'# Заявка', def:true }, + { key:'title', label:'Тема', def:true }, + { key:'intrastatus', label:'Статус в интре', def:true }, + { key:'client', label:'Клиент', def:true }, + { key:'date', label:'Время ответа', def:true }, + { key:'created', label:'Создана', def:true }, + { key:'preview', label:'Превью/Заметка', def:true }, + { key:'status', label:'Статус', def:true }, +]; +let visibleCols = new Set(ALL_COLS.filter(c=>c.def).map(c=>c.key)); +let colMenuOpen = false; +let filteredData = []; +let statFilter = 'all'; +let selectedRows = new Set(); +let mainTab = 'all'; // 'all' | 'open' | 'done' | 'empty' + +function setMainTab(tab, el) { + mainTab = tab; + document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('tab-btn-active')); + if (el) el.classList.add('tab-btn-active'); + renderTable(); +} + +function isTaskOpen(item) { + const sn = (item.task._statusname || item.task.statusname || '').toLowerCase(); + return sn.includes('открыт') || sn.includes('в работе') || sn.includes('новая') || sn.includes('new') || sn.includes('назначен'); +} +function isTaskDone(item) { + const sn = (item.task._statusname || item.task.statusname || '').toLowerCase(); + return sn.includes('выполнен') || sn.includes('закрыт') || sn.includes('решен'); +} +function isTaskEmpty(item) { + return !item.lastComment; +} + +// ── Helpers ─────────────────────────────────────────────────────── +function getTaskTags(task){const r=task.tags?.data??task.tags??[];return r.map(t=>({id:t.id??t,name:t.name??String(t)}));} +function buildTagIndex(){return [];} +function renderTagFilter(){} + +function abbrevName(name){ + if(!name||name==='—') return name||'—'; + const p=name.trim().split(/\s+/); + if(p.length===1) return p[0]; + return p[0]+' '+p[1][0]+'.'; +} + +const AV_COLORS=['#5b7fff','#ff4d4f','#52c41a','#fa8c16','#722ed1','#13c2c2','#eb2f96']; +function avatarColor(n){let h=0;for(let i=0;i<(n||'').length;i++)h=(h*31+n.charCodeAt(i))%AV_COLORS.length;return AV_COLORS[h];} +function avatarInitials(n){ + if(!n||n==='—') return'?'; + const p=n.trim().split(/\s+/); + return p.length>1?(p[0][0]+p[1][0]).toUpperCase():n.slice(0,2).toUpperCase(); +} + +// ── Chips ───────────────────────────────────────────────────────── +function setChip(el,on){ + if(!el)return; + el.classList.toggle('chip-on',on); + el.classList.toggle('chip-off',!on); +} +function syncAllChip(){setChip(document.getElementById('chip-all'),STATE_CHIP_KEYS.every(k=>stateChips[k]));} +function toggleStateChip(name){ + stateChips[name]=!stateChips[name]; + setChip(document.getElementById('chip-'+name),stateChips[name]); + syncAllChip(); renderTable(); +} +function toggleAllChip(){ + const t=!STATE_CHIP_KEYS.every(k=>stateChips[k]); + STATE_CHIP_KEYS.forEach(k=>{stateChips[k]=t;setChip(document.getElementById('chip-'+k),t);}); + setChip(document.getElementById('chip-all'),t); + renderTable(); +} + +// ── Init mark chips + bulk-select from MARKS_CONFIG ─────────────── +function initMarkChips(){ + // Chip dropdown items + const container=document.getElementById('mark-chips-container'); + if(container){ + container.innerHTML=MARKS_CONFIG.map(m=>` +
+ + ${m.chipLabel} + 0 +
`).join(''); + } + // Bulk-status select options + const sel=document.getElementById('bulk-status-sel'); + if(sel){ + const clearOpt=sel.querySelector('option[value="clear"]'); + MARKS_CONFIG.slice().reverse().forEach(m=>{ + const opt=document.createElement('option'); + opt.value=m.key; opt.textContent=m.label; + sel.insertBefore(opt, clearOpt); + }); + } +} +function toggleMentionedFilter(){ + mentionedFilterOn=!mentionedFilterOn; + setChip(document.getElementById('chip-mentioned'),mentionedFilterOn); + renderTable(); +} + +function toggleChipDropdown() { + const panel = document.getElementById('chip-dropdown-panel'); + if (!panel) return; + panel.classList.toggle('chip-dd-open'); +} + +function updateChipDropdownLabel() { + const allOn = STATE_CHIP_KEYS.every(k => stateChips[k]); + const activeKeys = STATE_CHIP_KEYS.filter(k => stateChips[k]); + const lbl = document.getElementById('chip-dropdown-label'); + if (!lbl) return; + if (allOn) { + lbl.textContent = 'Все статусы'; + } else if (activeKeys.length === 0) { + lbl.textContent = 'Ни один'; + } else if (activeKeys.length === 1) { + const key = activeKeys[0]; + if (key === 'unmarked') { lbl.textContent = 'Без статуса'; return; } + const m = MARKS_CONFIG.find(x => x.key === key); + lbl.textContent = m ? m.chipLabel : key; + } else { + lbl.textContent = `Статусов: ${activeKeys.length}`; + } +} + +function syncChipDropdownItems() { + const allOn = STATE_CHIP_KEYS.every(k => stateChips[k]); + const allItem = document.getElementById('chipdd-all'); + if (allItem) allItem.classList.toggle('chip-dd-active', allOn); + MARKS_CONFIG.forEach(m => { + const el = document.getElementById(`chipdd-${m.key}`); + if (el) el.classList.toggle('chip-dd-active', !!stateChips[m.key]); + }); + const uEl = document.getElementById('chipdd-unmarked'); + if (uEl) uEl.classList.toggle('chip-dd-active', !!stateChips['unmarked']); + updateChipDropdownLabel(); +} + +function setStatFilter(f,el){ + statFilter=f; + document.querySelectorAll('.stat-card').forEach(c=>c.classList.remove('active')); + if(el)el.classList.add('active'); + renderTable(); +} +function setNavView(v,e){} + +// ── Date filter ─────────────────────────────────────────────────── +function taskMatchesDate(task){ + const v=document.getElementById('date-filter')?.value||'0'; + if(v==='0')return true; + const now=new Date(),upd=new Date(task.updatedat); + if(v==='today')return upd.toDateString()===now.toDateString(); + if(v==='yesterday'){const y=new Date(now);y.setDate(y.getDate()-1);return upd.toDateString()===y.toDateString();} + return upd>=new Date(now.setDate(now.getDate()-parseInt(v))); +} + +// ── Status badges ───────────────────────────────────────────────── +function internalStatusBadge(tid){ + const m=MARKS_CONFIG.find(m=>isMark(tid,m.key)); + if(m) return`${m.label}`; + return`Без статуса`; +} +// ── IntraDesk status badge ──────────────────────────────────────── +function intraStatusBadge(task) { + const name = task._statusname || task.statusname || ''; + if (!name) { + // Показываем числовой id статуса как fallback + const sid = task.statusid || task.status; + if (sid) return `#${sid}`; + return ''; + } + const lc = name.toLowerCase(); + let cls = 'sb-intra-other'; + if (lc.includes('открыт') || lc.includes('new') || lc.includes('открытый')) cls = 'sb-intra-open'; + else if (lc.includes('работ') || lc.includes('прогресс') || lc.includes('назначен')) cls = 'sb-intra-work'; + else if (lc.includes('выполнен') || lc.includes('закрыт') || lc.includes('решен')) cls = 'sb-intra-done'; + else if (lc.includes('ожидан') || lc.includes('ожидает') || lc.includes('hold')) cls = 'sb-intra-hold'; + return `${esc(name)}`; +} +// Who answered — ring class for avatar +function whoAnsweredRing(lc){ + if(!lc) return 'ring-none'; + return lc.usertype===10 ? 'ring-internal' : 'ring-external'; +} +// Who answered — badge html +function whoAnsweredBadge(lc){ + if(!lc) return`Нет ответа`; + if(lc.usertype===10) return`Сотрудник`; + return`Клиент`; +} +// Row color — ONLY by internal marks, not by who answered +function rowColorClass(tid){ + const m=MARKS_CONFIG.find(m=>isMark(tid,m.key)); + return m?m.rowClass:''; +} + +// ── Who set a mark ──────────────────────────────────────────────── +function markSetByHtml(tid) { + const lines=MARKS_CONFIG + .filter(cfg=>marksCache[tid]?.[cfg.key]?.active&&marksCache[tid][cfg.key].by) + .map(cfg=>{ + const mc=marksCache[tid][cfg.key]; + return`👤 ${esc(abbrevName(mc.by))} · «${cfg.label}»${mc.at?` · ${timeAgo(new Date(mc.at))}`:''}`; + }); + return lines.length?`
${lines.join('')}
`:''; +} + +// ── Inline status picker ────────────────────────────────────────── +function statusPickerHtml(tid){ + const items=MARKS_CONFIG.map(m=>` +
+ ${m.label} +
`).join(''); + return`
+ ${internalStatusBadge(tid)} +
+
${items}
+
+
`; +} +function ispShow(el){ + const dd=el.querySelector('.isp-dropdown'); + if(!dd) return; + const r=el.getBoundingClientRect(); + dd.style.top=(r.bottom)+'px'; + dd.style.left=r.left+'px'; + dd.style.display='block'; +} +function ispHide(el){ + const dd=el.querySelector('.isp-dropdown'); + if(dd) dd.style.display='none'; +} + +// ── Column picker ───────────────────────────────────────────────── +function toggleColMenu(e){ + e.stopPropagation(); + colMenuOpen=!colMenuOpen; + document.getElementById('col-menu')?.classList.toggle('open',colMenuOpen); +} +document.addEventListener('click',()=>{ + colMenuOpen=false; + document.getElementById('col-menu')?.classList.remove('open'); +}); +function toggleCol(key){ + if(visibleCols.has(key))visibleCols.delete(key);else visibleCols.add(key); + renderColMenu(); renderTable(); +} +function renderColMenu(){ + const m=document.getElementById('col-menu'); + if(!m)return; + m.innerHTML=ALL_COLS.map(c=>` + `).join(''); +} + +// ── Sort state ──────────────────────────────────────────────────── +let sortCol = null; // 'date' | 'created' +let sortDir = 'desc'; +function toggleSort(col) { + if (sortCol === col) sortDir = sortDir === 'desc' ? 'asc' : 'desc'; + else { sortCol = col; sortDir = 'desc'; } + renderTable(); +} + +// ── Pagination state ────────────────────────────────────────────── +let currentPage = 1; +function getPageSize() { const v=parseInt(document.getElementById('opt-page-size')?.value||'50'); return v||0; } +function setPage(p) { currentPage = p; renderTable(true); } +function onPageSizeChange() { currentPage = 1; renderTable(); } + +// ── Chip logic: click label = solo, click dot = toggle ──────────── +function chipClickLabel(name) { + STATE_CHIP_KEYS.forEach(k => { stateChips[k] = (k === name); }); + syncChipDropdownItems(); + renderTable(); +} +function chipClickDot(name, e) { + e && e.stopPropagation(); + stateChips[name] = !stateChips[name]; + syncChipDropdownItems(); + renderTable(); +} +function toggleAllChip() { + const t = !STATE_CHIP_KEYS.every(k => stateChips[k]); + STATE_CHIP_KEYS.forEach(k => { stateChips[k] = t; }); + syncChipDropdownItems(); + renderTable(); +} + +// ── Render table ────────────────────────────────────────────────── +function renderTable(keepPage){ + if(!keepPage) currentPage = 1; + const search =document.getElementById('search-input')?.value?.toLowerCase()||''; + const remoteOnly =document.getElementById('opt-remote-only')?.checked; + + filteredData=allData.filter(item=>{ + const tid=String(item.task.id); + const marked=MARKS_CONFIG.some(m=>isMark(tid,m.key)); + for(const m of MARKS_CONFIG){if(isMark(tid,m.key)&&!stateChips[m.key])return false;} + if(!marked&&!stateChips.unmarked) return false; + if(mentionedFilterOn&&!mentionsData.some(m=>String(m.task_id)===tid)) return false; + if(search&&!(item.task.name?.toLowerCase().includes(search)||String(item.task.tasknumber).includes(search)||(item.task._clientname||item.task.clientname||'').toLowerCase().includes(search))) return false; + if(remoteOnly&&item.task.service!==84683) return false; + if(!taskMatchesDate(item.task)) return false; + if(statFilter!=='all'&&getAuthorType(item)!==statFilter) return false; + // Main tab filter + if(mainTab==='open' && !isTaskOpen(item)) return false; + if(mainTab==='done' && !isTaskDone(item)) return false; + if(mainTab==='empty' && !isTaskEmpty(item)) return false; + return true; + }); + + // Sort + if(sortCol) { + filteredData = [...filteredData].sort((a,b) => { + const da = sortCol==='date' + ? (a.lastComment?.eventat||a.task.updatedat||'') + : (a.task.createdat||''); + const db = sortCol==='date' + ? (b.lastComment?.eventat||b.task.updatedat||'') + : (b.task.createdat||''); + const cmp = da < db ? -1 : da > db ? 1 : 0; + return sortDir==='asc' ? cmp : -cmp; + }); + } + + // Counts + const base=allData.filter(i=>taskMatchesDate(i.task)); + const g=id=>document.getElementById(id); + const sv=(id,v)=>{const e=g(id);if(e)e.textContent=v;}; + sv('chip-all-cnt', base.length); + MARKS_CONFIG.forEach(m=>{ + sv(`chip-${m.key}-cnt`, base.filter(i=>isMark(String(i.task.id),m.key)).length); + }); + sv('chip-unmarked-cnt', base.filter(i=>{ + const tid=String(i.task.id);return !MARKS_CONFIG.some(m=>isMark(tid,m.key)); + }).length); + sv('stat-total', base.length); + sv('stat-external', base.filter(i=>getAuthorType(i)==='external').length); + sv('stat-internal', base.filter(i=>getAuthorType(i)==='internal').length); + sv('stat-nocomment', base.filter(i=>getAuthorType(i)==='nocomment').length); + sv('stat-unread', allData.filter(i=>isUnread(i.task)).length); + sv('nav-badge-tasks', filteredData.length); + sv('topbar-sub', `· ${filteredData.length} заявок`); + sv('total-loaded', filteredData.length); + sv('total-count', allData.length); + sv('tf-info', `${filteredData.length} из ${allData.length}`); + // Tab counts (using allData for totals regardless of chip filter) + sv('tcnt-all', allData.length); + sv('tcnt-open', allData.filter(i=>isTaskOpen(i)).length); + sv('tcnt-done', allData.filter(i=>isTaskDone(i)).length); + sv('tcnt-empty', allData.filter(i=>isTaskEmpty(i)).length); + + if(!filteredData.length){ + g('table-wrap').innerHTML=`
🔍
Заявок не найдено
Измените фильтры
`; + const pb=g('pagination-bar'); if(pb){pb.style.display='none';pb.innerHTML='';} + return; + } + + // Pagination slice + const PAGE_SIZE = getPageSize(); + const totalPages = PAGE_SIZE > 0 ? Math.ceil(filteredData.length / PAGE_SIZE) : 1; + if(currentPage > totalPages) currentPage = totalPages; + const pageData = PAGE_SIZE > 0 ? filteredData.slice((currentPage-1)*PAGE_SIZE, currentPage*PAGE_SIZE) : filteredData; + + const sc=k=>visibleCols.has(k); + const sortArrow = col => sortCol===col ? (sortDir==='asc' ? ' ↑' : ' ↓') : ' ↕'; + const sortStyle = col => `cursor:pointer;user-select:none;${sortCol===col?'color:var(--accent);':'color:inherit;'}`; + const thead=` + + ${sc('num') ?'#' :''} + ${sc('title') ?'Тема' :''} + ${sc('intrastatus')?'Статус в интре':''} + ${sc('status') ?'Статус' :''} + ${sc('client') ?'Кто ответил' :''} + ${sc('date') ?`Время ответа${sortArrow('date')}` :''} + ${sc('created')?`Создана${sortArrow('created')}` :''} + ${sc('preview')?'Превью' :''} + `; + + const rows=pageData.map(item=>{ + const {task,lastComment}=item; + const tid=String(task.id); + const num=task.tasknumber||task.id; + const idx=allData.indexOf(item); + const unread=isUnread(task); + const note=getNote(tid); + const cc=rowColorClass(tid); + const rowCls=((unread&&!cc?'row-unread ':'')+cc+(currentModalIdx===idx?' active-row':'')).trim(); + const authorName=lastComment?.username||task.updatedby||task.username||''; + const orgName=task._clientname||task.clientname||task.organization||''; + const unreadDot=unread?'':''; + const cntBadge=getCommentCount(tid)?`
💬 ${getCommentCount(tid)}
`:''; + const prevCell=note + ?`
📝 ${esc(note.slice(0,42))}${note.length>42?'…':''}
` + :(lastComment?`
${esc(stripHtml(lastComment.text||'').slice(0,52))}
`:''); + const whoLabel=lastComment + ?(lastComment.usertype===10 + ?'● Сотрудник' + :'● Клиент') + :(task.updatedby + ?`${esc(abbrevName(task.updatedby))}` + :'нет ответа'); + + return` + + + + ${sc('num') ?`#${num}${unreadDot}`:''} + ${sc('title') ?`
${esc(task.name)}
${orgName?`
${esc(orgName)}
`:''}`:''} + ${sc('intrastatus')?`${intraStatusBadge(task)}`:''} + ${sc('status') ?`${statusPickerHtml(tid)}`:''} + ${sc('client') ?`
+
${avatarInitials(authorName)}
+
+
${esc(abbrevName(authorName)||'—')}
+ ${whoLabel} +
+
`:''} + ${sc('date') ?`${lastComment?`
${fmtDateShort(new Date(lastComment.eventat))}
${fmtTime(new Date(lastComment.eventat))}
${timeAgo(new Date(lastComment.eventat))}
`:task.updatedat?`
${fmtDateShort(new Date(task.updatedat))}
${fmtTime(new Date(task.updatedat))}
${timeAgo(new Date(task.updatedat))}
`:'
'}`:''} + ${sc('created')?`${task.createdat?`
${fmtDateShort(new Date(task.createdat))}
${fmtTime(new Date(task.createdat))}
`:'
'}`:''} + ${sc('preview')?`${prevCell}${cntBadge}`:''} + `; + }).join(''); + + g('table-wrap').innerHTML=`${thead}${rows}
`; + + // Pagination bar + const pb = g('pagination-bar'); + if(pb) { + if(totalPages <= 1 || PAGE_SIZE === 0){ pb.style.display='none'; pb.innerHTML=''; } + else { + pb.style.display='flex'; + const start=(currentPage-1)*PAGE_SIZE+1, end=Math.min(currentPage*PAGE_SIZE,filteredData.length); + let pages=''; + for(let p=1;p<=totalPages;p++){ + if(p===1||p===totalPages||Math.abs(p-currentPage)<=2){ + pages+=``; + } else if(Math.abs(p-currentPage)===3){ + pages+=``; + } + } + pb.innerHTML=` + + ${pages} + + ${start}–${end} из ${filteredData.length}`; + } + } +} +function fmtDateShort(d){return d.toLocaleDateString('ru-RU',{day:'2-digit',month:'2-digit',year:'2-digit'});} +function fmtTime(d){return d.toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'});} +function toggleSelectAll(cb){document.querySelectorAll('.row-check').forEach(c=>{const i=+c.dataset.idx;if(cb.checked)selectedRows.add(i);else selectedRows.delete(i);c.checked=cb.checked;});updateBulkBar();} +function toggleRowCheck(idx,cb){if(cb.checked)selectedRows.add(idx);else selectedRows.delete(idx);updateBulkBar();} + +function updateBulkBar(){ + const info=document.getElementById('bulk-sel-info'); + const cnt =document.getElementById('bulk-sel-count'); + const sel =document.getElementById('bulk-status-sel'); + if(!info)return; + if(selectedRows.size>0){ + info.style.display='flex'; + if(cnt)cnt.textContent=`${selectedRows.size} выбрано`; + if(sel)sel.value=''; + }else{ + info.style.display='none'; + } +} + +function bulkApplyStatusFromSelect(sel){ + const v=sel.value; + sel.value=''; + if(!v)return; + bulkApplyStatus(v==='clear'?null:v); +} + +function bulkApplyStatus(markType){ + if(!selectedRows.size)return; + const now=new Date().toISOString(); + const tids=[...selectedRows].map(idx=>allData[idx]?String(allData[idx].task.id):null).filter(Boolean); + for(const tid of tids){ + if(!marksCache[tid])marksCache[tid]={}; + if(!markType){ + const active=STATUS_MARKS.find(m=>marksCache[tid][m]?.active); + STATUS_MARKS.forEach(m=>{marksCache[tid][m]={...(marksCache[tid][m]||{}),active:false};}); + if(active) apiPost('/api/marks',{task_id:tid,mark_type:active,value:false,clear_others:false}); + }else{ + STATUS_MARKS.forEach(m=>{marksCache[tid][m]={...(marksCache[tid][m]||{}),active:false};}); + marksCache[tid][markType]={active:true,by:currentUser?.username,at:now}; + apiPost('/api/marks',{task_id:tid,mark_type:markType,value:true,clear_others:true}); + } + } + renderTable(); + if(currentModalIdx!==null){ + const openTid=String(allData[currentModalIdx]?.task?.id); + if(tids.includes(openTid))renderSidePanel(currentModalIdx); + } +} + +// ── SIDE PANEL open/close ───────────────────────────────────────── +function openModal(idx){ + if(idx===null||idx===undefined||!allData[idx]) return; + currentModalIdx=idx; currentTab='last'; + const {task,lastComment}=allData[idx]; + + // Immediately mark read & update that row in-place + if(isUnread(task)){ + markSeen(task.id,task.updatedat); + const row=document.querySelector(`tbody tr[data-idx="${idx}"]`); + if(row){ + ['row-unread',...MARKS_CONFIG.map(m=>m.rowClass)].forEach(c=>row.classList.remove(c)); + const cc=rowColorClass(String(task.id)); + if(cc) row.classList.add(cc); + row.querySelector('.unread-dot-inline')?.remove(); + } + const su=document.getElementById('stat-unread'); + if(su) su.textContent=allData.filter(i=>isUnread(i.task)).length; + } + + // Mark mentions read + if(mentionsData.some(m=>String(m.task_id)===String(task.id)&&!m.read_at)){ + apiPost('/api/mentions/read',{task_id:String(task.id)}).then(r=>{ + if(r){ + mentionUnread=r.unread||0; + mentionsData=mentionsData.map(m=> + String(m.task_id)===String(task.id)?{...m,read_at:new Date().toISOString()}:m); + renderMentionsBell(); + } + }); + } + + // Highlight active row + document.querySelectorAll('.tasks-table tbody tr').forEach(r=>r.classList.remove('active-row')); + document.querySelector(`tbody tr[data-idx="${idx}"]`)?.classList.add('active-row'); + + // Show side panel + const dc=document.getElementById('detail-col'); + if(dc) dc.classList.remove('hidden'); + + renderSidePanel(idx); + loadCommentsForTask(String(task.id)); +} + +function closeModal(){ + const dc=document.getElementById('detail-col'); + if(dc) dc.classList.add('hidden'); + document.querySelectorAll('.tasks-table tbody tr').forEach(r=>r.classList.remove('active-row')); + currentModalIdx=null; +} +function closeModalOnOverlay(){} +document.addEventListener('keydown',e=>{if(e.key==='Escape')closeModal();}); + +// ── SIDE PANEL render ───────────────────────────────────────────── +function renderSidePanel(idx){ + const container=document.getElementById('detail-content'); + if(!container) return; + if(idx===null||idx===undefined||!allData[idx]){ + container.innerHTML=''; + return; + } + const {task,lastComment}=allData[idx]; + const tid=String(task.id); + const num=task.tasknumber||task.id; + const note=getNote(tid),noteBy=getNoteBy(tid),noteAt=getNoteAt(tid); + const cnt=getCommentCount(tid); + const authorName=lastComment?.username||task.username||'—'; + const rawDesc=task.description||task.descriptionhtml||task.body||task.text||''; + + const noteHtml=(openNoteIdx===idx) + ?`
+ +
+ + +
+
` + :note + ?`
📝 ${renderMentions(note)} +
${noteBy?`👤 ${esc(abbrevName(noteBy))}`:''}${noteAt?` · ${fmtDate(new Date(noteAt))}`:''}
+
` + :`
+ Добавить заметку
`; + + container.innerHTML=` +
+
+
+ ${internalStatusBadge(tid)} + ${whoAnsweredBadge(lastComment)} +
+
#${num} ${esc(task.name)}
+
${esc(abbrevName(authorName))}${task.clientname||task.organization?` · ${esc(task.clientname||task.organization)}`:''}
+
+
+ +
+
+ +
+ ${MARKS_CONFIG.map(m=>{ + const active=isMark(tid,m.key); + return``; + }).join('')} +
+ ${markSetByHtml(tid)} + + + +
${noteHtml}
+ +
+
Последний комментарий
+ +
Загрузить заявку
+
+ +
+ ${renderTabContent(idx,currentTab,rawDesc,lastComment)} +
+ + `; + + const ta=document.getElementById('composer-ta'); + if(ta){ + ta.addEventListener('keydown',e=>{ + if(e.ctrlKey&&e.key==='Enter'){e.preventDefault();submitComment();} + handleAutocompleteKey(e); + }); + } + if(openNoteIdx===idx){ + const nta=document.getElementById('detail-note-ta'); + if(nta){nta.focus();nta.setSelectionRange(nta.value.length,nta.value.length);} + } +} + +// aliases for SSE/mentions compatibility +function renderDetailPanel(idx){renderSidePanel(idx);} +function renderModalContent(idx){renderSidePanel(idx);} +function openDetail(idx){openModal(idx);} +function closeDetail(){closeModal();} + +// ── Tab content ─────────────────────────────────────────────────── +function renderTabContent(idx,tab,rawDesc,lastComment){ + const {task}=allData[idx]; + const tid=String(task.id); + + if(tab==='last'){ + const org=task.clientname||task.organization||''; + const authorName=lastComment?.username||'—'; + const created=task.createdat?new Date(task.createdat):''; + const descHtml=rawDesc + ?``:''; + const actHtml=lastComment + ?`
Последняя активность
+
+
${avatarInitials(authorName)}
+
+
${esc(abbrevName(authorName))}${timeAgo(new Date(lastComment.eventat))}
+
${esc(stripHtml(lastComment.text||'').slice(0,180))}
+
+
+
`:''; + const executorName = task._executorname || ''; + const executorGroupName = task._executorgroupname || ''; + return` +
+
+ + ${executorGroupName?`
Группа👥 ${esc(executorGroupName)}
`:''} + ${executorName?`
Исполнитель👤 ${esc(executorName)}
`:''} +
+
+ ${descHtml}${actHtml}`; + } + if(tab==='comments'){ + return renderCommentsHtml(tid,commentsCache[tid]||[]); + } + if(tab==='history'){ + setTimeout(()=>loadAndRenderHistory(idx,'sp-body'),10); + return`
Загружаю историю...
`; + } + return''; +} + +function switchTab(tab){ + currentTab=tab; + if(currentModalIdx===null) return; + ['last','comments','history'].forEach(t=>document.getElementById(`dtab-${t}`)?.classList.toggle('active',t===tab)); + const {task}=allData[currentModalIdx]; + const rawDesc=task.description||task.descriptionhtml||task.body||task.text||''; + const sb=document.getElementById('sp-body'); + if(sb) sb.innerHTML=renderTabContent(currentModalIdx,tab,rawDesc,allData[currentModalIdx].lastComment); +} + +// ── Comments ────────────────────────────────────────────────────── +function renderCommentsHtml(tid,comments){ + if(!comments.length) return`
Комментариев нет. Напишите первый!
`; + return`
+ ${comments.map(c=>{ + const own=c.author===currentUser.username; + const canEdit=own||currentUser.is_admin; + const te=c.text.replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/\n/g,'\\n'); + return`
+
+
${avatarInitials(c.author)}
+ ${esc(abbrevName(c.author))} + ${fmtDate(new Date(c.created_at))} + ${c.updated_at?`(ред.)`:''} +
+
${renderMentions(c.text)}
+ ${canEdit?`
+ ✎ Ред. + ✕ Удалить +
`:''} + +
`; + }).join('')} +
`; +} + +async function loadAndRenderHistory(idx,containerId){ + const {task}=allData[idx]; + const tid=String(task.id); + const res=await apiGet(`/api/tasks/history/${tid}`); + if(currentModalIdx!==idx||currentTab!=='history') return; + const el=document.getElementById(containerId); + if(!el) return; + const entries=res?.entries||[]; + if(!entries.length){el.innerHTML=`
История пуста
`;return;} + const rawDesc=task.description||task.descriptionhtml||task.body||task.text||''; + let db=''; + if(rawDesc) db=`
Описание
${sanitizeHtml(rawDesc)}
`; + const items=entries.map(entry=>{ + const staff=entry.usertype===10; + const evHtml=(entry.events?.data||[]).map(ev=>{ + const tn=EVENT_TYPE_NAMES[ev.type]||`тип ${ev.type}`; + const isCmt=COMMENT_TYPES.has(ev.type); + let body=''; + if(isCmt&&ev.stringvalue) body=`
${sanitizeHtml(ev.stringvalue)}
`; + else if(ev.type===10&&ev.entitylanguagevalue) body=`
«${esc(ev.entitylanguagevalue.valueold?.name?.ru||'?')}» → «${esc(ev.entitylanguagevalue.valuenew?.name?.ru||'?')}»
`; + else if(ev.entityvalue) body=`
«${esc(ev.entityvalue.valueold?.name||'?')}» → «${esc(ev.entityvalue.valuenew?.name||'?')}»
`; + return`
${esc(tn)}
${body}
`; + }).join(''); + return`
+
+
${avatarInitials(entry.username)}
+ ${esc(abbrevName(entry.username))} + ${new Date(entry.eventat).toLocaleString('ru-RU')} · ${timeAgo(new Date(entry.eventat))} +
${evHtml} +
`; + }).join(''); + el.innerHTML=`${db}
Событий: ${entries.length}
${items}`; +} + +// ── Submit comment ──────────────────────────────────────────────── +async function submitComment(){ + if(currentModalIdx===null) return; + const ta=document.getElementById('composer-ta'); + const text=ta?.value?.trim(); + if(!text) return; + const tid=String(allData[currentModalIdx].task.id); + ta.disabled=true; + const r=await apiPost('/api/comments',{task_id:tid,text}); + if(r?.id){ + if(!commentsCache[tid]) commentsCache[tid]=[]; + commentsCache[tid].push(r); + if(typeof commentCounts==='object') commentCounts[tid]=commentsCache[tid].length; + ta.value=''; + if(currentTab==='comments'){ + const sb=document.getElementById('sp-body'); + if(sb) sb.innerHTML=renderCommentsHtml(tid,commentsCache[tid]); + } + const dt=document.getElementById('dtab-comments'); + if(dt) dt.textContent=`💬 Наши (${commentsCache[tid].length})`; + renderTable(); + } else showToast('❌ Ошибка отправки','warn'); + ta.disabled=false; +} +// alias +function submitCommentFromModal(){submitComment();} + +function insertAtSymbol(){ + const ta=document.getElementById('composer-ta'); + if(!ta) return; + const p=ta.selectionStart; + ta.value=ta.value.slice(0,p)+'@'+ta.value.slice(p); + ta.selectionStart=ta.selectionEnd=p+1; + ta.focus(); onCommentInput(ta); +} + +// ── Note editor ─────────────────────────────────────────────────── +function openNoteEditor(idx,e){ + e&&e.stopPropagation(); + openNoteIdx=openNoteIdx===idx?null:idx; + if(currentModalIdx===idx) renderSidePanel(idx); + else renderTable(); +} +function closeNoteEditor(){ + openNoteIdx=null; + if(currentModalIdx!==null) renderSidePanel(currentModalIdx); + else renderTable(); +} +async function saveNoteAndClose(idx,e){ + e&&e.stopPropagation(); + const ta=document.getElementById('detail-note-ta'); + const tid=String(allData[idx].task.id); + const text=ta?ta.value:''; + if(text.trim()) notesCache[tid]={text:text.trim(),by:currentUser.username,at:new Date().toISOString()}; + else delete notesCache[tid]; + openNoteIdx=null; + if(currentModalIdx===idx) renderSidePanel(idx); + renderTable(); + const r=await apiPost('/api/notes',{task_id:tid,text}); + if(r&&!r.ok&&r.error) showToast('❌ '+r.error,'warn'); +} + +// ── Settings ────────────────────────────────────────────────────── +function openSettingsModal(){document.getElementById('settings-modal')?.classList.add('open');} +function closeSettingsModal(){document.getElementById('settings-modal')?.classList.remove('open');} + +// ── SSE refresh ─────────────────────────────────────────────────── +function refreshDetailIfOpen(tid){ + if(currentModalIdx===null) return; + if(String(allData[currentModalIdx].task.id)!==String(tid)) return; + renderSidePanel(currentModalIdx); + if(currentTab==='comments'){ + const sb=document.getElementById('sp-body'); + if(sb) sb.innerHTML=renderCommentsHtml(tid,commentsCache[tid]||[]); + } +} + +// Инициализация динамических чипов и bulk-select при загрузке DOM +document.addEventListener('DOMContentLoaded', () => { + initMarkChips(); + syncChipDropdownItems(); + // Close chip dropdown on outside click + document.addEventListener('click', e => { + if (!e.target.closest('#chip-dropdown-wrap')) { + document.getElementById('chip-dropdown-panel')?.classList.remove('chip-dd-open'); + } + }); +}); diff --git a/server.js b/server.js new file mode 100644 index 0000000..35116d6 --- /dev/null +++ b/server.js @@ -0,0 +1,597 @@ +'use strict'; + +const fs = require('fs'); +const express = require('express'); +const Database = require('better-sqlite3'); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const fetch = require('node-fetch'); +const path = require('path'); + +const DEFAULT_JWT_SECRET = 'CHANGE_ME_IN_PRODUCTION_PLEASE'; +const DEFAULT_ADMIN_SECRET = 'admin_setup_secret'; +const DB_PATH = process.env.DB_PATH || path.join('.', 'data', 'intradesk.db'); + +fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); + +const app = express(); +const db = new Database(DB_PATH); + +const JWT_SECRET = process.env.JWT_SECRET || DEFAULT_JWT_SECRET; +const ADMIN_SECRET = process.env.ADMIN_SECRET || DEFAULT_ADMIN_SECRET; +const PORT = process.env.PORT || 3000; + +if (process.env.NODE_ENV === 'production') { + if (JWT_SECRET === DEFAULT_JWT_SECRET) { + throw new Error('JWT_SECRET must be set in production'); + } + + if (ADMIN_SECRET === DEFAULT_ADMIN_SECRET) { + throw new Error('ADMIN_SECRET must be set in production'); + } +} + +// ── Schema ─────────────────────────────────────────────────────── +db.exec(` + PRAGMA journal_mode = WAL; + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS tasks_cache ( + id INTEGER PRIMARY KEY CHECK (id = 1), + data TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS marks ( + task_id TEXT NOT NULL, + mark_type TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + set_by TEXT NOT NULL, + set_at TEXT NOT NULL, + PRIMARY KEY (task_id, mark_type) + ); + + CREATE TABLE IF NOT EXISTS notes ( + task_id TEXT PRIMARY KEY, + text TEXT NOT NULL, + updated_by TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS task_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + author TEXT NOT NULL, + text TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT, + deleted INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_comments_task ON task_comments(task_id); + + CREATE TABLE IF NOT EXISTS mentions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + comment_id INTEGER, + mentioned_user TEXT NOT NULL, + mentioned_by TEXT NOT NULL, + created_at TEXT NOT NULL, + read_at TEXT + ); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS forced_comments ( + task_id TEXT PRIMARY KEY, + updatedat TEXT NOT NULL, + data TEXT, + fetched_at TEXT NOT NULL + ); +`); + +// ── Middleware ──────────────────────────────────────────────────── +app.use(express.json({ limit: '10mb' })); +app.use(express.static(path.join(__dirname, 'public'))); + +function auth(req, res, next) { + const header = req.headers.authorization || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : (req.query.token || null); + if (!token) return res.status(401).json({ error: 'Unauthorized' }); + try { req.user = jwt.verify(token, JWT_SECRET); next(); } + catch { res.status(401).json({ error: 'Token invalid or expired' }); } +} + +function adminOnly(req, res, next) { + if (req.user.username !== 'admin') return res.status(403).json({ error: 'Только для администратора' }); + next(); +} + +// ── Helpers ─────────────────────────────────────────────────────── +function getSettings() { + return Object.fromEntries(db.prepare('SELECT key,value FROM settings').all().map(r => [r.key, r.value])); +} +function setSetting(k, v) { + db.prepare('INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)').run(k, v); +} +function parseMentions(text) { + return [...new Set((text.match(/@([\w\u0400-\u04ff]+)/g) || []).map(m => m.slice(1).toLowerCase()))]; +} +// Ищем task_id по tasknumber из кэша +function taskIdByNumber(taskNumber) { + const cache = db.prepare('SELECT data FROM tasks_cache WHERE id=1').get(); + if (!cache) return null; + const tasks = JSON.parse(cache.data); + const t = tasks.find(t => String(t.tasknumber) === String(taskNumber)); + return t ? String(t.id) : null; +} +// Возвращает tasknumber по task_id из кэша +function taskNumberById(taskId) { + try { + const cache = db.prepare('SELECT data FROM tasks_cache WHERE id=1').get(); + if (!cache) return taskId; + const tasks = JSON.parse(cache.data); + const t = tasks.find(t => String(t.id) === String(taskId)); + return t ? String(t.tasknumber || t.id) : taskId; + } catch { return taskId; } +} + +// ── SSE ─────────────────────────────────────────────────────────── +const sseClients = new Map(); // key: username in lower-case +function sseAdd(u, res) { + const key = String(u).toLowerCase(); + if (!sseClients.has(key)) sseClients.set(key, new Set()); + sseClients.get(key).add(res); +} +function sseRemove(u, res) { + const key = String(u).toLowerCase(); + sseClients.get(key)?.delete(res); +} +function sseBroadcast(event, data, except = null) { + const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + const skip = except ? String(except).toLowerCase() : null; + sseClients.forEach((clients, key) => { + if (skip && key === skip) return; + clients.forEach(r => { try { r.write(msg); } catch {} }); + }); +} +function sseSendTo(u, event, data) { + const key = String(u).toLowerCase(); + const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + sseClients.get(key)?.forEach(r => { try { r.write(msg); } catch {} }); +} + +app.get('/healthz', (req, res) => { + try { + db.prepare('SELECT 1').get(); + res.json({ status: 'ok' }); + } catch (error) { + res.status(500).json({ status: 'error' }); + } +}); + +app.get('/api/events', auth, (req, res) => { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders(); + res.write(`: connected as ${req.user.username}\n\n`); + const ka = setInterval(() => { try { res.write(': ping\n\n'); } catch {} }, 25000); + sseAdd(req.user.username, res); + req.on('close', () => { clearInterval(ka); sseRemove(req.user.username, res); }); +}); + +// ── Auth ────────────────────────────────────────────────────────── +app.post('/api/auth/login', (req, res) => { + const { username, password } = req.body || {}; + if (!username || !password) return res.status(400).json({ error: 'Заполните все поля' }); + const user = db.prepare('SELECT * FROM users WHERE username=?').get(username); + if (!user || !bcrypt.compareSync(password, user.password_hash)) + return res.status(401).json({ error: 'Неверный логин или пароль' }); + const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '30d' }); + res.json({ token, username: user.username, is_admin: user.username === 'admin' }); +}); + +app.post('/api/auth/register', (req, res) => { + const { username, password, adminSecret } = req.body || {}; + const hdr = req.headers.authorization || ''; + const tok = hdr.startsWith('Bearer ') ? hdr.slice(7) : null; + let isAdmin = false; + if (tok) { try { isAdmin = jwt.verify(tok, JWT_SECRET).username === 'admin'; } catch {} } + if (adminSecret !== ADMIN_SECRET && !isAdmin) return res.status(403).json({ error: 'Нет прав' }); + if (!username || !password) return res.status(400).json({ error: 'Заполните все поля' }); + if (password.length < 6) return res.status(400).json({ error: 'Пароль минимум 6 символов' }); + try { + db.prepare('INSERT INTO users(username,password_hash) VALUES(?,?)').run(username, bcrypt.hashSync(password, 12)); + res.json({ ok: true }); + } catch { res.status(400).json({ error: 'Пользователь уже существует' }); } +}); + +app.get('/api/auth/me', auth, (req, res) => { + res.json({ username: req.user.username, is_admin: req.user.username === 'admin' }); +}); + +// ── Users ───────────────────────────────────────────────────────── +app.get('/api/users', auth, adminOnly, (req, res) => { + res.json(db.prepare('SELECT id,username,created_at FROM users').all()); +}); +app.get('/api/users/list', auth, (req, res) => { + res.json(db.prepare('SELECT username FROM users').all().map(u => u.username)); +}); +app.delete('/api/users/:id', auth, adminOnly, (req, res) => { + const u = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id); + if (!u) return res.status(404).json({ error: 'Не найден' }); + if (u.username === 'admin') return res.status(400).json({ error: 'Нельзя удалить admin' }); + db.prepare('DELETE FROM users WHERE id=?').run(req.params.id); + res.json({ ok: true }); +}); +app.put('/api/users/:id/password', auth, adminOnly, (req, res) => { + const { password } = req.body || {}; + if (!password || password.length < 6) return res.status(400).json({ error: 'Пароль минимум 6 символов' }); + const u = db.prepare('SELECT * FROM users WHERE id=?').get(req.params.id); + if (!u) return res.status(404).json({ error: 'Не найден' }); + db.prepare('UPDATE users SET password_hash=? WHERE id=?').run(bcrypt.hashSync(password, 12), req.params.id); + res.json({ ok: true }); +}); + +// ── Status ──────────────────────────────────────────────────────── +app.get('/api/status', auth, (req, res) => { + const cache = db.prepare('SELECT updated_at FROM tasks_cache WHERE id=1').get(); + const s = getSettings(); + res.json({ + last_updated: cache?.updated_at || null, + auto_refresh_interval: parseInt(s.auto_refresh_interval) || 300, + unread_mode: s.unread_mode || 'highlight', + export_fields: s.export_fields || '', + export_status_mode: s.export_status_mode || 'A', + }); +}); + +// ── Tasks ───────────────────────────────────────────────────────── +app.get('/api/tasks', auth, (req, res) => { + const cache = db.prepare('SELECT * FROM tasks_cache WHERE id=1').get(); + if (!cache) return res.json({ tasks: [], updated_at: null }); + res.json({ tasks: JSON.parse(cache.data), updated_at: cache.updated_at }); +}); + +app.post('/api/tasks/refresh', auth, async (req, res) => { + try { res.json({ ok: true, count: await fetchAndCacheTasks(), updated_at: new Date().toISOString() }); } + catch (e) { console.error('Refresh error:', e.message); res.status(500).json({ error: e.message }); } +}); + +app.post('/api/tasks/refresh/full', auth, adminOnly, async (req, res) => { + try { + const count = await fetchAndCacheAllTasks(); + res.json({ ok: true, count, updated_at: new Date().toISOString() }); + } catch (e) { console.error('Full refresh error:', e.message); res.status(500).json({ error: e.message }); } +}); + +// ── Marks ───────────────────────────────────────────────────────── +const _marksConfig = require('./public/js/statuses.js'); +const ALLOWED_MARKS = new Set(_marksConfig.map(m => m.key)); + +app.get('/api/marks', auth, (req, res) => { + const r = {}; + db.prepare('SELECT * FROM marks').all().forEach(m => { + if (!r[m.task_id]) r[m.task_id] = {}; + r[m.task_id][m.mark_type] = { active: !!m.is_active, by: m.set_by, at: m.set_at }; + }); + res.json(r); +}); + +const ALL_STATUS_MARKS = _marksConfig.map(m => m.key); + +app.post('/api/marks', auth, (req, res) => { + const { task_id, mark_type, value, clear_others } = req.body || {}; + if (!task_id || !ALLOWED_MARKS.has(mark_type)) return res.status(400).json({ error: 'Неверные параметры' }); + const now = new Date().toISOString(); + + // Если clear_others=true — сбрасываем все остальные статусы одним проходом + const clearedMarks = []; + if (value && clear_others) { + ALL_STATUS_MARKS.filter(t => t !== mark_type).forEach(t => { + const existing = db.prepare('SELECT is_active FROM marks WHERE task_id=? AND mark_type=?').get(task_id, t); + if (existing?.is_active) { + db.prepare('UPDATE marks SET is_active=0,set_by=?,set_at=? WHERE task_id=? AND mark_type=?') + .run(req.user.username, now, task_id, t); + clearedMarks.push(t); + } + }); + } + + db.prepare(`INSERT INTO marks(task_id,mark_type,is_active,set_by,set_at) VALUES(?,?,?,?,?) + ON CONFLICT(task_id,mark_type) DO UPDATE SET is_active=excluded.is_active,set_by=excluded.set_by,set_at=excluded.set_at`) + .run(task_id, mark_type, value ? 1 : 0, req.user.username, now); + + // Одно SSE-событие со списком сброшенных меток + sseBroadcast('mark', { + task_id, mark_type, value: !!value, by: req.user.username, at: now, + cleared_marks: clearedMarks, + }); + res.json({ ok: true }); +}); + +// ── Notes ───────────────────────────────────────────────────────── +app.get('/api/notes', auth, (req, res) => { + const r = {}; + db.prepare('SELECT * FROM notes').all().forEach(n => { r[n.task_id] = { text: n.text, by: n.updated_by, at: n.updated_at }; }); + res.json(r); +}); + +app.post('/api/notes', auth, (req, res) => { + const { task_id, text } = req.body || {}; + if (!task_id) return res.status(400).json({ error: 'task_id required' }); + const now = new Date().toISOString(); + const prev = db.prepare('SELECT text FROM notes WHERE task_id=?').get(task_id); + if (!text || !text.trim()) { + db.prepare('DELETE FROM notes WHERE task_id=?').run(task_id); + // Рассылаем всем (включая отправителя) для синхронизации всех вкладок + sseBroadcast('note', { task_id, text: '', by: req.user.username, at: now }); + } else { + db.prepare(`INSERT INTO notes(task_id,text,updated_by,updated_at) VALUES(?,?,?,?) + ON CONFLICT(task_id) DO UPDATE SET text=excluded.text,updated_by=excluded.updated_by,updated_at=excluded.updated_at`) + .run(task_id, text.trim(), req.user.username, now); + sseBroadcast('note', { task_id, text: text.trim(), by: req.user.username, at: now }); + try { processMentions(task_id, null, text.trim(), prev?.text || '', req.user.username, now); } catch(e) { console.error('processMentions error:', e.message); } + } + res.json({ ok: true }); +}); + +// ── Comments ────────────────────────────────────────────────────── +// GET /api/comments/counts — точный маршрут ДОЛЖЕН быть до /:taskId +app.get('/api/comments/counts', auth, (req, res) => { + const rows = db.prepare( + 'SELECT task_id, COUNT(*) as c FROM task_comments WHERE deleted=0 GROUP BY task_id' + ).all(); + const map = {}; + rows.forEach(r => { map[r.task_id] = r.c; }); + res.json(map); +}); + +// GET /api/comments/:taskId +app.get('/api/comments/:taskId', auth, (req, res) => { + const rows = db.prepare( + 'SELECT * FROM task_comments WHERE task_id=? AND deleted=0 ORDER BY created_at ASC' + ).all(req.params.taskId); + res.json(rows); +}); + +// POST /api/comments — новый комментарий +app.post('/api/comments', auth, (req, res) => { + const { task_id, text } = req.body || {}; + if (!task_id || !text?.trim()) return res.status(400).json({ error: 'Нет текста' }); + const now = new Date().toISOString(); + const info = db.prepare( + 'INSERT INTO task_comments(task_id,author,text,created_at) VALUES(?,?,?,?)' + ).run(task_id, req.user.username, text.trim(), now); + const comment = db.prepare('SELECT * FROM task_comments WHERE id=?').get(info.lastInsertRowid); + sseBroadcast('comment_new', { comment, task_number: taskNumberById(task_id) }, req.user.username); + processMentions(task_id, comment.id, text.trim(), '', req.user.username, now); + res.json(comment); +}); + +// PUT /api/comments/:id — редактировать свой +app.put('/api/comments/:id', auth, (req, res) => { + const c = db.prepare('SELECT * FROM task_comments WHERE id=? AND deleted=0').get(req.params.id); + if (!c) return res.status(404).json({ error: 'Не найден' }); + if (c.author !== req.user.username && req.user.username !== 'admin') + return res.status(403).json({ error: 'Нельзя редактировать чужой комментарий' }); + const { text } = req.body || {}; + if (!text?.trim()) return res.status(400).json({ error: 'Нет текста' }); + const now = new Date().toISOString(); + db.prepare('UPDATE task_comments SET text=?,updated_at=? WHERE id=?').run(text.trim(), now, c.id); + const updated = db.prepare('SELECT * FROM task_comments WHERE id=?').get(c.id); + sseBroadcast('comment_edit', { comment: updated, task_number: taskNumberById(c.task_id) }); + processMentions(c.task_id, c.id, text.trim(), c.text, req.user.username, now); + res.json(updated); +}); + +// DELETE /api/comments/:id — удалить свой (soft delete) +app.delete('/api/comments/:id', auth, (req, res) => { + const c = db.prepare('SELECT * FROM task_comments WHERE id=? AND deleted=0').get(req.params.id); + if (!c) return res.status(404).json({ error: 'Не найден' }); + if (c.author !== req.user.username && req.user.username !== 'admin') + return res.status(403).json({ error: 'Нельзя удалить чужой комментарий' }); + db.prepare('UPDATE task_comments SET deleted=1 WHERE id=?').run(c.id); + sseBroadcast('comment_delete', { comment_id: c.id, task_id: c.task_id, task_number: taskNumberById(c.task_id) }); + res.json({ ok: true }); +}); + +// ── Mentions ────────────────────────────────────────────────────── +function processMentions(task_id, comment_id, newText, prevText, byUser, now) { + const allUsers = db.prepare('SELECT username FROM users').all().map(u => u.username.toLowerCase()); + const newMentions = parseMentions(newText).filter(u => allUsers.includes(u) && u !== byUser.toLowerCase()); + const prevMentions= parseMentions(prevText); + const added = newMentions.filter(u => !prevMentions.includes(u)); + const taskNumber = taskNumberById(task_id); + added.forEach(uLower => { + db.prepare('DELETE FROM mentions WHERE task_id=? AND mentioned_user=? AND read_at IS NULL AND (comment_id=? OR comment_id IS NULL)') + .run(task_id, uLower, comment_id); + db.prepare('INSERT INTO mentions(task_id,comment_id,mentioned_user,mentioned_by,created_at) VALUES(?,?,?,?,?)') + .run(task_id, comment_id, uLower, byUser, now); + const unread = db.prepare('SELECT COUNT(*) as c FROM mentions WHERE mentioned_user=? AND read_at IS NULL').get(uLower).c; + sseSendTo(uLower, 'mention', { task_id, task_number: taskNumber, by: byUser, at: now, unread_count: unread }); + }); +} + +app.get('/api/mentions', auth, (req, res) => { + const rows = db.prepare('SELECT * FROM mentions WHERE mentioned_user=? ORDER BY created_at DESC').all(req.user.username); + res.json({ mentions: rows, unread: rows.filter(r => !r.read_at).length }); +}); + +app.post('/api/mentions/read', auth, (req, res) => { + const { task_id } = req.body || {}; + if (!task_id) return res.status(400).json({ error: 'task_id required' }); + db.prepare('UPDATE mentions SET read_at=? WHERE task_id=? AND mentioned_user=? AND read_at IS NULL') + .run(new Date().toISOString(), task_id, req.user.username); + const unread = db.prepare('SELECT COUNT(*) as c FROM mentions WHERE mentioned_user=? AND read_at IS NULL').get(req.user.username).c; + res.json({ ok: true, unread }); +}); + +// ── Settings ────────────────────────────────────────────────────── +const ALLOWED_SETTINGS = new Set([ + 'api_key', + 'status_ids', + 'event_types', + 'auto_refresh_interval', + 'unread_mode', + 'export_fields', + 'export_status_mode', +]); + +app.get('/api/settings', auth, adminOnly, (req, res) => { + const s = getSettings(); + res.json({ + status_ids: s.status_ids || '68051,68046', + event_types: s.event_types || '', + auto_refresh_interval: s.auto_refresh_interval || '300', + unread_mode: s.unread_mode || 'highlight', + export_fields: s.export_fields || '', + export_status_mode: s.export_status_mode || 'B', + api_key_set: !!s.api_key, + }); +}); + +app.post('/api/settings', auth, adminOnly, (req, res) => { + const body = req.body || {}; + Object.entries(body).forEach(([k, v]) => { if (ALLOWED_SETTINGS.has(k) && v !== undefined) setSetting(k, String(v)); }); + if (body.auto_refresh_interval !== undefined) setupAutoRefresh(); + res.json({ ok: true }); +}); + +// ── History proxy ───────────────────────────────────────────────── +app.get('/api/tasks/history/:taskId', auth, async (req, res) => { + console.log(`History proxy`); + const s = getSettings(); + if (!s.api_key) return res.status(400).json({ error: 'API ключ не задан' }); + const evTypes = s.event_types ? s.event_types.split(',').map(t => `events=${t.trim()}`).join('&') : ''; + const url = `https://apigw.intradesk.ru/taskhistory/api/v2.0/lifetime/${req.params.taskId}/full` + + `?ApiKey=${s.api_key}&sortDirection=Desc&top=100` + (evTypes ? '&'+evTypes : ''); + try { + const r = await fetch(url, { timeout: 15000 }); + res.json({ entries: (await r.json()).data || [] }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +// ── Last comment proxy — с кешем в БД по updatedat ─────────────── +app.get('/api/tasks/lastcomment/:taskId', auth, async (req, res) => { + console.log(`Last comment proxy`); + const s = getSettings(); + if (!s.api_key) return res.status(400).json({ error: 'API ключ не задан' }); + const taskId = req.params.taskId; + const updatedat = req.query.upd || null; + + // Проверяем кеш: если updatedat совпадает — отдаём сразу без запроса к IntraDesk + if (updatedat) { + const cached = db.prepare( + 'SELECT data FROM forced_comments WHERE task_id=? AND updatedat=?' + ).get(taskId, updatedat); + if (cached !== undefined) { + return res.json({ entries: cached.data ? JSON.parse(cached.data) : [], cached: true }); + } + } + + // Кеш-промах — идём в IntraDesk + const url = `https://apigw.intradesk.ru/taskhistory/api/v2.0/lifetime/${taskId}/full` + + `?ApiKey=${s.api_key}&sortDirection=Desc&top=5&events=50&events=55`; + try { + const r = await fetch(url, { timeout: 10000 }); + const entries = (await r.json()).data || []; + + // Сохраняем в БД (data=NULL если комментариев нет — чтобы не перезапрашивать) + if (updatedat) { + db.prepare(` + INSERT INTO forced_comments(task_id, updatedat, data, fetched_at) VALUES(?,?,?,?) + ON CONFLICT(task_id) DO UPDATE + SET updatedat=excluded.updatedat, data=excluded.data, fetched_at=excluded.fetched_at + `).run(taskId, updatedat, entries.length ? JSON.stringify(entries) : null, new Date().toISOString()); + } + + res.json({ entries }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +// ── IntraDesk fetch ─────────────────────────────────────────────── +async function buildTasksFromApi(top) { + const s = getSettings(); + if (!s.api_key) throw new Error('API ключ не задан.'); + const executorFilter = [ + 'executorgroup eq 243025', // Поддержка, 1-ая линия + 'executor eq 2227907', // Высоких Дмитрий + 'executor eq 525111', // Абрамов Сергей + 'executor eq 631481', // Железняков Антон + 'executor eq 2222953', // Коваленко Артем + ].join(' or '); + const fullFilter = `(${executorFilter})`; + const topParam = top > 0 ? top : 2000; + const url = `https://apigw.intradesk.ru/tasklist/odata/v3/tasks?ApiKey=${s.api_key}&$filter=${encodeURIComponent(`(${fullFilter})`)}&$orderby=updatedat%20desc&$top=${topParam}`; + console.log(`[IntraDesk] GET top=${topParam}`, url.replace(s.api_key, '***')); + const r = await fetch(url, { timeout: 30000 }); + console.log('[IntraDesk] Status:', r.status, r.statusText); + if (!r.ok) throw new Error(`IntraDesk вернул ${r.status}: ${r.statusText}`); + const json = await r.json(); + const tasks = json.value || []; + const dictMap = {}; + (json.dictionaries || []).forEach(d => { dictMap[d.id] = d.name; }); + tasks.forEach(task => { + if (task.clientid && dictMap[task.clientid]) task._clientname = dictMap[task.clientid]; + if (task.executor && dictMap[task.executor]) task._executorname = dictMap[task.executor]; + if (task.executorgroup && dictMap[task.executorgroup]) task._executorgroupname = dictMap[task.executorgroup]; + // API возвращает статус в поле 'status' (числовой id), иногда 'statusid' — проверяем оба + const statusKey = task.statusid || task.status; + if (statusKey && dictMap[statusKey]) task._statusname = dictMap[statusKey]; + }); + return tasks; +} + +// Quick refresh: fetch top-50 recent tasks and merge into cache +async function fetchAndCacheTasks() { + const fresh = await buildTasksFromApi(50); + const existing = (() => { + try { const c = db.prepare('SELECT data FROM tasks_cache WHERE id=1').get(); return c ? JSON.parse(c.data) : []; } + catch { return []; } + })(); + const byId = new Map(existing.map(t => [t.id, t])); + fresh.forEach(t => byId.set(t.id, t)); + const merged = [...byId.values()].sort((a, b) => (b.updatedat||'') > (a.updatedat||'') ? 1 : -1); + const now = new Date().toISOString(); + db.prepare(`INSERT INTO tasks_cache(id,data,updated_at) VALUES(1,?,?) + ON CONFLICT(id) DO UPDATE SET data=excluded.data,updated_at=excluded.updated_at`).run(JSON.stringify(merged), now); + sseBroadcast('tasks_updated', { updated_at: now, count: merged.length }); + return merged.length; +} + +// Full refresh: fetch all tasks and replace cache +async function fetchAndCacheAllTasks() { + const tasks = await buildTasksFromApi(0); + const now = new Date().toISOString(); + db.prepare(`INSERT INTO tasks_cache(id,data,updated_at) VALUES(1,?,?) + ON CONFLICT(id) DO UPDATE SET data=excluded.data,updated_at=excluded.updated_at`).run(JSON.stringify(tasks), now); + sseBroadcast('tasks_updated', { updated_at: now, count: tasks.length }); + return tasks.length; +} + +// ── Auto-refresh ────────────────────────────────────────────────── +let autoRefreshTimer = null; +function setupAutoRefresh() { + if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null; } + const interval = parseInt(getSettings().auto_refresh_interval) || 0; + if (interval <= 0) { console.log('[AutoRefresh] Выключен'); return; } + autoRefreshTimer = setInterval(async () => { + try { console.log(`[${new Date().toLocaleTimeString('ru-RU')}] AutoRefresh: ${await fetchAndCacheTasks()} заявок`); } + catch (e) { console.error('[AutoRefresh] Ошибка:', e.message); } + }, interval * 1000); + console.log(`[AutoRefresh] Каждые ${interval}с`); +} +setupAutoRefresh(); + +app.listen(PORT, () => { console.log(`\nServer listening on http://localhost:${PORT}\n`); }); diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..bebff3f --- /dev/null +++ b/start.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +cd "$SCRIPT_DIR" +mkdir -p data + +if [ ! -f .env ]; then + echo "Create .env from .env.example before starting the container." >&2 + exit 1 +fi + +docker compose up -d --build