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))
|
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"])
|
@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):
|
async def toggle_customer_time_card(customer_id: int, enabled: bool, user_id: Optional[int] = None):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -90,16 +90,21 @@
|
|||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="row mb-3">
|
<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()">
|
<input type="text" class="form-control" id="search-input" placeholder="🔍 Søg kunde..." onkeyup="filterTable()">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-4">
|
||||||
<select class="form-select" id="filter-select" onchange="filterTable()">
|
<select class="form-select" id="filter-select" onchange="filterTable()">
|
||||||
<option value="all">Alle kunder</option>
|
<option value="all">Alle kunder</option>
|
||||||
<option value="custom">Kun custom priser</option>
|
<option value="custom">Kun custom priser</option>
|
||||||
<option value="standard">Kun standard priser</option>
|
<option value="standard">Kun standard priser</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Customers Table -->
|
<!-- Customers Table -->
|
||||||
@ -109,6 +114,9 @@
|
|||||||
<table class="table table-hover" id="customers-table">
|
<table class="table table-hover" id="customers-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width: 40px;">
|
||||||
|
<input type="checkbox" class="form-check-input" id="select-all" onchange="toggleSelectAll()">
|
||||||
|
</th>
|
||||||
<th>Kunde</th>
|
<th>Kunde</th>
|
||||||
<th>vTiger ID</th>
|
<th>vTiger ID</th>
|
||||||
<th class="text-end">Timepris (DKK)</th>
|
<th class="text-end">Timepris (DKK)</th>
|
||||||
@ -224,9 +232,45 @@
|
|||||||
</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>
|
<script>
|
||||||
let allCustomers = [];
|
let allCustomers = [];
|
||||||
let defaultRate = 850.00; // Fallback værdi
|
let defaultRate = 850.00; // Fallback værdi
|
||||||
|
let selectedCustomers = new Set(); // Track selected customer IDs
|
||||||
|
|
||||||
// Load customers on page load
|
// Load customers on page load
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
@ -273,7 +317,7 @@
|
|||||||
const tbody = document.getElementById('customers-tbody');
|
const tbody = document.getElementById('customers-tbody');
|
||||||
|
|
||||||
if (customers.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -283,9 +327,17 @@
|
|||||||
const statusBadge = isCustom
|
const statusBadge = isCustom
|
||||||
? '<span class="badge bg-primary">Custom</span>'
|
? '<span class="badge bg-primary">Custom</span>'
|
||||||
: '<span class="badge bg-secondary">Standard</span>';
|
: '<span class="badge bg-secondary">Standard</span>';
|
||||||
|
const isChecked = selectedCustomers.has(customer.id) ? 'checked' : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="editable-row" id="row-${customer.id}">
|
<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, "\\'")}')">
|
<td style="cursor: pointer;" onclick="viewTimeEntries(${customer.id}, '${customer.name.replace(/'/g, "\\'")}')">
|
||||||
<strong>${customer.name}</strong>
|
<strong>${customer.name}</strong>
|
||||||
${customer.uses_time_card ? '<span class="badge bg-warning text-dark ms-2">Klippekort</span>' : ''}
|
${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');
|
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>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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