- 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.
671 lines
30 KiB
HTML
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 %}
|