- Implemented a new HTML page for managing customer time pricing with Bootstrap styling. - Added navigation and responsive design elements. - Integrated JavaScript for loading customer data, editing rates, and handling modals for time entries and order creation. - Included theme toggle functionality and statistics display for customer rates. - Enhanced user experience with toast notifications for actions performed. docs: Create e-conomic Write Mode guide - Added comprehensive documentation for exporting approved time entries to e-conomic as draft orders. - Detailed safety flags for write operations, including read-only and dry-run modes. - Provided activation steps, error handling, and best practices for using the e-conomic integration. migrations: Add user_company field to contacts and e-conomic customer number to customers - Created migration to add user_company field to contacts for better organization tracking. - Added e-conomic customer number field to tmodule_customers for invoice export synchronization.
556 lines
23 KiB
HTML
556 lines
23 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="da">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
|
<meta http-equiv="Pragma" content="no-cache">
|
|
<meta http-equiv="Expires" content="0">
|
|
<!-- Version: 2025-12-09-22:00 - FORCE RELOAD MED CMD+SHIFT+R / CTRL+SHIFT+R -->
|
|
<title>{{ page_title }} - BMC Hub</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
<style>
|
|
:root {
|
|
--bg-body: #f8f9fa;
|
|
--bg-card: #ffffff;
|
|
--text-primary: #2c3e50;
|
|
--text-secondary: #6c757d;
|
|
--accent: #0f4c75;
|
|
--accent-light: #eef2f5;
|
|
--success: #28a745;
|
|
--warning: #ffc107;
|
|
--danger: #dc3545;
|
|
--border-radius: 12px;
|
|
}
|
|
|
|
[data-theme="dark"] {
|
|
--bg-body: #1a1a1a;
|
|
--bg-card: #2d2d2d;
|
|
--text-primary: #e4e4e4;
|
|
--text-secondary: #a0a0a0;
|
|
--accent-light: #1e3a52;
|
|
}
|
|
|
|
body {
|
|
background-color: var(--bg-body);
|
|
color: var(--text-primary);
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
padding-top: 80px;
|
|
transition: background-color 0.3s, color 0.3s;
|
|
}
|
|
|
|
.navbar {
|
|
background: var(--bg-card);
|
|
box-shadow: 0 2px 15px rgba(0,0,0,0.1);
|
|
padding: 1rem 0;
|
|
}
|
|
|
|
.navbar-brand {
|
|
font-weight: 700;
|
|
color: var(--accent);
|
|
font-size: 1.25rem;
|
|
}
|
|
|
|
.nav-link {
|
|
color: var(--text-secondary);
|
|
padding: 0.6rem 1.2rem !important;
|
|
border-radius: var(--border-radius);
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.nav-link:hover {
|
|
background-color: var(--accent-light);
|
|
color: var(--accent);
|
|
}
|
|
|
|
.nav-link.active {
|
|
background-color: var(--accent);
|
|
color: white;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.card {
|
|
border: none;
|
|
border-radius: var(--border-radius);
|
|
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
|
|
background: var(--bg-card);
|
|
margin-bottom: 1.5rem;
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
.card:hover {
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.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>
|
|
</head>
|
|
<body>
|
|
<!-- Navigation -->
|
|
<nav class="navbar navbar-expand-lg fixed-top">
|
|
<div class="container-fluid">
|
|
<a class="navbar-brand" href="/dashboard">
|
|
<i class="bi bi-grid-3x3-gap-fill"></i> BMC Hub
|
|
</a>
|
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
|
<span class="navbar-toggler-icon"></span>
|
|
</button>
|
|
<div class="collapse navbar-collapse" id="navbarNav">
|
|
<ul class="navbar-nav">
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/dashboard">Dashboard</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link active" href="/timetracking">
|
|
<i class="bi bi-clock-history"></i> Oversigt
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/timetracking/wizard">
|
|
<i class="bi bi-check-circle"></i> Godkend Tider
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/timetracking/customers">
|
|
<i class="bi bi-building"></i> Kunder & Priser
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/timetracking/orders">
|
|
<i class="bi bi-receipt"></i> Ordrer
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
<ul class="navbar-nav ms-auto">
|
|
<li class="nav-item">
|
|
<button class="btn btn-link nav-link" onclick="toggleTheme()">
|
|
<i class="bi bi-moon-fill" id="theme-icon"></i>
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- Main 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>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
|
<script>
|
|
// Theme toggle
|
|
function toggleTheme() {
|
|
const html = document.documentElement;
|
|
const icon = document.getElementById('theme-icon');
|
|
if (html.getAttribute('data-theme') === 'dark') {
|
|
html.removeAttribute('data-theme');
|
|
icon.className = 'bi bi-moon-fill';
|
|
localStorage.setItem('theme', 'light');
|
|
} else {
|
|
html.setAttribute('data-theme', 'dark');
|
|
icon.className = 'bi bi-sun-fill';
|
|
localStorage.setItem('theme', 'dark');
|
|
}
|
|
}
|
|
|
|
// Load saved theme
|
|
if (localStorage.getItem('theme') === 'dark') {
|
|
document.documentElement.setAttribute('data-theme', 'dark');
|
|
document.getElementById('theme-icon').className = 'bi bi-sun-fill';
|
|
}
|
|
|
|
// 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-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);
|
|
}
|
|
}
|
|
|
|
// Load data on page load
|
|
loadCustomerStats();
|
|
</script>
|
|
</body>
|
|
</html>
|