meraproject/services/user-reader/static/time-entry.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

266 lines
11 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Запись часов</title>
<style>
body { font-family: system-ui, sans-serif; margin: 1rem; max-width: 42rem; background: #111; color: #e6e6e6; }
h1 { font-size: 1.2rem; }
.meta { color: #888; font-size: 0.85rem; margin-bottom: 1rem; }
a { color: #8cf; }
.panel { margin-bottom: 1rem; padding: 0.75rem; background: #1a1a1a; border: 1px solid #333; }
label { display: block; margin: 0.5rem 0 0.25rem; font-size: 0.85rem; color: #aaa; }
input, select, button {
width: 100%; box-sizing: border-box; padding: 0.45rem;
background: #222; border: 1px solid #444; color: #e6e6e6; font-size: 0.95rem;
}
input[type="checkbox"] { width: auto; margin-right: 0.5rem; }
button { cursor: pointer; margin-top: 0.75rem; width: auto; padding: 0.5rem 1rem; }
button.secondary { background: #333; margin-left: 0.5rem; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.err { color: #f66; }
.ok { color: #6f6; }
pre { background: #0d0d0d; border: 1px solid #333; padding: 0.75rem; overflow: auto; font-size: 0.8rem; }
.hint { font-size: 0.8rem; color: #777; margin-top: 0.25rem; }
#empFilter { margin-bottom: 0.35rem; }
</style>
</head>
<body>
<nav class="meta">
<a href="/">Сотрудники</a> · <a href="/labor">Трудозатраты</a> · <strong>Запись часов</strong> · <a href="/summary">Сводка</a>
</nav>
<h1>Мой табель</h1>
<p class="meta">Каждый сотрудник вносит часы <strong>только за себя</strong>. Выберите себя в списке — в API уйдёт один и тот же <code>emp_id</code> в заголовке и в записи.</p>
<div id="keybox" class="panel" style="display:none;">
<label>API-ключ (локальная отладка)</label>
<input type="password" id="apiKeyInput" placeholder="local-dev-key-change-in-prod" autocomplete="off" />
<button type="button" id="saveKey">Сохранить ключ</button>
</div>
<form id="form" class="panel">
<label for="empFilter">Поиск</label>
<input type="search" id="empFilter" placeholder="ФИО или id…" autocomplete="off" />
<label for="empId">Я — сотрудник</label>
<select id="empId" required>
<option value="">— загрузите список —</option>
</select>
<p class="hint">В проде этот выбор заменит логин вашего приложения; здесь — ручной выбор для теста.</p>
<label for="projectId">Проект</label>
<select id="projectId" required>
<option value="">— выберите себя в списке —</option>
</select>
<div class="row">
<div>
<label for="entryDate">Дата</label>
<input type="date" id="entryDate" required />
</div>
<div>
<label for="entryTime">Часы</label>
<input type="number" id="entryTime" min="0" max="24" step="0.25" value="8" required />
</div>
</div>
<label style="display:flex;align-items:center;margin-top:0.75rem;">
<input type="checkbox" id="isOver" />
Переработка
</label>
<button type="submit" id="submitBtn">Записать часы</button>
<button type="button" class="secondary" id="clearBtn">Сбросить день (0 ч)</button>
<button type="button" class="secondary" id="reloadBtn">Обновить списки</button>
</form>
<p id="status" class="meta"></p>
<pre id="result" hidden></pre>
<script>
const STORE_KEY = 'meraproject_user_reader_api_key';
const EMP_KEY = 'meraproject_time_entry_emp_id';
let allEmployees = [];
function headers(json) {
const h = json ? { 'Content-Type': 'application/json' } : {};
const k = sessionStorage.getItem(STORE_KEY);
if (k) h['X-Api-Key'] = k;
return h;
}
function selfHeaders(json) {
const h = headers(json);
const empId = document.getElementById('empId').value.trim();
if (empId) h['X-Acting-Emp-Id'] = empId;
return h;
}
function escapeHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function showKeyBox(show) {
document.getElementById('keybox').style.display = show ? 'block' : 'none';
}
function empLabel(e) {
const name = e.name || e.login || ('#' + e.id);
return e.id + ' — ' + name;
}
function renderEmpOptions(filter) {
const sel = document.getElementById('empId');
const saved = sessionStorage.getItem(EMP_KEY) || sel.value;
const q = (filter || '').trim().toLowerCase();
const list = allEmployees.filter(e => {
if (!q) return true;
const label = empLabel(e).toLowerCase();
return label.includes(q) || String(e.id).includes(q);
});
sel.innerHTML = '<option value="">— выберите —</option>' +
list.map(e => '<option value="' + e.id + '">' + escapeHtml(empLabel(e)) + '</option>').join('');
if (saved && list.some(e => String(e.id) === saved)) sel.value = saved;
}
async function loadEmployees() {
const status = document.getElementById('status');
status.textContent = 'Загрузка сотрудников…';
allEmployees = [];
let offset = 0;
const limit = 500;
while (true) {
const r = await fetch('/api/employees?limit=' + limit + '&offset=' + offset, { headers: headers() });
if (r.status === 401) { showKeyBox(true); status.textContent = 'Нужен API-ключ.'; return; }
if (!r.ok) throw new Error('employees ' + r.status);
const data = await r.json();
allEmployees.push(...(data.items || []));
if (allEmployees.length >= data.total || (data.items || []).length < limit) break;
offset += limit;
}
allEmployees.sort((a, b) => String(empLabel(a)).localeCompare(String(empLabel(b)), 'ru'));
renderEmpOptions(document.getElementById('empFilter').value);
const empId = document.getElementById('empId').value;
if (empId) await loadProjectsForEmp(empId);
status.textContent = 'Сотрудников в списке: ' + allEmployees.length;
}
async function loadProjectsForEmp(empId) {
const sel = document.getElementById('projectId');
if (!empId) {
sel.innerHTML = '<option value="">— выберите себя в списке —</option>';
return;
}
sel.innerHTML = '<option value="">Загрузка…</option>';
const r = await fetch(
'/api/project-members?emp_id=' + encodeURIComponent(empId) + '&active_only=true&limit=500',
{ headers: headers() }
);
if (!r.ok) throw new Error('project-members ' + r.status);
const items = (await r.json()).items || [];
if (!items.length) {
sel.innerHTML = '<option value="">Нет активных проектов</option>';
return;
}
sel.innerHTML = items.map(p =>
'<option value="' + p.project_id + '">' +
escapeHtml(
[p.project_code, p.step_name, p.project_name || p.project_id]
.filter(Boolean)
.join(' — ')
) +
'</option>'
).join('');
}
async function submitEntry(clearHours) {
const status = document.getElementById('status');
const result = document.getElementById('result');
const empId = document.getElementById('empId').value;
const projectId = document.getElementById('projectId').value;
const entryDate = document.getElementById('entryDate').value;
const time = clearHours ? 0 : parseFloat(document.getElementById('entryTime').value);
const over = document.getElementById('isOver').checked ? 1 : 0;
if (!empId) {
status.innerHTML = '<span class="err">Выберите себя в списке сотрудников</span>';
return;
}
if (!projectId || !entryDate) {
status.innerHTML = '<span class="err">Укажите проект и дату</span>';
return;
}
const body = {
project_id: parseInt(projectId, 10),
date: entryDate,
time: time,
over: over,
};
status.textContent = 'Отправка…';
result.hidden = true;
const r = await fetch('/api/time-entries', {
method: 'PUT',
headers: selfHeaders(true),
body: JSON.stringify(body),
});
const text = await r.text();
let data;
try { data = JSON.parse(text); } catch { data = text; }
if (r.ok) {
status.innerHTML = '<span class="ok">Записано: ' + escapeHtml(String(data.duration)) + ' ч.</span>';
} else {
const msg = data && data.detail ? (data.detail.message || JSON.stringify(data.detail)) : text;
status.innerHTML = '<span class="err">Ошибка ' + r.status + ': ' + escapeHtml(String(msg)) + '</span>';
}
result.hidden = false;
result.textContent = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
}
document.getElementById('saveKey').addEventListener('click', () => {
sessionStorage.setItem(STORE_KEY, document.getElementById('apiKeyInput').value.trim());
showKeyBox(false);
loadEmployees().catch(e => { document.getElementById('status').textContent = e.message; });
});
document.getElementById('empFilter').addEventListener('input', (e) => {
renderEmpOptions(e.target.value);
});
document.getElementById('empId').addEventListener('change', (e) => {
const id = e.target.value;
sessionStorage.setItem(EMP_KEY, id);
loadProjectsForEmp(id).catch(err => {
document.getElementById('status').textContent = err.message;
});
});
document.getElementById('form').addEventListener('submit', (e) => {
e.preventDefault();
submitEntry(false).catch(err => {
document.getElementById('status').innerHTML = '<span class="err">' + escapeHtml(err.message) + '</span>';
});
});
document.getElementById('clearBtn').addEventListener('click', () => {
submitEntry(true).catch(err => {
document.getElementById('status').innerHTML = '<span class="err">' + escapeHtml(err.message) + '</span>';
});
});
document.getElementById('reloadBtn').addEventListener('click', () => {
loadEmployees().catch(e => { document.getElementById('status').textContent = e.message; });
});
(function init() {
document.getElementById('entryDate').value = new Date().toISOString().slice(0, 10);
if (!sessionStorage.getItem(STORE_KEY)) showKeyBox(true);
loadEmployees().catch(e => { document.getElementById('status').textContent = e.message; });
})();
</script>
</body>
</html>