init
This commit is contained in:
commit
464d53c70b
30 changed files with 7364 additions and 0 deletions
14
.dockerignore
Normal file
14
.dockerignore
Normal 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
14
.env.example
Normal 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
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.env
|
||||
.env.runtime
|
||||
node_modules/
|
||||
data/
|
||||
22
.woodpecker.yml
Normal file
22
.woodpecker.yml
Normal 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
30
Dockerfile
Normal 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
81
README.md
Normal 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
8
deploy.sh
Normal 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
36
deploy/remote-deploy.sh
Normal 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
15
docker-compose.deploy.yml
Normal 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
16
docker-compose.yml
Normal 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
20
package.json
Normal 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
1421
public/css/app.css
Normal file
File diff suppressed because it is too large
Load diff
82
public/css/keyboard-additions.css
Normal file
82
public/css/keyboard-additions.css
Normal 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;
|
||||
}
|
||||
|
||||
/* ── Мигание кнопки статуса при нажатии 1–6 ──────────────────────── */
|
||||
@keyframes kb-flash-anim {
|
||||
0% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.8); transform: scale(1); }
|
||||
40% { box-shadow: 0 0 0 6px rgba(255, 255, 255, 0); transform: scale(1.06); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); transform: scale(1); }
|
||||
}
|
||||
|
||||
.mark-btn.kb-flash {
|
||||
animation: kb-flash-anim 0.4s ease forwards;
|
||||
}
|
||||
|
||||
/* ── Подсказка по шорткатам (опционально, показывать по ? или в футере) ── */
|
||||
.kb-help-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.kb-help-box {
|
||||
background: var(--bg2, #1e1e2e);
|
||||
border: 1px solid var(--border, #2d2d3e);
|
||||
border-radius: 12px;
|
||||
padding: 28px 36px;
|
||||
min-width: 380px;
|
||||
color: var(--text, #e0e0e0);
|
||||
}
|
||||
|
||||
.kb-help-box h3 {
|
||||
margin: 0 0 18px;
|
||||
font-size: 1rem;
|
||||
color: var(--accent, #5b7fff);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.kb-help-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
font-size: 0.85rem;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.kb-help-row:last-child { border-bottom: none; }
|
||||
|
||||
.kb-key {
|
||||
display: inline-block;
|
||||
background: var(--bg3, #2a2a3d);
|
||||
border: 1px solid var(--border, #3a3a4d);
|
||||
border-radius: 5px;
|
||||
padding: 2px 8px;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent, #5b7fff);
|
||||
white-space: nowrap;
|
||||
}
|
||||
310
public/index.html
Normal file
310
public/index.html
Normal 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
1610
public/index_old.html
Normal file
File diff suppressed because it is too large
Load diff
26
public/js/api.js
Normal file
26
public/js/api.js
Normal 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
233
public/js/auth.js
Normal 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
94
public/js/comments.js
Normal 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&¤tTab==='comments') dbody.innerHTML=renderCommentsHtml(taskId,rows);
|
||||
// Update tab label
|
||||
const tab=document.getElementById('dtab-comments');
|
||||
if(tab) tab.textContent=`Наши комментарии${rows.length?` (${rows.length})`:''}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderCommentsTab(){
|
||||
if(currentModalIdx===null) return;
|
||||
const taskId=String(allData[currentModalIdx].task.id);
|
||||
const comments=commentsCache[taskId]||[];
|
||||
const dbody=document.getElementById('detail-body');
|
||||
if(dbody) dbody.innerHTML=renderCommentsHtml(taskId,comments);
|
||||
}
|
||||
|
||||
function startEditComment(id, text) {
|
||||
const ea=document.getElementById(`cmt-edit-${id}`);
|
||||
const ta=document.getElementById(`cmt-edit-ta-${id}`);
|
||||
const td=document.getElementById(`cmt-text-${id}`);
|
||||
if(!ea||!ta) return;
|
||||
ta.value=text.replace(/\\n/g,'\n');
|
||||
ea.style.display='flex';td.style.display='none';ta.focus();
|
||||
}
|
||||
function cancelEditComment(id) {
|
||||
const ea=document.getElementById(`cmt-edit-${id}`);
|
||||
const td=document.getElementById(`cmt-text-${id}`);
|
||||
if(ea) ea.style.display='none';
|
||||
if(td) td.style.display='';
|
||||
}
|
||||
async function saveEditComment(id, taskId) {
|
||||
const ta=document.getElementById(`cmt-edit-ta-${id}`);
|
||||
const text=ta?.value?.trim();
|
||||
if(!text) return;
|
||||
const r=await apiPost(`/api/comments/${id}`,{text},'PUT');
|
||||
if(r?.id){
|
||||
commentsCache[taskId]=(commentsCache[taskId]||[]).map(c=>c.id===id?r:c);
|
||||
renderCommentsTab();
|
||||
} else showToast('❌ '+(r?.error||'Ошибка'),'warn');
|
||||
}
|
||||
async function deleteComment(id, taskId) {
|
||||
if(!confirm('Удалить комментарий?')) return;
|
||||
const r=await apiDelete(`/api/comments/${id}`);
|
||||
if(r?.ok){
|
||||
commentsCache[taskId]=(commentsCache[taskId]||[]).filter(c=>c.id!==id);
|
||||
if(typeof commentCounts==='object') commentCounts[taskId]=(commentsCache[taskId]||[]).length;
|
||||
renderCommentsTab();
|
||||
renderTable();
|
||||
} else showToast('❌ '+(r?.error||'Ошибка'),'warn');
|
||||
}
|
||||
|
||||
// @autocomplete
|
||||
function onCommentInput(ta) {
|
||||
const val=ta.value,pos=ta.selectionStart;
|
||||
const before=val.slice(0,pos);
|
||||
const match=before.match(/@([\w\u0400-\u04ff]*)$/);
|
||||
if(match){
|
||||
acState={active:true,start:before.length-match[0].length,query:match[1].toLowerCase()};
|
||||
showAutocomplete(ta,acState.query);
|
||||
} else {acState.active=false;hideAutocomplete();}
|
||||
}
|
||||
function showAutocomplete(ta,query) {
|
||||
const popup=document.getElementById('autocomplete-popup');
|
||||
const matches=knownUsers.filter(u=>u.startsWith(query)&&u!==currentUser.username.toLowerCase());
|
||||
if(!matches.length){hideAutocomplete();return;}
|
||||
popup.innerHTML=matches.slice(0,6).map(u=>`<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
157
public/js/diff.js
Normal 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
118
public/js/export.js
Normal 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
305
public/js/keyboard.js
Normal 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: Быстрая смена статуса клавишами 1–6
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
// Порядок статусов определяется полем kbShortcut в MARKS_CONFIG (statuses.js)
|
||||
const KB_STATUS_MAP = Object.fromEntries(
|
||||
MARKS_CONFIG.filter(m => m.kbShortcut).map(m => [m.kbShortcut, m.key])
|
||||
);
|
||||
|
||||
async function kbSetStatus(digit) {
|
||||
if (currentModalIdx === null) return; // меню должно быть открыто
|
||||
const markType = KB_STATUS_MAP[digit];
|
||||
if (!markType) return;
|
||||
|
||||
const item = allData[currentModalIdx];
|
||||
if (!item) return;
|
||||
const tid = String(item.task.id);
|
||||
|
||||
// Показать мгновенную визуальную индикацию
|
||||
kbShowStatusFlash(digit, markType);
|
||||
|
||||
// Вызываем существующий setMark (optimistic update + запрос к серверу)
|
||||
try {
|
||||
await setMark(tid, markType, null);
|
||||
} catch (err) {
|
||||
// setMark делает optimistic update сам; ошибки сервера — через showToast внутри setMark
|
||||
console.warn('[KB] setMark error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Мигающий тост-индикатор при смене статуса клавишей */
|
||||
function kbShowStatusFlash(digit, markType) {
|
||||
const cfg = MARKS_CONFIG.find(m => m.key === markType);
|
||||
|
||||
// Подсветить кнопку в боковом меню
|
||||
const btn = document.querySelector(`.sp-marks .mark-btn[data-mark="${markType}"]`);
|
||||
if (btn) {
|
||||
btn.classList.add('kb-flash');
|
||||
setTimeout(() => btn.classList.remove('kb-flash'), 600);
|
||||
}
|
||||
|
||||
// Тост (если showToast уже есть в проекте — через sse.js)
|
||||
if (typeof showToast === 'function') {
|
||||
const label = cfg ? `${cfg.kbShortcut} · ${cfg.label}` : markType;
|
||||
showToast(`⌨ ${label}`, 'mark', 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// РАЗДЕЛ 5: Центральный обработчик клавиатуры
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
function kbHandleKeydown(e) {
|
||||
// ── Игнорируем, если фокус в поле ввода ──────────────────────────
|
||||
if (kbIsInputFocused()) return;
|
||||
|
||||
// ── Игнорируем с модификаторами Ctrl/Cmd/Alt (не ломаем браузерные шорткаты) ──
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
|
||||
const len = kbListLength();
|
||||
if (len === 0) return;
|
||||
|
||||
// Если activeRowIdx не установлен — начинаем с 0
|
||||
const cur = activeRowIdx ?? -1;
|
||||
|
||||
switch (e.key) {
|
||||
// ── Навигация ───────────────────────────────────────────────────
|
||||
case 'ArrowUp': {
|
||||
e.preventDefault();
|
||||
kbSetActive(cur <= 0 ? 0 : cur - 1);
|
||||
break;
|
||||
}
|
||||
case 'ArrowDown': {
|
||||
e.preventDefault();
|
||||
kbSetActive(cur < 0 ? 0 : cur + 1);
|
||||
break;
|
||||
}
|
||||
case 'PageUp': {
|
||||
e.preventDefault();
|
||||
kbSetActive(Math.max(0, cur - KB_PAGE_SIZE));
|
||||
break;
|
||||
}
|
||||
case 'PageDown': {
|
||||
e.preventDefault();
|
||||
kbSetActive(cur < 0 ? KB_PAGE_SIZE : cur + KB_PAGE_SIZE);
|
||||
break;
|
||||
}
|
||||
case 'Home': {
|
||||
e.preventDefault();
|
||||
kbSetActive(0);
|
||||
break;
|
||||
}
|
||||
case 'End': {
|
||||
e.preventDefault();
|
||||
kbSetActive(len - 1);
|
||||
break;
|
||||
}
|
||||
case 'Enter': {
|
||||
// Enter открывает/закрывает боковое меню для текущей строки
|
||||
if (cur >= 0) {
|
||||
e.preventDefault();
|
||||
const allIdx = kbFilteredToAllIdx(cur);
|
||||
if (currentModalIdx === allIdx) {
|
||||
closeModal();
|
||||
} else if (allIdx !== null) {
|
||||
openModal(allIdx);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Боковое меню ────────────────────────────────────────────────
|
||||
case 'ArrowRight': {
|
||||
e.preventDefault();
|
||||
kbHandleArrowRight();
|
||||
break;
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
e.preventDefault();
|
||||
kbHandleArrowLeft();
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Быстрая смена статуса (только если меню открыто) ───────────
|
||||
case '1': case '2': case '3':
|
||||
case '4': case '5': case '6': {
|
||||
if (currentModalIdx !== null) {
|
||||
e.preventDefault();
|
||||
kbSetStatus(e.key);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Escape: закрыть меню, снять курсор ─────────────────────────
|
||||
// (Escape уже обрабатывается в ui.js через closeModal, но добавим снятие курсора)
|
||||
case 'Escape': {
|
||||
if (currentModalIdx !== null) {
|
||||
// Меню закроет ui.js сам, но сбрасываем курсор
|
||||
// activeRowIdx оставляем — юзер видит, где был
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return; // не наша клавиша — не вызываем preventDefault
|
||||
}
|
||||
}
|
||||
|
||||
// ── Единственный глобальный слушатель ─────────────────────────────
|
||||
// Capture: false (bubble) — достаточно, т.к. у нас нет stopPropagation в строках таблицы
|
||||
document.addEventListener('keydown', kbHandleKeydown, false);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// РАЗДЕЛ 6: Синхронизация cursor при рендере таблицы
|
||||
// Патчим renderTable() так, чтобы после рендера курсор восстанавливался.
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
(function patchRenderTable() {
|
||||
const _orig = window.renderTable;
|
||||
if (typeof _orig !== 'function') {
|
||||
// renderTable ещё не загружена — повторим при DOMContentLoaded
|
||||
document.addEventListener('DOMContentLoaded', patchRenderTable);
|
||||
return;
|
||||
}
|
||||
window.renderTable = function (...args) {
|
||||
_orig.apply(this, args);
|
||||
// После рендера восстановить подсветку курсора
|
||||
if (activeRowIdx !== null) {
|
||||
const allIdx = kbFilteredToAllIdx(activeRowIdx);
|
||||
if (allIdx !== null) kbHighlightRow(allIdx);
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// РАЗДЕЛ 7: Сброс activeRowIdx при клике мышью на строку таблицы
|
||||
// Чтобы курсор и мышь не расходились.
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
document.addEventListener('click', e => {
|
||||
const row = e.target.closest('.tasks-table tbody tr[data-idx]');
|
||||
if (!row) return;
|
||||
const allIdx = parseInt(row.dataset.idx, 10);
|
||||
if (!isNaN(allIdx)) {
|
||||
// Find position in filteredData
|
||||
const fd = typeof filteredData !== 'undefined' ? filteredData : allData;
|
||||
const item = allData[allIdx];
|
||||
const filteredIdx = item ? fd.indexOf(item) : -1;
|
||||
if (filteredIdx >= 0) {
|
||||
activeRowIdx = filteredIdx;
|
||||
}
|
||||
kbHighlightRow(allIdx);
|
||||
}
|
||||
}, false);
|
||||
60
public/js/mentions.js
Normal file
60
public/js/mentions.js
Normal 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
60
public/js/notes.js
Normal 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
115
public/js/sse.js
Normal 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
182
public/js/state.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||||
function fmtDate(d){return`${d.toLocaleDateString('ru-RU',{day:'2-digit',month:'2-digit',year:'2-digit'})} ${d.toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'})}`;}
|
||||
function timeAgo(d){const m=Math.floor((Date.now()-d)/60000);if(m<1)return'только что';if(m<60)return`${m} мин назад`;const h=Math.floor(m/60);if(h<24)return`${h} ч назад`;return`${Math.floor(h/24)} д назад`;}
|
||||
function datestamp(){const d=new Date();return`${d.getFullYear()}${String(d.getMonth()+1).padStart(2,'0')}${String(d.getDate()).padStart(2,'0')}`;}
|
||||
function stripHtml(s){const t=document.createElement('div');t.innerHTML=s;return(t.textContent||t.innerText||'').replace(/\s+/g,' ').trim();}
|
||||
function sanitizeHtml(raw){
|
||||
if(!raw)return'';
|
||||
if(!/<[a-z]/i.test(raw))return esc(raw);
|
||||
const t=document.createElement('div');t.innerHTML=raw;
|
||||
t.querySelectorAll('script,style,intradesk-initiator-notification-signature').forEach(e=>e.remove());
|
||||
t.querySelectorAll('intradesk-quote').forEach(e=>{const q=document.createElement('blockquote');q.className='intradesk-quote-block';q.innerHTML=e.innerHTML;e.replaceWith(q);});
|
||||
t.querySelectorAll('*').forEach(e=>{['style','class','color','face','size','bgcolor','align','contenteditable'].forEach(a=>e.removeAttribute(a));if(e.tagName==='FONT'){const s=document.createElement('span');s.innerHTML=e.innerHTML;e.replaceWith(s);}});
|
||||
return t.innerHTML;
|
||||
}
|
||||
function renderMentions(text) {
|
||||
return esc(text).replace(/@([\w\u0400-\u04ff]+)/g,'<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
642
public/js/statistics.js
Normal 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:'2–3', max:3, count:0, color:'var(--accent)'},
|
||||
{label:'4–6', max:6, count:0, color:'#d97706'},
|
||||
{label:'7+', max:Infinity, count:0, color:'var(--red)'},
|
||||
];
|
||||
data.forEach(({task:t}) => {
|
||||
let cnt=0;
|
||||
try { cnt=(t.attachments?JSON.parse(t.attachments):[]).length; } catch(e){}
|
||||
for (const b of ATT_B) { if (cnt<=b.max) { b.count++; break; } }
|
||||
});
|
||||
const maxAtt = Math.max(...ATT_B.map(b=>b.count), 1);
|
||||
|
||||
// 3. Время первого ответа по исполнителю
|
||||
const execRxMap = {};
|
||||
data.forEach(({task:t}) => {
|
||||
if (!t.reactiondatefact||!t.createdat||!t.executor) return;
|
||||
const m=(new Date(t.reactiondatefact)-new Date(t.createdat))/60000;
|
||||
if (m<=0||m>43200) return;
|
||||
const name = t._executorname||`#${t.executor}`;
|
||||
if (!execRxMap[name]) execRxMap[name]=[];
|
||||
execRxMap[name].push(m);
|
||||
});
|
||||
const execRxList = Object.entries(execRxMap)
|
||||
.filter(([,v])=>v.length>=1)
|
||||
.map(([name,times])=>({name, count:times.length, med:med(times)}))
|
||||
.sort((a,b)=>a.med-b.med)
|
||||
.slice(0,8);
|
||||
const maxRxMed = Math.max(...execRxList.map(e=>e.med), 1);
|
||||
|
||||
// 7. Повторные клиенты
|
||||
const repeatClients = Object.values(cMap).filter(v=>v.count>1).length;
|
||||
const totalClients = Object.keys(cMap).length;
|
||||
const repeatPct = totalClients ? Math.round(repeatClients/totalClients*100) : 0;
|
||||
|
||||
// ── HTML ────────────────────────────────────────────────────────
|
||||
document.getElementById('stats-content').innerHTML = `
|
||||
<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,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||
|
||||
function showLineTip(event,el){const tip=document.getElementById('line-tooltip');if(!tip)return;tip.textContent=`${el.dataset.lbl}: ${el.dataset.val} откр.`;tip.style.display='block';const wr=document.getElementById('stats-line-chart').getBoundingClientRect();tip.style.left=Math.min(event.clientX-wr.left+12,wr.width-130)+'px';tip.style.top=Math.max(event.clientY-wr.top-36,4)+'px';}
|
||||
function hideLineTip(){const t=document.getElementById('line-tooltip');if(t)t.style.display='none';}
|
||||
|
||||
function openTaskById(id){const i=allData.findIndex(x=>x.task.id===id);if(i!==-1){closeStatsPage();setTimeout(()=>openDetail(i),100);}}
|
||||
function onDataLoaded(){if(statsPageOpen)renderStatsPage();}
|
||||
208
public/js/statuses.js
Normal file
208
public/js/statuses.js
Normal 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
870
public/js/ui.js
Normal 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
597
server.js
Normal 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
14
start.sh
Normal 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
|
||||
Loading…
Add table
Reference in a new issue