1294 lines
58 KiB
HTML
1294 lines
58 KiB
HTML
{% 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>
|
||
|
||
<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 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,
|
||
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 %}
|