meraproject/services/user-reader/static/index.html
keboss-m 5c21d25d45 Initial commit: Merakomis portal, Docker stack and user-reader API.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 11:04:05 +03:00

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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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>