bmc_hub/app/timetracking/frontend/dashboard.html
Christian 38fa3b6c0a feat: Add subscriptions lock feature to customers
- Added a new column `subscriptions_locked` to the `customers` table to manage subscription access.
- Implemented a script to create new modules from a template, including updates to various files (module.json, README.md, router.py, views.py, and migration SQL).
- Developed a script to import BMC Office subscriptions from an Excel file into the database, including error handling and statistics reporting.
- Created a script to lookup and update missing CVR numbers using the CVR.dk API.
- Implemented a script to relink Hub customers to e-conomic customer numbers based on name matching.
- Developed scripts to sync CVR numbers from Simply-CRM and vTiger to the local customers database.
2025-12-13 12:06:28 +01:00

671 lines
30 KiB
HTML

{% extends "shared/frontend/base.html" %}
{% block title %}Timetracking Dashboard - BMC Hub{% endblock %}
{% block extra_css %}
<style>
/* Page specific styles */
.stat-card {
text-align: center;
padding: 1.5rem;
}
.stat-card h3 {
font-size: 2.5rem;
font-weight: 700;
color: var(--accent);
margin-bottom: 0.5rem;
}
.stat-card p {
color: var(--text-secondary);
font-size: 0.9rem;
margin: 0;
}
.btn-primary {
background-color: var(--accent);
border-color: var(--accent);
padding: 0.6rem 1.5rem;
border-radius: 8px;
}
.badge {
padding: 0.4rem 0.8rem;
border-radius: 6px;
font-weight: 500;
}
.table {
background: var(--bg-card);
border-radius: var(--border-radius);
}
.table th {
font-weight: 600;
color: var(--text-secondary);
font-size: 0.85rem;
text-transform: uppercase;
}
.sync-status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 8px;
background: var(--accent-light);
color: var(--accent);
font-size: 0.9rem;
}
.spinner-border-sm {
width: 1rem;
height: 1rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<h1 class="mb-1">
<i class="bi bi-clock-history text-primary"></i> Tidsregistrering
</h1>
<p class="text-muted">Synkroniser, godkend og fakturer tidsregistreringer fra vTiger</p>
</div>
</div>
<!-- Safety Status Banner -->
<div class="row mb-4">
<div class="col-12">
<div class="alert alert-info d-flex align-items-center" role="alert">
<i class="bi bi-shield-lock-fill me-2"></i>
<div>
<strong>Safety Mode Aktiv</strong> -
Modulet kører i read-only mode. Ingen ændringer sker i vTiger eller e-conomic.
<small class="d-block mt-1">
<span class="badge bg-success">vTiger: READ-ONLY</span>
<span class="badge bg-success ms-1">e-conomic: DRY-RUN</span>
</small>
</div>
</div>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card stat-card">
<h3 id="stat-customers">-</h3>
<p>Kunder med tider</p>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<h3 id="stat-pending" class="text-warning">-</h3>
<p>Afventer godkendelse</p>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<h3 id="stat-approved" class="text-success">-</h3>
<p>Godkendte</p>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<h3 id="stat-hours">-</h3>
<p>Timer godkendt</p>
</div>
</div>
</div>
<!-- Actions Row -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
<div>
<h5 class="mb-1">Synkronisering</h5>
<p class="text-muted mb-0 small">Hent nye tidsregistreringer fra vTiger</p>
</div>
<div class="d-flex gap-2 align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="hide-time-card"
onchange="loadCustomerStats()" checked>
<label class="form-check-label" for="hide-time-card">
Skjul klippekort-kunder
</label>
</div>
<button class="btn btn-primary" onclick="syncFromVTiger()" id="sync-btn">
<i class="bi bi-arrow-repeat"></i> Synkroniser
</button>
</div>
</div>
<div id="sync-status" class="mt-3 d-none"></div>
</div>
</div>
</div>
</div>
<!-- Customer List -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-white">
<h5 class="mb-0">Kunder med åbne tidsregistreringer</h5>
</div>
<div class="card-body">
<div id="loading" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
</div>
<div id="customer-table" class="d-none">
<table class="table table-hover">
<thead>
<tr>
<th>Kunde</th>
<th>Total Timer</th>
<th class="text-center">Afventer</th>
<th class="text-center">Godkendt</th>
<th class="text-center">Afvist</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="customer-tbody">
</tbody>
</table>
</div>
<div id="no-data" class="text-center py-4 d-none">
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-3">Ingen tidsregistreringer fundet</p>
<button class="btn btn-primary" onclick="syncFromVTiger()">
<i class="bi bi-arrow-repeat"></i> Synkroniser fra vTiger
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Time Entries Modal -->
<div class="modal fade" id="timeEntriesModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-clock-history"></i> Tidsregistreringer - <span id="modal-customer-name"></span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-info mb-3">
<i class="bi bi-info-circle"></i>
<strong>Bemærk:</strong> Oversigten viser kun <em>fakturabare, ikke-fakturerede</em> registreringer.
Her kan du se alle registreringer inkl. ikke-fakturabare og allerede fakturerede.
<div class="mt-2">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="filter-billable" checked onchange="filterModalEntries()">
<label class="form-check-label" for="filter-billable">Kun fakturabare</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="filter-not-invoiced" checked onchange="filterModalEntries()">
<label class="form-check-label" for="filter-not-invoiced">Kun ikke-fakturerede</label>
</div>
</div>
</div>
<div id="time-entries-loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="mt-2">Indlæser tidsregistreringer...</p>
</div>
<div id="time-entries-content" class="d-none">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Case</th>
<th>Dato</th>
<th>Timer</th>
<th>Status</th>
<th>Udført af</th>
<th>Handlinger</th>
</tr>
</thead>
<tbody id="time-entries-tbody"></tbody>
</table>
</div>
</div>
<div id="time-entries-empty" class="alert alert-info d-none">
Ingen tidsregistreringer fundet for denne kunde
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
</div>
</div>
</div>
</div>
<script>
// Load customer stats
async function loadCustomerStats() {
try {
// Check if we should hide time card customers
const hideTimeCard = document.getElementById('hide-time-card')?.checked ?? true;
const response = await fetch('/api/v1/timetracking/wizard/stats');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const customers = await response.json();
// Valider at vi fik et array
if (!Array.isArray(customers)) {
console.error('Invalid response format:', customers);
throw new Error('Uventet dataformat fra server');
}
// Filtrer kunder uden tidsregistreringer eller kun med godkendte/afviste
let activeCustomers = customers.filter(c =>
c.pending_count > 0 || c.approved_count > 0
);
// Filtrer klippekort-kunder hvis toggled
if (hideTimeCard) {
activeCustomers = activeCustomers.filter(c => !c.uses_time_card);
}
if (activeCustomers.length === 0) {
document.getElementById('loading').classList.add('d-none');
document.getElementById('no-data').classList.remove('d-none');
return;
}
// Calculate totals (kun aktive kunder)
let totalCustomers = activeCustomers.length;
let totalPending = activeCustomers.reduce((sum, c) => sum + (c.pending_count || 0), 0);
let totalApproved = activeCustomers.reduce((sum, c) => sum + (c.approved_count || 0), 0);
let totalHours = activeCustomers.reduce((sum, c) => sum + (parseFloat(c.total_approved_hours) || 0), 0);
// Update stat cards
document.getElementById('stat-customers').textContent = totalCustomers;
document.getElementById('stat-pending').textContent = totalPending;
document.getElementById('stat-approved').textContent = totalApproved;
document.getElementById('stat-hours').textContent = totalHours.toFixed(1) + 'h';
// Build table (kun aktive kunder)
const tbody = document.getElementById('customer-tbody');
tbody.innerHTML = activeCustomers.map(customer => `
<tr>
<td>
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>${customer.customer_name || 'Ukendt kunde'}</strong>
<br><small class="text-muted">${customer.total_entries || 0} registreringer</small>
</div>
${customer.uses_time_card ? '<span class="badge bg-info">Klippekort</span>' : ''}
</div>
</td>
<td>${parseFloat(customer.total_original_hours || 0).toFixed(1)}h</td>
<td class="text-center">
<span class="badge bg-warning">${customer.pending_count || 0}</span>
</td>
<td class="text-center">
<span class="badge bg-success">${customer.approved_count || 0}</span>
</td>
<td class="text-center">
<span class="badge bg-danger">${customer.rejected_count || 0}</span>
</td>
<td class="text-end">
<button class="btn btn-sm btn-info me-1"
onclick="viewTimeEntries(${customer.customer_id}, '${(customer.customer_name || 'Ukendt kunde').replace(/'/g, "\\'")}')"
title="Se alle tidsregistreringer">
<i class="bi bi-clock-history"></i>
</button>
<button class="btn btn-sm btn-outline-secondary me-1"
onclick="toggleTimeCard(${customer.customer_id}, ${customer.uses_time_card ? 'false' : 'true'})"
title="${customer.uses_time_card ? 'Fjern klippekort' : 'Markér som klippekort'}">
<i class="bi bi-card-checklist"></i>
</button>
${(customer.pending_count || 0) > 0 ? `
<a href="/timetracking/wizard?customer_id=${customer.customer_id}"
class="btn btn-sm btn-primary me-1">
<i class="bi bi-check-circle"></i> Godkend
</a>
` : ''}
${(customer.approved_count || 0) > 0 ? `
<button class="btn btn-sm btn-success"
onclick="generateOrder(${customer.customer_id})">
<i class="bi bi-receipt"></i> Opret ordre
</button>
` : ''}
</td>
</tr>
`).join('');
document.getElementById('loading').classList.add('d-none');
document.getElementById('customer-table').classList.remove('d-none');
} catch (error) {
console.error('Error loading stats:', error);
console.error('Error stack:', error.stack);
document.getElementById('loading').innerHTML = `
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle"></i>
<strong>Fejl ved indlæsning:</strong> ${error.message}
<br><small class="text-muted">Prøv at genindlæse siden med Cmd+Shift+R (Mac) eller Ctrl+Shift+F5 (Windows)</small>
</div>
`;
}
}
// Sync from vTiger
async function syncFromVTiger() {
const btn = document.getElementById('sync-btn');
const statusDiv = document.getElementById('sync-status');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Synkroniserer...';
statusDiv.classList.remove('d-none');
statusDiv.innerHTML = '<div class="alert alert-info mb-0"><i class="bi bi-hourglass-split"></i> Synkronisering i gang...</div>';
try {
const response = await fetch('/api/v1/timetracking/sync', {
method: 'POST'
});
const result = await response.json();
statusDiv.innerHTML = `
<div class="alert alert-success mb-0">
<strong><i class="bi bi-check-circle"></i> Synkronisering fuldført!</strong>
<ul class="mb-0 mt-2">
<li>Kunder: ${result.customers_imported || 0} nye, ${result.customers_updated || 0} opdateret</li>
<li>Cases: ${result.cases_imported || 0} nye, ${result.cases_updated || 0} opdateret</li>
<li>Tidsregistreringer: ${result.times_imported || 0} nye, ${result.times_updated || 0} opdateret</li>
</ul>
${result.duration_seconds ? `<small class="text-muted d-block mt-2">Varighed: ${result.duration_seconds.toFixed(1)}s</small>` : ''}
</div>
`;
// Reload data
setTimeout(() => {
location.reload();
}, 2000);
} catch (error) {
statusDiv.innerHTML = `
<div class="alert alert-danger mb-0">
<i class="bi bi-x-circle"></i> Synkronisering fejlede: ${error.message}
</div>
`;
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Synkroniser';
}
}
// Generate order for customer
async function generateOrder(customerId) {
if (!confirm('Opret ordre for alle godkendte tidsregistreringer?')) {
return;
}
try {
const response = await fetch(`/api/v1/timetracking/orders/generate/${customerId}`, {
method: 'POST'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Fejl ved oprettelse af ordre');
}
const order = await response.json();
// Show success message and redirect to orders page
alert(`✅ Ordre oprettet!\n\nOrdrenummer: ${order.order_number}\nTotal: ${parseFloat(order.total_amount).toFixed(2)} DKK\n\nDu redirectes nu til ordre-siden...`);
// Redirect to orders page instead of reloading
window.location.href = '/timetracking/orders';
} catch (error) {
alert('❌ Fejl ved oprettelse af ordre:\n\n' + error.message);
}
}
// Toggle time card for customer
async function toggleTimeCard(customerId, enabled) {
const action = enabled ? 'markere som klippekort' : 'fjerne klippekort-markering';
if (!confirm(`Er du sikker på at du vil ${action} for denne kunde?`)) {
return;
}
try {
const response = await fetch(`/api/v1/timetracking/customers/${customerId}/time-card?enabled=${enabled}`, {
method: 'PATCH'
});
if (!response.ok) {
throw new Error('Fejl ved opdatering');
}
// Reload customer list
loadCustomerStats();
} catch (error) {
alert('❌ Fejl: ' + error.message);
}
}
// Global variables for modal filtering
let allModalEntries = [];
let currentModalCustomerId = null;
let currentModalCustomerName = '';
// View time entries for customer
async function viewTimeEntries(customerId, customerName) {
currentModalCustomerId = customerId;
currentModalCustomerName = customerName;
document.getElementById('modal-customer-name').textContent = customerName;
document.getElementById('time-entries-loading').classList.remove('d-none');
document.getElementById('time-entries-content').classList.add('d-none');
document.getElementById('time-entries-empty').classList.add('d-none');
// Reset filters
document.getElementById('filter-billable').checked = true;
document.getElementById('filter-not-invoiced').checked = true;
const modal = new bootstrap.Modal(document.getElementById('timeEntriesModal'));
modal.show();
try {
const response = await fetch(`/api/v1/timetracking/customers/${customerId}/times`);
if (!response.ok) throw new Error('Failed to load time entries');
const data = await response.json();
allModalEntries = data.times || [];
document.getElementById('time-entries-loading').classList.add('d-none');
// Apply filters and render
filterModalEntries();
} catch (error) {
console.error('Error loading time entries:', error);
document.getElementById('time-entries-loading').classList.add('d-none');
showToast('Fejl ved indlæsning af tidsregistreringer', 'danger');
modal.hide();
}
}
// Filter modal entries based on checkboxes
function filterModalEntries() {
const filterBillable = document.getElementById('filter-billable').checked;
const filterNotInvoiced = document.getElementById('filter-not-invoiced').checked;
let filteredEntries = allModalEntries;
if (filterBillable) {
filteredEntries = filteredEntries.filter(e => e.billable !== false);
}
if (filterNotInvoiced) {
filteredEntries = filteredEntries.filter(e => {
const invoiced = e.vtiger_data?.cf_timelog_invoiced;
return invoiced === '0' || invoiced === 0 || invoiced === null;
});
}
if (filteredEntries.length === 0) {
document.getElementById('time-entries-content').classList.add('d-none');
document.getElementById('time-entries-empty').classList.remove('d-none');
return;
}
document.getElementById('time-entries-empty').classList.add('d-none');
document.getElementById('time-entries-content').classList.remove('d-none');
renderModalEntries(filteredEntries);
}
// Render entries in modal table
function renderModalEntries(entries) {
const tbody = document.getElementById('time-entries-tbody');
tbody.innerHTML = entries.map(entry => {
const date = new Date(entry.worked_date).toLocaleDateString('da-DK');
const statusBadge = {
'pending': '<span class="badge bg-warning">Afventer</span>',
'approved': '<span class="badge bg-success">Godkendt</span>',
'rejected': '<span class="badge bg-danger">Afvist</span>',
'billed': '<span class="badge bg-info">Faktureret</span>'
}[entry.status] || entry.status;
// Build case link
let caseLink = entry.case_title || 'Ingen case';
if (entry.case_vtiger_id) {
const recordId = entry.case_vtiger_id.split('x')[1];
const vtigerUrl = `https://bmcnetworks.od2.vtiger.com/view/detail?module=Cases&id=${recordId}&viewtype=summary`;
caseLink = `<a href="${vtigerUrl}" target="_blank" class="text-decoration-none">
${entry.case_title || 'Case'} <i class="bi bi-box-arrow-up-right"></i>
</a>`;
}
// Billable and invoiced badges
const invoiced = entry.vtiger_data?.cf_timelog_invoiced;
const badges = [];
if (entry.billable === false) {
badges.push('<span class="badge bg-secondary">Ikke fakturerbar</span>');
}
if (invoiced === '1' || invoiced === 1) {
badges.push('<span class="badge bg-dark">Faktureret i vTiger</span>');
}
return `
<tr>
<td>
${caseLink}
${badges.length > 0 ? '<br>' + badges.join(' ') : ''}
</td>
<td>${date}</td>
<td>
<strong>${entry.original_hours}t</strong>
${entry.approved_hours && entry.status === 'approved' ? `
<br><small class="text-muted">
Oprundet: <strong>${entry.approved_hours}t</strong>
${entry.rounded_to ? ` (${entry.rounded_to}t)` : ''}
</small>
` : ''}
</td>
<td>${statusBadge}</td>
<td>${entry.user_name || 'Ukendt'}</td>
<td>
${entry.status === 'pending' ? `
<a href="/timetracking/wizard?customer_id=${currentModalCustomerId}&time_id=${entry.id}" class="btn btn-sm btn-success">
<i class="bi bi-check"></i> Godkend
</a>
` : ''}
${entry.status === 'approved' && !entry.billed ? `
<button class="btn btn-sm btn-outline-danger" onclick="resetTimeEntry(${entry.id}, ${currentModalCustomerId}, '${currentModalCustomerName.replace(/'/g, "\\'")}')">
<i class="bi bi-arrow-counterclockwise"></i> Nulstil
</button>
` : ''}
</td>
</tr>
`;
}).join('');
}
// Approve time entry
async function approveTimeEntry(timeId, customerId, customerName) {
if (!confirm('Godkend denne tidsregistrering?')) return;
try {
const response = await fetch(`/api/v1/timetracking/wizard/approve/${timeId}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
if (!response.ok) throw new Error('Failed to approve');
showToast('✅ Tidsregistrering godkendt', 'success');
// Reload modal content
viewTimeEntries(customerId, customerName);
// Reload stats
loadCustomerStats();
} catch (error) {
console.error('Error approving:', error);
showToast('Fejl ved godkendelse', 'danger');
}
}
// Reset time entry back to pending
async function resetTimeEntry(timeId, customerId, customerName) {
if (!confirm('Nulstil denne tidsregistrering tilbage til pending?\n\nDen vil blive sat tilbage i godkendelses-køen.')) return;
try {
const response = await fetch(`/api/v1/timetracking/wizard/reset/${timeId}?reason=${encodeURIComponent('Reset til pending')}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to reset');
}
showToast('✅ Tidsregistrering nulstillet til pending', 'success');
// Reload modal content
viewTimeEntries(customerId, customerName);
// Reload stats
loadCustomerStats();
} catch (error) {
console.error('Error resetting:', error);
showToast('Fejl ved nulstilling', 'danger');
}
}
// Toast notification
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `alert alert-${type} position-fixed top-0 end-0 m-3`;
toast.style.zIndex = 9999;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// Load data on page load
loadCustomerStats();
</script>
</div>
{% endblock %}