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') ?`
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
|