689 lines
30 KiB
HTML
689 lines
30 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>IntradBot Dashboard</title>
|
||
<style>
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
:root{
|
||
--bg:#0d1117;--surface:#161b22;--surface2:#21262d;--border:#30363d;
|
||
--accent:#388bfd;--accent-h:#58a6ff;--success:#3fb950;--warn:#d29922;
|
||
--danger:#da3633;--text:#c9d1d9;--muted:#8b949e;--r:6px;
|
||
--font:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
--mono:'SFMono-Regular',Consolas,monospace;
|
||
}
|
||
body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:14px;display:flex;height:100vh;overflow:hidden}
|
||
|
||
/* ── Sidebar ── */
|
||
.sidebar{width:220px;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}
|
||
.brand{padding:18px 16px 16px;display:flex;align-items:center;gap:8px;font-size:15px;font-weight:700;border-bottom:1px solid var(--border)}
|
||
.nav{padding:8px 0;flex:1}
|
||
.nav-item{display:flex;align-items:center;gap:10px;padding:10px 16px;cursor:pointer;color:var(--muted);border-left:3px solid transparent;font-size:13px;transition:.15s}
|
||
.nav-item:hover{background:var(--surface2);color:var(--text)}
|
||
.nav-item.active{color:var(--accent-h);border-left-color:var(--accent);background:rgba(56,139,253,.1)}
|
||
.nav-icon{width:18px;text-align:center}
|
||
.sidebar-foot{padding:14px 16px;border-top:1px solid var(--border)}
|
||
.status-row{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}
|
||
.dot{width:8px;height:8px;border-radius:50%;background:var(--muted)}
|
||
.dot.on{background:var(--success);box-shadow:0 0 6px var(--success)}
|
||
.dot.off{background:var(--danger)}
|
||
.counter{margin-left:auto;background:var(--accent);color:#fff;border-radius:10px;padding:1px 7px;font-size:11px;font-weight:600;display:none}
|
||
.counter.show{display:inline}
|
||
|
||
/* ── Main ── */
|
||
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
||
.tab{display:none;flex:1;flex-direction:column;overflow:hidden}
|
||
.tab.active{display:flex}
|
||
.tab-head{padding:14px 24px;border-bottom:1px solid var(--border);background:var(--surface);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
|
||
.tab-head h2{font-size:15px;font-weight:600}
|
||
.tab-body{flex:1;overflow-y:auto;padding:16px 24px}
|
||
|
||
/* ── Buttons ── */
|
||
.btn{padding:6px 14px;border:1px solid var(--border);background:var(--surface2);color:var(--text);border-radius:var(--r);cursor:pointer;font-size:13px;font-family:var(--font);transition:.15s;display:inline-flex;align-items:center;gap:6px}
|
||
.btn:hover{border-color:var(--accent);color:var(--accent-h)}
|
||
.btn:disabled{opacity:.4;cursor:not-allowed}
|
||
.btn-primary{background:var(--accent);border-color:var(--accent);color:#fff}
|
||
.btn-primary:hover{background:var(--accent-h);border-color:var(--accent-h);color:#fff}
|
||
.btn-danger{background:var(--danger);border-color:var(--danger);color:#fff}
|
||
.btn-danger:hover{opacity:.85;color:#fff}
|
||
.btn-sm{padding:4px 10px;font-size:12px}
|
||
.btn-group{display:flex;gap:8px}
|
||
|
||
/* ── Inputs ── */
|
||
input,select,textarea{background:var(--surface2);border:1px solid var(--border);color:var(--text);padding:8px 12px;border-radius:var(--r);font-family:var(--font);font-size:13px;width:100%;transition:.15s}
|
||
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent)}
|
||
textarea{resize:vertical;min-height:80px}
|
||
select option{background:var(--surface2)}
|
||
.form-row{display:flex;gap:8px;margin-bottom:12px}
|
||
.form-row input,.form-row select{flex:1}
|
||
.form-group{margin-bottom:14px}
|
||
.form-label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin-bottom:5px}
|
||
|
||
/* ── Webhook cards ── */
|
||
.feed{display:flex;flex-direction:column;gap:10px}
|
||
.wcard{border:1px solid var(--border);border-radius:var(--r);overflow:hidden;background:var(--surface)}
|
||
.wcard.new{border-left:3px solid var(--success)}
|
||
.wcard.updated{border-left:3px solid var(--accent)}
|
||
.wcard-head{padding:12px 16px;display:flex;align-items:center;gap:10px;cursor:pointer;user-select:none}
|
||
.wcard-head:hover{background:var(--surface2)}
|
||
.badge{padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;text-transform:uppercase}
|
||
.badge-new{background:rgba(63,185,80,.15);color:var(--success)}
|
||
.badge-upd{background:rgba(56,139,253,.15);color:var(--accent-h)}
|
||
.wcard-num{font-weight:700;font-size:14px}
|
||
.wcard-name{color:var(--muted);font-size:13px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.wcard-time{color:var(--muted);font-size:12px;flex-shrink:0}
|
||
.chevron{color:var(--muted);font-size:11px;transition:.2s;flex-shrink:0}
|
||
.wcard.open .chevron{transform:rotate(180deg)}
|
||
.wcard-body{display:none;border-top:1px solid var(--border)}
|
||
.wcard.open .wcard-body{display:block}
|
||
.wsec{border-bottom:1px solid var(--border)}
|
||
.wsec:last-child{border-bottom:none}
|
||
.wsec-head{padding:8px 16px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);cursor:pointer;display:flex;align-items:center;gap:6px;background:rgba(255,255,255,.02)}
|
||
.wsec-head:hover{color:var(--text)}
|
||
.wsec-body{padding:12px 16px;display:none}
|
||
.wsec.open .wsec-body{display:block}
|
||
.wsec.open .wsec-chevron{transform:rotate(90deg)}
|
||
.wsec-chevron{transition:.15s}
|
||
.change-item{padding:4px 0;border-bottom:1px solid rgba(255,255,255,.04);font-size:13px}
|
||
.change-item:last-child{border-bottom:none}
|
||
.comment-item{background:rgba(56,139,253,.06);border-left:2px solid var(--accent);padding:6px 10px;margin:3px 0;border-radius:0 var(--r) var(--r) 0;font-size:13px}
|
||
.formatted-msg{font-family:var(--mono);font-size:12px;background:var(--bg);border:1px solid var(--border);border-radius:var(--r);padding:12px;white-space:pre-wrap;word-break:break-all;max-height:300px;overflow-y:auto}
|
||
|
||
/* ── Tasks ── */
|
||
.task-card{border:1px solid var(--border);border-radius:var(--r);padding:20px;background:var(--surface);margin-bottom:12px;cursor:pointer;transition:.15s}
|
||
.task-card:hover{border-color:var(--accent)}
|
||
.task-card.expanded{border-color:var(--accent)}
|
||
.task-title{font-size:15px;font-weight:600;margin-bottom:10px;display:flex;align-items:flex-start;gap:10px}
|
||
.task-num{color:var(--muted);font-size:13px;font-weight:400;flex-shrink:0}
|
||
.task-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px;margin-bottom:12px}
|
||
.meta-item{background:var(--surface2);border-radius:var(--r);padding:10px 12px}
|
||
.meta-label{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin-bottom:3px}
|
||
.meta-val{font-size:13px;font-weight:500}
|
||
.tasks-list{display:flex;flex-direction:column;gap:8px}
|
||
.status-badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px}
|
||
.s-open{background:rgba(63,185,80,.15);color:var(--success)}
|
||
.s-work{background:rgba(56,139,253,.15);color:var(--accent-h)}
|
||
.s-done{background:rgba(139,148,158,.15);color:var(--muted)}
|
||
.s-wait{background:rgba(210,153,34,.15);color:var(--warn)}
|
||
.task-desc{font-size:13px;color:var(--muted);padding:10px;background:var(--bg);border-radius:var(--r);margin-top:8px;white-space:pre-wrap}
|
||
|
||
/* ── API form ── */
|
||
.api-layout{display:grid;grid-template-columns:360px 1fr;gap:20px;align-items:start}
|
||
.panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:20px}
|
||
.panel-title{font-size:13px;font-weight:600;margin-bottom:16px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px}
|
||
.preview-url{font-family:var(--mono);font-size:12px;padding:8px 10px;background:var(--bg);border-radius:var(--r);margin-bottom:10px;color:var(--warn);word-break:break-all}
|
||
pre{font-family:var(--mono);font-size:12px;background:var(--bg);border:1px solid var(--border);border-radius:var(--r);padding:12px;overflow-x:auto;white-space:pre-wrap;word-break:break-all}
|
||
.result-block{margin-top:12px;padding:10px 14px;border-radius:var(--r);font-size:13px}
|
||
.result-ok{background:rgba(63,185,80,.1);border:1px solid var(--success);color:var(--success)}
|
||
.result-err{background:rgba(218,54,51,.1);border:1px solid var(--danger);color:var(--danger)}
|
||
.result-info{background:rgba(56,139,253,.1);border:1px solid var(--accent);color:var(--accent-h)}
|
||
.hidden{display:none!important}
|
||
.empty{text-align:center;color:var(--muted);padding:60px 20px;font-size:14px}
|
||
.spin{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:transparent}
|
||
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
||
::-webkit-scrollbar-thumb:hover{background:var(--muted)}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<aside class="sidebar">
|
||
<div class="brand">🤖 IntradBot</div>
|
||
<nav class="nav">
|
||
<div class="nav-item active" data-tab="webhooks" onclick="switchTab('webhooks',this)">
|
||
<span class="nav-icon">🔔</span> Вебхуки
|
||
<span class="counter" id="wh-counter">0</span>
|
||
</div>
|
||
<div class="nav-item" data-tab="tasks" onclick="switchTab('tasks',this)">
|
||
<span class="nav-icon">📋</span> Заявки
|
||
</div>
|
||
<div class="nav-item" data-tab="api" onclick="switchTab('api',this)">
|
||
<span class="nav-icon">⚙️</span> API
|
||
</div>
|
||
</nav>
|
||
<div class="sidebar-foot">
|
||
<div class="status-row">
|
||
<div class="dot" id="dot"></div>
|
||
<span id="dot-label">Подключение...</span>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<main class="main">
|
||
|
||
<!-- ── Вебхуки ── -->
|
||
<section class="tab active" id="tab-webhooks">
|
||
<div class="tab-head">
|
||
<h2>Вебхуки в реальном времени</h2>
|
||
<div class="btn-group">
|
||
<button class="btn btn-sm" onclick="clearFeed()">Очистить</button>
|
||
</div>
|
||
</div>
|
||
<div class="tab-body">
|
||
<div class="feed" id="feed">
|
||
<div class="empty" id="feed-empty">Ожидание вебхуков…</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── Заявки ── -->
|
||
<section class="tab" id="tab-tasks">
|
||
<div class="tab-head">
|
||
<h2>Заявки</h2>
|
||
<div class="btn-group">
|
||
<button class="btn btn-sm btn-primary" onclick="loadActive()">Загрузить активные</button>
|
||
</div>
|
||
</div>
|
||
<div class="tab-body">
|
||
<div class="form-row" style="margin-bottom:16px">
|
||
<input type="number" id="search-num" placeholder="Номер заявки" onkeydown="if(event.key==='Enter')searchTask()">
|
||
<button class="btn btn-primary" onclick="searchTask()" style="width:auto;flex-shrink:0">Найти</button>
|
||
</div>
|
||
<div id="tasks-result">
|
||
<div class="empty">Введите номер заявки или загрузите активные</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── API ── -->
|
||
<section class="tab" id="tab-api">
|
||
<div class="tab-head">
|
||
<h2>API — изменение заявки</h2>
|
||
</div>
|
||
<div class="tab-body">
|
||
<div class="api-layout">
|
||
|
||
<!-- Форма -->
|
||
<div class="panel">
|
||
<div class="panel-title">Параметры</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Номер заявки</label>
|
||
<input type="number" id="api-num" placeholder="272">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Действие</label>
|
||
<select id="api-action" onchange="renderActionFields()">
|
||
<option value="status">Сменить статус</option>
|
||
<option value="comment">Добавить комментарий</option>
|
||
<option value="executor">Назначить исполнителя</option>
|
||
<option value="priority">Изменить приоритет</option>
|
||
<option value="resolution_date">Плановый срок</option>
|
||
<option value="tags">Теги (через запятую)</option>
|
||
<option value="custom">Произвольный блок JSON</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div id="action-fields"></div>
|
||
|
||
<button class="btn btn-primary" style="width:100%" onclick="previewRequest()">
|
||
Предпросмотр запроса →
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Предпросмотр + результат -->
|
||
<div>
|
||
<div class="panel" id="preview-panel" style="margin-bottom:16px">
|
||
<div class="panel-title">Запрос будет отправлен</div>
|
||
<div class="preview-url" id="preview-url">—</div>
|
||
<pre id="preview-json">Нажмите «Предпросмотр запроса»</pre>
|
||
<div class="btn-group" style="margin-top:14px">
|
||
<button class="btn btn-danger" id="exec-btn" onclick="executeRequest()" disabled>
|
||
✓ Выполнить
|
||
</button>
|
||
<button class="btn" onclick="resetPreview()">Отмена</button>
|
||
</div>
|
||
</div>
|
||
<div id="api-result"></div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
</main>
|
||
|
||
<script>
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// GLOBALS
|
||
// ═══════════════════════════════════════════════════════════════
|
||
let whCount = 0;
|
||
let _pendingBlocks = null;
|
||
|
||
const STATUSES = {
|
||
68046:'Открыта', 68048:'Требует уточнения',
|
||
68051:'В работе', 68045:'Выполнена'
|
||
};
|
||
const PRIORITIES = {
|
||
29749:'Наивысший', 29598:'Критический',
|
||
29597:'Высокий', 29596:'Низкий'
|
||
};
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// TABS
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function switchTab(id, el) {
|
||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||
document.getElementById('tab-' + id).classList.add('active');
|
||
el.classList.add('active');
|
||
if (id === 'webhooks') {
|
||
whCount = 0;
|
||
const c = document.getElementById('wh-counter');
|
||
c.textContent = '0'; c.classList.remove('show');
|
||
}
|
||
if (id === 'api') renderActionFields();
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// SSE — вебхуки в реальном времени
|
||
// ═══════════════════════════════════════════════════════════════
|
||
const es = new EventSource('/stream');
|
||
es.onopen = () => setStatus(true);
|
||
es.onerror = () => setStatus(false);
|
||
es.onmessage = e => {
|
||
const d = JSON.parse(e.data);
|
||
if (d.type === 'ping') return;
|
||
if (d.type === 'webhook') addWebhookCard(d);
|
||
};
|
||
|
||
function setStatus(on) {
|
||
document.getElementById('dot').className = 'dot ' + (on ? 'on' : 'off');
|
||
document.getElementById('dot-label').textContent = on ? 'Подключено' : 'Нет связи';
|
||
}
|
||
|
||
function addWebhookCard(d) {
|
||
const feed = document.getElementById('feed');
|
||
document.getElementById('feed-empty')?.remove();
|
||
|
||
const isNew = d.operation === 'Created';
|
||
const cls = isNew ? 'new' : 'updated';
|
||
const badge = isNew
|
||
? '<span class="badge badge-new">Новая</span>'
|
||
: '<span class="badge badge-upd">Обновлена</span>';
|
||
|
||
const changes = (d.parsed?.changes || []);
|
||
const comments = (d.parsed?.comments || []);
|
||
|
||
// Секция: Изменения
|
||
let changesHtml = '';
|
||
if (changes.length || comments.length) {
|
||
const items = [
|
||
...changes.map(c => `<div class="change-item">${esc(c)}</div>`),
|
||
...comments.map(c => `<div class="comment-item">${esc(c)}</div>`),
|
||
].join('');
|
||
changesHtml = makeSec('📝 Изменения / комментарии', items, true);
|
||
}
|
||
|
||
// Секция: Форматированное сообщение
|
||
const fmtHtml = makeSec('💬 Сообщение в канале',
|
||
`<div class="formatted-msg">${esc(d.formatted || '')}</div>`);
|
||
|
||
// Секция: Сырые данные
|
||
const rawHtml = makeSec('{ } Сырые данные',
|
||
`<pre>${esc(JSON.stringify(d.raw, null, 2))}</pre>`);
|
||
|
||
const card = document.createElement('div');
|
||
card.className = `wcard ${cls}`;
|
||
card.innerHTML = `
|
||
<div class="wcard-head" onclick="toggleCard(this)">
|
||
${badge}
|
||
<span class="wcard-num">#${esc(d.task_number)}</span>
|
||
<span class="wcard-name">${esc(getTaskName(d.raw))}</span>
|
||
<span class="wcard-time">${esc(d.timestamp)}</span>
|
||
<span class="chevron">▼</span>
|
||
</div>
|
||
<div class="wcard-body">
|
||
${changesHtml}
|
||
${fmtHtml}
|
||
${rawHtml}
|
||
</div>`;
|
||
|
||
feed.insertBefore(card, feed.firstChild);
|
||
|
||
// Счётчик на вкладке
|
||
if (!document.querySelector('.nav-item[data-tab="webhooks"]').classList.contains('active')) {
|
||
whCount++;
|
||
const c = document.getElementById('wh-counter');
|
||
c.textContent = whCount; c.classList.add('show');
|
||
}
|
||
}
|
||
|
||
function makeSec(title, body, openByDefault = false) {
|
||
const cls = openByDefault ? 'wsec open' : 'wsec';
|
||
return `
|
||
<div class="${cls}">
|
||
<div class="wsec-head" onclick="toggleSec(this)">
|
||
<span class="wsec-chevron">▶</span> ${title}
|
||
</div>
|
||
<div class="wsec-body">${body}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function toggleCard(head) {
|
||
head.closest('.wcard').classList.toggle('open');
|
||
}
|
||
function toggleSec(head) {
|
||
head.closest('.wsec').classList.toggle('open');
|
||
}
|
||
|
||
function getTaskName(raw) {
|
||
try {
|
||
const n = raw.Fields?.name;
|
||
if (typeof n === 'object') return n.Value || '';
|
||
if (typeof n === 'string') return JSON.parse(n).Value || '';
|
||
} catch {}
|
||
return '';
|
||
}
|
||
|
||
function clearFeed() {
|
||
const feed = document.getElementById('feed');
|
||
feed.innerHTML = '<div class="empty" id="feed-empty">Ожидание вебхуков…</div>';
|
||
whCount = 0;
|
||
const c = document.getElementById('wh-counter');
|
||
c.textContent = '0'; c.classList.remove('show');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// ЗАЯВКИ
|
||
// ═══════════════════════════════════════════════════════════════
|
||
async function searchTask() {
|
||
const num = document.getElementById('search-num').value.trim();
|
||
if (!num) return;
|
||
const el = document.getElementById('tasks-result');
|
||
el.innerHTML = '<div class="empty"><span class="spin"></span> Загрузка…</div>';
|
||
try {
|
||
const r = await fetch(`/api/tasks/search?number=${num}`);
|
||
const data = await r.json();
|
||
if (data.error) { el.innerHTML = `<div class="empty">${esc(data.error)}</div>`; return; }
|
||
el.innerHTML = renderTaskCard(data);
|
||
} catch(e) {
|
||
el.innerHTML = `<div class="empty">Ошибка: ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function loadActive() {
|
||
const el = document.getElementById('tasks-result');
|
||
el.innerHTML = '<div class="empty"><span class="spin"></span> Загрузка активных заявок…</div>';
|
||
try {
|
||
const r = await fetch('/api/tasks/active');
|
||
const tasks = await r.json();
|
||
if (!tasks.length) { el.innerHTML = '<div class="empty">Активных заявок нет</div>'; return; }
|
||
el.innerHTML = `<div style="margin-bottom:8px;color:var(--muted);font-size:12px">Найдено: ${tasks.length}</div>` +
|
||
'<div class="tasks-list">' + tasks.map(t => renderTaskCard(t)).join('') + '</div>';
|
||
} catch(e) {
|
||
el.innerHTML = `<div class="empty">Ошибка: ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderTaskCard(t) {
|
||
const statusId = t.status;
|
||
const statusName= resolveId(STATUSES, statusId, 'statuses');
|
||
const priId = t.priority;
|
||
const priName = resolveId(PRIORITIES, priId, 'priorities');
|
||
const sCls = statusCls(statusId);
|
||
|
||
const meta = [
|
||
['Статус', `<span class="status-badge ${sCls}">${esc(statusName)}</span>`],
|
||
['Приоритет', esc(priName)],
|
||
['Исполнитель',esc(t.executorname || t.executor || '—')],
|
||
['Группа', esc(t.executorgroupname || t.executorgroup || '—')],
|
||
['Сервис', esc(t.servicename || t.service || '—')],
|
||
['Тип', esc(t.tasktypename || t.tasktype || '—')],
|
||
['Создана', esc(fmtDate(t.createdat))],
|
||
['Обновлена', esc(fmtDate(t.updatedat))],
|
||
['Создатель', esc(t.createdby || '—')],
|
||
['Плановый срок', esc(fmtDate(t.resolutiondateplan))],
|
||
].map(([l,v]) => `<div class="meta-item"><div class="meta-label">${l}</div><div class="meta-val">${v}</div></div>`).join('');
|
||
|
||
const desc = t.description
|
||
? `<div class="task-desc">${esc(t.description)}</div>` : '';
|
||
|
||
return `
|
||
<div class="task-card">
|
||
<div class="task-title">
|
||
<span class="task-num">#${t.tasknumber}</span>
|
||
${esc(t.name || '—')}
|
||
</div>
|
||
<div class="task-grid">${meta}</div>
|
||
${desc}
|
||
<div style="margin-top:10px">
|
||
<button class="btn btn-sm" onclick="fillApiForm(${t.tasknumber})">⚙️ Изменить через API</button>
|
||
<a href="https://cleverparking.intradesk.ru/task/list/${t.tasknumber}" target="_blank">
|
||
<button class="btn btn-sm" style="margin-left:6px">🔗 Открыть в системе</button>
|
||
</a>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function fillApiForm(num) {
|
||
switchTab('api', document.querySelector('.nav-item[data-tab="api"]'));
|
||
document.getElementById('api-num').value = num;
|
||
}
|
||
|
||
function statusCls(id) {
|
||
if (id === 68046) return 's-open';
|
||
if (id === 68051) return 's-work';
|
||
if (id === 68045) return 's-done';
|
||
if (id === 68048) return 's-wait';
|
||
return '';
|
||
}
|
||
|
||
function resolveId(dict, id, entity) {
|
||
if (dict[id]) return dict[id];
|
||
return id ? String(id) : '—';
|
||
}
|
||
|
||
function fmtDate(s) {
|
||
if (!s) return '—';
|
||
try {
|
||
return new Date(s).toLocaleString('ru-RU', {
|
||
day:'2-digit', month:'2-digit', year:'numeric',
|
||
hour:'2-digit', minute:'2-digit'
|
||
});
|
||
} catch { return s; }
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// API — форма изменения заявки
|
||
// ═══════════════════════════════════════════════════════════════
|
||
async function renderActionFields() {
|
||
const action = document.getElementById('api-action').value;
|
||
const el = document.getElementById('action-fields');
|
||
resetPreview();
|
||
|
||
if (action === 'status') {
|
||
let opts = Object.entries(STATUSES).map(([id,n]) =>
|
||
`<option value="${id}">${n}</option>`).join('');
|
||
try {
|
||
const r = await fetch('/api/ref/statuses');
|
||
const items = await r.json();
|
||
if (items.length) opts = items.map(i => `<option value="${i.id}">${esc(i.name)}</option>`).join('');
|
||
} catch {}
|
||
el.innerHTML = `
|
||
<div class="form-group">
|
||
<label class="form-label">Новый статус</label>
|
||
<select id="f-status">${opts}</select>
|
||
</div>`;
|
||
}
|
||
else if (action === 'comment') {
|
||
el.innerHTML = `
|
||
<div class="form-group">
|
||
<label class="form-label">Текст комментария</label>
|
||
<textarea id="f-comment" placeholder="Принято в работу…"></textarea>
|
||
</div>`;
|
||
}
|
||
else if (action === 'executor') {
|
||
el.innerHTML = `
|
||
<div class="form-group">
|
||
<label class="form-label">ID группы (groupid)</label>
|
||
<input type="number" id="f-groupid" placeholder="243025">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">ID сотрудника (userid, необязательно)</label>
|
||
<input type="number" id="f-userid" placeholder="109747">
|
||
</div>`;
|
||
}
|
||
else if (action === 'priority') {
|
||
let opts = Object.entries(PRIORITIES).map(([id,n]) =>
|
||
`<option value="${id}">${n}</option>`).join('');
|
||
try {
|
||
const r = await fetch('/api/ref/priorities');
|
||
const items = await r.json();
|
||
if (items.length) opts = items.map(i => `<option value="${i.id}">${esc(i.name)}</option>`).join('');
|
||
} catch {}
|
||
el.innerHTML = `
|
||
<div class="form-group">
|
||
<label class="form-label">Приоритет</label>
|
||
<select id="f-priority">${opts}</select>
|
||
</div>`;
|
||
}
|
||
else if (action === 'resolution_date') {
|
||
const now = new Date();
|
||
now.setDate(now.getDate() + 1);
|
||
const iso = now.toISOString().slice(0,16);
|
||
el.innerHTML = `
|
||
<div class="form-group">
|
||
<label class="form-label">Дата и время (локальное)</label>
|
||
<input type="datetime-local" id="f-date" value="${iso}">
|
||
</div>`;
|
||
}
|
||
else if (action === 'tags') {
|
||
el.innerHTML = `
|
||
<div class="form-group">
|
||
<label class="form-label">ID тегов через запятую</label>
|
||
<input id="f-tags" placeholder="32887, 32893">
|
||
</div>`;
|
||
}
|
||
else if (action === 'custom') {
|
||
el.innerHTML = `
|
||
<div class="form-group">
|
||
<label class="form-label">Название блока</label>
|
||
<input id="f-block-name" placeholder="status">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Значение (JSON)</label>
|
||
<textarea id="f-block-val" placeholder='{"value": 68051}'></textarea>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
function buildBlocks() {
|
||
const action = document.getElementById('api-action').value;
|
||
const jv = v => JSON.stringify({value: v});
|
||
|
||
if (action === 'status')
|
||
return {status: jv(parseInt(document.getElementById('f-status').value))};
|
||
if (action === 'comment')
|
||
return {comment: jv(document.getElementById('f-comment').value)};
|
||
if (action === 'executor') {
|
||
const v = {};
|
||
const gid = document.getElementById('f-groupid').value;
|
||
const uid = document.getElementById('f-userid').value;
|
||
if (gid) v.groupid = parseInt(gid);
|
||
if (uid) v.userid = parseInt(uid);
|
||
return {executor: jv(v)};
|
||
}
|
||
if (action === 'priority')
|
||
return {priority: jv(parseInt(document.getElementById('f-priority').value))};
|
||
if (action === 'resolution_date') {
|
||
const dt = new Date(document.getElementById('f-date').value);
|
||
return {resolutiondateplan: jv(dt.toISOString())};
|
||
}
|
||
if (action === 'tags') {
|
||
const ids = document.getElementById('f-tags').value
|
||
.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n));
|
||
return {tags: jv(ids)};
|
||
}
|
||
if (action === 'custom') {
|
||
const name = document.getElementById('f-block-name').value.trim();
|
||
const val = document.getElementById('f-block-val').value.trim();
|
||
if (!name) return null;
|
||
return {[name]: val};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function previewRequest() {
|
||
const num = parseInt(document.getElementById('api-num').value);
|
||
if (!num) { showResult('error', 'Укажите номер заявки'); return; }
|
||
|
||
const blocks = buildBlocks();
|
||
if (!blocks) { showResult('error', 'Заполните поля действия'); return; }
|
||
|
||
try {
|
||
const r = await fetch('/api/task/preview', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({number: num, blocks}),
|
||
});
|
||
const d = await r.json();
|
||
document.getElementById('preview-url').textContent = `${d.method} ${d.url}`;
|
||
document.getElementById('preview-json').textContent = JSON.stringify(d.body, null, 2);
|
||
document.getElementById('exec-btn').disabled = false;
|
||
document.getElementById('api-result').innerHTML = '';
|
||
_pendingBlocks = blocks;
|
||
} catch(e) {
|
||
showResult('error', e.message);
|
||
}
|
||
}
|
||
|
||
async function executeRequest() {
|
||
if (!_pendingBlocks) return;
|
||
const num = parseInt(document.getElementById('api-num').value);
|
||
const btn = document.getElementById('exec-btn');
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spin"></span> Выполняется…';
|
||
|
||
try {
|
||
const r = await fetch('/api/task/execute', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({number: num, blocks: _pendingBlocks}),
|
||
});
|
||
const d = await r.json();
|
||
if (d.success) {
|
||
showResult('ok', `✓ Заявка #${num} успешно изменена`);
|
||
} else {
|
||
showResult('error', `Ошибка: ${d.error || 'неизвестная ошибка'}`);
|
||
}
|
||
} catch(e) {
|
||
showResult('error', e.message);
|
||
}
|
||
|
||
btn.innerHTML = '✓ Выполнить';
|
||
btn.disabled = true;
|
||
_pendingBlocks = null;
|
||
}
|
||
|
||
function resetPreview() {
|
||
document.getElementById('preview-url').textContent = '—';
|
||
document.getElementById('preview-json').textContent = 'Нажмите «Предпросмотр запроса»';
|
||
document.getElementById('exec-btn').disabled = true;
|
||
document.getElementById('api-result').innerHTML = '';
|
||
_pendingBlocks = null;
|
||
}
|
||
|
||
function showResult(type, msg) {
|
||
const cls = type === 'ok' ? 'result-ok' : type === 'error' ? 'result-err' : 'result-info';
|
||
document.getElementById('api-result').innerHTML =
|
||
`<div class="result-block ${cls}">${esc(msg)}</div>`;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// UTILS
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function esc(s) {
|
||
return String(s ?? '')
|
||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.replace(/"/g,'"');
|
||
}
|
||
|
||
// Инициализация формы API при загрузке
|
||
renderActionFields();
|
||
</script>
|
||
</body>
|
||
</html>
|