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