Feature: Bulk customer hourly rate updates (v1.3.52)

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
This commit is contained in:
Christian 2025-12-23 14:31:10 +01:00
parent f8d9e0b252
commit 246ad27fe3
3 changed files with 249 additions and 3 deletions

View File

@ -688,6 +688,67 @@ async def update_customer_hourly_rate(customer_id: int, hourly_rate: float, user
raise HTTPException(status_code=500, detail=str(e))
@router.post("/customers/bulk-update-rate", tags=["Customers"])
async def bulk_update_customer_hourly_rates(
customer_ids: List[int],
hourly_rate: float,
user_id: Optional[int] = None
):
"""
Opdater timepris for flere kunder én gang.
Args:
customer_ids: Liste af kunde-ID'er
hourly_rate: Ny timepris i DKK (f.eks. 850.00)
Returns:
Antal opdaterede kunder
"""
try:
from decimal import Decimal
# Validate inputs
if not customer_ids:
raise HTTPException(status_code=400, detail="No customers selected")
if hourly_rate < 0:
raise HTTPException(status_code=400, detail="Hourly rate must be positive")
rate_decimal = Decimal(str(hourly_rate))
# Update all selected customers
execute_update(
"UPDATE tmodule_customers SET hourly_rate = %s, updated_at = CURRENT_TIMESTAMP WHERE id = ANY(%s)",
(rate_decimal, customer_ids)
)
# Count affected rows
updated_count = len(customer_ids)
# Audit log for each customer
for customer_id in customer_ids:
audit.log_event(
entity_type="customer",
entity_id=str(customer_id),
event_type="hourly_rate_updated",
details={"hourly_rate": float(hourly_rate), "bulk_update": True},
user_id=user_id
)
logger.info(f"✅ Bulk updated hourly rate for {updated_count} customers to {hourly_rate} DKK")
return {
"updated": updated_count,
"hourly_rate": float(hourly_rate)
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error in bulk hourly rate update: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/customers/{customer_id}/time-card", tags=["Customers"])
async def toggle_customer_time_card(customer_id: int, enabled: bool, user_id: Optional[int] = None):
"""

View File

@ -90,16 +90,21 @@
<!-- Filters -->
<div class="row mb-3">
<div class="col-md-6">
<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-6">
<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 -->
@ -109,6 +114,9 @@
<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>
@ -224,9 +232,45 @@
</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', () => {
@ -273,7 +317,7 @@
const tbody = document.getElementById('customers-tbody');
if (customers.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center py-4">Ingen kunder fundet</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-4">Ingen kunder fundet</td></tr>';
return;
}
@ -283,9 +327,17 @@
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>' : ''}
@ -802,6 +854,114 @@
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 %}

View File

@ -0,0 +1,25 @@
"""
Quick test of bulk customer hourly rate update endpoint
"""
import json
# Test payload structure
test_payload = {
"customer_ids": [1, 2, 3],
"hourly_rate": 1200.00
}
print("Test payload for POST /api/v1/timetracking/customers/bulk-update-rate:")
print(json.dumps(test_payload, indent=2))
print("\nExpected response:")
expected_response = {
"updated": 3,
"hourly_rate": 1200.00
}
print(json.dumps(expected_response, indent=2))
print("\nEndpoint ready for testing!")
print("curl -X POST http://172.16.31.183:8000/api/v1/timetracking/customers/bulk-update-rate \\")
print(' -H "Content-Type: application/json" \\')
print(f' -d \'{json.dumps(test_payload)}\'')