266 lines
11 KiB
HTML
266 lines
11 KiB
HTML
<!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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
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>
|