commit 464d53c70bf71a5af6e4dfd4069e86f740130984 Author: caps Date: Wed Mar 18 14:19:29 2026 +0300 init 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