bmc_hub/app/timetracking/frontend/wizard2.html

925 lines
36 KiB
HTML
Raw Normal View History

{% 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, "&#39;")})' 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 %}