- 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.
321 lines
11 KiB
HTML
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/\"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
</script>
|
|
{% endblock %}
|