- Added hub_customer_id to TModuleApprovalStats for better tracking. - Introduced TModuleWizardEditRequest for editing time entries, allowing updates to description, hours, and billing method. - Implemented approval and rejection logic for Hub Worklogs, including handling negative IDs. - Created a new endpoint for updating entry details, supporting both Hub Worklogs and Module Times. - Updated frontend to include an edit modal for time entries, with specific fields for Hub Worklogs and Module Times. - Enhanced customer statistics retrieval to include pending counts from Hub Worklogs. - Added migrations for ticket enhancements, including new fields and constraints for worklogs and prepaid cards.
925 lines
36 KiB
HTML
925 lines
36 KiB
HTML
{% extends "shared/frontend/base.html" %}
|
|
|
|
{% block title %}Godkend Tider V2 - BMC Hub{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
/* Clean Table Design */
|
|
.approval-table {
|
|
background: white;
|
|
border-radius: 12px;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
|
overflow: hidden;
|
|
margin-bottom: 3rem !important; /* Increased spacing */
|
|
border: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.approval-table th {
|
|
background-color: #f8f9fa;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
font-size: 0.75rem;
|
|
letter-spacing: 0.5px;
|
|
padding: 1rem;
|
|
border-bottom: 2px solid #e9ecef;
|
|
color: #64748b;
|
|
}
|
|
|
|
.approval-table td {
|
|
vertical-align: top; /* Align to top for better readability with long descriptions */
|
|
padding: 1.25rem 1rem;
|
|
border-bottom: 1px solid #f1f5f9;
|
|
}
|
|
|
|
.approval-table tbody tr:last-child td {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.case-group-header {
|
|
background-color: #f1f5f9 !important; /* Lighter background */
|
|
border-left: 6px solid var(--accent); /* Thicker accent */
|
|
padding: 1.5rem !important;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.case-title {
|
|
font-weight: 800;
|
|
color: #1e293b;
|
|
font-size: 1.25rem;
|
|
letter-spacing: -0.5px;
|
|
}
|
|
|
|
.case-description-box {
|
|
background-color: white;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 6px;
|
|
padding: 1rem;
|
|
margin-top: 0.75rem;
|
|
font-size: 0.9rem;
|
|
color: #475569;
|
|
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
|
}
|
|
|
|
.case-meta {
|
|
font-size: 0.85rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.entry-row:hover {
|
|
background-color: #f8fafc;
|
|
}
|
|
|
|
/* Input controls */
|
|
.hours-input {
|
|
width: 80px;
|
|
text-align: center;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.description-cell {
|
|
max-width: 400px;
|
|
position: relative;
|
|
}
|
|
|
|
.description-text {
|
|
white-space: pre-wrap;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
/* Floating Action Bar */
|
|
.action-bar {
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
background: white;
|
|
padding: 1rem;
|
|
box-shadow: 0 -4px 10px rgba(0,0,0,0.1);
|
|
z-index: 1000;
|
|
transform: translateY(100%);
|
|
transition: transform 0.3s ease-in-out;
|
|
}
|
|
|
|
.action-bar.visible {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
/* Status Badges */
|
|
.badge-soft-warning {
|
|
background-color: rgba(255, 193, 7, 0.15);
|
|
color: #856404;
|
|
}
|
|
|
|
.badge-soft-success {
|
|
background-color: rgba(40, 167, 69, 0.15);
|
|
color: #155724;
|
|
}
|
|
|
|
/* Billable Toggle */
|
|
.billable-toggle {
|
|
cursor: pointer;
|
|
opacity: 0.5;
|
|
transition: opacity 0.2s;
|
|
}
|
|
|
|
.billable-toggle.active {
|
|
opacity: 1;
|
|
color: var(--success);
|
|
}
|
|
|
|
.billable-toggle:not(.active) {
|
|
color: var(--secondary);
|
|
}
|
|
|
|
/* Travel Toggle */
|
|
.travel-toggle {
|
|
cursor: pointer;
|
|
opacity: 0.5;
|
|
transition: opacity 0.2s;
|
|
color: #6c757d;
|
|
}
|
|
|
|
.travel-toggle.active {
|
|
opacity: 1;
|
|
color: #fd7e14; /* Orange for travel */
|
|
}
|
|
|
|
/* Animation */
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(10px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.animate-in {
|
|
animation: fadeIn 0.4s ease-out forwards;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid py-4 px-4 m-0" style="max-width: 1600px; margin: 0 auto;">
|
|
|
|
<!-- Header Area -->
|
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
<div>
|
|
<h1 class="mb-1">
|
|
<i class="bi bi-check-all text-primary"></i> Godkend Tider (V2)
|
|
</h1>
|
|
<p class="text-muted mb-0">Hurtig godkendelse af tidsregistreringer pr. kunde</p>
|
|
</div>
|
|
<div>
|
|
<div class="d-flex gap-2">
|
|
<select id="customer-select" class="form-select" style="min-width: 300px;" onchange="changeCustomer(this.value)">
|
|
<option value="">Vælg kunde...</option>
|
|
</select>
|
|
<a href="/timetracking" class="btn btn-outline-secondary">
|
|
<i class="bi bi-arrow-left"></i> Tilbage
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<div id="loading-container" class="text-center py-5">
|
|
<div class="spinner-border text-primary" role="status"></div>
|
|
<p class="mt-2 text-muted">Henter tidsregistreringer...</p>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div id="empty-state" class="d-none text-center py-5">
|
|
<div class="display-1 text-muted mb-3"><i class="bi bi-check-circle"></i></div>
|
|
<h3>Alt godkendt!</h3>
|
|
<p class="text-muted">Ingen afventende tidsregistreringer for denne kunde.</p>
|
|
<button class="btn btn-outline-primary mt-2" onclick="loadCustomerList()">Opdater liste</button>
|
|
</div>
|
|
|
|
<!-- Main Content -->
|
|
<div id="main-content" class="d-none animate-in">
|
|
|
|
<!-- Summary Card -->
|
|
<div class="card mb-4 border-0 shadow-sm bg-primary text-white">
|
|
<div class="card-body p-4">
|
|
<div class="row align-items-center">
|
|
<div class="col-md-6">
|
|
<h2 id="customer-name" class="fw-bold mb-1">-</h2>
|
|
<div class="d-flex gap-3 text-white-50">
|
|
<span><i class="bi bi-tag"></i> Timepris: <span id="hourly-rate" class="fw-bold text-white">-</span> DKK</span>
|
|
<span><i class="bi bi-clock"></i> Afventer: <span id="pending-count" class="fw-bold text-white">-</span> stk</span>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6 text-end">
|
|
<div class="display-6 fw-bold"><span id="total-value">0,00</span> DKK</div>
|
|
<div class="text-white-50">Total værdi til godkendelse</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Group Actions -->
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<div class="d-flex gap-2">
|
|
<button class="btn btn-outline-secondary btn-sm" onclick="expandAll()">Fold alt ud</button>
|
|
<button class="btn btn-outline-secondary btn-sm" onclick="collapseAll()">Fold alt sammen</button>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<button class="btn btn-danger" onclick="rejectSelected()">
|
|
<i class="bi bi-x-circle"></i> Afvis Valgte
|
|
</button>
|
|
<button class="btn btn-success" onclick="approveAll()">
|
|
<i class="bi bi-check-circle"></i> Godkend Alle
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Approval Table -->
|
|
<div id="entries-container">
|
|
<!-- Populated by JS -->
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sticky Action Bar -->
|
|
<div id="selection-bar" class="action-bar d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<span class="fw-bold"><span id="selected-count">0</span> valgte</span>
|
|
<span class="text-muted mx-2">|</span>
|
|
<span class="text-primary fw-bold"><span id="selected-value">0,00</span> DKK</span>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<button class="btn btn-outline-secondary" onclick="clearSelection()">Annuller</button>
|
|
<button class="btn btn-danger" onclick="rejectSelected()">Afvis</button>
|
|
<button class="btn btn-success" onclick="approveSelected()">Godkend</button>
|
|
</div>
|
|
</div>
|
|
<!-- Edit Modal -->
|
|
<div class="modal fade" id="editEntryModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Rediger Worklog</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<input type="hidden" id="editEntryId">
|
|
<div class="mb-3">
|
|
<label class="form-label">Beskrivelse</label>
|
|
<textarea class="form-control" id="editDescription" rows="4"></textarea>
|
|
</div>
|
|
<div class="row g-3 mb-3">
|
|
<div class="col-md-6">
|
|
<label class="form-label">Timer</label>
|
|
<input type="number" class="form-control" id="editHours" step="0.25" min="0">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hub Worklog Specific Fields -->
|
|
<div id="hubFields" class="d-none p-3 bg-light rounded mb-3">
|
|
<label class="form-label fw-bold">Hub Billing settings</label>
|
|
<select class="form-select" id="editBillingMethod">
|
|
<option value="invoice">Faktura</option>
|
|
<option value="internal">Intern / Ingen faktura</option>
|
|
<option value="warranty">Garanti</option>
|
|
<option value="unknown">❓ Ved ikke</option>
|
|
</select>
|
|
<div class="form-text mt-2">
|
|
<i class="bi bi-info-circle"></i> <strong>Note:</strong> Klippekort skal vælges direkte i ticket-detaljerne eller worklog review.
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Module Time Specific Fields -->
|
|
<div id="moduleFields" class="d-none">
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" id="editBillable">
|
|
<label class="form-check-label" for="editBillable">Fakturerbar</label>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
|
|
<button type="button" class="btn btn-primary" onclick="saveEntryChanges()">Gem ændringer</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
let currentCustomerId = new URLSearchParams(window.location.search).get('customer_id');
|
|
let currentTimeId = new URLSearchParams(window.location.search).get('time_id');
|
|
let currentCustomerData = null;
|
|
let customerList = [];
|
|
let pendingEntries = [];
|
|
let selectedEntries = new Set();
|
|
|
|
// Config
|
|
const DEFAULT_RATE = 1200;
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
await loadCustomerList();
|
|
|
|
// Support linking via Hub Customer ID
|
|
if (!currentCustomerId) {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const hubId = params.get('hub_id');
|
|
if (hubId) {
|
|
// Determine mapped customer from loaded list
|
|
const found = customerList.find(c => c.hub_customer_id == hubId);
|
|
if (found) {
|
|
currentCustomerId = found.customer_id;
|
|
window.history.replaceState({}, '', `?customer_id=${currentCustomerId}`);
|
|
|
|
// Update dropdown
|
|
const select = document.getElementById('customer-select');
|
|
if(select) select.value = currentCustomerId;
|
|
|
|
loadCustomerEntries(currentCustomerId);
|
|
}
|
|
}
|
|
} else {
|
|
loadCustomerEntries(currentCustomerId);
|
|
}
|
|
});
|
|
|
|
async function loadCustomerList() {
|
|
try {
|
|
// Fetch stats to know which customers have pending entries
|
|
const response = await fetch('/api/v1/timetracking/wizard/stats');
|
|
const stats = await response.json();
|
|
|
|
customerList = stats.filter(c => c.pending_count > 0);
|
|
|
|
const select = document.getElementById('customer-select');
|
|
select.innerHTML = '<option value="">Vælg kunde...</option>';
|
|
|
|
customerList.forEach(c => {
|
|
const option = document.createElement('option');
|
|
option.value = c.customer_id;
|
|
option.textContent = `${c.customer_name} (${c.pending_count})`;
|
|
if (parseInt(currentCustomerId) === c.customer_id) {
|
|
option.selected = true;
|
|
}
|
|
select.appendChild(option);
|
|
});
|
|
|
|
if (!currentCustomerId && customerList.length > 0) {
|
|
// Determine auto-select logic?
|
|
// For now, let user pick
|
|
} else if (!currentCustomerId) {
|
|
document.getElementById('loading-container').innerHTML = `
|
|
<div class="mt-5">
|
|
<i class="bi bi-check-circle-fill text-success display-1"></i>
|
|
<h3 class="mt-3">Alt er ajour!</h3>
|
|
<p class="text-muted">Ingen kunder afventer godkendelse lige nu.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading customers:', error);
|
|
}
|
|
}
|
|
|
|
function changeCustomer(customerId) {
|
|
if (!customerId) return;
|
|
window.history.pushState({}, '', `?customer_id=${customerId}`);
|
|
currentCustomerId = customerId;
|
|
loadCustomerEntries(customerId);
|
|
}
|
|
|
|
async function loadCustomerEntries(customerId) {
|
|
document.getElementById('loading-container').classList.remove('d-none');
|
|
document.getElementById('main-content').classList.add('d-none');
|
|
document.getElementById('empty-state').classList.add('d-none');
|
|
|
|
try {
|
|
// First get customer details for rate
|
|
const custResponse = await fetch('/api/v1/timetracking/wizard/stats');
|
|
const allStats = await custResponse.json();
|
|
currentCustomerData = allStats.find(c => c.customer_id == customerId);
|
|
|
|
if (!currentCustomerData) {
|
|
// Might happen if there are no pending stats but we force-navigated?
|
|
// Fallback fetch
|
|
}
|
|
|
|
// Fetch entries. Since we don't have a direct "get all pending for customer" endpoint,
|
|
// we might need to iterate or create a new endpoint.
|
|
// But wait, the existing wizard.html fetches entries ONE BY ONE or by case.
|
|
// We need a way to get ALL pending entries for a customer.
|
|
// Let's use the router endpoint: /api/v1/timetracking/customers/{id}/times (but filter for pending)
|
|
|
|
const timesResponse = await fetch(`/api/v1/timetracking/customers/${customerId}/times`);
|
|
const timesData = await timesResponse.json();
|
|
|
|
// Filter only pending
|
|
// The endpoint returns ALL times. We filter in JS for now.
|
|
pendingEntries = timesData.times.filter(t => t.status === 'pending');
|
|
|
|
if (pendingEntries.length === 0) {
|
|
document.getElementById('loading-container').classList.add('d-none');
|
|
document.getElementById('empty-state').classList.remove('d-none');
|
|
return;
|
|
}
|
|
|
|
// Organize by Case
|
|
renderEntries();
|
|
updateSummary();
|
|
|
|
document.getElementById('loading-container').classList.add('d-none');
|
|
document.getElementById('main-content').classList.remove('d-none');
|
|
|
|
} catch (error) {
|
|
console.error('Error loading entries:', error);
|
|
document.getElementById('loading-container').innerHTML =
|
|
`<div class="alert alert-danger">Fejl ved indlæsning: ${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
function renderEntries() {
|
|
const container = document.getElementById('entries-container');
|
|
container.innerHTML = '';
|
|
|
|
// Group by Case ID
|
|
const groups = {};
|
|
pendingEntries.forEach(entry => {
|
|
const caseId = entry.case_id || 'no_case';
|
|
if (!groups[caseId]) {
|
|
groups[caseId] = {
|
|
title: entry.case_title || 'Ingen Case / Diverse',
|
|
meta: entry, // store for header info
|
|
entries: []
|
|
};
|
|
}
|
|
groups[caseId].entries.push(entry);
|
|
});
|
|
|
|
// Render each group
|
|
Object.entries(groups).forEach(([caseId, group]) => {
|
|
const groupDiv = document.createElement('div');
|
|
groupDiv.className = 'approval-table mb-4 animate-in';
|
|
|
|
// Header
|
|
const meta = group.meta;
|
|
const caseInfo = [];
|
|
if (meta.case_type) caseInfo.push(`<span class="badge bg-light text-dark border">${meta.case_type}</span>`);
|
|
if (meta.case_priority) caseInfo.push(`<span class="badge bg-light text-dark border">${meta.case_priority}</span>`);
|
|
const contactName = getContactName(meta); // Helper needed?
|
|
|
|
const headerHtml = `
|
|
<div class="case-group-header p-3">
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div>
|
|
<div class="case-title d-flex align-items-center gap-2">
|
|
${caseId === 'no_case' ? '<i class="bi bi-person-workspace text-secondary"></i>' : '<i class="bi bi-folder-fill text-primary"></i>'}
|
|
${meta.case_vtiger_id ? `<a href="https://bmcnetworks.od2.vtiger.com/index.php?module=HelpDesk&view=Detail&record=${meta.case_vtiger_id.replace('39x', '')}" target="_blank" class="text-decoration-none text-dark">${group.title} <i class="bi bi-box-arrow-up-right small ms-1"></i></a>` : `<span>${group.title}</span>`}
|
|
<span class="badge bg-white text-dark border ms-2">${meta.case_type || 'Support'}</span>
|
|
<span class="badge ${getPriorityBadgeClass(meta.case_priority)} ms-1">${meta.case_priority || 'Normal'}</span>
|
|
</div>
|
|
|
|
<!-- Case Description -->
|
|
${meta.case_description ? `
|
|
<div class="case-description-box">
|
|
<div class="fw-bold text-dark mb-1" style="font-size: 0.8rem; text-transform: uppercase;">Opgavebeskrivelse</div>
|
|
${truncateText(stripHtml(meta.case_description), 300)}
|
|
</div>
|
|
` : ''}
|
|
|
|
<div class="case-meta mt-2 text-muted small">
|
|
${contactName ? `<i class="bi bi-person me-1"></i> ${contactName}` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="text-end">
|
|
<span class="badge bg-white text-primary border fs-6">${group.entries.length} poster</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Table
|
|
let rowsHtml = '';
|
|
group.entries.forEach(entry => {
|
|
rowsHtml += `
|
|
<tr class="entry-row" id="row-${entry.id}">
|
|
<td style="width: 40px;">
|
|
<input type="checkbox" class="form-check-input entry-checkbox"
|
|
data-case-id="${caseId}"
|
|
value="${entry.id}" onchange="toggleSelection(${entry.id})">
|
|
</td>
|
|
<td style="width: 100px; white-space: nowrap;">
|
|
<div class="fw-bold">${formatDate(entry.worked_date)}</div>
|
|
<div class="small text-muted">${entry.user_name || 'Ukendt'}</div>
|
|
</td>
|
|
<td class="description-cell">
|
|
<div class="description-text">${entry.description || '<em class="text-muted">Ingen beskrivelse</em>'}</div>
|
|
</td>
|
|
<td style="width: 100px;" class="text-center">
|
|
<i class="bi bi-check-circle-fill fs-4 billable-toggle ${entry.billable !== false ? 'active' : ''}"
|
|
onclick="toggleBillable(${entry.id})"
|
|
title="Fakturerbar?"></i>
|
|
</td>
|
|
<td style="width: 40px;" class="text-center">
|
|
<i class="bi bi-car-front fs-5 travel-toggle ${entry.is_travel ? 'active' : ''}"
|
|
onclick="toggleTravel(${entry.id})"
|
|
title="Kørsel?"></i>
|
|
</td>
|
|
<td style="width: 150px;">
|
|
<div class="small text-muted mb-1">Registreret: ${formatHoursMinutes(entry.original_hours)}</div>
|
|
<div class="input-group input-group-sm">
|
|
<input type="number" class="form-control hours-input"
|
|
id="hours-${entry.id}"
|
|
value="${roundUpToQuarter(entry.original_hours)}"
|
|
step="0.25" min="0.25"
|
|
onchange="updateRowTotal(${entry.id})">
|
|
<span class="input-group-text">t</span>
|
|
</div>
|
|
</td>
|
|
<td style="width: 120px;" class="text-end fw-bold">
|
|
<span id="total-${entry.id}">-</span>
|
|
</td>
|
|
<td style="width: 140px;" class="text-end">
|
|
<div class="btn-group btn-group-sm">
|
|
<button class="btn btn-outline-primary" onclick='openEditModal(${JSON.stringify(entry).replace(/'/g, "'")})' title="Rediger">
|
|
<i class="bi bi-pencil"></i>
|
|
</button>
|
|
<button class="btn btn-outline-success" onclick="approveOne(${entry.id})" title="Godkend">
|
|
<i class="bi bi-check-lg"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
|
|
groupDiv.innerHTML = `
|
|
|
|
${headerHtml}
|
|
<div class="table-responsive">
|
|
<table class="table mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th><input type="checkbox" class="form-check-input" onchange="toggleGroupSelection(this, '${caseId}')"></th>
|
|
<th>Dato</th>
|
|
<th>Beskrivelse</th>
|
|
<th class="text-center">Fakt.</th>
|
|
<th class="text-center">Kørsel</th>
|
|
<th>Timer</th>
|
|
<th class="text-end">Total (DKK)</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${rowsHtml}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
|
|
container.appendChild(groupDiv);
|
|
|
|
// Note: We used to update totals here, but that crashed updateSummary loop
|
|
// because not all groups were rendered yet.
|
|
});
|
|
|
|
// Init totals AFTER all rows are in the DOM
|
|
pendingEntries.forEach(e => updateRowTotal(e.id));
|
|
|
|
// Highlight specific time_id if present
|
|
if (currentTimeId) {
|
|
const row = document.getElementById(`row-${currentTimeId}`);
|
|
if (row) {
|
|
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
row.classList.add('table-warning'); // Bootstrap highlight
|
|
setTimeout(() => row.classList.remove('table-warning'), 3000);
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateSummary() {
|
|
const name = currentCustomerData?.customer_name || 'Kunde';
|
|
const rate = currentCustomerData?.customer_rate || DEFAULT_RATE;
|
|
|
|
document.getElementById('customer-name').textContent = name;
|
|
document.getElementById('hourly-rate').textContent = parseFloat(rate).toFixed(2);
|
|
document.getElementById('pending-count').textContent = pendingEntries.length;
|
|
|
|
let totalValue = 0;
|
|
pendingEntries.forEach(entry => {
|
|
const hoursInput = document.getElementById(`hours-${entry.id}`);
|
|
if (!hoursInput) return; // Skip if not rendered yet
|
|
|
|
const hours = parseFloat(hoursInput.value || entry.original_hours);
|
|
// Only count if billable
|
|
const toggle = document.querySelector(`#row-${entry.id} .billable-toggle`);
|
|
const isBillable = toggle && toggle.classList.contains('active');
|
|
|
|
if (isBillable) {
|
|
totalValue += hours * rate;
|
|
}
|
|
});
|
|
|
|
document.getElementById('total-value').textContent = totalValue.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
}
|
|
|
|
function updateRowTotal(entryId) {
|
|
const row = document.getElementById(`row-${entryId}`);
|
|
if (!row) return;
|
|
|
|
const rate = parseFloat(currentCustomerData?.customer_rate || DEFAULT_RATE);
|
|
const hoursInput = document.getElementById(`hours-${entryId}`);
|
|
const hours = parseFloat(hoursInput.value || 0);
|
|
|
|
const toggle = document.querySelector(`#row-${entryId} .billable-toggle`);
|
|
const isBillable = toggle && toggle.classList.contains('active');
|
|
|
|
const totalElem = document.getElementById(`total-${entryId}`);
|
|
|
|
if (isBillable) {
|
|
const total = hours * rate;
|
|
totalElem.textContent = total.toLocaleString('da-DK', { minimumFractionDigits: 2 });
|
|
totalElem.classList.remove('text-muted', 'text-decoration-line-through');
|
|
row.style.opacity = '1';
|
|
} else {
|
|
totalElem.textContent = '0,00';
|
|
totalElem.classList.add('text-muted', 'text-decoration-line-through');
|
|
// Visual feedback for non-billable
|
|
row.style.opacity = '0.7';
|
|
}
|
|
|
|
updateSummary();
|
|
updateSelectionBar();
|
|
}
|
|
|
|
function toggleBillable(entryId) {
|
|
const toggle = document.querySelector(`#row-${entryId} .billable-toggle`);
|
|
toggle.classList.toggle('active');
|
|
updateRowTotal(entryId);
|
|
}
|
|
|
|
function toggleTravel(entryId) {
|
|
const toggle = document.querySelector(`#row-${entryId} .travel-toggle`);
|
|
toggle.classList.toggle('active');
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '';
|
|
const d = new Date(dateStr);
|
|
return d.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit' });
|
|
}
|
|
|
|
function formatHoursMinutes(decimalHours) {
|
|
if (!decimalHours) return '0t 0m';
|
|
const hours = Math.floor(decimalHours);
|
|
const minutes = Math.floor((decimalHours - hours) * 60);
|
|
if (hours === 0) {
|
|
return `${minutes}m`;
|
|
} else if (minutes === 0) {
|
|
return `${hours}t`;
|
|
} else {
|
|
return `${hours}t ${minutes}m`;
|
|
}
|
|
}
|
|
|
|
function roundUpToQuarter(hours) {
|
|
// Round up to nearest 0.5 (30 minutes)
|
|
return Math.ceil(hours * 2) / 2;
|
|
}
|
|
|
|
function getContactName(meta) {
|
|
// vtiger data extraction if needed
|
|
return null;
|
|
}
|
|
|
|
// --- Selection Logic ---
|
|
function getPriorityBadgeClass(priority) {
|
|
if (!priority) return 'bg-light text-dark border';
|
|
const p = priority.toLowerCase();
|
|
if (p.includes('høj') || p.includes('urgent') || p.includes('high') || p.includes('critical')) return 'bg-danger text-white';
|
|
if (p.includes('mellem') || p.includes('medium')) return 'bg-warning text-dark';
|
|
if (p.includes('lav') || p.includes('low')) return 'bg-success text-white';
|
|
return 'bg-light text-dark border';
|
|
}
|
|
|
|
function stripHtml(html) {
|
|
if (!html) return '';
|
|
const tmp = document.createElement("DIV");
|
|
tmp.innerHTML = html;
|
|
return tmp.textContent || tmp.innerText || "";
|
|
}
|
|
|
|
function truncateText(text, length) {
|
|
if (!text) return '';
|
|
if (text.length <= length) return text;
|
|
return text.substring(0, length) + '...';
|
|
}
|
|
|
|
function toggleSelection(entryId) {
|
|
if (selectedEntries.has(entryId)) {
|
|
selectedEntries.delete(entryId);
|
|
} else {
|
|
selectedEntries.add(entryId);
|
|
}
|
|
updateSelectionBar();
|
|
}
|
|
|
|
function toggleGroupSelection(checkbox, caseId) {
|
|
const checkboxes = document.querySelectorAll(`.entry-checkbox[data-case-id="${caseId}"]`);
|
|
checkboxes.forEach(cb => {
|
|
if (cb.checked !== checkbox.checked) {
|
|
cb.checked = checkbox.checked;
|
|
// Update selection set via toggleSelection logic
|
|
// Since toggleSelection expects the ID and relies on current state,
|
|
// we can just call it if the state mismatch.
|
|
// However, toggleSelection toggles based on set presence.
|
|
// It's safer to manually manipulate the set here.
|
|
const id = parseInt(cb.value);
|
|
if (checkbox.checked) {
|
|
selectedEntries.add(id);
|
|
} else {
|
|
selectedEntries.delete(id);
|
|
}
|
|
}
|
|
});
|
|
updateSelectionBar();
|
|
}
|
|
|
|
function updateSelectionBar() {
|
|
const bar = document.getElementById('selection-bar');
|
|
const count = selectedEntries.size;
|
|
|
|
if (count > 0) {
|
|
bar.classList.add('visible');
|
|
document.getElementById('selected-count').textContent = count;
|
|
|
|
// Calc value of selection
|
|
let val = 0;
|
|
const rate = parseFloat(currentCustomerData?.customer_rate || DEFAULT_RATE);
|
|
selectedEntries.forEach(id => {
|
|
const hours = parseFloat(document.getElementById(`hours-${id}`).value || 0);
|
|
const isBillable = document.querySelector(`#row-${id} .billable-toggle`).classList.contains('active');
|
|
if (isBillable) val += hours * rate;
|
|
});
|
|
document.getElementById('selected-value').textContent = val.toLocaleString('da-DK', {minimumFractionDigits: 2});
|
|
|
|
} else {
|
|
bar.classList.remove('visible');
|
|
}
|
|
}
|
|
|
|
function clearSelection() {
|
|
selectedEntries.clear();
|
|
document.querySelectorAll('.entry-checkbox').forEach(cb => cb.checked = false);
|
|
updateSelectionBar();
|
|
}
|
|
|
|
// --- Actions ---
|
|
async function approveOne(entryId) {
|
|
await processApproval([entryId]);
|
|
}
|
|
|
|
async function approveSelected() {
|
|
await processApproval(Array.from(selectedEntries));
|
|
}
|
|
|
|
async function approveAll() {
|
|
const allIds = pendingEntries.map(e => e.id);
|
|
if (confirm(`Er du sikker på du vil godkende alle ${allIds.length} tidsregistreringer?`)) {
|
|
await processApproval(allIds);
|
|
}
|
|
}
|
|
|
|
async function processApproval(ids) {
|
|
// Prepare payload with current values (hours, billable state)
|
|
const items = ids.map(id => {
|
|
return {
|
|
id: id,
|
|
billable_hours: parseFloat(document.getElementById(`hours-${id}`).value),
|
|
hourly_rate: currentCustomerData?.customer_rate || DEFAULT_RATE,
|
|
billable: document.querySelector(`#row-${id} .billable-toggle`).classList.contains('active'),
|
|
is_travel: document.querySelector(`#row-${id} .travel-toggle`).classList.contains('active')
|
|
};
|
|
});
|
|
|
|
// We accept list approval via a loop or new bulk endpoint.
|
|
// Let's loop for now to reuse existing endpoint or create a bulk one.
|
|
// It's safer to implement a bulk endpoint in backend, but for speed let's iterate.
|
|
// Actually, let's just make a specialized bulk endpoint or reuse the loop in JS
|
|
|
|
let successCount = 0;
|
|
|
|
// Show loading overlay
|
|
document.getElementById('loading-container').classList.remove('d-none');
|
|
document.getElementById('loading-container').innerHTML = '<div class="spinner-border text-primary"></div><p>Behandler godkendelser...</p>';
|
|
document.getElementById('main-content').classList.add('d-none');
|
|
|
|
try {
|
|
for (const item of items) {
|
|
await fetch(`/api/v1/timetracking/wizard/approve/${item.id}`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(item)
|
|
});
|
|
successCount++;
|
|
}
|
|
|
|
// Reload
|
|
loadCustomerEntries(currentCustomerId);
|
|
// Also refresh stats
|
|
loadCustomerList();
|
|
clearSelection();
|
|
|
|
} catch (e) {
|
|
alert('Fejl under godkendelse: ' + e.message);
|
|
loadCustomerEntries(currentCustomerId); // Refresh anyway
|
|
}
|
|
}
|
|
|
|
async function rejectSelected() {
|
|
const ids = Array.from(selectedEntries);
|
|
if (ids.length === 0) return;
|
|
|
|
const note = prompt("Begrundelse for afvisning:");
|
|
if (note === null) return;
|
|
|
|
// Loop reject
|
|
for (const id of ids) {
|
|
await fetch(`/api/v1/timetracking/wizard/reject/${id}`, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ rejection_note: note })
|
|
});
|
|
}
|
|
|
|
loadCustomerEntries(currentCustomerId);
|
|
clearSelection();
|
|
}
|
|
|
|
// --- Edit Modal Logic ---
|
|
let editModal = null;
|
|
|
|
function openEditModal(entry) {
|
|
if (!editModal) {
|
|
editModal = new bootstrap.Modal(document.getElementById('editEntryModal'));
|
|
}
|
|
|
|
document.getElementById('editEntryId').value = entry.id;
|
|
document.getElementById('editDescription').value = entry.description || '';
|
|
document.getElementById('editHours').value = entry.original_hours;
|
|
|
|
const hubFields = document.getElementById('hubFields');
|
|
const moduleFields = document.getElementById('moduleFields');
|
|
|
|
if (entry.id < 0) {
|
|
// Hub Worklog
|
|
hubFields.classList.remove('d-none');
|
|
moduleFields.classList.add('d-none');
|
|
// Assuming _billing_method was passed via backend view logic
|
|
const billing = entry._billing_method || 'invoice';
|
|
document.getElementById('editBillingMethod').value = billing;
|
|
} else {
|
|
// Module Time
|
|
hubFields.classList.add('d-none');
|
|
moduleFields.classList.remove('d-none');
|
|
document.getElementById('editBillable').checked = entry.billable !== false;
|
|
}
|
|
|
|
editModal.show();
|
|
}
|
|
|
|
async function saveEntryChanges() {
|
|
const id = document.getElementById('editEntryId').value;
|
|
const desc = document.getElementById('editDescription').value;
|
|
const hours = document.getElementById('editHours').value;
|
|
|
|
const payload = {
|
|
description: desc,
|
|
original_hours: parseFloat(hours)
|
|
};
|
|
|
|
if (parseInt(id) < 0) {
|
|
payload.billing_method = document.getElementById('editBillingMethod').value;
|
|
} else {
|
|
payload.billable = document.getElementById('editBillable').checked;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/v1/timetracking/wizard/entry/${id}`, {
|
|
method: 'PATCH',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (!res.ok) throw new Error("Update failed");
|
|
|
|
editModal.hide();
|
|
// Reload EVERYTHING because changing hours/billing affects sums
|
|
loadCustomerEntries(currentCustomerId);
|
|
|
|
} catch (e) {
|
|
alert("Kunne ikke gemme: " + e.message);
|
|
}
|
|
}
|
|
</script>
|
|
{% endblock %}
|