diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..611a241 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,22 @@ +services: + intradesk-monitor: + build: + context: . + dockerfile: Dockerfile + container_name: intradesk-monitor-local + restart: unless-stopped + env_file: .env + ports: + - "${APP_PORT:-3000}:3000" + volumes: + - ./data:/data + environment: + NODE_ENV: production + PORT: 3000 + DB_PATH: /data/intradesk.db + healthcheck: + test: ["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));"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s diff --git a/intradesk-monitor.zip b/intradesk-monitor.zip deleted file mode 100644 index eca1bde..0000000 Binary files a/intradesk-monitor.zip and /dev/null differ diff --git a/public/index.html b/public/index.html index 0765aba..5593214 100644 --- a/public/index.html +++ b/public/index.html @@ -76,6 +76,12 @@ Sync Done with Intra by status_id +
+ +
diff --git a/public/js/auth.js b/public/js/auth.js index 37c271f..5d75cec 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -282,6 +282,7 @@ function applySettings(s) { if (f('setting-auto-reset-on-update')) f('setting-auto-reset-on-update').checked = String(s.auto_reset_on_update || '0') === '1'; if (f('setting-sync-reply-wait')) f('setting-sync-reply-wait').checked = String(s.sync_reply_wait || '0') === '1'; if (f('setting-sync-done-intra')) f('setting-sync-done-intra').checked = String(s.sync_done_with_intra || '0') === '1'; + if (f('setting-sync-addfield-status')) f('setting-sync-addfield-status').checked = String(s.sync_addfield_status || '0') === '1'; if (f('setting-intra-done-status-ids')) f('setting-intra-done-status-ids').value = s.intra_done_status_ids || ''; if (f('apikey-status')) f('apikey-status').textContent = s.api_key_set ? '(ключ задан ✓)' : '(не задан!)'; syncDoneSelectFromInput(); @@ -341,6 +342,7 @@ async function saveSettings() { auto_reset_on_update: document.getElementById('setting-auto-reset-on-update')?.checked ? '1' : '0', sync_reply_wait: document.getElementById('setting-sync-reply-wait')?.checked ? '1' : '0', sync_done_with_intra: document.getElementById('setting-sync-done-intra')?.checked ? '1' : '0', + sync_addfield_status: document.getElementById('setting-sync-addfield-status')?.checked ? '1' : '0', intra_done_status_ids: document.getElementById('setting-intra-done-status-ids')?.value.trim() || '', }; const apiKey = document.getElementById('api-key').value.trim(); diff --git a/public/js/ui.js b/public/js/ui.js index 37b9735..2ce4c4b 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -1,13 +1,14 @@ // ── 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 }, + { key:'num', label:'# Заявка', def:true }, + { key:'title', label:'Тема', def:true }, + { key:'intrastatus', label:'Статус в интре', def:true }, + { key:'servstatus', 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; @@ -454,6 +455,18 @@ function intraStatusBadge(task) { else if (lc.includes('ожидан') || lc.includes('hold')) cls = 'sb-intra-hold'; return `${esc(name)}`; } +// ── IntraDesk additional field status badge ─────────────────────── +function intraAddfieldBadge(task) { + const val = task._addfield_status; + if (!val) return ''; + const match = (typeof MARKS_CONFIG !== 'undefined' ? MARKS_CONFIG : []) + .find((m) => m.label && m.label.toLowerCase() === val.toLowerCase()); + if (match) { + return `` + + `${esc(val)}`; + } + return `${esc(val)}`; +} // Who answered — ring class for avatar function whoAnsweredRing(lc){ if(!lc) return 'ring-none'; @@ -704,6 +717,7 @@ function renderTable(keepPage){ ${sc('num') ?'#' :''} ${sc('title') ?'Тема' :''} ${sc('intrastatus')?'Статус в интре':''} + ${sc('servstatus') ?'Служ. статус в интре':''} ${sc('status') ?'Статус' :''} ${sc('client') ?'Кто ответил' :''} ${sc('date') ?`Время ответа${sortArrow('date')}` :''} @@ -742,6 +756,7 @@ function renderTable(keepPage){ ${sc('num') ?`#${num}${unreadDot}`:''} ${sc('title') ?`
${esc(task.name)}
${orgName?`
${esc(orgName)}
`:''}`:''} ${sc('intrastatus')?`${intraStatusBadge(task)}`:''} + ${sc('servstatus') ?`${intraAddfieldBadge(task)}`:''} ${sc('status') ?`${statusPickerHtml(tid)}`:''} ${sc('client') ?`
${avatarInitials(authorName)}
diff --git a/refresh-service.js b/refresh-service.js index 25e2bb2..4ac9c4d 100644 --- a/refresh-service.js +++ b/refresh-service.js @@ -22,6 +22,8 @@ const REMOVE_EVENT_TYPES = new Set([ const COMMENT_EVENT_TYPES = new Set([50, 55]); +const INTRA_ADDFIELD_ALIAS = 'addfield_2f6eaa2vnutrennij_statusSingleSelect'; + function toBoolFlag(value) { return String(value || '0') === '1' || value === true; } @@ -164,6 +166,9 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c 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 `), + getStatusKeyByLabel: db.prepare( + "SELECT key FROM internal_statuses WHERE is_active=1 AND lower(label)=lower(?) LIMIT 1" + ), }; function getSyncState() { @@ -205,6 +210,7 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c auto_reset_on_update: toBoolFlag(s.auto_reset_on_update), sync_reply_wait: toBoolFlag(s.sync_reply_wait), sync_done_with_intra: toBoolFlag(s.sync_done_with_intra), + sync_addfield_status: toBoolFlag(s.sync_addfield_status), intra_done_status_ids: doneIds, }; } @@ -277,10 +283,29 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c } } + async function updateIntraAdditionalField(taskNumber, statusLabel, apiKey) { + if (!apiKey) throw new Error('API ключ не задан'); + const url = `https://apigw.intradesk.ru/changes/v3/tasks/?ApiKey=${apiKey}`; + const response = await fetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + number: Number(taskNumber), + blocks: { [INTRA_ADDFIELD_ALIAS]: JSON.stringify({ value: statusLabel || null }) }, + Channel: 'api', + }), + timeout: 15000, + }); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`IntraDesk вернул ${response.status}: ${text.slice(0, 200)}`); + } + } + async function applyStatusAutomationForChangedTasks(changedTaskIds, tasksById, settings, reason = '') { const changedIds = [...(changedTaskIds || [])].map((v) => String(v)); if (!changedIds.length) return; - if (!settings.sync_done_with_intra && !settings.sync_reply_wait && !settings.auto_reset_on_update) return; + if (!settings.sync_done_with_intra && !settings.sync_reply_wait && !settings.auto_reset_on_update && !settings.sync_addfield_status) return; const systemStatuses = getSystemStatusKeys(); const doneKey = systemStatuses.done || null; @@ -326,6 +351,18 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c if (!applied && settings.auto_reset_on_update && currentMark) { clearTaskMarks(taskId, 'system'); + applied = true; + } + + if (settings.sync_addfield_status) { + const addfieldVal = task._addfield_status; + if (addfieldVal) { + const row = stmts.getStatusKeyByLabel.get(addfieldVal); + if (row && currentMark !== row.key) { + setExclusiveMark(taskId, row.key, 'system'); + logger.log(`[Refresh] Inbound addfield sync: task ${taskId} → mark "${row.key}" (${reason})`); + } + } } } } @@ -421,6 +458,8 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c if (task.executorgroup && dictMap[task.executorgroup]) task._executorgroupname = dictMap[task.executorgroup]; const statusKey = task.statusid || task.status; if (statusKey && dictMap[statusKey]) task._statusname = dictMap[statusKey]; + const af = (task.additionalfields?.data || []).find((d) => d.alias === INTRA_ADDFIELD_ALIAS); + task._addfield_status = af?.value?.stringvalue || null; }); upsertIntraStatusesFromTasks(tasks); @@ -741,6 +780,7 @@ function createRefreshService({ db, fetch, getSettings, sseBroadcast, logger = c requestRemoveTask, parseWebhookAction, extractWebhookTaskId, + updateIntraAdditionalField, }; } diff --git a/server.js b/server.js index 45f900c..37cc83c 100644 --- a/server.js +++ b/server.js @@ -767,6 +767,26 @@ app.post('/api/marks', auth, (req, res) => { task_id, mark_type: markType, value: !!value, by: req.user.username, at: now, cleared_marks: clearedMarks, }); + + // Outbound sync: push mark label to IntraDesk additional field + const settings = getSettings(); + if (settings.sync_addfield_status === '1' && settings.api_key) { + try { + const cacheRow = db.prepare('SELECT data FROM tasks_cache WHERE id=1').get(); + const tasks = JSON.parse(cacheRow?.data || '[]'); + const task = tasks.find((t) => String(t.id) === String(task_id)); + if (task) { + const statusLabel = value + ? db.prepare('SELECT label FROM internal_statuses WHERE key=?').get(markType)?.label || null + : null; + refreshService.updateIntraAdditionalField(task.tasknumber, statusLabel, settings.api_key) + .catch((e) => console.error('[Marks] IntraDesk addfield update error:', e.message)); + } + } catch (e) { + console.error('[Marks] Outbound sync error:', e.message); + } + } + res.json({ ok: true }); }); @@ -904,6 +924,7 @@ const ALLOWED_SETTINGS = new Set([ 'sync_reply_wait', 'sync_done_with_intra', 'intra_done_status_ids', + 'sync_addfield_status', ]); app.get('/api/settings', auth, adminOnly, (req, res) => { @@ -922,6 +943,7 @@ app.get('/api/settings', auth, adminOnly, (req, res) => { sync_reply_wait: s.sync_reply_wait || '0', sync_done_with_intra: s.sync_done_with_intra || '0', intra_done_status_ids: s.intra_done_status_ids || '', + sync_addfield_status: s.sync_addfield_status || '0', api_key_set: !!s.api_key, webhook_secret_set: !!s.webhook_secret, }); @@ -935,7 +957,7 @@ app.post('/api/settings', auth, adminOnly, (req, res) => { setSetting(k, normalizeCsvIds(v)); return; } - if (k === 'auto_reset_on_update' || k === 'sync_reply_wait' || k === 'sync_done_with_intra') { + if (k === 'auto_reset_on_update' || k === 'sync_reply_wait' || k === 'sync_done_with_intra' || k === 'sync_addfield_status') { setSetting(k, String(v) === '1' || v === true ? '1' : '0'); return; } diff --git a/start-local.bat b/start-local.bat new file mode 100644 index 0000000..b938cc6 --- /dev/null +++ b/start-local.bat @@ -0,0 +1,48 @@ +@echo off +setlocal enabledelayedexpansion + +cd /d "%~dp0" + +if not exist ".env" ( + echo [ERROR] Файл .env не найден. + echo Скопируйте .env.example в .env и заполните переменные. + pause + exit /b 1 +) + +if not exist "data" mkdir data + +where docker >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] Docker не найден. Установите Docker Desktop: https://www.docker.com/products/docker-desktop + pause + exit /b 1 +) + +:: Читаем PORT из .env для вывода в консоль +set PORT=3000 +for /f "usebackq tokens=1,* delims==" %%A in (".env") do ( + set "ln=%%A" + if /i "!ln!" == "APP_PORT" set PORT=%%B +) + +echo. +echo ================================================ +echo Intra Monitor — локальный запуск в Docker +echo Сборка образа из исходников... +echo ================================================ +echo. + +docker compose -f docker-compose.local.yml up -d --build +if errorlevel 1 ( + echo [ERROR] docker compose завершился с ошибкой. + pause + exit /b 1 +) + +echo. +echo [OK] Контейнер запущен: http://localhost:%PORT% +echo Логи: docker compose -f docker-compose.local.yml logs -f +echo Стоп: docker compose -f docker-compose.local.yml down +echo. +pause