170 lines
6.7 KiB
HTML
170 lines
6.7 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 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> · <a href="/labor">Трудозатраты</a> ·
|
|||
|
|
<a href="/time-entry">Запись часов</a> · <a href="/summary">Сводка</a> · <strong>Проекты</strong>
|
|||
|
|
</nav>
|
|||
|
|
<h1>Сводка: проект × отдел × раздел</h1>
|
|||
|
|
<p class="meta">Суммарные часы по проекту с разбивкой по орготделу и разделу (ОВ1, ТХ…).</p>
|
|||
|
|
|
|||
|
|
<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>С <input type="date" id="dateFrom" /></label>
|
|||
|
|
<label>По <input type="date" id="dateTo" /></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/project-report">/api/project-report</a></p>
|
|||
|
|
<p id="status" class="meta">Выберите период и нажмите «Загрузить».</p>
|
|||
|
|
<div id="wrap"></div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
const STORE_KEY = 'meraproject_user_reader_api_key';
|
|||
|
|
|
|||
|
|
const COLUMNS = [
|
|||
|
|
{ key: 'id', label: 'id' },
|
|||
|
|
{ key: 'project_code', label: 'project_code' },
|
|||
|
|
{ key: 'step_name', label: 'Стадия' },
|
|||
|
|
{ key: 'status_name', label: 'Статус' },
|
|||
|
|
{ key: 'archive', label: 'archive', num: true },
|
|||
|
|
{ key: 'project_name', label: 'project_name' },
|
|||
|
|
{ key: 'department', label: 'department' },
|
|||
|
|
{ key: 'section', label: 'section' },
|
|||
|
|
{ key: 'hours', label: 'hours', num: true },
|
|||
|
|
{ key: 'over', label: 'over', num: true },
|
|||
|
|
{ key: 'over1', label: 'over1', num: true },
|
|||
|
|
{ key: 'over2', label: 'over2', num: true },
|
|||
|
|
{ key: 'total', label: 'total', num: true },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
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';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderTable(items) {
|
|||
|
|
const wrap = document.getElementById('wrap');
|
|||
|
|
if (!items || !items.length) {
|
|||
|
|
wrap.innerHTML = '<p>Нет строк за выбранный период.</p>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const th = COLUMNS.map(c => '<th>' + escapeHtml(c.label) + '</th>').join('');
|
|||
|
|
const tr = items.map(row => '<tr>' + COLUMNS.map(c => {
|
|||
|
|
const v = row[c.key];
|
|||
|
|
const cls = c.num ? ' 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 df = document.getElementById('dateFrom').value;
|
|||
|
|
const dt = document.getElementById('dateTo').value;
|
|||
|
|
if (!df || !dt) throw new Error('Нужны даты «С» и «По»');
|
|||
|
|
const params = new URLSearchParams();
|
|||
|
|
params.set('date_from', df);
|
|||
|
|
params.set('date_to', dt);
|
|||
|
|
params.set('limit', '2000');
|
|||
|
|
const proj = document.getElementById('projectId').value.trim();
|
|||
|
|
if (proj) params.set('project_id', proj);
|
|||
|
|
return '/api/project-report?' + 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 || [];
|
|||
|
|
status.textContent = 'период: ' + data.date_from + ' … ' + data.date_to
|
|||
|
|
+ ' · всего строк: ' + (data.total != null ? data.total : items.length);
|
|||
|
|
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>
|