Added bulk selection and update functionality for customer hourly rates:
Frontend (customers.html):
- Added checkbox column with select-all functionality
- Created bulk price update modal with customer list
- Implemented JavaScript for selection state management
- Shows selected count in UI badge
- Supports indeterminate state for partial selection
Backend (router.py):
- New POST /api/v1/timetracking/customers/bulk-update-rate endpoint
- Accepts {customer_ids: List[int], hourly_rate: float}
- Updates multiple customers in single SQL query
- Creates audit log entries for each updated customer
- Returns updated count
Use case: Select multiple customers and update hourly rate simultaneously
968 lines
44 KiB
HTML
968 lines
44 KiB
HTML
{% 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 %}
|