meraproject/services/user-reader/static/time-entry.html

266 lines
11 KiB
HTML
Raw Permalink Normal View History

<!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>