192 lines
7.8 KiB
HTML
192 lines
7.8 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.85rem; }
|
|||
|
|
th, td { border: 1px solid #333; padding: 0.35rem 0.45rem; text-align: left; }
|
|||
|
|
th { background: #222; position: sticky; top: 0; }
|
|||
|
|
tr:nth-child(even) { background: #1a1a1a; }
|
|||
|
|
.err { color: #f66; }
|
|||
|
|
a { color: #8cf; }
|
|||
|
|
.panel { margin-bottom: 1rem; padding: 0.75rem; background: #1a1a1a; border: 1px solid #333; }
|
|||
|
|
.panel label { margin-right: 0.75rem; font-size: 0.85rem; }
|
|||
|
|
.panel input, .panel select, .panel button {
|
|||
|
|
padding: 0.35rem; margin: 0.2rem 0.35rem 0.2rem 0;
|
|||
|
|
background: #222; border: 1px solid #444; color: #e6e6e6;
|
|||
|
|
}
|
|||
|
|
.panel button { cursor: pointer; }
|
|||
|
|
#wrap { overflow-x: auto; max-height: 70vh; }
|
|||
|
|
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<nav class="meta" style="margin-bottom:0.75rem;">
|
|||
|
|
<a href="/">Сотрудники</a> · <strong>Трудозатраты</strong> · <a href="/time-entry">Запись часов</a> · <a href="/summary">Сводка</a> · <a href="/project-report">Проекты</a>
|
|||
|
|
</nav>
|
|||
|
|
<h1>Трудозатраты</h1>
|
|||
|
|
|
|||
|
|
<div id="keybox" class="panel" style="display:none;">
|
|||
|
|
<div>API-ключ (<code>USER_READER_API_KEY</code>):</div>
|
|||
|
|
<input type="password" id="apiKeyInput" placeholder="ключ" autocomplete="off" />
|
|||
|
|
<button type="button" id="saveKey">Сохранить</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="panel">
|
|||
|
|
<label>Данные
|
|||
|
|
<select id="dataset">
|
|||
|
|
<option value="labor-summary">Сводка (сотр. + проект + раздел)</option>
|
|||
|
|
<option value="time-entries">Записи времени (по дням)</option>
|
|||
|
|
<option value="project-members">Участники проектов</option>
|
|||
|
|
<option value="projects">Проекты</option>
|
|||
|
|
<option value="sections">Разделы</option>
|
|||
|
|
</select>
|
|||
|
|
</label>
|
|||
|
|
<label>С <input type="date" id="dateFrom" /></label>
|
|||
|
|
<label>По <input type="date" id="dateTo" /></label>
|
|||
|
|
<label>emp_id <input type="number" id="empId" min="1" placeholder="—" style="width:5rem;" /></label>
|
|||
|
|
<label>project_id <input type="number" id="projectId" min="1" placeholder="—" style="width:5rem;" /></label>
|
|||
|
|
<button type="button" id="loadBtn">Загрузить</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<p class="meta">
|
|||
|
|
API: <a href="/api/labor/meta">/api/labor/meta</a>,
|
|||
|
|
<a href="/api/labor-summary">/api/labor-summary</a>,
|
|||
|
|
<a href="/api/time-entries">/api/time-entries</a>,
|
|||
|
|
<a href="/api/projects">/api/projects</a>,
|
|||
|
|
<a href="/api/sections">/api/sections</a>,
|
|||
|
|
<a href="/api/project-members">/api/project-members</a>
|
|||
|
|
</p>
|
|||
|
|
<p id="status" class="meta">Выберите период и нажмите «Загрузить».</p>
|
|||
|
|
<div id="wrap"></div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
const STORE_KEY = 'meraproject_user_reader_api_key';
|
|||
|
|
|
|||
|
|
function headers() {
|
|||
|
|
const k = sessionStorage.getItem(STORE_KEY);
|
|||
|
|
const h = {};
|
|||
|
|
if (k) h['X-Api-Key'] = k;
|
|||
|
|
return h;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function escapeHtml(s) {
|
|||
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function defaultDates() {
|
|||
|
|
const to = new Date();
|
|||
|
|
const from = new Date();
|
|||
|
|
from.setMonth(from.getMonth() - 1);
|
|||
|
|
const fmt = d => d.toISOString().slice(0, 10);
|
|||
|
|
document.getElementById('dateFrom').value = fmt(from);
|
|||
|
|
document.getElementById('dateTo').value = fmt(to);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showKeyBox(show) {
|
|||
|
|
document.getElementById('keybox').style.display = show ? 'block' : 'none';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const NUM_COLS = new Set(['hours','over','over1','over2','total','duration','role','section_id','emp_id','project_id','member_id','id','parent','step','director','team','active','is_over','archive','removed']);
|
|||
|
|
|
|||
|
|
function renderTable(items) {
|
|||
|
|
const wrap = document.getElementById('wrap');
|
|||
|
|
if (!items || !items.length) {
|
|||
|
|
wrap.innerHTML = '<p>Нет строк.</p>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const cols = Object.keys(items[0]);
|
|||
|
|
const th = cols.map(c => '<th>' + escapeHtml(c) + '</th>').join('');
|
|||
|
|
const tr = items.map(row => '<tr>' + cols.map(c => {
|
|||
|
|
const v = row[c];
|
|||
|
|
const cls = NUM_COLS.has(c) ? ' class="num"' : '';
|
|||
|
|
return '<td' + cls + '>' + escapeHtml(v == null ? '' : v) + '</td>';
|
|||
|
|
}).join('') + '</tr>').join('');
|
|||
|
|
wrap.innerHTML = '<table><thead><tr>' + th + '</tr></thead><tbody>' + tr + '</tbody></table>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildUrl() {
|
|||
|
|
const ds = document.getElementById('dataset').value;
|
|||
|
|
const params = new URLSearchParams();
|
|||
|
|
params.set('limit', '500');
|
|||
|
|
const df = document.getElementById('dateFrom').value;
|
|||
|
|
const dt = document.getElementById('dateTo').value;
|
|||
|
|
const emp = document.getElementById('empId').value.trim();
|
|||
|
|
const proj = document.getElementById('projectId').value.trim();
|
|||
|
|
|
|||
|
|
if (ds === 'labor-summary') {
|
|||
|
|
if (!df || !dt) throw new Error('Для сводки нужны даты «С» и «По»');
|
|||
|
|
params.set('date_from', df);
|
|||
|
|
params.set('date_to', dt);
|
|||
|
|
} else if (ds === 'time-entries') {
|
|||
|
|
if (df) params.set('date_from', df);
|
|||
|
|
if (dt) params.set('date_to', dt);
|
|||
|
|
}
|
|||
|
|
if (emp) params.set('emp_id', emp);
|
|||
|
|
if (proj) params.set('project_id', proj);
|
|||
|
|
|
|||
|
|
const paths = {
|
|||
|
|
'labor-summary': '/api/labor-summary',
|
|||
|
|
'time-entries': '/api/time-entries',
|
|||
|
|
'project-members': '/api/project-members',
|
|||
|
|
'projects': '/api/projects',
|
|||
|
|
'sections': '/api/sections',
|
|||
|
|
};
|
|||
|
|
return paths[ds] + '?' + params.toString();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function load() {
|
|||
|
|
const status = document.getElementById('status');
|
|||
|
|
try {
|
|||
|
|
const h = await fetch('/api/health').then(r => r.json());
|
|||
|
|
if (!h.ok) {
|
|||
|
|
status.textContent = 'БД недоступна: ' + (h.error || '');
|
|||
|
|
status.className = 'meta err';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const url = buildUrl();
|
|||
|
|
const r = await fetch(url, { headers: headers() });
|
|||
|
|
if (r.status === 401) {
|
|||
|
|
status.textContent = 'Нужен API-ключ.';
|
|||
|
|
status.className = 'meta err';
|
|||
|
|
showKeyBox(true);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!r.ok) {
|
|||
|
|
const err = await r.json().catch(() => ({}));
|
|||
|
|
throw new Error(err.detail || (r.status + ' ' + r.statusText));
|
|||
|
|
}
|
|||
|
|
const data = await r.json();
|
|||
|
|
showKeyBox(false);
|
|||
|
|
const items = data.items || [];
|
|||
|
|
const extra = [];
|
|||
|
|
if (data.date_from) extra.push('период: ' + data.date_from + ' … ' + data.date_to);
|
|||
|
|
if (data.total != null) extra.push('всего: ' + data.total);
|
|||
|
|
if (data.count != null && data.count !== data.total) extra.push('в ответе: ' + data.count);
|
|||
|
|
status.textContent = document.getElementById('dataset').selectedOptions[0].text + (extra.length ? ' · ' + extra.join(' · ') : '');
|
|||
|
|
status.className = 'meta';
|
|||
|
|
renderTable(items);
|
|||
|
|
} catch (e) {
|
|||
|
|
status.textContent = 'Ошибка: ' + e.message;
|
|||
|
|
status.className = 'meta err';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.getElementById('loadBtn').addEventListener('click', load);
|
|||
|
|
document.getElementById('saveKey').addEventListener('click', function() {
|
|||
|
|
const v = document.getElementById('apiKeyInput').value.trim();
|
|||
|
|
if (v) sessionStorage.setItem(STORE_KEY, v);
|
|||
|
|
load();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
defaultDates();
|
|||
|
|
if (!sessionStorage.getItem(STORE_KEY)) showKeyBox(true);
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|