2026-01-10 01:37:08 +01:00
|
|
|
{% extends "shared/frontend/base.html" %}
|
|
|
|
|
|
|
|
|
|
{% block title %}Tidsregistreringer - BMC Hub{% endblock %}
|
|
|
|
|
|
|
|
|
|
{% block extra_css %}
|
|
|
|
|
<style>
|
|
|
|
|
/* Clean Filters */
|
|
|
|
|
.filter-card {
|
|
|
|
|
background: white;
|
|
|
|
|
border: 1px solid #e2e8f0;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
padding: 1.5rem;
|
|
|
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.03);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Table Styling */
|
|
|
|
|
.registrations-table {
|
|
|
|
|
background: white;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
border: 1px solid #e2e8f0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.registrations-table th {
|
|
|
|
|
background-color: #f8f9fa;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
letter-spacing: 0.5px;
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
border-bottom: 2px solid #e9ecef;
|
|
|
|
|
color: #64748b;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.registrations-table td {
|
|
|
|
|
vertical-align: middle;
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
border-bottom: 1px solid #f1f5f9;
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.registrations-table tr:hover {
|
|
|
|
|
background-color: #f8fafc;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.status-badge {
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
padding: 0.25rem 0.5rem;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.status-pending { background: #fff3cd; color: #856404; }
|
|
|
|
|
.status-approved { background: #d1e7dd; color: #0f5132; }
|
|
|
|
|
.status-rejected { background: #f8d7da; color: #842029; }
|
|
|
|
|
.status-billed { background: #cfe2ff; color: #084298; }
|
|
|
|
|
|
2026-08-17 19:27:43 +02:00
|
|
|
.report-summary {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(5, minmax(130px, 1fr));
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.report-summary-card {
|
|
|
|
|
padding: 0.9rem;
|
|
|
|
|
border: 1px solid #e2e8f0;
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
background: #fff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.report-summary-card .value { font-size: 1.35rem; font-weight: 700; }
|
|
|
|
|
|
|
|
|
|
@media (max-width: 992px) {
|
|
|
|
|
.report-summary { grid-template-columns: repeat(2, 1fr); }
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-10 01:37:08 +01:00
|
|
|
</style>
|
|
|
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
|
|
|
|
|
{% endblock %}
|
|
|
|
|
|
|
|
|
|
{% block content %}
|
|
|
|
|
<div class="container-fluid py-4 px-4 m-0" style="max-width: 1600px; margin: 0 auto;">
|
|
|
|
|
|
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 class="mb-1">Tidsregistreringer</h1>
|
|
|
|
|
<p class="text-muted mb-0">Søg og filtrer i alle registreringer</p>
|
|
|
|
|
</div>
|
2026-08-17 19:27:43 +02:00
|
|
|
<div class="d-flex gap-2">
|
|
|
|
|
<button class="btn btn-outline-success" onclick="exportReportCsv()"><i class="bi bi-file-earmark-spreadsheet"></i> Eksportér CSV</button>
|
|
|
|
|
<button class="btn btn-outline-primary" onclick="loadData()"><i class="bi bi-arrow-clockwise"></i> Opdater</button>
|
|
|
|
|
</div>
|
2026-01-10 01:37:08 +01:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Filters -->
|
|
|
|
|
<div class="filter-card mb-4">
|
|
|
|
|
<div class="row g-3">
|
|
|
|
|
<div class="col-md-3">
|
|
|
|
|
<label class="form-label small text-muted text-uppercase fw-bold">Søgning</label>
|
|
|
|
|
<div class="input-group">
|
|
|
|
|
<span class="input-group-text bg-white"><i class="bi bi-search"></i></span>
|
|
|
|
|
<input type="text" id="filter-search" class="form-control" placeholder="Kunde, beskrivelse, case..." onkeyup="debounceLoad()">
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-2">
|
|
|
|
|
<label class="form-label small text-muted text-uppercase fw-bold">Status</label>
|
|
|
|
|
<select id="filter-status" class="form-select" onchange="loadData()">
|
|
|
|
|
<option value="">Alle</option>
|
|
|
|
|
<option value="pending">Afventer</option>
|
|
|
|
|
<option value="approved">Godkendt</option>
|
|
|
|
|
<option value="billed">Faktureret</option>
|
|
|
|
|
<option value="rejected">Afvist</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-3">
|
|
|
|
|
<label class="form-label small text-muted text-uppercase fw-bold">Tekniker</label>
|
|
|
|
|
<input type="text" id="filter-user" class="form-control" placeholder="Navn..." onkeyup="debounceLoad()">
|
|
|
|
|
</div>
|
2026-08-17 19:27:43 +02:00
|
|
|
<div class="col-md-2">
|
|
|
|
|
<label class="form-label small text-muted text-uppercase fw-bold">Arbejdstype</label>
|
|
|
|
|
<select id="filter-work-type" class="form-select" onchange="loadData()">
|
|
|
|
|
<option value="">Alle typer</option>
|
|
|
|
|
<option value="support">Support</option>
|
|
|
|
|
<option value="troubleshooting">Fejlsøgning</option>
|
|
|
|
|
<option value="development">Udvikling</option>
|
|
|
|
|
<option value="maintenance">Vedligehold</option>
|
|
|
|
|
<option value="on_site">Kørsel / On-site</option>
|
|
|
|
|
<option value="meeting">Møde</option>
|
|
|
|
|
<option value="other">Andet</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-2">
|
|
|
|
|
<label class="form-label small text-muted text-uppercase fw-bold">Fra dato</label>
|
|
|
|
|
<input type="date" id="filter-start-date" class="form-control" onchange="loadData()">
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-md-2">
|
|
|
|
|
<label class="form-label small text-muted text-uppercase fw-bold">Til dato</label>
|
|
|
|
|
<input type="date" id="filter-end-date" class="form-control" onchange="loadData()">
|
|
|
|
|
</div>
|
2026-01-10 01:37:08 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-08-17 19:27:43 +02:00
|
|
|
<div id="report-summary" class="report-summary mb-4"></div>
|
|
|
|
|
|
2026-01-10 01:37:08 +01:00
|
|
|
<!-- Table -->
|
|
|
|
|
<div class="registrations-table">
|
|
|
|
|
<div class="table-responsive">
|
|
|
|
|
<table class="table mb-0">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Dato</th>
|
|
|
|
|
<th style="width: 20%;">Kunde</th>
|
|
|
|
|
<th style="width: 25%;">Beskrivelse / Case</th>
|
|
|
|
|
<th>Tekniker</th>
|
2026-08-17 19:27:43 +02:00
|
|
|
<th>Type</th>
|
2026-01-10 01:37:08 +01:00
|
|
|
<th class="text-center">Timer</th>
|
|
|
|
|
<th class="text-center">Fakt.</th>
|
|
|
|
|
<th>Status</th>
|
|
|
|
|
<th class="text-end">Handlinger</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody id="table-body">
|
2026-08-17 19:27:43 +02:00
|
|
|
<tr><td colspan="9" class="text-center py-5 text-muted">Henter data...</td></tr>
|
2026-01-10 01:37:08 +01:00
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<!-- Pagination logic could go here -->
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Details Modal -->
|
|
|
|
|
<div class="modal fade" id="detailsModal" tabindex="-1">
|
|
|
|
|
<div class="modal-dialog modal-lg">
|
|
|
|
|
<div class="modal-content">
|
|
|
|
|
<div class="modal-header">
|
|
|
|
|
<h5 class="modal-title">Detaljer</h5>
|
|
|
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="modal-body" id="details-content">
|
|
|
|
|
<!-- Content -->
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{% endblock %}
|
|
|
|
|
|
|
|
|
|
{% block extra_js %}
|
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
|
|
|
|
|
<script>
|
|
|
|
|
let debounceTimer;
|
|
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
|
|
loadData();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function debounceLoad() {
|
|
|
|
|
clearTimeout(debounceTimer);
|
|
|
|
|
debounceTimer = setTimeout(loadData, 500);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 19:27:43 +02:00
|
|
|
const workTypeLabels = {
|
|
|
|
|
support: 'Support', troubleshooting: 'Fejlsøgning', development: 'Udvikling',
|
|
|
|
|
maintenance: 'Vedligehold', on_site: 'Kørsel / On-site', meeting: 'Møde', other: 'Andet'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function buildReportParams(limit = 100) {
|
|
|
|
|
const params = new URLSearchParams({ limit, offset: 0 });
|
|
|
|
|
const values = {
|
|
|
|
|
search: document.getElementById('filter-search').value,
|
|
|
|
|
status: document.getElementById('filter-status').value,
|
|
|
|
|
user_name: document.getElementById('filter-user').value,
|
|
|
|
|
work_type: document.getElementById('filter-work-type').value,
|
|
|
|
|
start_date: document.getElementById('filter-start-date').value,
|
|
|
|
|
end_date: document.getElementById('filter-end-date').value
|
|
|
|
|
};
|
|
|
|
|
Object.entries(values).forEach(([key, value]) => { if (value) params.append(key, value); });
|
|
|
|
|
return params;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-10 01:37:08 +01:00
|
|
|
async function loadData() {
|
|
|
|
|
const tbody = document.getElementById('table-body');
|
2026-08-17 19:27:43 +02:00
|
|
|
const params = buildReportParams();
|
2026-01-10 01:37:08 +01:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/v1/timetracking/times?${params.toString()}`);
|
|
|
|
|
const data = await response.json();
|
2026-08-17 19:27:43 +02:00
|
|
|
renderReportSummary(data);
|
2026-01-10 01:37:08 +01:00
|
|
|
|
|
|
|
|
if (data.times.length === 0) {
|
2026-08-17 19:27:43 +02:00
|
|
|
tbody.innerHTML = `<tr><td colspan="9" class="text-center py-5">Ingen resultater fundet</td></tr>`;
|
2026-01-10 01:37:08 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tbody.innerHTML = data.times.map(t => `
|
|
|
|
|
<tr>
|
|
|
|
|
<td>
|
|
|
|
|
<div class="fw-bold">${formatDate(t.worked_date)}</div>
|
|
|
|
|
<div class="small text-muted">${t.created_at ? new Date(t.created_at).toLocaleTimeString().slice(0,5) : ''}</div>
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
<div class="fw-bold text-dark">${t.customer_name || 'Ukendt'}</div>
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
<div class="small fw-bold text-primary mb-1">
|
|
|
|
|
${t.case_vtiger_id ? `<a href="https://bmcnetworks.od2.vtiger.com/index.php?module=HelpDesk&view=Detail&record=${t.case_vtiger_id.replace('39x','')}" target="_blank">${t.case_title || 'Ingen Case'}</a>` : (t.case_title || 'Ingen Case')}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="text-secondary small" style="max-height: 3em; overflow: hidden; text-overflow: ellipsis;">
|
|
|
|
|
${t.description || '-'}
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
${t.user_name || '-'}
|
|
|
|
|
</td>
|
2026-08-17 19:27:43 +02:00
|
|
|
<td><span class="badge bg-secondary">${workTypeLabels[t.work_type || 'support'] || t.work_type || 'Support'}</span></td>
|
2026-01-10 01:37:08 +01:00
|
|
|
<td class="text-center">
|
|
|
|
|
<span class="badge bg-light text-dark border">${parseFloat(t.original_hours).toFixed(2)}</span>
|
|
|
|
|
</td>
|
|
|
|
|
<td class="text-center">
|
|
|
|
|
${t.billable ? '<i class="bi bi-check-circle-fill text-success"></i>' : '<i class="bi bi-dash-circle text-muted"></i>'}
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
<span class="status-badge status-${t.status}">${getStatusLabel(t.status)}</span>
|
|
|
|
|
</td>
|
|
|
|
|
<td class="text-end">
|
|
|
|
|
<button class="btn btn-sm btn-outline-secondary" onclick="showDetails(${t.id})">
|
|
|
|
|
<i class="bi bi-eye"></i>
|
|
|
|
|
</button>
|
|
|
|
|
<a href="/timetracking/wizard2?customer_id=${t.customer_id}&time_id=${t.id}" class="btn btn-sm btn-outline-primary" title="Gå til godkendelse">
|
|
|
|
|
<i class="bi bi-arrow-right"></i>
|
|
|
|
|
</a>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
`).join('');
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(error);
|
2026-08-17 19:27:43 +02:00
|
|
|
tbody.innerHTML = `<tr><td colspan="9" class="text-center py-5 text-danger">Fejl ved hentning af data</td></tr>`;
|
2026-01-10 01:37:08 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 19:27:43 +02:00
|
|
|
function renderReportSummary(data) {
|
|
|
|
|
const summary = data.summary || {};
|
|
|
|
|
const cards = [
|
|
|
|
|
['Timer', Number(summary.total_hours || 0).toFixed(2)],
|
|
|
|
|
['Fakturerbare timer', Number(summary.billable_hours || 0).toFixed(2)],
|
|
|
|
|
['Registreringer', summary.total_entries || 0],
|
|
|
|
|
['Medarbejdere', summary.total_employees || 0],
|
|
|
|
|
['Sager', summary.total_cases || 0]
|
|
|
|
|
];
|
|
|
|
|
const typeText = (data.by_work_type || [])
|
|
|
|
|
.map(row => `${workTypeLabels[row.work_type] || row.work_type}: ${Number(row.hours || 0).toFixed(2)} t`)
|
|
|
|
|
.join(' · ');
|
|
|
|
|
document.getElementById('report-summary').innerHTML = cards.map(([label, value]) => `
|
|
|
|
|
<div class="report-summary-card"><div class="small text-muted">${label}</div><div class="value">${value}</div></div>
|
|
|
|
|
`).join('') + (typeText ? `<div class="small text-muted grid-column-span-all" style="grid-column:1/-1">Fordeling: ${typeText}</div>` : '');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function exportReportCsv() {
|
|
|
|
|
const response = await fetch(`/api/v1/timetracking/times?${buildReportParams(10000).toString()}`);
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
if (!response.ok) return alert(data.detail || 'Kunne ikke eksportere rapporten');
|
|
|
|
|
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`;
|
|
|
|
|
const rows = [['Dato', 'Kunde', 'Sag', 'Medarbejder', 'Type', 'Beskrivelse', 'Timer', 'Fakturerbar', 'Status']];
|
|
|
|
|
(data.times || []).forEach(t => rows.push([
|
|
|
|
|
t.worked_date, t.customer_name, t.case_title, t.user_name,
|
|
|
|
|
workTypeLabels[t.work_type || 'support'] || t.work_type,
|
|
|
|
|
t.description, t.original_hours, t.billable ? 'Ja' : 'Nej', t.status
|
|
|
|
|
]));
|
|
|
|
|
const csv = '\ufeff' + rows.map(row => row.map(quote).join(';')).join('\n');
|
|
|
|
|
const link = document.createElement('a');
|
|
|
|
|
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
|
|
|
|
|
link.download = `tidsrapport-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
|
|
|
link.click();
|
|
|
|
|
URL.revokeObjectURL(link.href);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-10 01:37:08 +01:00
|
|
|
function formatDate(dateStr) {
|
|
|
|
|
if (!dateStr) return '';
|
|
|
|
|
return new Date(dateStr).toLocaleDateString('da-DK');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getStatusLabel(status) {
|
|
|
|
|
const labels = {
|
|
|
|
|
'pending': 'Afventer',
|
|
|
|
|
'approved': 'Godkendt',
|
|
|
|
|
'rejected': 'Afvist',
|
|
|
|
|
'billed': 'Faktureret'
|
|
|
|
|
};
|
|
|
|
|
return labels[status] || status;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function showDetails(id) {
|
|
|
|
|
const modal = new bootstrap.Modal(document.getElementById('detailsModal'));
|
|
|
|
|
const content = document.getElementById('details-content');
|
|
|
|
|
content.innerHTML = '<div class="text-center"><div class="spinner-border"></div></div>';
|
|
|
|
|
modal.show();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/v1/timetracking/times/${id}`);
|
|
|
|
|
const t = await res.json();
|
|
|
|
|
|
|
|
|
|
content.innerHTML = `
|
|
|
|
|
<table class="table table-bordered">
|
|
|
|
|
<tr><th>ID</th><td>${t.id}</td></tr>
|
|
|
|
|
<tr><th>Kunde</th><td>${t.customer_name}</td></tr>
|
|
|
|
|
<tr><th>Case</th><td>${t.case_title}</td></tr>
|
|
|
|
|
<tr><th>Beskrivelse</th><td>${t.description}</td></tr>
|
|
|
|
|
<tr><th>Timer</th><td>${t.original_hours}</td></tr>
|
|
|
|
|
<tr><th>Status</th><td>${t.status}</td></tr>
|
|
|
|
|
<tr><th>Raw Data</th><td><pre class="bg-light p-2 small">${JSON.stringify(t, null, 2)}</pre></td></tr>
|
|
|
|
|
</table>
|
|
|
|
|
`;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
content.innerHTML = `<div class="alert alert-danger">Fejl: ${e.message}</div>`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
{% endblock %}
|