This commit is contained in:
dv 2026-03-23 22:25:01 +03:00
parent 2467cc5fce
commit fc2b7f762a
8 changed files with 165 additions and 10 deletions

22
docker-compose.local.yml Normal file
View file

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

Binary file not shown.

View file

@ -76,6 +76,12 @@
Sync Done with Intra by status_id
</label>
</div>
<div class="form-row">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text2);">
<input type="checkbox" id="setting-sync-addfield-status" style="accent-color:var(--accent);">
Синхронизировать «Служебный статус» с доп. полем IntraDesk (двусторонняя)
</label>
</div>
<div class="form-row">
<div class="form-field" style="flex:1;">
<label>Done Intra IDs (CSV)</label>

View file

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

View file

@ -3,6 +3,7 @@ const ALL_COLS = [
{ 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 },
@ -454,6 +455,18 @@ function intraStatusBadge(task) {
else if (lc.includes('ожидан') || lc.includes('hold')) cls = 'sb-intra-hold';
return `<span class="status-badge ${cls}"><span class="dot"></span>${esc(name)}</span>`;
}
// ── IntraDesk additional field status badge ───────────────────────
function intraAddfieldBadge(task) {
const val = task._addfield_status;
if (!val) return '<span style="color:var(--text3);font-size:11px;">—</span>';
const match = (typeof MARKS_CONFIG !== 'undefined' ? MARKS_CONFIG : [])
.find((m) => m.label && m.label.toLowerCase() === val.toLowerCase());
if (match) {
return `<span class="status-badge" style="background:${match.colorBg||'var(--surface2)'};border-color:${match.colorBorder||'var(--border2)'};color:${match.colorText||'var(--text2)'};">` +
`<span class="dot" style="background:${match.colorDot||match.ispColor||'#1677ff'};"></span>${esc(val)}</span>`;
}
return `<span class="status-badge sb-intra-other"><span class="dot"></span>${esc(val)}</span>`;
}
// Who answered — ring class for avatar
function whoAnsweredRing(lc){
if(!lc) return 'ring-none';
@ -704,6 +717,7 @@ function renderTable(keepPage){
${sc('num') ?'<th style="width:70px;">#</th>' :''}
${sc('title') ?'<th>Тема</th>' :''}
${sc('intrastatus')?'<th style="width:110px;">Статус в интре</th>':''}
${sc('servstatus') ?'<th style="width:130px;">Служ. статус в интре</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>` :''}
@ -742,6 +756,7 @@ function renderTable(keepPage){
${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('servstatus') ?`<td>${intraAddfieldBadge(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>

View file

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

View file

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

48
start-local.bat Normal file
View file

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