- Implemented a new HTML page for managing customer time pricing with Bootstrap styling. - Added navigation and responsive design elements. - Integrated JavaScript for loading customer data, editing rates, and handling modals for time entries and order creation. - Included theme toggle functionality and statistics display for customer rates. - Enhanced user experience with toast notifications for actions performed. docs: Create e-conomic Write Mode guide - Added comprehensive documentation for exporting approved time entries to e-conomic as draft orders. - Detailed safety flags for write operations, including read-only and dry-run modes. - Provided activation steps, error handling, and best practices for using the e-conomic integration. migrations: Add user_company field to contacts and e-conomic customer number to customers - Created migration to add user_company field to contacts for better organization tracking. - Added e-conomic customer number field to tmodule_customers for invoice export synchronization.
1334 lines
58 KiB
HTML
1334 lines
58 KiB
HTML
<!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>
|
|
|
|
<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;
|
|
|
|
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
|
|
})
|
|
});
|
|
|
|
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>
|