bmc_hub/app/timetracking/frontend/wizard.html

1348 lines
58 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">
<!-- Version: 2025-12-09 22:15 - Added vTiger case link -->
<title>Godkend Tider - 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;
}
.progress-container {
position: relative;
padding: 1.5rem 0;
}
.progress {
height: 8px;
border-radius: 10px;
background-color: var(--accent-light);
}
.progress-bar {
background-color: var(--accent);
}
.time-entry-card {
border-left: 4px solid var(--accent);
}
.time-entry-card .card-body {
padding: 2rem;
}
#case-header-title {
text-transform: uppercase !important;
font-weight: 700 !important;
letter-spacing: 0.5px !important;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid var(--accent-light);
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
color: var(--text-secondary);
font-weight: 500;
font-size: 0.9rem;
}
.info-value {
font-weight: 600;
color: var(--text-primary);
}
.rounding-controls {
background: var(--accent-light);
padding: 1.5rem;
border-radius: var(--border-radius);
margin-top: 1.5rem;
}
.btn-action {
padding: 0.8rem 2rem;
font-weight: 600;
border-radius: 8px;
min-width: 150px;
}
.completion-card {
text-align: center;
padding: 3rem 2rem;
}
.completion-card i {
font-size: 4rem;
margin-bottom: 1.5rem;
}
.badge-large {
font-size: 1rem;
padding: 0.6rem 1.2rem;
border-radius: 8px;
}
.description-box {
background: var(--accent-light);
padding: 1rem;
border-radius: 8px;
margin-top: 1rem;
font-family: monospace;
white-space: pre-wrap;
}
/* Internal Comments Styling */
.comment-item {
background: var(--bg-card);
border: 1px solid rgba(0,0,0,0.1);
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 0.75rem;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.comment-item:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
border-color: var(--accent);
}
.comment-preview {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.5;
white-space: pre-wrap;
}
.comment-item.expanded .comment-preview {
-webkit-line-clamp: unset;
display: block;
}
.comment-expand-btn {
font-size: 0.7rem;
color: var(--accent);
margin-top: 0.5rem;
font-weight: 600;
text-align: center;
padding: 0.25rem;
background: var(--accent-light);
border-radius: 4px;
}
.comment-meta {
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid rgba(0,0,0,0.05);
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spin {
animation: spin 1s linear infinite;
display: inline-block;
}
</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" href="/timetracking">
<i class="bi bi-clock-history"></i> Oversigt
</a>
</li>
<li class="nav-item">
<a class="nav-link active" 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>
</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 py-4">
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="mb-1">
<i class="bi bi-check2-circle text-primary"></i> Godkend Tidsregistreringer
</h1>
<p class="text-muted mb-0">Gennemgå og godkend tider én ad gangen</p>
</div>
<a href="/timetracking" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Tilbage til oversigt
</a>
</div>
</div>
</div>
<!-- Progress Bar -->
<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 mb-2">
<h6 class="mb-0" id="progress-title">Indlæser...</h6>
<span class="badge bg-primary" id="progress-badge">0 / 0</span>
</div>
<div class="progress">
<div class="progress-bar" role="progressbar" id="progress-bar"
style="width: 0%"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Loading State -->
<div id="loading-state" class="row">
<div class="col-12">
<div class="card">
<div class="card-body text-center py-5">
<div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
<p class="text-muted">Henter næste tidsregistrering...</p>
</div>
</div>
</div>
</div>
<!-- Time Entry Container - Shows all pending timelogs in case -->
<div id="time-entry-container" class="row d-none">
<div class="col-lg-8">
<!-- Header Card -->
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<div class="flex-grow-1">
<h4 class="mb-1">
<i class="bi bi-folder"></i> <span id="case-header-title">-</span>
</h4>
<p class="text-muted mb-2">
<i class="bi bi-building"></i> <span id="case-header-customer">-</span>
</p>
<div class="d-flex gap-3 mb-2" id="case-meta-info">
<span id="case-brand-info" style="display: none;">
<i class="bi bi-tag"></i> <strong>Brand:</strong> <span id="case-brand">-</span>
</span>
<span id="case-type-info" style="display: none;">
<i class="bi bi-diagram-3"></i> <strong>Type:</strong> <span id="case-type">-</span>
</span>
<span id="case-contact-info" style="display: none;">
<i class="bi bi-person"></i> <strong>Kontakt:</strong> <span id="case-contact">-</span>
</span>
<span id="case-priority-info" style="display: none;">
<i class="bi bi-exclamation-circle"></i> <strong>Prioritet:</strong> <span id="case-priority">-</span>
</span>
<span id="case-invoiced-info" style="display: none;">
<i class="bi bi-receipt"></i> <strong>Faktureret:</strong> <span id="case-invoiced" class="badge bg-success">Ja</span>
</span>
</div>
<div class="alert alert-light mb-0" id="case-summary-container" style="display: none;">
<strong><i class="bi bi-file-text"></i> Case Beskrivelse:</strong>
<p class="mb-0 mt-2" id="case-summary-text">-</p>
</div>
</div>
<div class="text-end ms-3">
<span class="badge bg-info badge-large mb-2" id="case-header-count">0 tidsregistreringer</span>
<div class="d-flex gap-2 justify-content-end mb-2">
<button class="btn btn-outline-secondary btn-sm" onclick="syncCaseComments()" id="sync-comments-btn">
<i class="bi bi-arrow-clockwise"></i> Sync kommentarer
</button>
</div>
<div>
<button class="btn btn-success btn-sm" onclick="approveAllEntries()">
<i class="bi bi-check-all"></i> Godkend alle
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Time Entry Cards - One per timelog -->
<div id="time-entries-list">
<!-- Populated by JavaScript -->
</div>
</div>
<!-- Action Buttons & Sidebar -->
<div class="col-lg-4">
<!-- Customer Context -->
<div class="card">
<div class="card-body">
<h6 class="mb-3">
<i class="bi bi-graph-up"></i> Kunde Status
</h6>
<div id="customer-context">
<div class="mb-2">
<small class="text-muted">Timepris:</small>
<div class="fw-bold" id="context-hourly-rate">-</div>
</div>
<div class="mb-2">
<small class="text-muted">Afventer godkendelse:</small>
<div class="fw-bold" id="context-pending">-</div>
</div>
<div class="mb-2">
<small class="text-muted">Godkendte timer:</small>
<div class="fw-bold text-success" id="context-approved">-</div>
</div>
</div>
</div>
</div>
<!-- Case Context -->
<div class="card mt-3" id="case-context-card">
<div class="card-body">
<h6 class="mb-3">
<i class="bi bi-folder"></i> Case Historik
</h6>
<!-- Internal Comments Section -->
<div id="internal-comments-section" style="display: none;" class="mb-3">
<small class="text-muted d-block mb-2">
<i class="bi bi-chat-left-text"></i> Interne Kommentarer:
</small>
<div id="internal-comments-list" class="small">
<!-- Populated by JavaScript -->
</div>
</div>
<!-- Case Timeline (all timelogs) -->
<div id="case-comments">
<small class="text-muted d-block mb-2">Alle tidsregistreringer i case:</small>
<div id="case-comments-list" class="small" style="max-height: 400px; overflow-y: auto;">
<!-- Populated by JavaScript -->
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Completion State -->
<div id="completion-state" class="row d-none">
<div class="col-12">
<div class="card">
<div class="card-body completion-card">
<i class="bi bi-check-circle text-success"></i>
<h3 class="mb-3">Alle tider gennemgået!</h3>
<p class="text-muted mb-4">
Der er ingen flere tidsregistreringer der afventer godkendelse.
<br>
Gå til Dashboard for at oprette fakturaordrer.
</p>
<div class="d-flex gap-3 justify-content-center">
<a href="/timetracking" class="btn btn-primary btn-lg">
<i class="bi bi-house"></i> Tilbage til Dashboard
</a>
<a href="/timetracking/orders" class="btn btn-outline-success btn-lg">
<i class="bi bi-receipt"></i> Se Ordrer
</a>
</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>
let currentEntry = null;
let currentCustomerId = null;
let defaultHourlyRate = 850.00; // Fallback værdi, hentes fra API
// 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 config from API
async function loadConfig() {
try {
const response = await fetch('/api/v1/timetracking/config');
if (response.ok) {
const config = await response.json();
defaultHourlyRate = config.default_hourly_rate;
}
} catch (error) {
console.warn('Failed to load config, using fallback rate:', error);
}
}
// Get URL parameters
const urlParams = new URLSearchParams(window.location.search);
currentCustomerId = urlParams.get('customer_id');
// Load config on startup
loadConfig();
// Load next entry
async function loadNextEntry() {
document.getElementById('loading-state').classList.remove('d-none');
document.getElementById('time-entry-container').classList.add('d-none');
document.getElementById('completion-state').classList.add('d-none');
try {
const url = currentCustomerId
? `/api/v1/timetracking/wizard/next?customer_id=${currentCustomerId}`
: '/api/v1/timetracking/wizard/next';
const response = await fetch(url);
if (response.status === 404) {
// No more entries
showCompletion();
return;
}
const data = await response.json();
// Check if has_next is false
if (!data.has_next || !data.time_entry) {
showCompletion();
return;
}
currentEntry = data.time_entry;
// Fetch ALL pending timelogs in this case
if (currentEntry.case_id) {
const caseResponse = await fetch(`/api/v1/timetracking/wizard/case/${currentEntry.case_id}/entries`);
if (caseResponse.ok) {
window.currentCaseEntries = await caseResponse.json();
} else {
window.currentCaseEntries = [currentEntry];
}
} else {
window.currentCaseEntries = [currentEntry];
}
displayCaseEntries(data);
await loadCustomerContext();
await loadCaseContext();
document.getElementById('loading-state').classList.add('d-none');
document.getElementById('time-entry-container').classList.remove('d-none');
} catch (error) {
console.error('Error loading entry:', error);
alert('Fejl ved indlæsning: ' + error.message);
}
}
// Display all case entries as separate cards
function displayCaseEntries(data) {
const entries = window.currentCaseEntries || [];
const container = document.getElementById('time-entries-list');
if (entries.length === 0) {
container.innerHTML = '<div class="alert alert-info">Ingen pending tidsregistreringer</div>';
return;
}
// Use first entry from currentCaseEntries (has full metadata from LEFT JOIN)
const entry = entries[0];
// Update header
document.getElementById('case-header-title').textContent = entry.case_title || 'Ingen case';
document.getElementById('case-header-customer').textContent = entry.customer_name || '-';
document.getElementById('case-header-count').textContent = `${entries.length} tidsregistrering${entries.length > 1 ? 'er' : ''}`;
// Show case metadata (brand, type, contact) - from case_vtiger_data
const vtigerData = entry.case_vtiger_data || entry.vtiger_data || {};
console.log('Case metadata debug:', {
entry_has_vtiger: !!entry.case_vtiger_data,
brand: vtigerData.cf_cases_brands,
casetype: vtigerData.cf_cases_casetype,
contact_name: entry.contact_name,
contact_company: entry.contact_company,
priority: vtigerData.casepriority,
is_billed: vtigerData.is_billed
});
// Brand (correct field: cf_cases_brands)
const brand = vtigerData.cf_cases_brands || '';
if (brand) {
document.getElementById('case-brand').textContent = brand;
document.getElementById('case-brand-info').style.display = 'inline';
} else {
document.getElementById('case-brand-info').style.display = 'none';
}
// Case Type (correct field: cf_cases_casetype)
const caseType = vtigerData.cf_cases_casetype || '';
if (caseType) {
document.getElementById('case-type').textContent = caseType;
document.getElementById('case-type-info').style.display = 'inline';
} else {
document.getElementById('case-type-info').style.display = 'none';
}
// Contact Person (name comes from LEFT JOIN with contacts table)
const contactName = entry.contact_name || '';
const contactCompany = entry.contact_company || '';
if (contactName) {
// Show name and company if both exist
if (contactCompany) {
document.getElementById('case-contact').textContent = `${contactName} (${contactCompany})`;
} else {
document.getElementById('case-contact').textContent = contactName;
}
document.getElementById('case-contact-info').style.display = 'inline';
} else {
document.getElementById('case-contact-info').style.display = 'none';
}
// Priority (casepriority field)
const priority = vtigerData.casepriority || '';
if (priority) {
document.getElementById('case-priority').textContent = priority;
document.getElementById('case-priority-info').style.display = 'inline';
} else {
document.getElementById('case-priority-info').style.display = 'none';
}
// Invoiced status (is_billed field)
const isBilled = vtigerData.is_billed === '1' || vtigerData.is_billed === 1;
if (isBilled) {
document.getElementById('case-invoiced').textContent = 'Ja';
document.getElementById('case-invoiced').className = 'badge bg-success';
document.getElementById('case-invoiced-info').style.display = 'inline';
} else if (vtigerData.is_billed === '0' || vtigerData.is_billed === 0) {
document.getElementById('case-invoiced').textContent = 'Nej';
document.getElementById('case-invoiced').className = 'badge bg-warning';
document.getElementById('case-invoiced-info').style.display = 'inline';
} else {
document.getElementById('case-invoiced-info').style.display = 'none';
}
// Display internal comments if available
displayInternalComments(vtigerData);
// Show case summary/description if available
const caseSummary = entry.case_description || vtigerData.case_description || vtigerData.description || '';
if (caseSummary && caseSummary.trim()) {
// Convert HTML to plain text
const tempDiv = document.createElement('div');
tempDiv.innerHTML = caseSummary;
const plainText = tempDiv.textContent || tempDiv.innerText || '';
// Only show if there's actual content after stripping HTML
if (plainText.trim().length > 0) {
document.getElementById('case-summary-text').textContent = plainText.trim();
document.getElementById('case-summary-container').style.display = 'block';
} else {
document.getElementById('case-summary-container').style.display = 'none';
}
} else {
document.getElementById('case-summary-container').style.display = 'none';
}
// Build cards for each entry
container.innerHTML = entries.map((e, index) => {
const date = new Date(e.worked_date).toLocaleDateString('da-DK', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric'
});
// Get vTiger description from vtiger_data
let vtigerDescription = e.description || '';
if (e.vtiger_data && typeof e.vtiger_data === 'object') {
vtigerDescription = e.vtiger_data.description || e.vtiger_data.comment || e.description || 'Ingen beskrivelse';
}
// Build case link
let caseLink = '';
if (e.case_vtiger_id) {
const recordId = e.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">
<i class="bi bi-box-arrow-up-right"></i> ${e.case_title || 'Åbn case'}
</a>`;
} else {
caseLink = 'Ingen case';
}
// Get hourly rate (customer rate or default)
const hourlyRate = e.customer_hourly_rate || currentEntry.customer_hourly_rate || defaultHourlyRate;
return `
<div class="card time-entry-card mb-3" id="entry-card-${e.id}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<h5 class="mb-0">
<i class="bi bi-clock"></i> Tidsregistrering #${index + 1}
</h5>
<span class="badge badge-large bg-info">Afventer godkendelse</span>
</div>
<div class="info-row">
<span class="info-label">
<i class="bi bi-folder"></i> Case
</span>
<span class="info-value">${caseLink}</span>
</div>
<div class="info-row">
<span class="info-label">
<i class="bi bi-calendar-event"></i> Dato
</span>
<span class="info-value">${date}</span>
</div>
<div class="info-row">
<span class="info-label">
<i class="bi bi-clock"></i> Original Timer
</span>
<span class="info-value">${e.original_hours} timer</span>
</div>
<div class="info-row">
<span class="info-label">
<i class="bi bi-person"></i> Udført af
</span>
<span class="info-value">${e.user_name || e.time_user_name || 'Ukendt'}</span>
</div>
<div class="mt-3">
<label class="info-label d-block mb-2">
<i class="bi bi-file-text"></i> vTiger Beskrivelse
</label>
<div class="description-box">${vtigerDescription}</div>
</div>
<div class="rounding-controls">
<h6 class="mb-3">
<i class="bi bi-calculator"></i> Afrunding & Pris
</h6>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Metode</label>
<select class="form-select rounding-method" data-entry-id="${e.id}" onchange="calculateBillableHoursForEntry(${e.id})">
<option value="none">Ingen afrunding</option>
<option value="nearest_quarter">Nærmeste 0.25 time</option>
<option value="nearest_half">Nærmeste 0.5 time</option>
<option value="up_quarter">Afrund op til 0.25</option>
<option value="up_half" selected>Afrund op til 0.5</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Minimum timer</label>
<input type="number" class="form-control minimum-hours" data-entry-id="${e.id}"
value="0" min="0" step="0.25" onchange="calculateBillableHoursForEntry(${e.id})">
</div>
<div class="col-md-4">
<label class="form-label">Timepris (DKK)</label>
<input type="number" class="form-control hourly-rate" data-entry-id="${e.id}"
value="${hourlyRate}" min="0" step="50" onchange="calculateBillableHoursForEntry(${e.id})">
</div>
</div>
<div class="mt-3 p-3 bg-white rounded">
<div class="d-flex justify-content-between align-items-center">
<span class="fw-bold">Fakturerbare timer:</span>
<span class="fs-4 fw-bold text-primary billable-hours" id="billable-hours-${e.id}">-</span>
</div>
<div class="d-flex justify-content-between align-items-center mt-2">
<span class="fw-bold">Total beløb:</span>
<span class="fs-5 fw-bold text-success" id="total-amount-${e.id}">-</span>
</div>
</div>
<div class="mt-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="travel-${e.id}" ${e.is_travel ? 'checked' : ''}>
<label class="form-check-label" for="travel-${e.id}">
<i class="bi bi-car-front"></i> Indeholder kørsel
</label>
</div>
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button class="btn btn-success flex-fill" onclick="approveEntry(${e.id})">
<i class="bi bi-check-circle"></i> Godkend
</button>
<button class="btn btn-danger flex-fill" onclick="rejectEntry(${e.id})">
<i class="bi bi-x-circle"></i> Afvis
</button>
</div>
</div>
</div>
`;
}).join('');
// Calculate initial billable hours for all entries
entries.forEach(e => {
calculateBillableHoursForEntry(e.id);
});
// Update progress
const progress = data.progress || {};
const total = progress.total_entries || 0;
const processed = (progress.approved_entries || 0) + (progress.rejected_entries || 0);
const percent = total > 0 ? Math.round((processed / total) * 100) : 0;
document.getElementById('progress-title').textContent = entry.customer_name || 'Ukendt kunde';
document.getElementById('progress-badge').textContent = `${processed} / ${total}`;
document.getElementById('progress-bar').style.width = percent + '%';
}
// Display internal comments from case vtiger_data
function displayInternalComments(vtigerData) {
const commentsContainer = document.getElementById('internal-comments-list');
const commentsSection = document.getElementById('internal-comments-section');
console.log('displayInternalComments called:', {
vtigerData: vtigerData,
has_internal_comments: !!vtigerData.internal_comments,
has_comments: !!vtigerData.comments,
comments: vtigerData.internal_comments || vtigerData.comments
});
// Check if comments exist (adjust field name based on actual vTiger structure)
const comments = vtigerData.internal_comments || vtigerData.comments || [];
if (!comments || comments.length === 0) {
commentsSection.style.display = 'none';
return;
}
// Build comment items with preview and click-to-expand
const commentHtml = comments.map((comment, index) => {
const commentText = typeof comment === 'string' ? comment : (comment.text || comment.comment || '');
const commentDate = comment.created_at || comment.date || '';
const commentAuthor = comment.author || comment.user || '';
// Count lines to determine if expand button is needed
const lines = commentText.split('\n').length;
const needsExpand = lines > 2;
return `
<div class="comment-item" onclick="toggleComment(this)" data-index="${index}">
<div class="comment-preview">${commentText}</div>
${commentDate || commentAuthor ? `<div class="comment-meta">${commentAuthor ? commentAuthor + ' - ' : ''}${commentDate}</div>` : ''}
${needsExpand ? '<div class="comment-expand-btn">Klik for at udvide ↓</div>' : ''}
</div>
`;
}).join('');
console.log('Setting comments HTML:', {
commentHtml_length: commentHtml.length,
commentsContainer_exists: !!commentsContainer,
commentsSection_exists: !!commentsSection
});
commentsContainer.innerHTML = commentHtml;
commentsSection.style.display = 'block';
console.log('Comments section display set to block');
}
// Toggle comment expand/collapse
function toggleComment(element) {
const isExpanded = element.classList.contains('expanded');
const expandBtn = element.querySelector('.comment-expand-btn');
if (isExpanded) {
element.classList.remove('expanded');
if (expandBtn) expandBtn.textContent = 'Klik for at udvide ↓';
} else {
element.classList.add('expanded');
if (expandBtn) expandBtn.textContent = 'Klik for at skjule ↑';
}
}
// Sync comments for current case
async function syncCaseComments() {
if (!currentCaseId) {
showNotification('Ingen case valgt', 'warning');
return;
}
const btn = document.getElementById('sync-comments-btn');
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-arrow-clockwise spin"></i> Synkroniserer...';
try {
const response = await fetch(`/api/v1/timetracking/sync/case/${currentCaseId}/comments`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
showNotification(`✅ ${result.comments} kommentarer synkroniseret`, 'success');
// Reload case entries to get updated comments
await loadNextEntry();
} else {
showNotification('❌ Kunne ikke synkronisere kommentarer', 'danger');
}
} catch (error) {
console.error('Error syncing comments:', error);
showNotification('❌ Fejl ved synkronisering', 'danger');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Sync kommentarer';
}
}
// Load customer context
async function loadCustomerContext() {
try {
const response = await fetch(`/api/v1/timetracking/wizard/progress/${currentEntry.customer_id}`);
const progress = await response.json();
const hourlyRate = currentEntry.customer_hourly_rate || defaultHourlyRate;
document.getElementById('context-hourly-rate').textContent = hourlyRate.toFixed(2) + ' DKK';
// Vis antal registreringer (vi har ikke timer-totaler i progress endpointet)
document.getElementById('context-pending').textContent =
`${progress.pending_entries || 0} registreringer`;
document.getElementById('context-approved').textContent =
`${progress.approved_entries || 0} registreringer`;
} catch (error) {
console.error('Error loading customer context:', error);
}
}
// Load case context (comments and other timelogs)
async function loadCaseContext() {
const caseCard = document.getElementById('case-context-card');
const commentsList = document.getElementById('case-comments-list');
const timelogsList = document.getElementById('case-timelogs-list');
// Hide if no case
if (!currentEntry.case_id) {
caseCard.style.display = 'none';
return;
}
try {
// Fetch case details (includes comments and all timelogs)
const response = await fetch(`/api/v1/timetracking/wizard/case/${currentEntry.case_id}/details`);
if (!response.ok) {
caseCard.style.display = 'none';
return;
}
const caseData = await response.json();
// Display timelogs as comments (ModComments ARE the case comments in vTiger)
// Show ALL timelogs including current one - they serve as the case history
if (caseData.timelogs && caseData.timelogs.length > 0) {
const sortedTimelogs = caseData.timelogs.sort((a, b) =>
new Date(b.worked_date) - new Date(a.worked_date)
);
commentsList.innerHTML = sortedTimelogs.map(timelog => {
const date = new Date(timelog.worked_date).toLocaleDateString('da-DK', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
const isCurrent = timelog.id === currentEntry.id;
const borderStyle = isCurrent ? 'border-left: 3px solid var(--accent);' : '';
const bgColor = isCurrent ? 'background-color: var(--accent-light);' : 'background-color: #f8f9fa;';
const statusBadge = timelog.status === 'approved'
? '<span class="badge bg-success ms-2">Godkendt</span>'
: timelog.status === 'rejected'
? '<span class="badge bg-danger ms-2">Afvist</span>'
: timelog.status === 'pending'
? '<span class="badge bg-warning text-dark ms-2">Afventer</span>'
: '';
const currentBadge = isCurrent ? '<span class="badge bg-primary ms-2">Aktuel</span>' : '';
return `
<div class="mb-2 p-2 rounded" style="${borderStyle} ${bgColor}">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>${timelog.customer_name || 'Unknown'}</strong>
${currentBadge}
${statusBadge}
</div>
<small class="text-muted">${date} - ${timelog.original_hours}h</small>
</div>
<div class="mt-1 small">${timelog.description || '(Ingen beskrivelse)'}</div>
</div>
`;
}).join('');
} else {
commentsList.innerHTML = '<em class="text-muted">Ingen tidsregistreringer i case</em>';
}
// Check if there are multiple pending entries in this case
const pendingTimelogs = caseData.timelogs.filter(t => t.status === 'pending');
const bulkApproveBtn = document.getElementById('bulk-approve-btn');
if (bulkApproveBtn) {
if (pendingTimelogs.length > 1) {
bulkApproveBtn.style.display = 'block';
// Store pending timelogs for bulk approval
window.casePendingTimelogs = pendingTimelogs;
} else {
bulkApproveBtn.style.display = 'none';
}
}
caseCard.style.display = 'block';
} catch (error) {
console.error('Error loading case context:', error);
caseCard.style.display = 'none';
}
}
// Show bulk approval panel
function showBulkApproval() {
if (!window.casePendingTimelogs || window.casePendingTimelogs.length === 0) {
return;
}
const panel = document.getElementById('bulk-approval-panel');
const list = document.getElementById('bulk-entries-list');
// Calculate total hours
const totalHours = window.casePendingTimelogs.reduce((sum, t) =>
sum + parseFloat(t.original_hours || 0), 0
);
list.innerHTML = `
<div class="small">
<strong>${window.casePendingTimelogs.length} tidsregistreringer</strong> -
Total: ${totalHours.toFixed(2)} timer
<ul class="mt-2 mb-0">
${window.casePendingTimelogs.map(t => `
<li>${new Date(t.worked_date).toLocaleDateString('da-DK')} -
${t.original_hours}h - ${t.description || 'Ingen beskrivelse'}</li>
`).join('')}
</ul>
</div>
`;
panel.style.display = 'block';
}
// Hide bulk approval panel
function hideBulkApproval() {
document.getElementById('bulk-approval-panel').style.display = 'none';
}
// Execute bulk approval
async function executeBulkApproval() {
if (!currentEntry || !currentEntry.case_id) {
return;
}
if (!confirm(`Godkend alle ${window.casePendingTimelogs.length} pending tidsregistreringer i denne case?`)) {
return;
}
try {
const response = await fetch(`/api/v1/timetracking/wizard/case/${currentEntry.case_id}/approve-all`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Fejl ved bulk godkendelse');
}
const result = await response.json();
alert(`✅ ${result.approved_count} tidsregistreringer godkendt!\nTotal: ${result.total_hours} timer`);
// Hide panel and load next entry
hideBulkApproval();
loadNextEntry();
} catch (error) {
console.error('Error during bulk approval:', error);
alert('❌ Fejl: ' + error.message);
}
}
// Calculate billable hours for a specific entry
function calculateBillableHoursForEntry(entryId) {
const entry = window.currentCaseEntries.find(e => e.id === entryId);
if (!entry) return;
const methodSelect = document.querySelector(`.rounding-method[data-entry-id="${entryId}"]`);
const minimumInput = document.querySelector(`.minimum-hours[data-entry-id="${entryId}"]`);
const hourlyRateInput = document.querySelector(`.hourly-rate[data-entry-id="${entryId}"]`);
const billableDisplay = document.getElementById(`billable-hours-${entryId}`);
const totalAmountDisplay = document.getElementById(`total-amount-${entryId}`);
if (!methodSelect || !minimumInput || !billableDisplay) return;
const method = methodSelect.value;
const minimum = parseFloat(minimumInput.value) || 0;
const hourlyRate = parseFloat(hourlyRateInput?.value) || 850;
const original = parseFloat(entry.original_hours) || 0;
let billable = original;
// Apply rounding
switch (method) {
case 'none':
billable = original;
break;
case 'nearest_quarter':
billable = Math.round(original * 4) / 4;
break;
case 'nearest_half':
billable = Math.round(original * 2) / 2;
break;
case 'up_quarter':
billable = Math.ceil(original * 4) / 4;
break;
case 'up_half':
billable = Math.ceil(original * 2) / 2;
break;
}
// Apply minimum
billable = Math.max(billable, minimum);
// Calculate total amount
const totalAmount = billable * hourlyRate;
billableDisplay.textContent = billable.toFixed(2) + ' timer';
if (totalAmountDisplay) {
totalAmountDisplay.textContent = totalAmount.toFixed(2) + ' DKK';
}
// Store for later use
if (!window.entryBillableHours) window.entryBillableHours = {};
if (!window.entryHourlyRates) window.entryHourlyRates = {};
window.entryBillableHours[entryId] = billable;
window.entryHourlyRates[entryId] = hourlyRate;
}
// Calculate billable hours (legacy - for single entry mode)
function calculateBillableHours() {
const method = document.getElementById('rounding-method').value;
const minimum = parseFloat(document.getElementById('minimum-hours').value) || 0;
const original = parseFloat(currentEntry.original_hours) || 0;
let billable = original;
// Apply rounding
switch (method) {
case 'none':
billable = original;
break;
case 'nearest_quarter':
billable = Math.round(original * 4) / 4;
break;
case 'nearest_half':
billable = Math.round(original * 2) / 2;
break;
case 'up_quarter':
billable = Math.ceil(original * 4) / 4;
break;
case 'up_half':
billable = Math.ceil(original * 2) / 2;
break;
}
// Apply minimum
billable = Math.max(billable, minimum);
document.getElementById('billable-hours').textContent = billable.toFixed(2) + ' timer';
currentEntry.billable_hours = billable;
}
// Approve entry (updated to support multiple entries)
async function approveEntry(entryId) {
const entry = window.currentCaseEntries.find(e => e.id === entryId);
if (!entry) {
alert('Entry not found');
return;
}
// Get billable hours and hourly rate from calculation
const billableHours = window.entryBillableHours?.[entryId] || entry.original_hours;
const hourlyRate = window.entryHourlyRates?.[entryId] || entry.customer_hourly_rate || defaultHourlyRate;
// Get travel checkbox state
const travelCheckbox = document.getElementById(`travel-${entryId}`);
const isTravel = travelCheckbox ? travelCheckbox.checked : false;
try {
const response = await fetch(`/api/v1/timetracking/wizard/approve/${entryId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
billable_hours: billableHours,
hourly_rate: hourlyRate,
is_travel: isTravel
})
});
if (!response.ok) {
throw new Error('Approval failed');
}
// Remove card from display
const card = document.getElementById(`entry-card-${entryId}`);
if (card) {
card.style.opacity = '0.5';
card.querySelector('.btn-success').disabled = true;
card.querySelector('.btn-danger').disabled = true;
setTimeout(() => {
card.remove();
// Check if more entries remain
const remaining = document.querySelectorAll('.time-entry-card').length;
if (remaining === 0) {
loadNextEntry();
}
}, 500);
}
} catch (error) {
console.error('Error approving entry:', error);
alert('Fejl ved godkendelse: ' + error.message);
}
}
// Reject entry (updated to support multiple entries)
async function rejectEntry(entryId) {
const entry = window.currentCaseEntries.find(e => e.id === entryId);
if (!entry) {
alert('Entry not found');
return;
}
if (!confirm('Er du sikker på at du vil afvise denne tidsregistrering? Dette kan ikke fortrydes.')) {
return;
}
try {
const response = await fetch(`/api/v1/timetracking/wizard/reject/${entryId}`, {
method: 'POST'
});
if (!response.ok) {
throw new Error('Rejection failed');
}
// Remove card from display
const card = document.getElementById(`entry-card-${entryId}`);
if (card) {
card.style.opacity = '0.5';
card.querySelector('.btn-success').disabled = true;
card.querySelector('.btn-danger').disabled = true;
setTimeout(() => {
card.remove();
// Check if more entries remain
const remaining = document.querySelectorAll('.time-entry-card').length;
if (remaining === 0) {
loadNextEntry();
}
}, 500);
}
} catch (error) {
console.error('Error rejecting entry:', error);
alert('Fejl ved afvisning: ' + error.message);
}
}
// Approve all entries in current case
async function approveAllEntries() {
const entries = window.currentCaseEntries || [];
if (entries.length === 0) return;
const totalHours = Object.values(window.entryBillableHours || {}).reduce((sum, h) => sum + h, 0);
if (!confirm(`Godkend alle ${entries.length} tidsregistreringer?\nTotal: ${totalHours.toFixed(2)} timer`)) {
return;
}
try {
// Approve each entry individually
for (const entry of entries) {
await approveEntry(entry.id);
}
// Load next after short delay
setTimeout(() => loadNextEntry(), 1000);
} catch (error) {
console.error('Error approving all:', error);
alert('Fejl ved godkendelse: ' + error.message);
}
}
// Show completion
function showCompletion() {
document.getElementById('loading-state').classList.add('d-none');
document.getElementById('time-entry-container').classList.add('d-none');
document.getElementById('completion-state').classList.remove('d-none');
}
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Keyboard shortcuts disabled for multi-entry mode
// Users should click buttons for each entry
});
// Load first entry
loadNextEntry();
</script>
</body>
</html>