bmc_hub/app/timetracking/frontend/customers.html

968 lines
44 KiB
HTML
Raw Normal View History

{% extends "shared/frontend/base.html" %}
{% block title %}Kunde Timepriser - BMC Hub{% endblock %}
{% block extra_css %}
<style>
/* Page specific styles */
.table-hover tbody tr:hover {
background-color: var(--accent-light);
cursor: pointer;
}
.rate-input {
width: 150px;
text-align: right;
}
.editable-row {
transition: all 0.3s;
}
.editable-row.editing {
background-color: #fff3cd !important;
}
.badge-rate {
font-size: 0.9rem;
padding: 0.4rem 0.8rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container py-4">
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="mb-1">
<i class="bi bi-building text-primary"></i> Kunde Timepriser
</h1>
<p class="text-muted mb-0">Administrer timepriser for kunder</p>
</div>
<div>
<span class="badge bg-info badge-rate">
<i class="bi bi-cash"></i> Standard: <span id="default-rate">850</span> DKK/time
</span>
</div>
</div>
</div>
</div>
<!-- Stats Cards -->
<div class="row mb-4" id="stats-cards">
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">Total Kunder</h6>
<h3 class="mb-0" id="total-customers">-</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">Custom Priser</h6>
<h3 class="mb-0" id="custom-rates">-</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">Standard Priser</h6>
<h3 class="mb-0" id="standard-rates">-</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h6 class="text-muted mb-2">Gennemsnitspris</h6>
<h3 class="mb-0" id="avg-rate">-</h3>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="row mb-3">
<div class="col-md-4">
<input type="text" class="form-control" id="search-input" placeholder="🔍 Søg kunde..." onkeyup="filterTable()">
</div>
<div class="col-md-4">
<select class="form-select" id="filter-select" onchange="filterTable()">
<option value="all">Alle kunder</option>
<option value="custom">Kun custom priser</option>
<option value="standard">Kun standard priser</option>
</select>
</div>
<div class="col-md-4">
<button class="btn btn-primary w-100" id="bulk-price-btn" onclick="openBulkPriceModal()" disabled>
<i class="bi bi-tag"></i> Opdater pris (<span id="selected-count">0</span> valgt)
</button>
</div>
</div>
<!-- Customers Table -->
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover" id="customers-table">
<thead>
<tr>
<th style="width: 40px;">
<input type="checkbox" class="form-check-input" id="select-all" onchange="toggleSelectAll()">
</th>
<th>Kunde</th>
<th>vTiger ID</th>
<th class="text-end">Timepris (DKK)</th>
<th class="text-center">Status</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="customers-tbody">
<tr>
<td colspan="5" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Time Entries Modal -->
<div class="modal fade" id="timeEntriesModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-clock-history"></i> Tidsregistreringer - <span id="modal-customer-name"></span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="time-entries-loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="mt-2">Indlæser tidsregistreringer...</p>
</div>
<div id="time-entries-content" class="d-none">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Case</th>
<th>Dato</th>
<th>Timer</th>
<th>Status</th>
<th>Udført af</th>
<th>Handlinger</th>
</tr>
</thead>
<tbody id="time-entries-tbody"></tbody>
</table>
</div>
</div>
<div id="time-entries-empty" class="alert alert-info d-none">
Ingen tidsregistreringer fundet for denne kunde
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
</div>
</div>
</div>
</div>
<!-- Create Order Modal -->
<div class="modal fade" id="createOrderModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-plus-circle"></i> Opret ordre - <span id="order-customer-name"></span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="order-loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="mt-2">Henter godkendte tidsregistreringer...</p>
</div>
<div id="order-content" class="d-none">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> Denne handling vil oprette en ordre for <strong>alle godkendte</strong> tidsregistreringer for denne kunde.
</div>
<div id="order-summary" class="mb-3"></div>
</div>
<div id="order-empty" class="alert alert-warning d-none">
<i class="bi bi-exclamation-triangle"></i> Ingen godkendte tidsregistreringer fundet for denne kunde
</div>
<div id="order-creating" class="text-center py-5 d-none">
<div class="spinner-border text-success" role="status"></div>
<p class="mt-2">Opretter ordre...</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-success" id="confirm-create-order" disabled>
<i class="bi bi-check-circle"></i> Opret ordre
</button>
</div>
</div>
</div>
</div>
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Bulk Price Update Modal -->
<div class="modal fade" id="bulkPriceModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-tag"></i> Opdater timepris for flere kunder
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> Du har valgt <strong><span id="bulk-customer-count">0</span> kunder</strong>
</div>
<div class="mb-3">
<label for="bulk-price-input" class="form-label">Ny timepris (DKK)</label>
<input type="number" class="form-control" id="bulk-price-input"
min="0" step="50" placeholder="f.eks. 1200">
<div class="form-text">Indtast ny timepris for alle valgte kunder</div>
</div>
<div id="bulk-selected-customers" class="mt-3">
<strong>Valgte kunder:</strong>
<ul id="bulk-customer-list" class="mt-2" style="max-height: 200px; overflow-y: auto;"></ul>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="updateBulkPrices()">
<i class="bi bi-check-circle"></i> Opdater priser
</button>
</div>
</div>
</div>
</div>
<script>
let allCustomers = [];
let defaultRate = 850.00; // Fallback værdi
let selectedCustomers = new Set(); // Track selected customer IDs
// Load customers on page load
document.addEventListener('DOMContentLoaded', () => {
loadConfig();
loadCustomers();
});
// Load configuration
async function loadConfig() {
try {
const response = await fetch('/api/v1/timetracking/config');
if (response.ok) {
const config = await response.json();
defaultRate = config.default_hourly_rate;
document.getElementById('default-rate').textContent = defaultRate.toFixed(2);
}
} catch (error) {
console.warn('Failed to load config, using fallback rate:', error);
}
}
// Load all customers
async function loadCustomers() {
try {
const response = await fetch('/api/v1/timetracking/customers?include_time_card=true');
if (!response.ok) throw new Error('Failed to load customers');
const data = await response.json();
allCustomers = data.customers || [];
renderTable();
updateStats();
} catch (error) {
console.error('Error loading customers:', error);
document.getElementById('customers-tbody').innerHTML = `
<tr><td colspan="5" class="text-center text-danger">Fejl ved indlæsning: ${error.message}</td></tr>
`;
}
}
// Render table
function renderTable(filteredCustomers = null) {
const customers = filteredCustomers || allCustomers;
const tbody = document.getElementById('customers-tbody');
if (customers.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-4">Ingen kunder fundet</td></tr>';
return;
}
tbody.innerHTML = customers.map(customer => {
const rate = customer.hourly_rate || defaultRate;
const isCustom = customer.hourly_rate !== null;
const statusBadge = isCustom
? '<span class="badge bg-primary">Custom</span>'
: '<span class="badge bg-secondary">Standard</span>';
const isChecked = selectedCustomers.has(customer.id) ? 'checked' : '';
return `
<tr class="editable-row" id="row-${customer.id}">
<td>
<input type="checkbox" class="form-check-input customer-checkbox"
data-customer-id="${customer.id}"
data-customer-name="${customer.name.replace(/'/g, "\\'")}"
onchange="toggleCustomerSelection(${customer.id})"
${isChecked}>
</td>
<td style="cursor: pointer;" onclick="viewTimeEntries(${customer.id}, '${customer.name.replace(/'/g, "\\'")}')">
<strong>${customer.name}</strong>
${customer.uses_time_card ? '<span class="badge bg-warning text-dark ms-2">Klippekort</span>' : ''}
</td>
<td><small class="text-muted">${customer.vtiger_id || '-'}</small></td>
<td class="text-end">
<span class="rate-display" id="rate-display-${customer.id}">${rate.toFixed(2)}</span>
<input type="number" class="form-control rate-input d-none"
id="rate-input-${customer.id}"
value="${rate}"
step="50"
min="0">
</td>
<td class="text-center">${statusBadge}</td>
<td class="text-end">
<button class="btn btn-sm btn-success me-1"
onclick="createOrderForCustomer(${customer.id}, '${customer.name.replace(/'/g, "\\'")}')">
<i class="bi bi-plus-circle"></i> Ordre
</button>
<button class="btn btn-sm btn-info me-1"
onclick="viewTimeEntries(${customer.id}, '${customer.name.replace(/'/g, "\\'")}')">
<i class="bi bi-clock-history"></i>
</button>
<button class="btn btn-sm btn-primary edit-btn"
id="edit-btn-${customer.id}"
onclick="editRate(${customer.id})">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-success save-btn d-none"
id="save-btn-${customer.id}"
onclick="saveRate(${customer.id})">
<i class="bi bi-check"></i> Gem
</button>
<button class="btn btn-sm btn-secondary cancel-btn d-none"
id="cancel-btn-${customer.id}"
onclick="cancelEdit(${customer.id})">
<i class="bi bi-x"></i> Annuller
</button>
${isCustom ? `
<button class="btn btn-sm btn-outline-danger"
onclick="resetToDefault(${customer.id})">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
` : ''}
</td>
</tr>
`;
}).join('');
}
// Edit rate
function editRate(customerId) {
const row = document.getElementById(`row-${customerId}`);
row.classList.add('editing');
document.getElementById(`rate-display-${customerId}`).classList.add('d-none');
document.getElementById(`rate-input-${customerId}`).classList.remove('d-none');
document.getElementById(`rate-input-${customerId}`).focus();
document.getElementById(`edit-btn-${customerId}`).classList.add('d-none');
document.getElementById(`save-btn-${customerId}`).classList.remove('d-none');
document.getElementById(`cancel-btn-${customerId}`).classList.remove('d-none');
}
// Cancel edit
function cancelEdit(customerId) {
const row = document.getElementById(`row-${customerId}`);
row.classList.remove('editing');
const customer = allCustomers.find(c => c.id === customerId);
const originalRate = customer.hourly_rate || defaultRate;
document.getElementById(`rate-input-${customerId}`).value = originalRate;
document.getElementById(`rate-display-${customerId}`).classList.remove('d-none');
document.getElementById(`rate-input-${customerId}`).classList.add('d-none');
document.getElementById(`edit-btn-${customerId}`).classList.remove('d-none');
document.getElementById(`save-btn-${customerId}`).classList.add('d-none');
document.getElementById(`cancel-btn-${customerId}`).classList.add('d-none');
}
// Save rate
async function saveRate(customerId) {
const newRate = parseFloat(document.getElementById(`rate-input-${customerId}`).value);
if (newRate < 0) {
alert('Timepris kan ikke være negativ');
return;
}
try {
const response = await fetch(`/api/v1/timetracking/customers/${customerId}/hourly-rate?hourly_rate=${newRate}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'}
});
if (!response.ok) throw new Error('Failed to update rate');
const result = await response.json();
// Update local data
const customer = allCustomers.find(c => c.id === customerId);
customer.hourly_rate = newRate;
// Update display
document.getElementById(`rate-display-${customerId}`).textContent = newRate.toFixed(2);
cancelEdit(customerId);
// Reload to update badges
await loadCustomers();
// Show success message
showToast('✅ Timepris opdateret', 'success');
} catch (error) {
console.error('Error saving rate:', error);
alert('Fejl ved opdatering: ' + error.message);
}
}
// Reset to default
async function resetToDefault(customerId) {
if (!confirm('Nulstil til standard timepris?')) return;
try {
const response = await fetch(`/api/v1/timetracking/customers/${customerId}/hourly-rate?hourly_rate=${defaultRate}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'}
});
if (!response.ok) throw new Error('Failed to reset rate');
// Update local data
const customer = allCustomers.find(c => c.id === customerId);
customer.hourly_rate = null; // NULL = uses default
await loadCustomers();
showToast('✅ Nulstillet til standard', 'success');
} catch (error) {
console.error('Error resetting rate:', error);
alert('Fejl ved nulstilling: ' + error.message);
}
}
// Filter table
function filterTable() {
const searchTerm = document.getElementById('search-input').value.toLowerCase();
const filterType = document.getElementById('filter-select').value;
let filtered = allCustomers.filter(customer => {
const matchesSearch = customer.name.toLowerCase().includes(searchTerm);
let matchesFilter = true;
if (filterType === 'custom') {
matchesFilter = customer.hourly_rate !== null;
} else if (filterType === 'standard') {
matchesFilter = customer.hourly_rate === null;
}
return matchesSearch && matchesFilter;
});
renderTable(filtered);
}
// Update stats
function updateStats() {
const total = allCustomers.length;
const customRates = allCustomers.filter(c => c.hourly_rate !== null).length;
const standardRates = total - customRates;
const rates = allCustomers.map(c => c.hourly_rate || defaultRate);
const avgRate = rates.reduce((sum, r) => sum + r, 0) / total;
document.getElementById('total-customers').textContent = total;
document.getElementById('custom-rates').textContent = customRates;
document.getElementById('standard-rates').textContent = standardRates;
document.getElementById('avg-rate').textContent = avgRate.toFixed(2) + ' DKK';
}
// Toast notification
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `alert alert-${type} position-fixed top-0 end-0 m-3`;
toast.style.zIndex = 9999;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// Store current customer ID for modal actions
let currentModalCustomerId = null;
// View time entries for customer
async function viewTimeEntries(customerId, customerName) {
currentModalCustomerId = customerId;
document.getElementById('modal-customer-name').textContent = customerName;
document.getElementById('time-entries-loading').classList.remove('d-none');
document.getElementById('time-entries-content').classList.add('d-none');
document.getElementById('time-entries-empty').classList.add('d-none');
const modal = new bootstrap.Modal(document.getElementById('timeEntriesModal'));
modal.show();
try {
const response = await fetch(`/api/v1/timetracking/customers/${customerId}/times`);
if (!response.ok) throw new Error('Failed to load time entries');
const data = await response.json();
const entries = data.times || [];
document.getElementById('time-entries-loading').classList.add('d-none');
if (entries.length === 0) {
document.getElementById('time-entries-empty').classList.remove('d-none');
return;
}
const tbody = document.getElementById('time-entries-tbody');
tbody.innerHTML = entries.map(entry => {
const date = new Date(entry.worked_date).toLocaleDateString('da-DK');
const statusBadge = {
'pending': '<span class="badge bg-warning">Afventer</span>',
'approved': '<span class="badge bg-success">Godkendt</span>',
'rejected': '<span class="badge bg-danger">Afvist</span>',
'billed': '<span class="badge bg-info">Faktureret</span>'
}[entry.status] || entry.status;
// Build case link
let caseLink = entry.case_title || 'Ingen case';
if (entry.case_vtiger_id) {
const recordId = entry.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">
${entry.case_title || 'Case'} <i class="bi bi-box-arrow-up-right"></i>
</a>`;
}
return `
<tr>
<td>${caseLink}</td>
<td>${date}</td>
<td>
<strong>${entry.original_hours}t</strong>
${entry.approved_hours && entry.status === 'approved' ? `
<br><small class="text-muted">
Oprundet: <strong>${entry.approved_hours}t</strong>
${entry.rounded_to ? ` (${entry.rounded_to}t)` : ''}
</small>
` : ''}
</td>
<td>${statusBadge}</td>
<td>${entry.user_name || 'Ukendt'}</td>
<td>
${entry.status === 'pending' ? `
<a href="/timetracking/wizard?customer_id=${currentModalCustomerId}&time_id=${entry.id}" class="btn btn-sm btn-success">
<i class="bi bi-check"></i> Godkend
</a>
` : ''}
${entry.status === 'approved' && !entry.billed ? `
<button class="btn btn-sm btn-outline-danger" onclick="resetTimeEntry(${entry.id})">
<i class="bi bi-arrow-counterclockwise"></i> Nulstil
</button>
` : ''}
</td>
</tr>
`;
}).join('');
document.getElementById('time-entries-content').classList.remove('d-none');
} catch (error) {
console.error('Error loading time entries:', error);
document.getElementById('time-entries-loading').classList.add('d-none');
showToast('Fejl ved indlæsning af tidsregistreringer', 'danger');
modal.hide();
}
}
// Approve time entry
async function approveTimeEntry(timeId) {
if (!confirm('Godkend denne tidsregistrering?')) return;
try {
const response = await fetch(`/api/v1/timetracking/wizard/approve/${timeId}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
if (!response.ok) throw new Error('Failed to approve');
showToast('✅ Tidsregistrering godkendt', 'success');
// Reload modal content
const modalCustomerId = document.getElementById('modal-customer-name').textContent;
const customer = allCustomers.find(c => c.name === modalCustomerId);
if (customer) {
viewTimeEntries(customer.id, customer.name);
}
} catch (error) {
console.error('Error approving:', error);
showToast('Fejl ved godkendelse', 'danger');
}
}
// Reset time entry back to pending
async function resetTimeEntry(timeId) {
if (!confirm('Nulstil denne tidsregistrering tilbage til pending?\n\nDen vil blive sat tilbage i godkendelses-køen.')) return;
try {
const response = await fetch(`/api/v1/timetracking/wizard/reset/${timeId}?reason=${encodeURIComponent('Reset til pending')}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to reset');
}
showToast('✅ Tidsregistrering nulstillet til pending', 'success');
// Reload modal content
const modalCustomerId = document.getElementById('modal-customer-name').textContent;
const customer = allCustomers.find(c => c.name === modalCustomerId);
if (customer) {
viewTimeEntries(customer.id, customer.name);
}
} catch (error) {
console.error('Error resetting:', error);
showToast('Fejl ved nulstilling', 'danger');
}
}
// Create order for customer
let currentOrderCustomerId = null;
async function createOrderForCustomer(customerId, customerName) {
currentOrderCustomerId = customerId;
document.getElementById('order-customer-name').textContent = customerName;
document.getElementById('order-loading').classList.remove('d-none');
document.getElementById('order-content').classList.add('d-none');
document.getElementById('order-empty').classList.add('d-none');
document.getElementById('order-creating').classList.add('d-none');
document.getElementById('confirm-create-order').disabled = true;
const modal = new bootstrap.Modal(document.getElementById('createOrderModal'));
modal.show();
try {
// Fetch customer's approved time entries
const response = await fetch(`/api/v1/timetracking/customers/${customerId}/times`);
if (!response.ok) throw new Error('Failed to load time entries');
const data = await response.json();
// Filter for approved and billable entries
const approvedEntries = (data.times || []).filter(entry =>
entry.status === 'approved' && entry.billable !== false
);
document.getElementById('order-loading').classList.add('d-none');
if (approvedEntries.length === 0) {
document.getElementById('order-empty').classList.remove('d-none');
return;
}
// Build summary
const totalHours = approvedEntries.reduce((sum, entry) =>
sum + parseFloat(entry.approved_hours || entry.original_hours || 0), 0
);
const customer = allCustomers.find(c => c.id === customerId);
const hourlyRate = customer?.hourly_rate || defaultRate;
const subtotal = totalHours * hourlyRate;
const vat = subtotal * 0.25;
const total = subtotal + vat;
// Group by case
const caseGroups = {};
approvedEntries.forEach(entry => {
const caseId = entry.case_id || 'no_case';
if (!caseGroups[caseId]) {
caseGroups[caseId] = {
title: entry.case_title || 'Ingen case',
entries: []
};
}
caseGroups[caseId].entries.push(entry);
});
const summaryHtml = `
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title mb-3">Ordre oversigt</h6>
<div class="row mb-3">
<div class="col-6">
<strong>Antal godkendte tider:</strong>
</div>
<div class="col-6 text-end">
${approvedEntries.length} stk
</div>
</div>
<div class="row mb-3">
<div class="col-6">
<strong>Total timer:</strong>
</div>
<div class="col-6 text-end">
${totalHours.toFixed(2)} timer
</div>
</div>
<div class="row mb-3">
<div class="col-6">
<strong>Timepris:</strong>
</div>
<div class="col-6 text-end">
${hourlyRate.toFixed(2)} DKK
</div>
</div>
<hr>
<div class="row mb-2">
<div class="col-6">
<strong>Subtotal (ekskl. moms):</strong>
</div>
<div class="col-6 text-end">
${subtotal.toFixed(2)} DKK
</div>
</div>
<div class="row mb-2">
<div class="col-6">
Moms (25%):
</div>
<div class="col-6 text-end">
${vat.toFixed(2)} DKK
</div>
</div>
<div class="row">
<div class="col-6">
<strong>Total (inkl. moms):</strong>
</div>
<div class="col-6 text-end">
<strong>${total.toFixed(2)} DKK</strong>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<h6 class="card-title mb-3">Cases inkluderet</h6>
${Object.entries(caseGroups).map(([caseId, group]) => `
<div class="mb-2">
<strong>${group.title}</strong>
<span class="badge bg-secondary">${group.entries.length} tidsregistreringer</span>
<span class="badge bg-info">${group.entries.reduce((sum, e) => sum + parseFloat(e.approved_hours || e.original_hours || 0), 0).toFixed(2)} timer</span>
</div>
`).join('')}
</div>
</div>
`;
document.getElementById('order-summary').innerHTML = summaryHtml;
document.getElementById('order-content').classList.remove('d-none');
document.getElementById('confirm-create-order').disabled = false;
} catch (error) {
console.error('Error loading order preview:', error);
document.getElementById('order-loading').classList.add('d-none');
showToast('Fejl ved indlæsning af ordre forhåndsvisning', 'danger');
modal.hide();
}
}
// Confirm order creation
document.getElementById('confirm-create-order')?.addEventListener('click', async function() {
if (!currentOrderCustomerId) return;
// Hide summary, show creating state
document.getElementById('order-content').classList.add('d-none');
document.getElementById('order-creating').classList.remove('d-none');
this.disabled = true;
try {
const response = await fetch(`/api/v1/timetracking/orders/generate/${currentOrderCustomerId}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to create order');
}
const order = await response.json();
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('createOrderModal'));
modal.hide();
// Show success and redirect
showToast(`✅ Ordre ${order.order_number} oprettet!`, 'success');
// Reload customers to update stats
await loadCustomers();
// Redirect to order detail after 1 second
setTimeout(() => {
window.location.href = `/timetracking/orders?order_id=${order.id}`;
}, 1500);
} catch (error) {
console.error('Error creating order:', error);
document.getElementById('order-creating').classList.add('d-none');
document.getElementById('order-content').classList.remove('d-none');
this.disabled = false;
showToast(`Fejl ved oprettelse af ordre: ${error.message}`, 'danger');
}
});
// Bulk selection functions
function toggleCustomerSelection(customerId) {
if (selectedCustomers.has(customerId)) {
selectedCustomers.delete(customerId);
} else {
selectedCustomers.add(customerId);
}
updateBulkUI();
}
function toggleSelectAll() {
const selectAllCheckbox = document.getElementById('select-all');
const checkboxes = document.querySelectorAll('.customer-checkbox');
if (selectAllCheckbox.checked) {
checkboxes.forEach(cb => {
selectedCustomers.add(parseInt(cb.dataset.customerId));
cb.checked = true;
});
} else {
selectedCustomers.clear();
checkboxes.forEach(cb => cb.checked = false);
}
updateBulkUI();
}
function updateBulkUI() {
const count = selectedCustomers.size;
document.getElementById('selected-count').textContent = count;
document.getElementById('bulk-price-btn').disabled = count === 0;
// Update select-all checkbox state
const totalVisible = document.querySelectorAll('.customer-checkbox').length;
const selectAllCheckbox = document.getElementById('select-all');
if (selectAllCheckbox) {
selectAllCheckbox.checked = count > 0 && count === totalVisible;
selectAllCheckbox.indeterminate = count > 0 && count < totalVisible;
}
}
function openBulkPriceModal() {
if (selectedCustomers.size === 0) return;
// Update customer count
document.getElementById('bulk-customer-count').textContent = selectedCustomers.size;
// Build list of selected customers
const customerList = document.getElementById('bulk-customer-list');
const selectedCustomerData = allCustomers.filter(c => selectedCustomers.has(c.id));
customerList.innerHTML = selectedCustomerData.map(customer =>
`<li>${customer.name} (nuværende: ${(customer.hourly_rate || defaultRate).toFixed(2)} DKK)</li>`
).join('');
// Clear previous input
document.getElementById('bulk-price-input').value = '';
// Show modal
const modal = new bootstrap.Modal(document.getElementById('bulkPriceModal'));
modal.show();
}
async function updateBulkPrices() {
const newPrice = parseFloat(document.getElementById('bulk-price-input').value);
if (!newPrice || newPrice < 0) {
showToast('Indtast venligst en gyldig pris', 'warning');
return;
}
const customerIds = Array.from(selectedCustomers);
try {
const response = await fetch('/api/v1/timetracking/customers/bulk-update-rate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
customer_ids: customerIds,
hourly_rate: newPrice
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Fejl ved opdatering');
}
const result = await response.json();
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('bulkPriceModal'));
modal.hide();
// Clear selection
selectedCustomers.clear();
document.getElementById('select-all').checked = false;
// Reload data
await loadCustomers();
showToast(`✅ Opdateret pris for ${result.updated} kunder`, 'success');
} catch (error) {
console.error('Error updating bulk prices:', error);
showToast(`Fejl ved opdatering: ${error.message}`, 'danger');
}
}
</script>
</div>
{% endblock %}