bmc_hub/app/timetracking/frontend/service_contract_report.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

432 lines
16 KiB
HTML

{% extends "shared/frontend/base.html" %}
{% block title %}Servicekontrakt Rapport - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.report-hero {
background: linear-gradient(135deg, #0f4c75 0%, #0a3a59 100%);
color: #ffffff;
border-radius: 14px;
padding: 1.75rem;
margin-bottom: 1.5rem;
box-shadow: 0 16px 34px rgba(15, 76, 117, 0.24);
}
.report-hero h1 {
margin: 0;
font-size: 1.55rem;
font-weight: 700;
}
.report-hero p {
margin: 0.4rem 0 0;
opacity: 0.9;
}
.report-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1.25rem;
}
.filters-grid {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.9rem;
align-items: end;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(5, minmax(120px, 1fr));
gap: 0.75rem;
margin: 1rem 0;
}
.metric {
background: color-mix(in srgb, var(--accent, #0f4c75) 10%, var(--bg-card));
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 0.8rem;
text-align: center;
}
.metric .value {
font-size: 1.3rem;
font-weight: 700;
line-height: 1.1;
}
.metric .label {
font-size: 0.82rem;
opacity: 0.75;
}
.case-card {
border: 1px solid var(--border-color);
border-radius: 10px;
margin-bottom: 0.8rem;
overflow: hidden;
}
.case-head {
background: color-mix(in srgb, var(--accent, #0f4c75) 8%, var(--bg-card));
padding: 0.8rem 1rem;
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.case-title {
font-weight: 700;
}
.table-wrap {
padding: 0.5rem 1rem 1rem;
}
.table-sm th,
.table-sm td {
font-size: 0.86rem;
vertical-align: top;
}
.status-pill {
border-radius: 999px;
padding: 0.2rem 0.6rem;
font-size: 0.78rem;
border: 1px solid var(--border-color);
background: var(--bg-card);
}
.empty-state {
border: 1px dashed var(--border-color);
border-radius: 12px;
padding: 2rem 1rem;
text-align: center;
opacity: 0.8;
}
@media (max-width: 992px) {
.filters-grid {
grid-template-columns: 1fr;
}
.metrics-grid {
grid-template-columns: repeat(2, minmax(120px, 1fr));
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="report-hero">
<h1>Servicekontrakt Rapport</h1>
<p>Vaelg kunde og servicekontrakt for at se relaterede cases og timelogs fra vTiger.</p>
</div>
<div class="report-card mb-3">
<div class="filters-grid">
<div>
<label for="customerSelect" class="form-label fw-semibold">Kunde</label>
<select id="customerSelect" class="form-select">
<option value="">-- Vaelg kunde --</option>
</select>
</div>
<div>
<label for="contractSelect" class="form-label fw-semibold">Servicekontrakt</label>
<select id="contractSelect" class="form-select" disabled>
<option value="">-- Vaelg servicekontrakt --</option>
</select>
</div>
<div class="d-flex gap-2">
<button id="loadBtn" class="btn btn-primary" disabled>
<i class="bi bi-search me-1"></i>Hent rapport
</button>
<button id="pdfBtn" class="btn btn-outline-primary" disabled>
<i class="bi bi-file-earmark-pdf me-1"></i>PDF
</button>
<button id="excelBtn" class="btn btn-outline-success" disabled>
<i class="bi bi-file-earmark-excel me-1"></i>Excel
</button>
</div>
</div>
<div id="filterHint" class="small text-muted mt-2">Start med at vaelge en kunde.</div>
</div>
<div id="errorBox" class="alert alert-danger d-none" role="alert"></div>
<div id="reportSection" class="d-none">
<div class="report-card mb-3">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
<div>
<h4 class="mb-1" id="reportContractTitle">-</h4>
<div class="text-muted small" id="reportContractMeta">-</div>
<div class="small" id="reportContractLink"></div>
</div>
</div>
<div class="metrics-grid" id="metricsGrid"></div>
</div>
<div class="report-card" id="casesContainer"></div>
</div>
</div>
<script>
const state = {
customers: [],
contracts: [],
selectedAccountId: '',
selectedContractId: '',
report: null,
};
const customerSelect = document.getElementById('customerSelect');
const contractSelect = document.getElementById('contractSelect');
const loadBtn = document.getElementById('loadBtn');
const pdfBtn = document.getElementById('pdfBtn');
const excelBtn = document.getElementById('excelBtn');
const filterHint = document.getElementById('filterHint');
const errorBox = document.getElementById('errorBox');
const reportSection = document.getElementById('reportSection');
const casesContainer = document.getElementById('casesContainer');
const metricsGrid = document.getElementById('metricsGrid');
document.addEventListener('DOMContentLoaded', async () => {
await loadCustomers();
customerSelect.addEventListener('change', onCustomerChange);
contractSelect.addEventListener('change', onContractChange);
loadBtn.addEventListener('click', loadReport);
pdfBtn.addEventListener('click', downloadPdf);
excelBtn.addEventListener('click', downloadExcel);
});
async function loadCustomers() {
clearError();
try {
const response = await fetch('/api/v1/timetracking/service-contract-report/customers');
if (!response.ok) {
throw new Error('Kunne ikke hente kunder');
}
state.customers = await response.json();
for (const customer of state.customers) {
const option = document.createElement('option');
option.value = customer.account_id;
option.textContent = `${customer.account_name} (${customer.account_id})`;
customerSelect.appendChild(option);
}
} catch (error) {
showError(error.message || 'Fejl ved indlaesning af kunder');
}
}
async function onCustomerChange(event) {
state.selectedAccountId = event.target.value || '';
state.selectedContractId = '';
reportSection.classList.add('d-none');
contractSelect.innerHTML = '<option value="">-- Vaelg servicekontrakt --</option>';
contractSelect.disabled = !state.selectedAccountId;
loadBtn.disabled = true;
pdfBtn.disabled = true;
excelBtn.disabled = true;
if (!state.selectedAccountId) {
filterHint.textContent = 'Start med at vaelge en kunde.';
return;
}
clearError();
filterHint.textContent = 'Henter servicekontrakter...';
try {
const response = await fetch(`/api/v1/timetracking/service-contract-report/contracts?account_id=${encodeURIComponent(state.selectedAccountId)}`);
if (!response.ok) {
throw new Error('Kunne ikke hente servicekontrakter');
}
state.contracts = await response.json();
for (const contract of state.contracts) {
const option = document.createElement('option');
option.value = contract.id;
const number = contract.contract_number || '-';
const subject = contract.subject || 'Uden titel';
option.textContent = `${number} - ${subject}`;
contractSelect.appendChild(option);
}
filterHint.textContent = state.contracts.length
? 'Vaelg servicekontrakt og hent rapporten.'
: 'Ingen servicekontrakter fundet for kunden.';
} catch (error) {
showError(error.message || 'Fejl ved hentning af servicekontrakter');
filterHint.textContent = 'Kunne ikke hente servicekontrakter.';
}
}
function onContractChange(event) {
state.selectedContractId = event.target.value || '';
loadBtn.disabled = !state.selectedContractId;
pdfBtn.disabled = !state.selectedContractId;
excelBtn.disabled = !state.selectedContractId;
}
async function loadReport() {
if (!state.selectedAccountId || !state.selectedContractId) {
return;
}
clearError();
loadBtn.disabled = true;
loadBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Henter...';
try {
const url = `/api/v1/timetracking/service-contract-report/data?account_id=${encodeURIComponent(state.selectedAccountId)}&contract_id=${encodeURIComponent(state.selectedContractId)}`;
const response = await fetch(url);
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.detail || 'Kunne ikke hente rapportdata');
}
state.report = await response.json();
renderReport();
} catch (error) {
showError(error.message || 'Fejl ved hentning af rapport');
} finally {
loadBtn.disabled = false;
loadBtn.innerHTML = '<i class="bi bi-search me-1"></i>Hent rapport';
}
}
function renderReport() {
if (!state.report) {
reportSection.classList.add('d-none');
return;
}
const { customer, contract, cases, summary } = state.report;
document.getElementById('reportContractTitle').textContent = `${contract.contract_number || '-'} - ${contract.subject || 'Uden titel'}`;
document.getElementById('reportContractMeta').textContent = `${customer.account_name} (${customer.account_id})`;
const contractLinkEl = document.getElementById('reportContractLink');
if (contract.vtiger_url) {
contractLinkEl.innerHTML = `<a href="${escapeHtml(contract.vtiger_url)}" target="_blank" rel="noopener noreferrer">Aabn servicekontrakt i vTiger</a>`;
} else {
contractLinkEl.textContent = '';
}
metricsGrid.innerHTML = [
metricHtml('Cases', summary.total_cases),
metricHtml('Timelogs', summary.total_timelogs),
metricHtml('Timer', summary.total_hours)
].join('');
if (!Array.isArray(cases) || cases.length === 0) {
casesContainer.innerHTML = `
<div class="empty-state">
<i class="bi bi-inbox fs-2 d-block mb-2"></i>
<div>Ingen cases fundet for den valgte kontrakt.</div>
</div>
`;
reportSection.classList.remove('d-none');
return;
}
casesContainer.innerHTML = cases.map(caseItem => {
const rows = (caseItem.timelogs || []).map(log => `
<tr>
<td>${escapeHtml(log.worked_date || '-')}</td>
<td>${escapeHtml(log.employee_initials || '-')}</td>
<td>${escapeHtml(log.hours ?? 0)}</td>
<td>${escapeHtml(log.description || '')}</td>
<td>${log.vtiger_url ? `<a href="${escapeHtml(log.vtiger_url)}" target="_blank" rel="noopener noreferrer">vTiger</a>` : '-'}</td>
</tr>
`).join('');
const tableContent = rows || '<tr><td colspan="5" class="text-muted">Ingen timelogs</td></tr>';
return `
<div class="case-card">
<div class="case-head">
<div>
<div class="case-title">${escapeHtml(caseItem.title || '-')}</div>
<div class="small text-muted">CC-nummer: ${escapeHtml(caseItem.cc_number || caseItem.id || '-')}</div>
<div class="small text-muted">Kontaktperson: ${escapeHtml(caseItem.contact_person || '-')}</div>
<div class="small">${caseItem.vtiger_url ? `<a href="${escapeHtml(caseItem.vtiger_url)}" target="_blank" rel="noopener noreferrer">Aabn case i vTiger</a>` : ''}</div>
<div class="small mt-1">${escapeHtml(caseItem.description || 'Ingen beskrivelse')}</div>
</div>
<div class="small text-end">
<div>Prioritet: <strong>${escapeHtml(caseItem.priority || '-')}</strong></div>
<div>Timelogs: <strong>${escapeHtml(caseItem.timelog_count ?? 0)}</strong></div>
<div>Timer: <strong>${escapeHtml(caseItem.total_hours ?? 0)}</strong></div>
</div>
</div>
<div class="table-wrap table-responsive">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Dato</th>
<th>Medarbejder</th>
<th>Timer</th>
<th>Beskrivelse</th>
<th>Link</th>
</tr>
</thead>
<tbody>${tableContent}</tbody>
</table>
</div>
</div>
`;
}).join('');
reportSection.classList.remove('d-none');
}
function metricHtml(label, value) {
return `
<div class="metric">
<div class="value">${escapeHtml(value ?? 0)}</div>
<div class="label">${escapeHtml(label)}</div>
</div>
`;
}
function downloadPdf() {
if (!state.selectedAccountId || !state.selectedContractId) {
return;
}
const url = `/api/v1/timetracking/service-contract-report/pdf?account_id=${encodeURIComponent(state.selectedAccountId)}&contract_id=${encodeURIComponent(state.selectedContractId)}`;
window.open(url, '_blank');
}
function downloadExcel() {
if (!state.selectedAccountId || !state.selectedContractId) {
return;
}
const url = `/api/v1/timetracking/service-contract-report/excel?account_id=${encodeURIComponent(state.selectedAccountId)}&contract_id=${encodeURIComponent(state.selectedContractId)}`;
window.open(url, '_blank');
}
function showError(message) {
errorBox.textContent = message;
errorBox.classList.remove('d-none');
}
function clearError() {
errorBox.classList.add('d-none');
errorBox.textContent = '';
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
</script>
{% endblock %}