bmc_hub/app/timetracking/frontend/dashboard.html

556 lines
23 KiB
HTML
Raw Normal View History

<!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>