bmc_hub/app/timetracking/frontend/wizard.html
Christian a1d4696005 feat: Add new time tracking wizard and registrations view
- Implemented a new simplified time tracking wizard (wizard2) for approval processes.
- Added a registrations view to list all time tracking entries.
- Enhanced the existing wizard.html to include a billable checkbox for entries.
- Updated JavaScript logic to handle billable state and travel status for time entries.
- Introduced a cleanup step in the deployment script to remove old images.
- Created a new HTML template for registrations with filtering and pagination capabilities.
2026-01-10 01:37:08 +01:00

1305 lines
58 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "shared/frontend/base.html" %}
{% block title %}Godkend Tider - BMC Hub{% endblock %}
{% block extra_css %}
<style>
/* Page specific styles */
.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>
{% endblock %}
{% block 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>
let currentEntry = null;
let currentCustomerId = null;
let currentCaseId = null;
let defaultHourlyRate = 1200.00; // Fallback værdi, hentes fra API
// 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');
const specificTimeId = urlParams.get('time_id');
// Load config on startup
loadConfig();
// Load specific entry or next entry
if (specificTimeId) {
loadSpecificEntry(parseInt(specificTimeId));
} else {
loadNextEntry();
}
// Load a specific time entry
async function loadSpecificEntry(timeId) {
console.log('🎯 Loading specific entry:', timeId);
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 response = await fetch(`/api/v1/timetracking/times/${timeId}`);
if (!response.ok) {
throw new Error('Time entry not found');
}
const entry = await response.json();
console.log('📥 Loaded entry:', entry.id, 'Status:', entry.status, 'Date:', entry.worked_date);
currentEntry = entry;
currentCaseId = entry.case_id;
// When loading a specific entry via time_id parameter, only show that one entry
console.log('🎯 Showing only the requested entry');
window.currentCaseEntries = [entry];
// Show the entry
displayCaseEntries({
time_entry: entry,
has_next: true,
remaining_count: 1
});
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 specific entry:', error);
showToast('Kunne ikke indlæse tidsregistrering', 'danger');
// Fall back to loading next entry
setTimeout(() => loadNextEntry(), 1000);
}
}
// 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;
currentCaseId = currentEntry.case_id;
// 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');
console.log('🖼️ displayCaseEntries called with', entries.length, 'entries');
console.log('🎯 currentEntry.id:', currentEntry?.id);
if (entries.length === 0) {
container.innerHTML = '<div class="alert alert-info">Ingen pending tidsregistreringer</div>';
return;
}
// Find the entry we want to display (either currentEntry or first in list)
let entry;
if (currentEntry && currentEntry.id) {
entry = entries.find(e => e.id === currentEntry.id) || entries[0];
console.log('🔎 Looking for entry', currentEntry.id, '- Found:', entry.id);
} else {
entry = entries[0];
console.log(' No currentEntry set, using first entry:', entry.id);
}
console.log('✅ Displaying entry:', entry.id, 'Date:', entry.worked_date);
// 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) - parse as float if string
const rateValue = e.customer_rate || currentEntry.customer_rate || defaultHourlyRate;
const hourlyRate = typeof rateValue === 'string' ? parseFloat(rateValue) : rateValue;
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 class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="billable-${e.id}" ${e.billable !== false ? 'checked' : ''}>
<label class="form-check-label" for="billable-${e.id}">
<i class="bi bi-cash-coin"></i> Fakturerbar
</label>
</div>
</div>
<div class="mt-3">
<label class="form-label">
<i class="bi bi-pencil"></i> Godkendelsesnote (valgfri)
</label>
<textarea class="form-control" id="approval-note-${e.id}" rows="2"
placeholder="Tilføj evt. note til denne godkendelse..."></textarea>
</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();
// Parse hourly rate as number (may be string from DB)
const rateValue = currentEntry.customer_rate || defaultHourlyRate;
const hourlyRate = typeof rateValue === 'string' ? parseFloat(rateValue) : rateValue;
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) || 1200;
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 rateValue = window.entryHourlyRates?.[entryId] || entry.customer_rate || defaultHourlyRate;
const hourlyRate = typeof rateValue === 'string' ? parseFloat(rateValue) : rateValue;
// Get travel checkbox state
const travelCheckbox = document.getElementById(`travel-${entryId}`);
const isTravel = travelCheckbox ? travelCheckbox.checked : false;
// Get billable checkbox state
const billableCheckbox = document.getElementById(`billable-${entryId}`);
const isBillable = billableCheckbox ? billableCheckbox.checked : true;
// Get approval note
const approvalNoteField = document.getElementById(`approval-note-${entryId}`);
const approvalNote = approvalNoteField ? approvalNoteField.value.trim() : '';
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,
billable: isBillable,
approval_note: approvalNote || null
})
});
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
});
// Note: Initial entry loading is handled above based on URL parameters
// (either loadSpecificEntry or loadNextEntry is called conditionally)
</script>
</div>
{% endblock %}