This commit is contained in:
caps 2026-03-18 14:19:29 +03:00
commit 464d53c70b
30 changed files with 7364 additions and 0 deletions

14
.dockerignore Normal file
View file

@ -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

14
.env.example Normal file
View file

@ -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

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
.env
.env.runtime
node_modules/
data/

22
.woodpecker.yml Normal file
View file

@ -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}

30
Dockerfile Normal file
View file

@ -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"]

81
README.md Normal file
View file

@ -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
```

8
deploy.sh Normal file
View file

@ -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"

36
deploy/remote-deploy.sh Normal file
View file

@ -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 <<EOF
IMAGE_REPO=${IMAGE_REPO}
IMAGE_TAG=${IMAGE_TAG}
EOF
fi
if [ ! -f .env.runtime ]; then
echo ".env.runtime is missing. Provide IMAGE_REPO and IMAGE_TAG for the deploy." >&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

15
docker-compose.deploy.yml Normal file
View file

@ -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

16
docker-compose.yml Normal file
View file

@ -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

20
package.json Normal file
View file

@ -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"
}
}

1421
public/css/app.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,82 @@
/*
keyboard-additions.css
Добавить в конец основного CSS файла или подключить отдельно:
<link rel="stylesheet" href="/css/keyboard-additions.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;
}
/* ── Мигание кнопки статуса при нажатии 16 ──────────────────────── */
@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;
}

310
public/index.html Normal file
View file

@ -0,0 +1,310 @@
<!DOCTYPE html>
<html lang="ru" id="html-root">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Заявки — CleverParking</title>
<link rel="stylesheet" href="/css/app.css">
<link rel="stylesheet" href="/css/keyboard-additions.css">
<script>if(localStorage.getItem('theme')==='dark')document.documentElement.classList.add('dark');</script>
</head>
<body>
<div class="toast-wrap" id="toast-wrap"></div>
<!-- LOGIN -->
<div class="login-overlay" id="login-overlay">
<div class="login-box">
<div class="login-logo"><div class="login-logo-icon">//</div><div class="login-logo-text">Заявки</div></div>
<div class="login-sub">Сервисная поддержка · CleverParking</div>
<div class="login-field"><label>Логин</label><input type="text" id="login-username" autocomplete="username" placeholder="username"></div>
<div class="login-field"><label>Пароль</label><input type="password" id="login-password" autocomplete="current-password" placeholder="••••••"></div>
<button class="login-btn" onclick="doLogin()">Войти</button>
<div class="login-error" id="login-error"></div>
</div>
</div>
<!-- SETTINGS MODAL -->
<div class="settings-overlay" id="settings-modal" onclick="if(event.target===this)closeSettingsModal()">
<div class="settings-box">
<div class="settings-header">
<div class="settings-title">⚙ Настройки</div>
<button class="modal-close-btn" onclick="closeSettingsModal()">×</button>
</div>
<div class="settings-body">
<div class="settings-section-title">Подключение к IntraDesk API</div>
<div class="form-row">
<div class="form-field" style="flex:2;min-width:240px;">
<label>API Key <span id="apikey-status" style="color:var(--green);font-weight:500;text-transform:none;letter-spacing:0;"></span></label>
<input type="password" id="api-key" placeholder="Введите новый ключ для обновления">
</div>
<div class="form-field"><label>Статусы (ID через запятую)</label><input type="text" id="status-ids" placeholder="68051,68046,..."></div>
</div>
<div class="form-row">
<div class="form-field"><label>Фильтр событий (пусто = все)</label><input type="text" id="event-types" placeholder="50,55,10,30"></div>
<div class="form-field"><label>Автообновление (сек, 0=выкл)</label><input type="number" id="server-refresh-interval" placeholder="300" min="0"></div>
</div>
<button class="btn btn-primary" onclick="saveSettings()">💾 Сохранить</button>
<button class="btn btn-default" onclick="loadAllTasks()" id="refresh-all-btn" style="margin-left:8px;">🔄 Обновить ВСЕ заявки</button>
<span id="refresh-all-status" style="font-size:12px;color:var(--text3);margin-left:10px;"></span>
<hr class="divider">
<div class="settings-section-title">Управление пользователями</div>
<div class="form-row">
<div class="form-field"><label>Логин</label><input type="text" id="new-user-login" placeholder="username"></div>
<div class="form-field"><label>Пароль (мин. 6 символов)</label><input type="password" id="new-user-pass" placeholder="••••••"></div>
<div style="display:flex;align-items:flex-end;padding-bottom:1px;"><button class="btn btn-default" onclick="createUser()">+ Создать</button></div>
</div>
<div id="user-msg" style="font-size:12px;min-height:16px;margin-bottom:8px;"></div>
<table class="users-table">
<thead><tr><th>Логин</th><th>Создан</th><th>Новый пароль</th><th></th></tr></thead>
<tbody id="users-tbody"></tbody>
</table>
</div>
</div>
</div>
<!-- CONFIRM MODAL -->
<div class="confirm-overlay" id="confirm-modal" onclick="if(event.target===this)this.classList.remove('open')">
<div class="confirm-box">
<div class="confirm-title">Подтверждение</div>
<div class="confirm-msg">Отметить <span id="confirm-count">0</span> заявок как прочитанных?</div>
<div class="confirm-actions">
<button class="btn btn-primary" onclick="markAllRead();document.getElementById('confirm-modal').classList.remove('open')">Да</button>
<button class="btn btn-default" onclick="document.getElementById('confirm-modal').classList.remove('open')">Отмена</button>
</div>
</div>
</div>
<!-- APP -->
<div class="app-layout" id="app-shell" style="display:none;">
<!-- скрытые элементы для обратной совместимости -->
<span id="sb-email" style="display:none;"></span>
<span id="nav-badge-tasks" style="display:none;"></span>
<div class="main-area">
<div class="topbar">
<!-- Логотип -->
<div class="topbar-logo">
<div class="topbar-logo-icon">//</div>
<span class="topbar-logo-text">Заявки</span>
</div>
<div class="topbar-sep"></div>
<!-- Навигационные табы (рядом с логотипом) -->
<div class="topbar-nav-tabs">
<button class="nav-tab nav-tab-active" id="nav-btn-tasks" onclick="closeStatsPage()">
<svg width="13" height="13" fill="none" viewBox="0 0 16 16"><rect x="1" y="2" width="14" height="2.5" rx="1" fill="currentColor"/><rect x="1" y="6.75" width="14" height="2.5" rx="1" fill="currentColor"/><rect x="1" y="11.5" width="9" height="2.5" rx="1" fill="currentColor"/></svg>
Заявки
</button>
<button class="nav-tab" id="nav-btn-stats" onclick="openStatsPage()">
<svg width="13" height="13" fill="none" viewBox="0 0 16 16"><rect x="1" y="9" width="3" height="6" rx="1" fill="currentColor"/><rect x="6" y="5" width="3" height="10" rx="1" fill="currentColor"/><rect x="11" y="1" width="3" height="14" rx="1" fill="currentColor"/></svg>
Статистика
</button>
</div>
<div style="flex:1;"></div>
<!-- Мета -->
<div class="topbar-meta">
<span>Обновлено: <span id="last-update"></span></span>
<span>Загружено: <span id="total-loaded"></span></span>
</div>
<!-- Обновить -->
<button class="btn btn-primary" id="load-btn" onclick="loadTasks()" style="font-size:12px;padding:6px 14px;">↻ Обновить</button>
<!-- Уведомления -->
<div class="topbar-notif-wrap">
<button class="topbar-icon-btn" id="notif-btn" onclick="toggleMentionsDropdown()" title="Упоминания">
<svg width="14" height="14" fill="none" viewBox="0 0 16 16"><path d="M8 2a5 5 0 00-5 5v2l-1 2h12l-1-2V7a5 5 0 00-5-5z" stroke="currentColor" stroke-width="1.3"/><path d="M6.5 13a1.5 1.5 0 003 0" stroke="currentColor" stroke-width="1.3"/></svg>
<span class="topbar-notif-badge" id="notif-badge" style="display:none;">0</span>
</button>
<div class="mentions-dropdown" id="mentions-dropdown">
<div class="mentions-dd-header">🔔 Упоминания</div>
<div id="mentions-list"></div>
</div>
</div>
<!-- Пользователь -->
<div class="topbar-user">
<div class="user-avatar" id="sb-avatar" style="width:28px;height:28px;font-size:11px;flex-shrink:0;">?</div>
<span class="topbar-username" id="sb-username"></span>
</div>
<!-- Настройки (только admin) -->
<button class="topbar-icon-btn" id="nav-settings" onclick="openSettingsModal()" style="display:none;" title="Настройки">
<svg width="15" height="15" fill="none" viewBox="0 0 16 16"><circle cx="8" cy="8" r="2" stroke="currentColor" stroke-width="1.3"/><path d="M8 2v1M8 13v1M2 8h1M13 8h1M3.5 3.5l.7.7M11.8 11.8l.7.7M3.5 12.5l.7-.7M11.8 4.2l.7-.7" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
</button>
<!-- Тёмная тема -->
<button class="topbar-icon-btn" id="theme-toggle-btn" onclick="toggleTheme()" title="Переключить тему">
<svg id="theme-icon-light" width="15" height="15" fill="none" viewBox="0 0 16 16" style="display:none;"><circle cx="8" cy="8" r="3" stroke="currentColor" stroke-width="1.4"/><path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M3.05 3.05l1.06 1.06M11.89 11.89l1.06 1.06M3.05 12.95l1.06-1.06M11.89 4.11l1.06-1.06" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
<svg id="theme-icon-dark" width="15" height="15" fill="none" viewBox="0 0 16 16"><path d="M13.5 10A6 6 0 016 2.5a6 6 0 100 11 6 6 0 007.5-3.5z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
</button>
<!-- Выйти -->
<button class="topbar-logout-btn" onclick="doLogout()">Выйти</button>
</div>
<div class="content-area" style="position:relative;">
<div class="update-banner" id="update-banner">
🔄 Появились новые данные на сервере
<button class="update-banner-btn" onclick="reloadData()">Обновить</button>
</div>
<!-- STATISTICS PAGE -->
<div id="stats-page" style="display:none;flex-direction:column;position:absolute;inset:0;overflow-y:auto;background:var(--bg);z-index:10;">
<div id="stats-content"></div>
</div>
<!-- MAIN TASKS AREA -->
<div id="tasks-main-area" style="display:contents;">
<!-- TASKS SPLIT (list + side panel) -->
<div class="tasks-split">
<!-- TASKS PANEL -->
<div class="tasks-panel">
<!-- Filters -->
<div class="filter-area">
<!-- Tab bar: Все / Открытые / Выполненные / Пустые -->
<div class="tab-bar" id="main-tab-bar">
<button class="tab-btn tab-btn-active" id="tabbtn-all" onclick="setMainTab('all',this)">Все заявки <span class="tab-cnt" id="tcnt-all">0</span></button>
<button class="tab-btn" id="tabbtn-open" onclick="setMainTab('open',this)">Открытые <span class="tab-cnt" id="tcnt-open">0</span></button>
<button class="tab-btn" id="tabbtn-done" onclick="setMainTab('done',this)">Выполненные <span class="tab-cnt" id="tcnt-done">0</span></button>
<button class="tab-btn" id="tabbtn-empty" onclick="setMainTab('empty',this)">Пустые <span class="tab-cnt" id="tcnt-empty">0</span></button>
</div>
<div class="filter-row">
<select class="filter-select" id="date-filter" onchange="renderTable()">
<option value="0">Все даты</option>
<option value="today">Сегодня</option>
<option value="yesterday">Вчера</option>
<option value="3">3 дня</option>
<option value="7">Неделя</option>
<option value="14">2 недели</option>
<option value="30">Месяц</option>
</select>
<select class="filter-select" id="opt-page-size" onchange="onPageSizeChange()">
<option value="25">25 на стр.</option>
<option value="50" selected>50 на стр.</option>
<option value="100">100 на стр.</option>
<option value="250">250 на стр.</option>
<option value="500">500 на стр.</option>
<option value="0">Все</option>
</select>
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text3);cursor:pointer;white-space:nowrap;">
<input type="checkbox" id="opt-remote-only" onchange="renderTable()" style="accent-color:var(--accent);"> Удалённая техподдержка
</label>
<!-- Status filter dropdown -->
<div class="chip-dropdown-wrap" id="chip-dropdown-wrap">
<button class="chip-dropdown-btn" id="chip-dropdown-btn" onclick="toggleChipDropdown()">
<span id="chip-dropdown-label">Все статусы</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 4l4 4 4-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div class="chip-dropdown-panel" id="chip-dropdown-panel">
<div class="chip-dd-item chip-dd-active" id="chipdd-all" onclick="toggleAllChip()">
<span class="chip-dd-dot" style="background:var(--accent)"></span>
<span class="chip-dd-lbl">Все</span>
<span class="chip-dd-cnt" id="chip-all-cnt">0</span>
</div>
<div id="mark-chips-container"></div>
<div class="chip-dd-item" id="chipdd-unmarked" onclick="chipClickLabel('unmarked')">
<span class="chip-dd-dot" style="background:var(--text2)"></span>
<span class="chip-dd-lbl">Без статуса</span>
<span class="chip-dd-cnt" id="chip-unmarked-cnt">0</span>
</div>
</div>
</div>
<input class="filter-search" type="text" id="search-input" placeholder="Поиск..." oninput="renderTable()">
</div>
</div>
<!-- Bulk bar -->
<div class="bulk-bar">
<label class="bulk-check-wrap"><input type="checkbox" id="check-all" onchange="toggleSelectAll(this)"> Выбрать все</label>
<button class="bulk-action-btn" onclick="promptMarkAllRead()">✓ Прочитано</button>
<button class="bulk-action-btn" onclick="exportCSV()">↓ CSV</button>
<div class="bulk-sel-info" id="bulk-sel-info" style="display:none;">
<span class="bulk-sel-count" id="bulk-sel-count">0 выбрано</span>
<span class="bulk-sep">·</span>
<span class="bulk-status-label">Статус:</span>
<!-- options generated from MARKS_CONFIG by initMarkChips() -->
<select class="filter-select bulk-status-sel" id="bulk-status-sel" onchange="bulkApplyStatusFromSelect(this)">
<option value="" disabled selected>— установить —</option>
<option value="clear">✕ Снять статус</option>
</select>
</div>
<div class="tf-info" id="tf-info"></div>
<!-- Column chooser -->
<!-- <div class="col-chooser-wrap">
<button class="col-chooser-btn" onclick="toggleColMenu(event)">
<svg width="12" height="12" fill="none" viewBox="0 0 12 12"><rect x="1" y="2" width="10" height="1.5" rx=".75" fill="currentColor"/><rect x="1" y="5.25" width="7" height="1.5" rx=".75" fill="currentColor"/><rect x="1" y="8.5" width="5" height="1.5" rx=".75" fill="currentColor"/></svg>
Колонки
</button>
<div class="col-menu" id="col-menu"></div>
</div> -->
</div>
<!-- Table -->
<div class="table-wrap" id="table-wrap">
<div class="state-box"><div class="spinner"></div><div class="msg">Загружаю заявки...</div></div>
</div>
<!-- Pagination -->
<div class="pagination-bar" id="pagination-bar" style="display:none;"></div>
</div><!-- /tasks-panel -->
<!-- DETAIL SIDE PANEL -->
<div class="detail-col hidden" id="detail-col">
<div id="detail-content" style="display:flex;flex-direction:column;height:100%;overflow:hidden;"></div>
</div>
</div><!-- /tasks-split -->
</div><!-- /tasks-main-area -->
</div><!-- /content-area -->
</div><!-- /main-area -->
</div><!-- /app-layout -->
<script src="/js/statuses.js"></script>
<script src="/js/state.js"></script>
<script src="/js/api.js"></script>
<script src="/js/sse.js"></script>
<script src="/js/mentions.js"></script>
<script src="/js/notes.js"></script>
<script src="/js/comments.js"></script>
<script src="/js/ui.js"></script>
<script src="/js/export.js"></script>
<script src="/js/auth.js"></script>
<script src="/js/diff.js"></script>
<script src="/js/keyboard.js"></script>
<script src="/js/statistics.js"></script>
<script>
// ── Theme ──────────────────────────────────────────────────────────
(function() {
if (localStorage.getItem('theme') === 'dark') {
document.documentElement.classList.add('dark');
}
})();
function updateThemeIcon() {
const dark = document.documentElement.classList.contains('dark');
const li = document.getElementById('theme-icon-light');
const di = document.getElementById('theme-icon-dark');
if (li) li.style.display = dark ? 'block' : 'none';
if (di) di.style.display = dark ? 'none' : 'block';
}
function toggleTheme() {
const dark = document.documentElement.classList.toggle('dark');
localStorage.setItem('theme', dark ? 'dark' : 'light');
updateThemeIcon();
}
window.addEventListener('DOMContentLoaded', updateThemeIcon);
// ── Stats page toggle ──────────────────────────────────────────────
function toggleStatsPage() {
if (statsPageOpen) closeStatsPage();
else openStatsPage();
const btn = document.getElementById('stats-nav-btn');
if (btn) btn.classList.toggle('active', statsPageOpen);
}
</script>
</body>
</html>

1610
public/index_old.html Normal file

File diff suppressed because it is too large Load diff

26
public/js/api.js Normal file
View file

@ -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; }
}

233
public/js/auth.js Normal file
View file

@ -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=>`
<tr>
<td style="font-weight:600;">${esc(u.username)}</td>
<td>${u.created_at?new Date(u.created_at).toLocaleDateString('ru-RU'):'—'}</td>
<td><input class="inline-pass" type="password" id="pass-${u.id}" placeholder="новый пароль">
<button class="btn btn-default" style="font-size:11px;padding:4px 10px;margin-left:6px;" onclick="changePassword(${u.id})">Сменить</button></td>
<td>${u.username!=='admin'?`<button class="btn btn-danger" style="font-size:11px;padding:4px 10px;" onclick="deleteUser(${u.id},'${esc(u.username)}')">Удалить</button>`:''}</td>
</tr>`).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');
});

94
public/js/comments.js Normal file
View file

@ -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&&currentTab==='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=>`<div class="autocomplete-item" data-user="${esc(u)}" onclick="insertMention('${esc(u)}')">${esc(u)}</div>`).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<items.length-1){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();
}

157
public/js/diff.js Normal file
View file

@ -0,0 +1,157 @@
// ═══════════════════════════════════════════════════════════════════
// diff.js — Безопасное diff-обновление списка заявок
//
// Подключить в index.html ПОСЛЕ state.js и keyboard.js:
// <script src="/js/diff.js"></script>
//
// Заменяет прямое присваивание 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);
}
};
})();

118
public/js/export.js Normal file
View file

@ -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');
}

305
public/js/keyboard.js Normal file
View file

@ -0,0 +1,305 @@
// ═══════════════════════════════════════════════════════════════════
// keyboard.js — Централизованный менеджер клавиатурных шорткатов
// Подключить в index.html ПОСЛЕ всех остальных скриптов:
// <script src="/js/keyboard.js"></script>
// ═══════════════════════════════════════════════════════════════════
// ── Константы навигации ────────────────────────────────────────────
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: Быстрая смена статуса клавишами 16
// ═══════════════════════════════════════════════════════════════════
// Порядок статусов определяется полем 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);

60
public/js/mentions.js Normal file
View file

@ -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='<div class="mentions-empty">Нет упоминаний</div>';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`<div class="mention-item ${unread?'unread':''}" onclick="jumpToMention('${m.task_id}')">
<span class="m-dot ${unread?'':'read'}"></span>
<span class="m-task">#${num}</span>
<span class="m-by"> ${esc(m.mentioned_by)}</span>
<span class="m-time">${timeAgo(new Date(m.created_at))}</span>
</div>`;
}).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);
}
}

60
public/js/notes.js Normal file
View file

@ -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); }

115
public/js/sse.js Normal file
View file

@ -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;}
}

182
public/js/state.js Normal file
View file

@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
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,'<span class="mention-tag">@$1</span>');
}
function showLoading() {
document.getElementById('table-wrap').innerHTML=`<div class="state-box"><div class="spinner"></div><div class="msg">Загружаю заявки...</div></div>`;
}
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('');
}

642
public/js/statistics.js Normal file
View file

@ -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<wEnd;}).length);
wClosed.push(data.filter(({task:t})=>{const c=new Date(t.resolutiondatefact||t.closedat||0);return c>=wStart&&c<wEnd&&isTaskDone({task:t});}).length);
}
const maxOpen = Math.max(...wOpen,1);
const maxDual = Math.max(...wCreated,...wClosed,1);
// Воронка
const FB = [
{label:'< 1 часа', max:60, color:'#0d9488'},
{label:'< 4 часов',max:240, color:'#0284c7'},
{label:'< 1 дня', max:1440, color:'var(--accent)'},
{label:'< 3 дней', max:4320, color:'#d97706'},
{label:'< недели', max:10080, color:'var(--orange)'},
{label:'< месяца', max:43200, color:'var(--red)'},
{label:'≥ месяца', max:Infinity,color:'#820014'},
];
FB.forEach(b=>b.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&&c<wEnd;});
expWBase.push(inWk.length);
expWCounts.push(inWk.filter(({task:t})=>t.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:'23', max:3, count:0, color:'var(--accent)'},
{label:'46', 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 = `
<div class="stats-inner">
<!-- KPI -->
<div class="stats-kpi-row">
${kpi('blue', iconGrid(), total, 'Всего заявок')}
${kpi('accent',iconOpen(), openCount, 'Открытых')}
${kpi('green', iconDone(), doneCount, 'Выполненных')}
${kpi('orange',`<svg width="24" height="24" fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.8"/><path d="M12 7v5l3 3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>`,
`${noExecPct}%`, 'Без исполнителя')}
${kpi('red',`<svg width="24" height="24" fill="none" viewBox="0 0 24 24"><path d="M12 8v4l3 3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.8"/></svg>`,
`${expiredPct}%`, 'Просрочено')}
${kpi('teal',`<svg width="24" height="24" fill="none" viewBox="0 0 24 24"><path d="M5 12l5 5L20 7" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
`${sameDayPct}%`, 'Закрыто в день')}
${kpi('indigo',`<svg width="24" height="24" fill="none" viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" stroke="currentColor" stroke-width="1.8"/><circle cx="9" cy="7" r="4" stroke="currentColor" stroke-width="1.8"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>`,
`${repeatPct}%`, 'Повторных клиентов')}
</div>
<!-- Два доната + Тайминги -->
<div class="stats-charts-row">
<div class="stats-card" style="flex:1;min-width:240px;">
<div class="stats-card-title">Метки все заявки</div>
<div class="stats-donut-wrap">${renderDonut(sc,uc,total)}</div>
<div class="stats-legend">${donutLegend(sc,uc,total)}</div>
</div>
<div class="stats-card" style="flex:1;min-width:240px;">
<div class="stats-card-title">Метки только открытые</div>
<div class="stats-donut-wrap">${renderDonut(scOpen,ucOpen,openCount)}</div>
<div class="stats-legend">${donutLegend(scOpen,ucOpen,openCount)}</div>
</div>
<div class="stats-card" style="flex:2;min-width:300px;">
<div class="stats-card-title" style="justify-content:space-between;">
<span> Время реагирования и закрытия</span>
<div class="stats-period-tabs">
${PERIODS.map((p,i)=>`<button class="stats-period-tab${i===timingPeriodIdx?' active':''}" onclick="timingPeriodIdx=${i};renderTimingCard()">${p.label}</button>`).join('')}
</div>
</div>
<div id="stats-timing-body">${renderTimingBody(AP)}</div>
</div>
</div>
<!-- Открытые на конец недели -->
<div class="stats-card">
<div class="stats-card-title">📈 Открытых заявок на конец недели</div>
<div class="stats-line-chart" id="stats-line-chart">
<div class="stats-line-tooltip" id="line-tooltip" style="display:none;"></div>
<svg class="stats-svg" viewBox="0 0 ${WEEKS*60} 130" preserveAspectRatio="none">
<defs><linearGradient id="lineGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="var(--accent)" stop-opacity="0.18"/><stop offset="100%" stop-color="var(--accent)" stop-opacity="0.01"/></linearGradient></defs>
${(()=>{ 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'<path d="'+area+'" fill="url(#lineGrad)"/><path d="'+line+'" fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>'+pts.map(([x,y],i)=>'<circle class="line-dot" cx="'+x+'" cy="'+y+'" r="5" fill="var(--accent)" stroke="var(--surface)" stroke-width="2" data-val="'+wOpen[i]+'" data-lbl="'+wLabels[i]+'" onmouseenter="showLineTip(event,this)" onmouseleave="hideLineTip()"/>').join(''); })()}
</svg>
<div class="stats-line-xlabels">${wLabels.map((l,i)=>i%2===0?`<span>${l}</span>`:'<span></span>').join('')}</div>
</div>
</div>
<!-- Создано / Закрыто -->
<div class="stats-card">
<div class="stats-card-title">
📊 Создано / Закрыто по неделям
<span class="stats-legend-inline"><span style="background:var(--accent);"></span> создано</span>
<span class="stats-legend-inline"><span style="background:var(--green);"></span> закрыто</span>
</div>
<div class="stats-activity-chart">
${wCreated.map((cr,i)=>{ const cl=wClosed[i],hCr=Math.round(cr/maxDual*100),hCl=Math.round(cl/maxDual*100); return'<div class="stats-activity-col"><div class="stats-activity-bar-wrap" style="position:relative;"><div class="stats-activity-bar" style="height:'+Math.max(hCr,cr?3:0)+'%;position:absolute;bottom:0;left:2%;width:44%;"></div><div class="stats-activity-bar stats-activity-bar-green" style="height:'+Math.max(hCl,cl?3:0)+'%;position:absolute;bottom:0;right:2%;width:44%;opacity:.8;"></div></div><div class="stats-activity-count"></div><div class="stats-activity-label">'+wLabels[i]+'</div></div>'; }).join('')}
</div>
</div>
<!-- Исполнители + Воронка + Возраст -->
<div class="stats-charts-row">
<div class="stats-card" style="flex:1;min-width:220px;">
<div class="stats-card-title">👤 Нагрузка исполнителей</div>
${eList.length===0?`<div class="stats-empty">Нет данных</div>`:`<div class="stats-bar-chart">${eList.map(([name,v])=>{ const tot=v.open+v.done,pct=tot/maxE*100,opPct=tot?v.open/tot*100:0; return'<div class="stats-bar-row"><div class="stats-bar-label" title="'+escHtml(name)+'">'+escHtml(name)+'</div><div class="stats-bar-track" style="position:relative;"><div class="stats-exec-bar-done" style="width:'+pct+'%;"></div><div class="stats-exec-bar-open" style="width:'+pct*opPct/100+'%;"></div></div><div style="display:flex;flex-direction:column;align-items:flex-end;gap:1px;flex-shrink:0;min-width:72px;"><span style="font-size:11px;font-weight:700;color:var(--accent);">'+v.open+' откр.</span><span style="font-size:10px;color:var(--text3);">'+v.done+' завер.</span></div></div>'; }).join('')}<div class="stats-exec-legend"><span class="stats-exec-leg-item"><span class="stats-exec-leg-dot" style="background:var(--accent);"></span>Открытые</span><span class="stats-exec-leg-item"><span class="stats-exec-leg-dot" style="background:var(--green);opacity:.45;"></span>Завершённые</span></div></div>`}
</div>
<div class="stats-card" style="flex:1;min-width:200px;">
<div class="stats-card-title"> Воронка времени жизни</div>
${fTotal===0?`<div class="stats-empty">Нет закрытых заявок</div>`:`<div class="stats-bar-chart">${FB.map(b=>{const pct=b.count/maxF*100,share=fTotal?Math.round(b.count/fTotal*100):0;return'<div class="stats-bar-row"><div class="stats-bar-label">'+b.label+'</div><div class="stats-bar-track"><div class="stats-bar-fill" style="width:'+Math.max(pct,b.count?2:0)+'%;background:'+b.color+';"></div></div><div class="stats-bar-count">'+b.count+'</div><div class="stats-bar-pct">'+share+'%</div></div>';}).join('')}</div>`}
</div>
<div class="stats-card" style="flex:1;min-width:200px;">
<div class="stats-card-title">📅 Возраст заявок</div>
<div class="stats-bar-chart">${AB.map(b=>{const share=total?Math.round(b.count/total*100):0;return'<div class="stats-bar-row"><div class="stats-bar-label">'+b.label+'</div><div class="stats-bar-track"><div class="stats-bar-fill" style="width:'+Math.max(b.count/maxAB*100,b.count?2:0)+'%;background:'+b.color+';"></div></div><div class="stats-bar-count">'+b.count+'</div><div class="stats-bar-pct">'+share+'%</div></div>';}).join('')}</div>
</div>
</div>
<!-- Клиенты -->
<div class="stats-card">
<div class="stats-clients-header">
<span class="stats-card-title" style="margin:0;flex:1;">🔁 Клиенты</span>
<label class="stats-clients-check-wrap">
<input type="checkbox" id="clients-only-open" ${clientsOnlyOpen?'checked':''} onchange="clientsOnlyOpen=this.checked;renderClientsList()">
Только с открытыми
</label>
<button class="stats-clients-toggle" id="clients-expand-btn" onclick="clientsExpanded=!clientsExpanded;this.textContent=clientsExpanded?'▲ Свернуть':'▼ Все клиенты';renderClientsList()">${clientsExpanded?'▲ Свернуть':'▼ Все клиенты'}</button>
</div>
<div id="stats-clients-list-wrap"></div>
</div>
<!-- -->
<!-- НОВЫЕ КАРТОЧКИ -->
<!-- -->
<!-- 1. Тепловая карта активности -->
<div class="stats-card">
<div class="stats-card-title">🌡 Тепловая карта активности (час × день недели)</div>
<div class="stats-heatmap-wrap">
<div class="stats-heatmap-hours">
${Array.from({length:24},(_,h)=>`<div class="stats-hm-hlbl">${h}</div>`).join('')}
</div>
${HM_ORDER.map((dayIdx,i)=>{
const row=HM[dayIdx];
return'<div class="stats-hm-row"><div class="stats-hm-dlbl">'+HM_LABELS[i]+'</div>'+row.map((v,h)=>'<div class="stats-hm-cell" style="--hi:'+((v/hmMax)).toFixed(3)+';" title="'+HM_LABELS[i]+' '+h+':00 — '+v+' заявок"><span>'+(v||'')+'</span></div>').join('')+'</div>';
}).join('')}
<div class="stats-hm-legend">
<span>меньше</span>
${[0,.2,.4,.6,.8,1].map(hi=>`<div class="stats-hm-leg-cell" style="--hi:${hi};"></div>`).join('')}
<span>больше</span>
</div>
</div>
</div>
<!-- 5. Тип проблемы + 11. Время жизни по типу + 15. Тип поддержки -->
<div class="stats-charts-row">
<div class="stats-card" style="flex:1;min-width:220px;">
<div class="stats-card-title">🏷 Тип проблемы</div>
<div class="stats-bar-chart">
${allCats.map(c=>{ const pct=c.count/maxCat*100, share=total?Math.round(c.count/total*100):0; return'<div class="stats-bar-row"><div class="stats-bar-label">'+c.label+'</div><div class="stats-bar-track"><div class="stats-bar-fill" style="width:'+Math.max(pct,c.count?2:0)+'%;background:'+c.color+';"></div></div><div class="stats-bar-count">'+c.count+'</div><div class="stats-bar-pct">'+share+'%</div></div>'; }).join('')}
</div>
</div>
<div class="stats-card" style="flex:1;min-width:220px;">
<div class="stats-card-title"> Время закрытия по типу (медиана)</div>
${allCats.filter(c=>c.times&&c.times.length>0).length===0
?`<div class="stats-empty">Нет закрытых заявок</div>`
:`<div class="stats-bar-chart">${allCats.map(c=>{ const m=med(c.times); if(!m)return''; const pct=m/maxCatMed*100; return'<div class="stats-bar-row"><div class="stats-bar-label">'+c.label+'</div><div class="stats-bar-track"><div class="stats-bar-fill" style="width:'+Math.max(pct,2)+'%;background:'+c.color+';"></div></div><div class="stats-bar-count" style="min-width:52px;font-size:10px;">'+fmtDur(m)+'</div></div>'; }).join('')}</div><div class="stats-timing-note">Медиана по закрытым заявкам каждого типа</div>`}
</div>
<div class="stats-card" style="flex:1;min-width:200px;">
<div class="stats-card-title">🔧 Тип поддержки</div>
<div class="stats-bar-chart">
<div class="stats-bar-row"><div class="stats-bar-label">Удалённая</div><div class="stats-bar-track"><div class="stats-bar-fill" style="width:${Math.max(svcRemote/svcMax*100,svcRemote?2:0)}%;background:#0284c7;"></div></div><div class="stats-bar-count">${svcRemote}</div><div class="stats-bar-pct">${total?Math.round(svcRemote/total*100):0}%</div></div>
<div class="stats-bar-row"><div class="stats-bar-label">Выездная</div><div class="stats-bar-track"><div class="stats-bar-fill" style="width:${Math.max(svcField/svcMax*100,svcField?2:0)}%;background:#d97706;"></div></div><div class="stats-bar-count">${svcField}</div><div class="stats-bar-pct">${total?Math.round(svcField/total*100):0}%</div></div>
${svcOther>0?`<div class="stats-bar-row"><div class="stats-bar-label">Прочие</div><div class="stats-bar-track"><div class="stats-bar-fill" style="width:${Math.max(svcOther/svcMax*100,2)}%;background:var(--border2);"></div></div><div class="stats-bar-count">${svcOther}</div><div class="stats-bar-pct">${total?Math.round(svcOther/total*100):0}%</div></div>`:''}
</div>
<div class="stats-timing-note">84683 удалённая · 84682 выездная</div>
</div>
</div>
<!-- 8. Эмоции + 9. Оценки + 20. Вложения -->
<div class="stats-charts-row">
<div class="stats-card" style="flex:1;min-width:220px;">
<div class="stats-card-title">😊 Эмоциональная оценка ИИ</div>
${emoList.length===0
?`<div class="stats-empty">Нет данных aiemotionalassessment</div>`
:`<div class="stats-bar-chart">${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'<div class="stats-bar-row"><div class="stats-bar-label">'+escHtml(label)+'</div><div class="stats-bar-track"><div class="stats-bar-fill" style="width:'+Math.max(pct,cnt?2:0)+'%;background:'+color+';"></div></div><div class="stats-bar-count">'+cnt+'</div><div class="stats-bar-pct">'+share+'%</div></div>'; }).join('')}</div><div class="stats-timing-note">${emoTotal} из ${total} заявок имеют оценку</div>`}
</div>
<div class="stats-card" style="flex:1;min-width:200px;">
<div class="stats-card-title"> Оценки от клиентов (evaluation)</div>
<div style="display:flex;flex-direction:column;gap:16px;margin-top:6px;">
<div>
<div style="font-size:11px;color:var(--text3);margin-bottom:6px;">Конверсия закрытых оценка</div>
<div style="display:flex;align-items:center;gap:10px;">
<div style="flex:1;height:10px;background:var(--surface2);border-radius:5px;overflow:hidden;">
<div style="width:${evalPct}%;height:100%;background:#d97706;border-radius:5px;transition:width .5s;"></div>
</div>
<div style="font-size:16px;font-weight:800;color:#d97706;min-width:44px;text-align:right;">${evalPct}%</div>
</div>
<div style="font-size:11px;color:var(--text3);margin-top:4px;">${evalCount} из ${doneCount} закрытых</div>
</div>
${evalAvg?`<div><div style="font-size:11px;color:var(--text3);margin-bottom:4px;">Средний балл</div><div style="font-size:32px;font-weight:800;color:#d97706;line-height:1;">${evalAvg}</div><div style="font-size:11px;color:var(--text3);">по ${evalCount} оценкам</div></div>`:`<div class="stats-empty" style="padding:8px 0;">Оценок пока нет</div>`}
</div>
</div>
<div class="stats-card" style="flex:1;min-width:200px;">
<div class="stats-card-title">📎 Вложения на заявку</div>
<div class="stats-bar-chart">
${ATT_B.map(b=>{ const pct=b.count/maxAtt*100, share=total?Math.round(b.count/total*100):0; return'<div class="stats-bar-row"><div class="stats-bar-label">'+b.label+'</div><div class="stats-bar-track"><div class="stats-bar-fill" style="width:'+Math.max(pct,b.count?2:0)+'%;background:'+b.color+';"></div></div><div class="stats-bar-count">'+b.count+'</div><div class="stats-bar-pct">'+share+'%</div></div>'; }).join('')}
</div>
<div class="stats-timing-note">Косвенный показатель сложности заявки</div>
</div>
</div>
<!-- 3. Время первого ответа по исполнителю + 17. Просроченные дедлайны -->
<div class="stats-charts-row">
<div class="stats-card" style="flex:1;min-width:220px;">
<div class="stats-card-title"> Время первого ответа по исполнителю</div>
${execRxList.length===0
?`<div class="stats-empty">Нет данных по реакции</div>`
:`<div class="stats-bar-chart">${execRxList.map(e=>{ const pct=e.med/maxRxMed*100; return'<div class="stats-bar-row"><div class="stats-bar-label" title="'+escHtml(e.name)+'">'+escHtml(e.name)+'</div><div class="stats-bar-track"><div class="stats-bar-fill" style="width:'+Math.max(pct,2)+'%;background:var(--accent);"></div></div><div class="stats-bar-count" style="min-width:52px;font-size:10px;">'+fmtDur(e.med)+'</div><div class="stats-bar-pct" style="font-size:10px;">'+e.count+'шт</div></div>'; }).join('')}</div><div class="stats-timing-note">Медиана времени реакции · сортировка от быстрых к медленным</div>`}
</div>
<div class="stats-card" style="flex:2;min-width:280px;">
<div class="stats-card-title" style="justify-content:space-between;">
🚨 Просроченные дедлайны по неделям
<span style="font-size:12px;font-weight:700;color:var(--red);">${expiredTotal} всего (${expiredPct}%)</span>
</div>
<div class="stats-activity-chart">
${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'<div class="stats-activity-col"><div class="stats-activity-bar-wrap"><div class="stats-activity-bar" style="height:'+h+'%;position:absolute;bottom:0;left:10%;width:80%;background:'+color+';"></div></div><div class="stats-activity-count" style="font-size:9px;color:'+(cnt?'var(--text2)':'transparent')+' ;">'+cnt+'</div><div class="stats-activity-label">'+expWLabels[i]+'</div></div>'; }).join('')}
</div>
<div class="stats-timing-note">isresolutionexpired = true · по дате создания заявки</div>
</div>
</div>
<!-- 18. Приоритетные клиенты + 19. Нерабочее время -->
<div class="stats-charts-row">
<div class="stats-card" style="flex:1;min-width:220px;">
<div class="stats-card-title">🔥 Клиенты с приоритетными заявками</div>
${prioList.length===0
?`<div class="stats-empty">Нет заявок с полем priority</div>`
:`<div class="stats-bar-chart">${prioList.map(([name,v])=>{ const pct=v.count/maxPrio*100, opPct=v.count?v.open/v.count*100:0; return'<div class="stats-bar-row"><div class="stats-bar-label" title="'+escHtml(name)+'">'+escHtml(name)+'</div><div class="stats-bar-track" style="position:relative;"><div style="position:absolute;left:0;top:0;height:100%;width:'+pct+'%;background:color-mix(in srgb,var(--red) 25%,var(--surface2));border-radius:5px;"></div><div style="position:absolute;left:0;top:0;height:100%;width:'+(pct*opPct/100)+'%;background:var(--red);border-radius:5px;transition:width .5s;"></div></div><div class="stats-bar-count">'+v.count+'</div>'+(v.open?'<span style="font-size:10px;color:var(--red);background:color-mix(in srgb,var(--red) 12%,transparent);padding:1px 5px;border-radius:8px;flex-shrink:0;">'+v.open+' откр.</span>':'')+'</div>'; }).join('')}</div><div class="stats-timing-note">Заявки с ненулевым полем priority</div>`}
</div>
<div class="stats-card" style="flex:1;min-width:220px;">
<div class="stats-card-title">🌙 Активность в нерабочее время</div>
<div style="display:flex;flex-direction:column;gap:18px;margin-top:8px;">
<div>
<div style="font-size:11px;color:var(--text3);margin-bottom:6px;">Ночные заявки (22:00 07:00)</div>
<div style="display:flex;align-items:center;gap:10px;">
<div style="flex:1;height:10px;background:var(--surface2);border-radius:5px;overflow:hidden;">
<div style="width:${nightPct}%;height:100%;background:#7c3aed;border-radius:5px;transition:width .5s;"></div>
</div>
<div style="font-size:16px;font-weight:800;color:#7c3aed;min-width:44px;text-align:right;">${nightPct}%</div>
</div>
<div style="font-size:11px;color:var(--text3);margin-top:3px;">${nightCount} заявок из ${total}</div>
</div>
<div>
<div style="font-size:11px;color:var(--text3);margin-bottom:6px;">Выходные дни (сб / вс)</div>
<div style="display:flex;align-items:center;gap:10px;">
<div style="flex:1;height:10px;background:var(--surface2);border-radius:5px;overflow:hidden;">
<div style="width:${weekendPct}%;height:100%;background:#0284c7;border-radius:5px;transition:width .5s;"></div>
</div>
<div style="font-size:16px;font-weight:800;color:#0284c7;min-width:44px;text-align:right;">${weekendPct}%</div>
</div>
<div style="font-size:11px;color:var(--text3);margin-top:3px;">${weekendCount} заявок из ${total}</div>
</div>
</div>
<div class="stats-timing-note">Аргумент для обоснования дежурства / доп. тарификации</div>
</div>
</div>
</div>`;
renderClientsList();
saveStatsCache();
}
// Тайминги — body
function renderTimingBody(p) {
if (!p.reaction.length && !p.resolution.length)
return `<div class="stats-empty">Нет данных за этот период</div>`;
return `
<div class="stats-timing-rows">
<div class="stats-timing-row-label"> Время реакции</div>
<div class="stats-timing-grid" style="margin-bottom:10px;">
<div class="stats-timing-cell"><div class="stats-timing-val">${fmtDur(avg(p.reaction))}</div><div class="stats-timing-lbl">Среднее</div></div>
<div class="stats-timing-cell"><div class="stats-timing-val" style="color:#0d9488;">${fmtDur(med(p.reaction))}</div><div class="stats-timing-lbl">Медиана</div></div>
<div class="stats-timing-cell stats-timing-muted"><div class="stats-timing-val">${p.reaction.length}</div><div class="stats-timing-lbl">Заявок</div></div>
</div>
<div class="stats-timing-row-label">🏁 Время до закрытия</div>
<div class="stats-timing-grid">
<div class="stats-timing-cell"><div class="stats-timing-val">${fmtDur(avg(p.resolution))}</div><div class="stats-timing-lbl">Среднее</div></div>
<div class="stats-timing-cell"><div class="stats-timing-val" style="color:#0d9488;">${fmtDur(med(p.resolution))}</div><div class="stats-timing-lbl">Медиана</div></div>
<div class="stats-timing-cell stats-timing-muted"><div class="stats-timing-val">${p.resolution.length}</div><div class="stats-timing-lbl">Закрыто</div></div>
</div>
</div>
<div class="stats-timing-note">По полям «Дата реакции» и «Дата решения» · период: ${p.label}</div>`;
}
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()<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);}
});
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=`<div class="stats-empty">${clientsOnlyOpen?'Нет клиентов с открытыми заявками':'Нет повторных обращений'}</div>`;return;}
wrap.innerHTML=`
<div class="stats-clients-list" style="display:flex;flex-direction:column;gap:6px;">
${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'<div class="stats-clients-row"><div class="stats-clients-name" title="'+escHtml(name)+'">'+escHtml(name)+'</div><div class="stats-clients-track"><div class="stats-clients-bar-total" style="width:'+pct+'%;"></div><div class="stats-clients-bar-open" style="width:'+pct*opPct/100+'%;"></div></div><div class="stats-clients-counts"><span class="stats-clients-total">'+v.count+'</span>'+(v.open?'<span class="stats-clients-open-badge">'+v.open+' откр.</span>':'')+'</div></div>'; }).join('')}
</div>
${!clientsExpanded&&hidden>0?`<div style="text-align:center;margin-top:10px;font-size:12px;color:var(--text3);">+ ещё ${hidden} — <span style="color:var(--accent);cursor:pointer;" onclick="clientsExpanded=true;document.getElementById('clients-expand-btn').textContent='▲ Свернуть';renderClientsList()">показать все</span></div>`:''}`;
}
// 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`<div class="stats-kpi-card stats-kpi-${c}"><div class="stats-kpi-icon">${icon}</div><div class="stats-kpi-val">${val}</div><div class="stats-kpi-lbl">${lbl}</div></div>`;}
function iconGrid(){return`<svg width="24" height="24" fill="none" viewBox="0 0 24 24"><rect x="3" y="3" width="8" height="8" rx="1.5" fill="currentColor"/><rect x="13" y="3" width="8" height="8" rx="1.5" fill="currentColor"/><rect x="3" y="13" width="8" height="8" rx="1.5" fill="currentColor"/><rect x="13" y="13" width="8" height="8" rx="1.5" fill="currentColor" opacity=".45"/></svg>`;}
function iconOpen(){return`<svg width="24" height="24" fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.8"/><path d="M8 12h8M12 8v8" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>`;}
function iconDone(){return`<svg width="24" height="24" fill="none" viewBox="0 0 24 24"><path d="M5 12l5 5L20 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;}
function renderDonut(sc,uc,tot){
if(!tot)return'<div class="stats-empty">Нет данных</div>';
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=`<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${s.color}" stroke-width="${sw}" stroke-dasharray="${dash.toFixed(2)} ${gap.toFixed(2)}" stroke-dashoffset="${(-off).toFixed(2)}" style="transition:stroke-dasharray .5s;"/>`;off+=dash;return el;});
return`<svg viewBox="0 0 140 140" class="stats-donut-svg"><circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="var(--surface2)" stroke-width="${sw}"/>${paths.join('')}<text x="${cx}" y="${cy-6}" text-anchor="middle" font-size="20" font-weight="700" fill="var(--text)">${tot}</text><text x="${cx}" y="${cy+12}" text-anchor="middle" font-size="10" fill="var(--text3)">заявок</text></svg>`;
}
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`<div class="stats-legend-item"><span class="stats-legend-dot" style="background:${m.ispColor};"></span><span class="stats-legend-lbl">${m.chipLabel}</span><span class="stats-legend-val">${c}</span><span class="stats-legend-pct">${p}%</span></div>`;}).join('')+
`<div class="stats-legend-item"><span class="stats-legend-dot" style="background:var(--border2);"></span><span class="stats-legend-lbl">Без статуса</span><span class="stats-legend-val">${uc}</span><span class="stats-legend-pct">${tot?Math.round(uc/tot*100):0}%</span></div>`;
}
function escHtml(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
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();}

208
public/js/statuses.js Normal file
View file

@ -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;

870
public/js/ui.js Normal file
View file

@ -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=>`
<div class="chip-dd-item chip-dd-mark" id="chipdd-${m.key}" onclick="chipClickLabel('${m.key}')">
<span class="chip-dd-dot" style="background:${m.chipDotColor};"></span>
<span class="chip-dd-lbl">${m.chipLabel}</span>
<span class="chip-dd-cnt" id="chip-${m.key}-cnt">0</span>
</div>`).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`<span class="status-badge ${m.badgeClass}"><span class="dot"></span>${m.label}</span>`;
return`<span class="status-badge sb-none"><span class="dot"></span>Без статуса</span>`;
}
// ── 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 `<span class="status-badge sb-intra-other"><span class="dot"></span>#${sid}</span>`;
return '<span style="color:var(--text3);font-size:11px;">—</span>';
}
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 `<span class="status-badge ${cls}"><span class="dot"></span>${esc(name)}</span>`;
}
// 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`<span class="status-badge sb-none"><span class="dot"></span>Нет ответа</span>`;
if(lc.usertype===10) return`<span class="status-badge sb-internal"><span class="dot"></span>Сотрудник</span>`;
return`<span class="status-badge sb-external"><span class="dot"></span>Клиент</span>`;
}
// 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`<span class="mset-item">👤 ${esc(abbrevName(mc.by))} · «${cfg.label}»${mc.at?` · ${timeAgo(new Date(mc.at))}`:''}`;
});
return lines.length?`<div class="sp-mark-setby">${lines.join('')}</div>`:'';
}
// ── Inline status picker ──────────────────────────────────────────
function statusPickerHtml(tid){
const items=MARKS_CONFIG.map(m=>`
<div class="isp-item ${isMark(tid,m.key)?'isp-active':''}" onclick="toggleMark('${tid}','${m.key}',event)">
<span class="isp-dot" style="background:${m.ispColor};"></span>${m.label}
</div>`).join('');
return`<div class="inline-status-picker" onmouseenter="ispShow(this)" onmouseleave="ispHide(this)">
${internalStatusBadge(tid)}
<div class="isp-dropdown">
<div class="isp-dropdown-inner">${items}</div>
</div>
</div>`;
}
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=>`
<label class="col-menu-item" onclick="event.stopPropagation()">
<input type="checkbox" ${visibleCols.has(c.key)?'checked':''} onchange="toggleCol('${c.key}')">
<span>${c.label}</span>
</label>`).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=`<div class="state-box"><span class="icon">🔍</span><div class="msg">Заявок не найдено</div><div class="sub">Измените фильтры</div></div>`;
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=`<thead><tr>
<th style="width:32px;padding:8px 6px;"></th>
${sc('num') ?'<th style="width:70px;">#</th>' :''}
${sc('title') ?'<th>Тема</th>' :''}
${sc('intrastatus')?'<th style="width:110px;">Статус в интре</th>':''}
${sc('status') ?'<th style="width:130px;">Статус</th>' :''}
${sc('client') ?'<th style="width:170px;">Кто ответил</th>' :''}
${sc('date') ?`<th style="width:105px;${sortStyle('date')}" onclick="toggleSort('date')">Время ответа<span style="font-size:10px;opacity:.6;">${sortArrow('date')}</span></th>` :''}
${sc('created')?`<th style="width:95px;${sortStyle('created')}" onclick="toggleSort('created')">Создана<span style="font-size:10px;opacity:.6;">${sortArrow('created')}</span></th>` :''}
${sc('preview')?'<th style="width:135px;">Превью</th>' :''}
</tr></thead>`;
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?'<span class="unread-dot-inline"></span>':'';
const cntBadge=getCommentCount(tid)?`<div class="comment-count-badge">💬 ${getCommentCount(tid)}</div>`:'';
const prevCell=note
?`<div class="note-indicator" title="${esc(note)}">📝 ${esc(note.slice(0,42))}${note.length>42?'…':''}</div>`
:(lastComment?`<div class="preview-text">${esc(stripHtml(lastComment.text||'').slice(0,52))}</div>`:'');
const whoLabel=lastComment
?(lastComment.usertype===10
?'<span class="who-label who-internal">● Сотрудник</span>'
:'<span class="who-label who-external">● Клиент</span>')
:(task.updatedby
?`<span class="who-label who-none">${esc(abbrevName(task.updatedby))}</span>`
:'<span class="who-label who-none">нет ответа</span>');
return`<tr onclick="openModal(${idx})" data-idx="${idx}" class="${rowCls}">
<td style="padding:8px 6px;" onclick="event.stopPropagation()">
<input type="checkbox" class="row-check" data-idx="${idx}" style="accent-color:var(--accent);" ${selectedRows.has(idx)?'checked':''} onchange="toggleRowCheck(${idx},this)">
</td>
${sc('num') ?`<td><span class="task-num">#${num}</span>${unreadDot}</td>`:''}
${sc('title') ?`<td><div class="task-title-main" title="${esc(task.name)}">${esc(task.name)}</div>${orgName?`<div class="task-title-sub">${esc(orgName)}</div>`:''}</td>`:''}
${sc('intrastatus')?`<td>${intraStatusBadge(task)}</td>`:''}
${sc('status') ?`<td onclick="event.stopPropagation()">${statusPickerHtml(tid)}</td>`:''}
${sc('client') ?`<td><div class="client-info">
<div class="client-avatar ${whoAnsweredRing(lastComment)}" style="background:${avatarColor(authorName)};color:#fff;">${avatarInitials(authorName)}</div>
<div>
<div class="client-name">${esc(abbrevName(authorName)||'—')}</div>
${whoLabel}
</div>
</div></td>`:''}
${sc('date') ?`<td>${lastComment?`<div class="date-main">${fmtDateShort(new Date(lastComment.eventat))}</div><div class="date-sub">${fmtTime(new Date(lastComment.eventat))}</div><div class="date-ago">${timeAgo(new Date(lastComment.eventat))}</div>`:task.updatedat?`<div class="date-main">${fmtDateShort(new Date(task.updatedat))}</div><div class="date-sub">${fmtTime(new Date(task.updatedat))}</div><div class="date-ago">${timeAgo(new Date(task.updatedat))}</div>`:'<div class="date-main">—</div>'}</td>`:''}
${sc('created')?`<td>${task.createdat?`<div class="date-main">${fmtDateShort(new Date(task.createdat))}</div><div class="date-sub">${fmtTime(new Date(task.createdat))}</div>`:'<div class="date-main">—</div>'}</td>`:''}
${sc('preview')?`<td>${prevCell}${cntBadge}</td>`:''}
</tr>`;
}).join('');
g('table-wrap').innerHTML=`<table class="tasks-table">${thead}<tbody>${rows}</tbody></table>`;
// 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+=`<button class="pg-btn${p===currentPage?' pg-active':''}" onclick="setPage(${p})">${p}</button>`;
} else if(Math.abs(p-currentPage)===3){
pages+=`<span class="pg-ellipsis">…</span>`;
}
}
pb.innerHTML=`
<button class="pg-btn" onclick="setPage(${currentPage-1})" ${currentPage===1?'disabled':''}></button>
${pages}
<button class="pg-btn" onclick="setPage(${currentPage+1})" ${currentPage===totalPages?'disabled':''}></button>
<span class="pg-info">${start}${end} из ${filteredData.length}</span>`;
}
}
}
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)
?`<div class="detail-note-editor">
<textarea id="detail-note-ta" placeholder="Заметка...">${esc(note)}</textarea>
<div class="detail-note-actions">
<button class="btn btn-primary" style="font-size:11px;padding:5px 12px;" onclick="saveNoteAndClose(${idx},event)">Сохранить</button>
<button class="btn btn-default" style="font-size:11px;padding:5px 12px;" onclick="closeNoteEditor()">Отмена</button>
</div>
</div>`
:note
?`<div class="detail-note-text" onclick="openNoteEditor(${idx},event)">📝 ${renderMentions(note)}
<div style="font-size:10px;color:var(--text3);margin-top:3px;">${noteBy?`👤 ${esc(abbrevName(noteBy))}`:''}${noteAt?` · ${fmtDate(new Date(noteAt))}`:''} </div>
</div>`
:`<div class="detail-note-empty" onclick="openNoteEditor(${idx},event)">+ Добавить заметку</div>`;
container.innerHTML=`
<div class="sp-header">
<div style="flex:1;min-width:0;">
<div class="sp-badges">
${internalStatusBadge(tid)}
${whoAnsweredBadge(lastComment)}
</div>
<div class="detail-title">#${num} ${esc(task.name)}</div>
<div class="detail-author">${esc(abbrevName(authorName))}${task.clientname||task.organization?` · ${esc(task.clientname||task.organization)}`:''}</div>
</div>
<div class="sp-header-actions">
<button class="sp-close-btn" onclick="closeModal()">×</button>
</div>
</div>
<div class="sp-note">
<a class="sp-ext-link" href="${TASK_URL}${num}" target="_blank" onclick="event.stopPropagation()"> Открыть в IntraDesk</a>
</div>
<div class="sp-marks">
${MARKS_CONFIG.map(m=>{
const active=isMark(tid,m.key);
return`<button class="mark-btn ${m.btnClass}${active?' mbtn-active':''}" data-mark="${m.key}" onclick="toggleMark('${tid}','${m.key}',event)">${m.btnIcon} ${m.btnLabel}</button>`;
}).join('')}
</div>
${markSetByHtml(tid)}
<div class="sp-note">${noteHtml}</div>
<div class="sp-tabs">
<div class="detail-tab ${currentTab==='last'?'active':''}" id="dtab-last" onclick="switchTab('last')">Последний комментарий</div>
<!--<div class="detail-tab ${currentTab==='comments'?'active':''}" id="dtab-comments" onclick="switchTab('comments')">💬 Наши${cnt?` (${cnt})`:''}</div>-->
<div class="detail-tab ${currentTab==='history'?'active':''}" id="dtab-history" onclick="switchTab('history')">Загрузить заявку</div>
</div>
<div class="sp-body" id="sp-body">
${renderTabContent(idx,currentTab,rawDesc,lastComment)}
</div>
<!-- <div class="sp-composer">
<div style="position:relative;">
<textarea class="composer-textarea" id="composer-ta"
placeholder="Написать комментарий... (Ctrl+Enter)" oninput="onCommentInput(this)"></textarea>
<div class="autocomplete-popup" id="autocomplete-popup"
style="display:none;position:absolute;bottom:100%;left:0;margin-bottom:4px;"></div>
</div>
<div class="composer-footer">
<button class="composer-tool" title="@упомянуть" onclick="insertAtSymbol()">@</button>
<button class="btn btn-primary btn-send" style="margin-left:auto;" onclick="submitComment()">Отправить</button>
</div>
</div> -->`;
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
?`<!--<div class="sp-section"><div class="sp-section-title">Описание</div><div class="detail-description">${sanitizeHtml(rawDesc)}</div></div>-->`:'';
const actHtml=lastComment
?`<div class="sp-section"><div class="sp-section-title">Последняя активность</div>
<div class="last-activity-item">
<div class="la-avatar" style="background:${avatarColor(authorName)};">${avatarInitials(authorName)}</div>
<div>
<div><span class="la-name">${esc(abbrevName(authorName))}</span><span class="la-time">${timeAgo(new Date(lastComment.eventat))}</span></div>
<div class="la-text">${esc(stripHtml(lastComment.text||'').slice(0,180))}</div>
</div>
</div>
</div>`:'';
const executorName = task._executorname || '';
const executorGroupName = task._executorgroupname || '';
return`
<div class="sp-section">
<div class="detail-meta-grid">
<!-- ${org?`<div class="detail-meta-row"><span class="detail-meta-label">Клиент</span><span class="detail-meta-val"><span class="client-dot"></span>${esc(org)}</span></div>`:''}
${created?`<div class="detail-meta-row"><span class="detail-meta-label">Создана</span><span class="detail-meta-val">${fmtDate(created)}</span></div>`:''}
<div class="detail-meta-row"><span class="detail-meta-label">Кто ответил</span>${whoAnsweredBadge(lastComment)}</div>
<div class="detail-meta-row"><span class="detail-meta-label">Статус</span>${internalStatusBadge(tid)}</div> -->
${executorGroupName?`<div class="detail-meta-row"><span class="detail-meta-label">Группа</span><span class="detail-meta-val">👥 ${esc(executorGroupName)}</span></div>`:''}
${executorName?`<div class="detail-meta-row"><span class="detail-meta-label">Исполнитель</span><span class="detail-meta-val">👤 ${esc(executorName)}</span></div>`:''}
</div>
</div>
${descHtml}${actHtml}`;
}
if(tab==='comments'){
return renderCommentsHtml(tid,commentsCache[tid]||[]);
}
if(tab==='history'){
setTimeout(()=>loadAndRenderHistory(idx,'sp-body'),10);
return`<div class="state-box" style="padding:28px 0;"><div class="spinner" style="width:20px;height:20px;margin-bottom:8px;"></div><div class="msg" style="font-size:12px;">Загружаю историю...</div></div>`;
}
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`<div style="padding:20px 0;text-align:center;color:var(--text3);font-size:12px;">Комментариев нет. Напишите первый!</div>`;
return`<div style="display:flex;flex-direction:column;gap:8px;">
${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`<div class="detail-comment ${own?'own':''}" id="cmt-${c.id}">
<div class="dc-header">
<div class="client-avatar" style="background:${avatarColor(c.author)};color:#fff;width:20px;height:20px;font-size:8px;flex-shrink:0;">${avatarInitials(c.author)}</div>
<span class="dc-author">${esc(abbrevName(c.author))}</span>
<span class="dc-time">${fmtDate(new Date(c.created_at))}</span>
${c.updated_at?`<span class="dc-edited">(ред.)</span>`:''}
</div>
<div class="dc-text" id="cmt-text-${c.id}">${renderMentions(c.text)}</div>
${canEdit?`<div class="dc-actions">
<span class="dc-btn" onclick="startEditComment(${c.id},'${te}')"> Ред.</span>
<span class="dc-btn del" onclick="deleteComment(${c.id},'${tid}')"> Удалить</span>
</div>`:''}
<div id="cmt-edit-${c.id}" style="display:none;margin-top:6px;flex-direction:column;gap:5px;">
<textarea style="width:100%;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);font-family:'Inter',sans-serif;font-size:12px;padding:7px;outline:none;resize:vertical;min-height:54px;" id="cmt-edit-ta-${c.id}"></textarea>
<div style="display:flex;gap:5px;">
<button class="btn btn-primary" style="font-size:11px;padding:4px 10px;" onclick="saveEditComment(${c.id},'${tid}')">Сохранить</button>
<button class="btn btn-default" style="font-size:11px;padding:4px 10px;" onclick="cancelEditComment(${c.id})">Отмена</button>
</div>
</div>
</div>`;
}).join('')}
</div>`;
}
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=`<div style="padding:20px 0;text-align:center;color:var(--text3);font-size:12px;">История пуста</div>`;return;}
const rawDesc=task.description||task.descriptionhtml||task.body||task.text||'';
let db='';
if(rawDesc) db=`<div class="history-entry"><div class="sp-section-title" style="margin-bottom:5px;">Описание</div><div class="he-text">${sanitizeHtml(rawDesc)}</div></div>`;
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=`<div class="he-text" style="margin-top:3px;">${sanitizeHtml(ev.stringvalue)}</div>`;
else if(ev.type===10&&ev.entitylanguagevalue) body=`<div class="he-pill">«${esc(ev.entitylanguagevalue.valueold?.name?.ru||'?')}» → «${esc(ev.entitylanguagevalue.valuenew?.name?.ru||'?')}»</div>`;
else if(ev.entityvalue) body=`<div class="he-pill">«${esc(ev.entityvalue.valueold?.name||'?')}» → «${esc(ev.entityvalue.valuenew?.name||'?')}»</div>`;
return`<div style="margin-bottom:3px;"><div class="he-type ${isCmt?'comment':''}">${esc(tn)}</div>${body}</div>`;
}).join('');
return`<div class="history-entry">
<div class="he-header">
<div class="client-avatar" style="background:${avatarColor(entry.username)};color:#fff;width:22px;height:22px;font-size:8px;flex-shrink:0;">${avatarInitials(entry.username)}</div>
<span class="he-author ${staff?'staff':'client'}">${esc(abbrevName(entry.username))}</span>
<span class="he-time">${new Date(entry.eventat).toLocaleString('ru-RU')} · ${timeAgo(new Date(entry.eventat))}</span>
</div>${evHtml}
</div>`;
}).join('');
el.innerHTML=`${db}<div style="font-size:10px;color:var(--text3);padding:4px 0 8px;">Событий: ${entries.length}</div>${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');
}
});
});

597
server.js Normal file
View file

@ -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`); });

14
start.sh Normal file
View file

@ -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