186 lines
7.4 KiB
HTML
186 lines
7.4 KiB
HTML
|
|
<!DOCTYPE html>
|
||
|
|
<html lang="ru">
|
||
|
|
<head>
|
||
|
|
<meta charset="utf-8" />
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
|
|
<title>Сотрудники (read-only)</title>
|
||
|
|
<style>
|
||
|
|
body { font-family: system-ui, sans-serif; margin: 1rem; background: #111; color: #e6e6e6; }
|
||
|
|
h1 { font-size: 1.2rem; }
|
||
|
|
.meta { color: #888; font-size: 0.85rem; margin-bottom: 1rem; }
|
||
|
|
table { border-collapse: collapse; width: 100%; font-size: 0.9rem; }
|
||
|
|
th, td { border: 1px solid #333; padding: 0.4rem 0.5rem; text-align: left; }
|
||
|
|
th { background: #222; }
|
||
|
|
tr:nth-child(even) { background: #1a1a1a; }
|
||
|
|
.err { color: #f66; }
|
||
|
|
a { color: #8cf; }
|
||
|
|
.keybox { margin-bottom: 1rem; padding: 0.75rem; background: #1a1a1a; border: 1px solid #333; max-width: 32rem; }
|
||
|
|
.keybox input { width: 100%; max-width: 24rem; margin: 0.35rem 0; padding: 0.35rem; background: #222; border: 1px solid #444; color: #e6e6e6; }
|
||
|
|
.keybox button { padding: 0.35rem 0.75rem; margin-right: 0.5rem; cursor: pointer; }
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<nav class="meta" style="margin-bottom:0.75rem;">
|
||
|
|
<strong>Сотрудники</strong> · <a href="/labor">Трудозатраты</a> · <a href="/time-entry">Запись часов</a> · <a href="/summary">Сводка</a> · <a href="/project-report">Проекты</a>
|
||
|
|
</nav>
|
||
|
|
<h1>Сотрудники</h1>
|
||
|
|
<div id="keybox" class="keybox" style="display:none;">
|
||
|
|
<div>Задайте API-ключ (как в <code>USER_READER_API_KEY</code> на сервере):</div>
|
||
|
|
<input type="password" id="apiKeyInput" placeholder="ключ" autocomplete="off" />
|
||
|
|
<div>
|
||
|
|
<button type="button" id="saveKey">Сохранить в сессии браузера</button>
|
||
|
|
</div>
|
||
|
|
<p class="meta">Ключ хранится в <code>sessionStorage</code> (до закрытия вкладки). Для внешних систем: заголовок <code>X-Api-Key</code> или <code>Authorization: Bearer</code>.</p>
|
||
|
|
</div>
|
||
|
|
<p class="meta">API: <a href="/api/meta">/api/meta</a>, <a href="/api/employees">/api/employees</a>, <a href="/api/employees/delta?since_updated=0">/api/employees/delta</a> (раз в 5 мин). Пароли не отдаются.</p>
|
||
|
|
<p id="status" class="meta">Загрузка…</p>
|
||
|
|
<div id="wrap"></div>
|
||
|
|
<script>
|
||
|
|
const STORE_KEY = 'meraproject_user_reader_api_key';
|
||
|
|
let meta = null;
|
||
|
|
let deltaField = null;
|
||
|
|
let lastSince = 0;
|
||
|
|
const itemsMap = new Map();
|
||
|
|
let pollTimer = null;
|
||
|
|
|
||
|
|
function headers() {
|
||
|
|
const k = sessionStorage.getItem(STORE_KEY);
|
||
|
|
const h = {};
|
||
|
|
if (k) h['X-Api-Key'] = k;
|
||
|
|
return h;
|
||
|
|
}
|
||
|
|
|
||
|
|
function rowId(r) {
|
||
|
|
if (r.id != null) return String(r.id);
|
||
|
|
const k = Object.keys(r||{}).find(x => x === 'id' || x.endsWith('_id'));
|
||
|
|
return k != null ? String(r[k]) : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function maxDeltaFromItems(items, field) {
|
||
|
|
if (!field || !items.length) return 0;
|
||
|
|
let m = 0;
|
||
|
|
for (const it of items) {
|
||
|
|
const v = it[field];
|
||
|
|
const n = typeof v === 'number' ? v : parseInt(String(v || '0'), 10);
|
||
|
|
if (!isNaN(n) && n > m) m = n;
|
||
|
|
}
|
||
|
|
return m;
|
||
|
|
}
|
||
|
|
|
||
|
|
function escapeHtml(s) {
|
||
|
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderTable() {
|
||
|
|
const wrap = document.getElementById('wrap');
|
||
|
|
const list = Array.from(itemsMap.values());
|
||
|
|
if (!list.length) {
|
||
|
|
wrap.innerHTML = '<p>Нет строк.</p>';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const cols = Object.keys(list[0]);
|
||
|
|
const th = cols.map(c => '<th>' + escapeHtml(c) + '</th>').join('');
|
||
|
|
const tr = list.map(row => '<tr>' + cols.map(c => '<td>' + escapeHtml(String(row[c] ?? '')) + '</td>').join('') + '</tr>').join('');
|
||
|
|
wrap.innerHTML = '<table><thead><tr>' + th + '</tr></thead><tbody>' + tr + '</tbody></table>';
|
||
|
|
}
|
||
|
|
|
||
|
|
function showKeyBox(show) {
|
||
|
|
document.getElementById('keybox').style.display = show ? 'block' : 'none';
|
||
|
|
}
|
||
|
|
|
||
|
|
async function pollDelta() {
|
||
|
|
const status = document.getElementById('status');
|
||
|
|
if (!deltaField || lastSince < 0) return;
|
||
|
|
try {
|
||
|
|
const r = await fetch('/api/employees/delta?since_updated=' + encodeURIComponent(lastSince) + '&limit=500', { headers: headers() });
|
||
|
|
if (r.status === 401) {
|
||
|
|
showKeyBox(true);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!r.ok) throw new Error('delta ' + r.status);
|
||
|
|
const d = await r.json();
|
||
|
|
for (const it of d.items || []) {
|
||
|
|
const id = rowId(it);
|
||
|
|
if (id != null) itemsMap.set(id, it);
|
||
|
|
}
|
||
|
|
lastSince = Math.max(lastSince, d.max_updated || lastSince);
|
||
|
|
renderTable();
|
||
|
|
const t = new Date().toLocaleString();
|
||
|
|
status.textContent = status.textContent.replace(/· последний опрос:.*$/, '') + ' · последний опрос: ' + t + ' (delta +' + (d.count || 0) + ')';
|
||
|
|
} catch (e) {
|
||
|
|
console.warn(e);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function load() {
|
||
|
|
const status = document.getElementById('status');
|
||
|
|
const wrap = document.getElementById('wrap');
|
||
|
|
try {
|
||
|
|
const h = await fetch('/api/health').then(r => r.json());
|
||
|
|
if (!h.ok) {
|
||
|
|
status.textContent = 'БД недоступна: ' + (h.error || JSON.stringify(h));
|
||
|
|
status.className = 'meta err';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const [m, data] = await Promise.all([
|
||
|
|
fetch('/api/meta', { headers: headers() }).then(async r => {
|
||
|
|
if (r.status === 401) { const e = new Error('401'); e.code = 401; throw e; }
|
||
|
|
if (!r.ok) throw new Error('meta ' + r.status);
|
||
|
|
return r.json();
|
||
|
|
}),
|
||
|
|
fetch('/api/employees?limit=500', { headers: headers() }).then(async r => {
|
||
|
|
if (r.status === 401) { const e = new Error('401'); e.code = 401; throw e; }
|
||
|
|
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
|
||
|
|
return r.json();
|
||
|
|
}),
|
||
|
|
]);
|
||
|
|
meta = m;
|
||
|
|
deltaField = m.delta_field || null;
|
||
|
|
showKeyBox(false);
|
||
|
|
|
||
|
|
const tab = data.physical_table || data.table || '—';
|
||
|
|
const mode = m.schema_mode || '—';
|
||
|
|
status.textContent =
|
||
|
|
'[' + mode + '] Таблица: ' + tab +
|
||
|
|
' · всего: ' + data.total + ', показано: ' + data.items.length +
|
||
|
|
' · опрос delta: каждые 5 мин';
|
||
|
|
|
||
|
|
itemsMap.clear();
|
||
|
|
for (const it of data.items || []) {
|
||
|
|
const id = rowId(it);
|
||
|
|
if (id != null) itemsMap.set(id, it);
|
||
|
|
}
|
||
|
|
lastSince = maxDeltaFromItems(data.items || [], deltaField);
|
||
|
|
|
||
|
|
if (!data.items.length) {
|
||
|
|
wrap.innerHTML = '<p>Нет строк.</p>';
|
||
|
|
} else {
|
||
|
|
renderTable();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (pollTimer) clearInterval(pollTimer);
|
||
|
|
pollTimer = setInterval(pollDelta, 5 * 60 * 1000);
|
||
|
|
} catch (e) {
|
||
|
|
if (e.code === 401 || (e.message && e.message.includes('401'))) {
|
||
|
|
status.textContent = 'Нужен API-ключ.';
|
||
|
|
status.className = 'meta err';
|
||
|
|
showKeyBox(true);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
status.textContent = 'Ошибка: ' + e.message;
|
||
|
|
status.className = 'meta err';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
document.getElementById('saveKey').addEventListener('click', function() {
|
||
|
|
const v = document.getElementById('apiKeyInput').value.trim();
|
||
|
|
if (v) sessionStorage.setItem(STORE_KEY, v);
|
||
|
|
load();
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!sessionStorage.getItem(STORE_KEY)) showKeyBox(true);
|
||
|
|
load();
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|