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:
parent
f8d9e0b252
commit
246ad27fe3
@ -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 på é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):
|
||||
"""
|
||||
|
||||
@ -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 %}
|
||||
|
||||
25
test_bulk_customer_update.py
Normal file
25
test_bulk_customer_update.py
Normal 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)}\'')
|
||||
Loading…
Reference in New Issue
Block a user