bmc_hub/app/timetracking/frontend/employee_log.html
Christian a36e3e716f feat: Add Service Contract Report page with customer and contract selection
- Implemented a new HTML page for generating service contract reports.
- Added CSS styles for report layout and components.
- Developed JavaScript functionality for loading customers and contracts, fetching report data, and rendering metrics and cases.
- Included buttons for downloading reports in PDF and Excel formats.

docs: Create Route Auth Audit for route access control

- Generated an audit report detailing route access requirements.
- Classified routes based on authentication needs and documented them in a markdown file.

feat: Introduce buzzwords and mission projects tables in the database

- Created `buzzwords` and `sag_buzzwords` tables for managing keywords related to SAG cases.
- Established `mission_projects`, `mission_project_milestones`, and `mission_project_blockers` tables for project management.
- Updated `sag_sager` table to link with mission projects and milestones, including necessary foreign key constraints.
2026-05-12 08:41:13 +02:00

321 lines
11 KiB
HTML

{% extends "shared/frontend/base.html" %}
{% block title %}Medarbejder Log - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.log-hero {
background: linear-gradient(135deg, #0f4c75 0%, #1b262c 100%);
color: #fff;
padding: 1.6rem;
border-radius: 14px;
margin-bottom: 1.2rem;
}
.log-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1rem;
margin-bottom: 1rem;
}
.filters {
display: grid;
grid-template-columns: repeat(5, minmax(150px, 1fr));
gap: 0.8rem;
align-items: end;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(140px, 1fr));
gap: 0.75rem;
}
.stat-box {
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 0.8rem;
text-align: center;
background: color-mix(in srgb, var(--accent, #0f4c75) 8%, var(--bg-card));
}
.stat-box .value {
font-size: 1.3rem;
font-weight: 700;
}
.log-table-wrap {
overflow-x: auto;
}
.log-table {
width: 100%;
border-collapse: collapse;
min-width: 980px;
}
.log-table th,
.log-table td {
border-bottom: 1px solid var(--border-color);
padding: 0.55rem;
vertical-align: top;
font-size: 0.86rem;
}
.sticky-col {
position: sticky;
left: 0;
background: var(--bg-card);
z-index: 2;
min-width: 200px;
border-right: 1px solid var(--border-color);
}
.period-cell {
min-width: 130px;
border-radius: 8px;
padding: 0.45rem;
}
.status-ok {
background: #d1e7dd;
color: #0f5132;
}
.status-warning {
background: #fff3cd;
color: #664d03;
}
.status-missing {
background: #f8d7da;
color: #842029;
}
.small-muted {
color: var(--text-secondary);
font-size: 0.78rem;
}
@media (max-width: 992px) {
.filters {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: 1fr 1fr;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="log-hero">
<h1 class="h4 mb-1">Medarbejder Log</h1>
<div>Visualiser registreret tid over dag, uge og maaned, og find manglende tider/sager.</div>
</div>
<div class="log-card">
<div class="filters">
<div>
<label class="form-label small">Visning</label>
<select id="granularity" class="form-select">
<option value="day">Dag</option>
<option value="week" selected>Uge</option>
<option value="month">Maaned</option>
</select>
</div>
<div>
<label class="form-label small">Fra dato</label>
<input id="startDate" type="date" class="form-control">
</div>
<div>
<label class="form-label small">Til dato</label>
<input id="endDate" type="date" class="form-control">
</div>
<div>
<label class="form-label small">Maal timer/dag</label>
<input id="targetHours" type="number" min="0" max="24" step="0.5" value="7.5" class="form-control">
</div>
<div>
<button id="loadBtn" class="btn btn-primary w-100"><i class="bi bi-arrow-repeat me-1"></i>Opdater</button>
</div>
</div>
</div>
<div class="log-card">
<div class="stats-grid" id="statsGrid"></div>
</div>
<div id="errorBox" class="alert alert-danger d-none"></div>
<div class="log-card log-table-wrap">
<table class="log-table">
<thead>
<tr id="tableHeadRow">
<th class="sticky-col">Medarbejder</th>
<th>Total timer</th>
<th>Registreringer uden sag</th>
<th>Completeness</th>
</tr>
</thead>
<tbody id="tableBody">
<tr><td colspan="12" class="py-4 text-center small-muted">Henter data...</td></tr>
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const granularityEl = document.getElementById('granularity');
const startDateEl = document.getElementById('startDate');
const endDateEl = document.getElementById('endDate');
const targetHoursEl = document.getElementById('targetHours');
const loadBtnEl = document.getElementById('loadBtn');
const tableHeadRowEl = document.getElementById('tableHeadRow');
const tableBodyEl = document.getElementById('tableBody');
const statsGridEl = document.getElementById('statsGrid');
const errorBoxEl = document.getElementById('errorBox');
document.addEventListener('DOMContentLoaded', async () => {
setDefaultDates();
loadBtnEl.addEventListener('click', loadOverview);
granularityEl.addEventListener('change', setDefaultDates);
await loadOverview();
});
function setDefaultDates() {
const today = new Date();
const end = formatDate(today);
let start = new Date(today);
if (granularityEl.value === 'day') {
start.setDate(today.getDate() - 13);
} else if (granularityEl.value === 'week') {
start.setDate(today.getDate() - 55);
} else {
start.setMonth(today.getMonth() - 5);
start.setDate(1);
}
startDateEl.value = formatDate(start);
endDateEl.value = end;
}
async function loadOverview() {
errorBoxEl.classList.add('d-none');
const params = new URLSearchParams({
granularity: granularityEl.value,
start_date: startDateEl.value,
end_date: endDateEl.value,
target_daily_hours: targetHoursEl.value || '7.5'
});
try {
const response = await fetch(`/api/v1/timetracking/employee-log/overview?${params.toString()}`);
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.detail || 'Kunne ikke hente medarbejder log');
}
renderOverview(payload);
} catch (error) {
errorBoxEl.textContent = error.message || 'Ukendt fejl';
errorBoxEl.classList.remove('d-none');
tableBodyEl.innerHTML = '<tr><td colspan="12" class="py-4 text-center text-danger">Fejl ved hentning af data</td></tr>';
}
}
function renderOverview(payload) {
const periods = payload.periods || [];
const employees = payload.employees || [];
tableHeadRowEl.innerHTML = `
<th class="sticky-col">Medarbejder</th>
<th>Total timer</th>
<th>Registreringer uden sag</th>
<th>Completeness</th>
${periods.map(p => `<th>${escapeHtml(p.label)}</th>`).join('')}
`;
if (!employees.length) {
tableBodyEl.innerHTML = `<tr><td colspan="${4 + periods.length}" class="py-4 text-center small-muted">Ingen data i valgt periode</td></tr>`;
renderStats([]);
return;
}
tableBodyEl.innerHTML = employees.map(employee => {
const periodCells = periods.map(period => {
const p = employee.periods[period.key] || {hours: 0, expected_hours: 0, gap_hours: 0, entries: 0, cases: 0, missing_case_entries: 0, status: 'missing'};
return `
<td>
<div class="period-cell status-${p.status}">
<div><strong>${formatHours(p.hours)}h</strong> / ${formatHours(p.expected_hours)}h</div>
<div class="small-muted">Gap: ${formatHours(p.gap_hours)}h</div>
<div class="small-muted">Cases: ${p.cases} | Uden sag: ${p.missing_case_entries}</div>
</div>
</td>
`;
}).join('');
return `
<tr>
<td class="sticky-col">
<div><strong>${escapeHtml(employee.employee_name || 'Ukendt')}</strong></div>
<div class="small-muted">${escapeHtml(employee.employee_key || '-')}</div>
</td>
<td>${formatHours(employee.total_hours)}h</td>
<td>${employee.missing_case_entries || 0}</td>
<td>${employee.completeness_percent || 0}%</td>
${periodCells}
</tr>
`;
}).join('');
renderStats(employees);
}
function renderStats(employees) {
const totals = employees.reduce((acc, e) => {
acc.hours += Number(e.total_hours || 0);
acc.missingCases += Number(e.missing_case_entries || 0);
acc.missingPeriods += Number(e.missing_periods || 0);
return acc;
}, {hours: 0, missingCases: 0, missingPeriods: 0});
const avgCompleteness = employees.length
? (employees.reduce((sum, e) => sum + Number(e.completeness_percent || 0), 0) / employees.length)
: 0;
statsGridEl.innerHTML = `
<div class="stat-box"><div class="value">${employees.length}</div><div class="small-muted">Medarbejdere</div></div>
<div class="stat-box"><div class="value">${formatHours(totals.hours)}h</div><div class="small-muted">Total registreret</div></div>
<div class="stat-box"><div class="value">${totals.missingPeriods}</div><div class="small-muted">Manglende perioder</div></div>
<div class="stat-box"><div class="value">${totals.missingCases}</div><div class="small-muted">Registreringer uden sag</div></div>
<div class="stat-box"><div class="value">${avgCompleteness.toFixed(1)}%</div><div class="small-muted">Gns. completeness</div></div>
`;
}
function formatDate(dateObj) {
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const day = String(dateObj.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function formatHours(v) {
return Number(v || 0).toFixed(1);
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#039;');
}
</script>
{% endblock %}