feat: Add new time tracking wizard and registrations view

- Implemented a new simplified time tracking wizard (wizard2) for approval processes.
- Added a registrations view to list all time tracking entries.
- Enhanced the existing wizard.html to include a billable checkbox for entries.
- Updated JavaScript logic to handle billable state and travel status for time entries.
- Introduced a cleanup step in the deployment script to remove old images.
- Created a new HTML template for registrations with filtering and pagination capabilities.
This commit is contained in:
Christian 2026-01-10 01:37:08 +01:00
parent 19827d03a8
commit a1d4696005
10 changed files with 1622 additions and 12 deletions

View File

@ -270,6 +270,7 @@
</a> </a>
<ul class="dropdown-menu" data-submenu="timetracking"> <ul class="dropdown-menu" data-submenu="timetracking">
<li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li> <li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li> <li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li> <li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li> <li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>

View File

@ -266,6 +266,41 @@ class EconomicExportService:
customer_number = customer_data['economic_customer_number'] customer_number = customer_data['economic_customer_number']
# 🔍 VALIDATE: Check if customer exists in e-conomic
logger.info(f"🔍 Validating customer {customer_number} exists in e-conomic...")
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.api_url}/customers/{customer_number}",
headers=self._get_headers(),
timeout=aiohttp.ClientTimeout(total=10)
) as response:
if response.status == 404:
raise HTTPException(
status_code=400,
detail=f"Kunde '{order['customer_name']}' med e-conomic nummer '{customer_number}' findes ikke i e-conomic. Kontroller kundenummeret i Customers modulet."
)
elif response.status != 200:
logger.warning(f"⚠️ Could not validate customer: {response.status}")
# Continue anyway - might be network issue
# 🔍 VALIDATE: Check if layout exists in e-conomic
layout_number = settings.TIMETRACKING_ECONOMIC_LAYOUT
logger.info(f"🔍 Validating layout {layout_number} exists in e-conomic...")
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.api_url}/layouts/{layout_number}",
headers=self._get_headers(),
timeout=aiohttp.ClientTimeout(total=10)
) as response:
if response.status == 404:
raise HTTPException(
status_code=400,
detail=f"Layout nummer '{layout_number}' findes ikke i e-conomic. Opdater TIMETRACKING_ECONOMIC_LAYOUT i .env filen med et gyldigt layout nummer fra e-conomic."
)
elif response.status != 200:
logger.warning(f"⚠️ Could not validate layout: {response.status}")
# Continue anyway - might be network issue
# Build e-conomic draft order payload # Build e-conomic draft order payload
economic_payload = { economic_payload = {
"date": order['order_date'].isoformat() if hasattr(order['order_date'], 'isoformat') else str(order['order_date']), "date": order['order_date'].isoformat() if hasattr(order['order_date'], 'isoformat') else str(order['order_date']),

View File

@ -100,6 +100,9 @@ class OrderService:
c.vtiger_id as case_vtiger_id, c.vtiger_id as case_vtiger_id,
COALESCE(c.vtiger_data->>'case_no', c.vtiger_data->>'ticket_no') as case_number, COALESCE(c.vtiger_data->>'case_no', c.vtiger_data->>'ticket_no') as case_number,
c.vtiger_data->>'ticket_title' as vtiger_title, c.vtiger_data->>'ticket_title' as vtiger_title,
c.priority as case_priority,
c.status as case_status,
c.module_type as case_type,
CONCAT(cont.first_name, ' ', cont.last_name) as contact_name CONCAT(cont.first_name, ' ', cont.last_name) as contact_name
FROM tmodule_times t FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id JOIN tmodule_cases c ON t.case_id = c.id
@ -136,6 +139,9 @@ class OrderService:
'case_vtiger_id': time_entry.get('case_vtiger_id'), 'case_vtiger_id': time_entry.get('case_vtiger_id'),
'case_number': time_entry.get('case_number'), # Fra vtiger_data 'case_number': time_entry.get('case_number'), # Fra vtiger_data
'case_title': case_title, # Case titel fra vTiger 'case_title': case_title, # Case titel fra vTiger
'case_priority': time_entry.get('case_priority'), # Prioritet
'case_status': time_entry.get('case_status'), # Status
'case_type': time_entry.get('case_type'), # Brand/Type (module_type)
'contact_name': time_entry.get('contact_name'), 'contact_name': time_entry.get('contact_name'),
'worked_date': time_entry.get('worked_date'), # Seneste dato 'worked_date': time_entry.get('worked_date'), # Seneste dato
'is_travel': False, # Marker hvis nogen entry er rejse 'is_travel': False, # Marker hvis nogen entry er rejse
@ -193,11 +199,30 @@ class OrderService:
# Sidste fallback hvis intet andet # Sidste fallback hvis intet andet
case_title = "Support arbejde" case_title = "Support arbejde"
# Build description med case nummer prefix # Build description med case nummer, titel, dato, type, prioritet
description_parts = []
# Case nummer og titel
if case_number: if case_number:
description = f"{case_number} - {case_title}" description_parts.append(f"{case_number} - {case_title}")
else: else:
description = case_title description_parts.append(case_title)
# Dato
if group.get('worked_date'):
date_str = group['worked_date'].strftime('%d.%m.%Y')
description_parts.append(f"Dato: {date_str}")
# Brand/Type (module_type)
if group.get('case_type'):
description_parts.append(f"Type: {group['case_type']}")
# Prioritet
if group.get('case_priority'):
description_parts.append(f"Prioritet: {group['case_priority']}")
# Join all parts with newlines for multi-line description
description = "\n".join(description_parts)
# Calculate line total # Calculate line total
line_total = case_hours * hourly_rate line_total = case_hours * hourly_rate

View File

@ -34,6 +34,7 @@ from app.timetracking.backend.wizard import wizard
from app.timetracking.backend.order_service import order_service from app.timetracking.backend.order_service import order_service
from app.timetracking.backend.economic_export import economic_service from app.timetracking.backend.economic_export import economic_service
from app.timetracking.backend.audit import audit from app.timetracking.backend.audit import audit
from app.services.customer_consistency import CustomerConsistencyService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -436,23 +437,18 @@ async def approve_time_entry(
approved_hours = Decimal(str(billable_hours)) approved_hours = Decimal(str(billable_hours))
rounded_to = None rounded_to = None
# Opdater med hourly_rate hvis angivet # Note: hourly_rate is stored on customer level (tmodule_customers.hourly_rate), not on time entries
hourly_rate = request.get('hourly_rate') # Frontend sends it for calculation display but we don't store it per time entry
if hourly_rate is not None:
execute_update(
"UPDATE tmodule_times SET hourly_rate = %s WHERE id = %s",
(Decimal(str(hourly_rate)), time_id)
)
# Godkend med alle felter # Godkend med alle felter
logger.info(f"🔍 Creating approval for time_id={time_id}: approved_hours={approved_hours}, rounded_to={rounded_to}, is_travel={request.get('is_travel', False)}") logger.info(f"🔍 Creating approval for time_id={time_id}: approved_hours={approved_hours}, rounded_to={rounded_to}, is_travel={request.get('is_travel', False)}, billable={request.get('billable', True)}")
approval = TModuleTimeApproval( approval = TModuleTimeApproval(
time_id=time_id, time_id=time_id,
approved_hours=approved_hours, approved_hours=approved_hours,
rounded_to=rounded_to, rounded_to=rounded_to,
approval_note=request.get('approval_note'), approval_note=request.get('approval_note'),
billable=True, # Default til fakturerbar billable=request.get('billable', True), # Accept from request, default til fakturerbar
is_travel=request.get('is_travel', False) is_travel=request.get('is_travel', False)
) )
@ -566,6 +562,152 @@ async def get_customer_progress(customer_id: int):
# ORDER ENDPOINTS # ORDER ENDPOINTS
# ============================================================================ # ============================================================================
@router.get("/customers/{customer_id}/data-consistency", tags=["Customers", "Data Consistency"])
async def check_tmodule_customer_data_consistency(customer_id: int):
"""
🔍 Check data consistency across Hub, vTiger, and e-conomic for tmodule_customer.
Before creating order, verify customer data is in sync across all systems.
Maps tmodule_customers.hub_customer_id to the consistency service.
Returns discrepancies found between the three systems.
"""
try:
from app.core.config import settings
if not settings.AUTO_CHECK_CONSISTENCY:
return {
"enabled": False,
"message": "Data consistency checking is disabled"
}
# Get tmodule_customer and find linked hub customer
tmodule_customer = execute_query_single(
"SELECT * FROM tmodule_customers WHERE id = %s",
(customer_id,)
)
if not tmodule_customer:
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found in tmodule_customers")
# Get linked hub customer ID
hub_customer_id = tmodule_customer.get('hub_customer_id')
if not hub_customer_id:
return {
"enabled": True,
"customer_id": customer_id,
"discrepancy_count": 0,
"discrepancies": {},
"systems_available": {
"hub": False,
"vtiger": bool(tmodule_customer.get('vtiger_id')),
"economic": False
},
"message": "Customer not linked to Hub - cannot check consistency"
}
# Use Hub customer ID for consistency check
consistency_service = CustomerConsistencyService()
# Fetch data from all systems
all_data = await consistency_service.fetch_all_data(hub_customer_id)
# Compare data
discrepancies = consistency_service.compare_data(all_data)
# Count actual discrepancies
discrepancy_count = sum(
1 for field_data in discrepancies.values()
if field_data['discrepancy']
)
return {
"enabled": True,
"customer_id": customer_id,
"hub_customer_id": hub_customer_id,
"discrepancy_count": discrepancy_count,
"discrepancies": discrepancies,
"systems_available": {
"hub": True,
"vtiger": all_data.get('vtiger') is not None,
"economic": all_data.get('economic') is not None
}
}
except Exception as e:
logger.error(f"❌ Failed to check consistency for tmodule_customer {customer_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/customers/{customer_id}/sync-field", tags=["Customers", "Data Consistency"])
async def sync_tmodule_customer_field(
customer_id: int,
field_name: str = Body(..., description="Hub field name to sync"),
source_system: str = Body(..., description="Source system: hub, vtiger, or economic"),
source_value: str = Body(..., description="The correct value to sync")
):
"""
🔄 Sync a single field across all systems for tmodule_customer.
Takes the correct value from one system and updates the others.
Maps tmodule_customers.hub_customer_id to the consistency service.
"""
try:
from app.core.config import settings
# Validate source system
if source_system not in ['hub', 'vtiger', 'economic']:
raise HTTPException(
status_code=400,
detail=f"Invalid source_system: {source_system}. Must be hub, vtiger, or economic"
)
# Get tmodule_customer and find linked hub customer
tmodule_customer = execute_query_single(
"SELECT * FROM tmodule_customers WHERE id = %s",
(customer_id,)
)
if not tmodule_customer:
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found in tmodule_customers")
# Get linked hub customer ID
hub_customer_id = tmodule_customer.get('hub_customer_id')
if not hub_customer_id:
raise HTTPException(
status_code=400,
detail="Customer not linked to Hub - cannot sync fields"
)
consistency_service = CustomerConsistencyService()
# Perform sync on the linked Hub customer
results = await consistency_service.sync_field(
customer_id=hub_customer_id,
field_name=field_name,
source_system=source_system,
source_value=source_value
)
logger.info(f"✅ Field '{field_name}' synced from {source_system}: {results}")
return {
"success": True,
"field": field_name,
"source": source_system,
"value": source_value,
"results": results
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"❌ Failed to sync field for tmodule_customer {customer_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/orders/generate/{customer_id}", response_model=TModuleOrderWithLines, tags=["Orders"]) @router.post("/orders/generate/{customer_id}", response_model=TModuleOrderWithLines, tags=["Orders"])
async def generate_order(customer_id: int, user_id: Optional[int] = None): async def generate_order(customer_id: int, user_id: Optional[int] = None):
""" """
@ -1087,6 +1229,8 @@ async def get_customer_time_entries(customer_id: int, status: Optional[str] = No
COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title, COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title,
c.vtiger_id AS case_vtiger_id, c.vtiger_id AS case_vtiger_id,
c.description AS case_description, c.description AS case_description,
c.priority AS case_priority,
c.module_type AS case_type,
cust.name AS customer_name cust.name AS customer_name
FROM tmodule_times t FROM tmodule_times t
LEFT JOIN tmodule_cases c ON t.case_id = c.id LEFT JOIN tmodule_cases c ON t.case_id = c.id
@ -1121,6 +1265,62 @@ async def get_customer_time_entries(customer_id: int, status: Optional[str] = No
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/times", tags=["Times"])
async def list_time_entries(
limit: int = 100,
offset: int = 0,
status: Optional[str] = None,
customer_id: Optional[int] = None,
user_name: Optional[str] = None,
search: Optional[str] = None
):
"""
Hent liste af tidsregistreringer med filtre.
"""
try:
query = """
SELECT t.*,
COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title,
c.priority AS case_priority,
cust.name AS customer_name
FROM tmodule_times t
LEFT JOIN tmodule_cases c ON t.case_id = c.id
LEFT JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE 1=1
"""
params = []
if status:
query += " AND t.status = %s"
params.append(status)
if customer_id:
query += " AND t.customer_id = %s"
params.append(customer_id)
if user_name:
query += " AND t.user_name ILIKE %s"
params.append(f"%{user_name}%")
if search:
query += """ AND (
t.description ILIKE %s OR
cust.name ILIKE %s OR
c.title ILIKE %s
)"""
wildcard = f"%{search}%"
params.extend([wildcard, wildcard, wildcard])
query += " ORDER BY t.worked_date DESC, t.id DESC LIMIT %s OFFSET %s"
params.extend([limit, offset])
times = execute_query(query, tuple(params))
return {"times": times}
except Exception as e:
logger.error(f"Error listing times: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/times/{time_id}", tags=["Times"]) @router.get("/times/{time_id}", tags=["Times"])
async def get_time_entry(time_id: int): async def get_time_entry(time_id: int):
""" """

View File

@ -267,10 +267,58 @@
</div> </div>
</div> </div>
<!-- Data Consistency Comparison Modal -->
<div class="modal fade" id="consistencyModal" tabindex="-1" aria-labelledby="consistencyModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="consistencyModalLabel">
<i class="bi bi-diagram-3 me-2"></i>Sammenlign Kundedata
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
<strong>Vejledning:</strong> Vælg den korrekte værdi for hvert felt med uoverensstemmelser.
Når du klikker "Synkroniser Valgte", vil de valgte værdier blive opdateret i alle systemer før ordren oprettes.
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 20%;">Felt</th>
<th style="width: 20%;">BMC Hub</th>
<th style="width: 20%;">vTiger</th>
<th style="width: 20%;">e-conomic</th>
<th style="width: 20%;">Vælg Korrekt</th>
</tr>
</thead>
<tbody id="consistencyTableBody">
<!-- Populated by JavaScript -->
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" onclick="skipConsistencyCheck()">
<i class="bi bi-x-circle me-2"></i>Spring Over
</button>
<button type="button" class="btn btn-primary" onclick="syncSelectedFields()">
<i class="bi bi-arrow-repeat me-2"></i>Synkroniser Valgte
</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 let selectedCustomers = new Set(); // Track selected customer IDs
let consistencyData = null; // Store consistency check data
let pendingOrderCustomerId = null; // Store customer ID for pending order
// Load customers on page load // Load customers on page load
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@ -676,6 +724,7 @@
async function createOrderForCustomer(customerId, customerName) { async function createOrderForCustomer(customerId, customerName) {
currentOrderCustomerId = customerId; currentOrderCustomerId = customerId;
pendingOrderCustomerId = customerId;
document.getElementById('order-customer-name').textContent = customerName; document.getElementById('order-customer-name').textContent = customerName;
document.getElementById('order-loading').classList.remove('d-none'); document.getElementById('order-loading').classList.remove('d-none');
document.getElementById('order-content').classList.add('d-none'); document.getElementById('order-content').classList.add('d-none');
@ -687,6 +736,43 @@
modal.show(); modal.show();
try { try {
// 🔍 STEP 1: Check data consistency first
const consistencyResponse = await fetch(`/api/v1/timetracking/customers/${customerId}/data-consistency`);
const consistency = await consistencyResponse.json();
// If consistency check is enabled and there are discrepancies, show them first
if (consistency.enabled && consistency.discrepancy_count > 0) {
consistencyData = consistency;
modal.hide(); // Hide order modal
showConsistencyModal(); // Show consistency modal
return; // Wait for user to sync fields
}
// STEP 2: If no discrepancies (or check disabled), proceed with order creation
await loadOrderPreview(customerId, customerName);
} catch (error) {
console.error('Error checking consistency or loading order preview:', error);
document.getElementById('order-loading').classList.add('d-none');
showToast('Fejl ved indlæsning: ' + error.message, 'danger');
modal.hide();
}
}
async function loadOrderPreview(customerId, customerName) {
// This is the original order preview logic extracted into a separate function
try {
// Show order modal if not already showing
const orderModal = bootstrap.Modal.getInstance(document.getElementById('createOrderModal')) ||
new bootstrap.Modal(document.getElementById('createOrderModal'));
// Reset states
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;
// Fetch customer's approved time entries // Fetch customer's approved time entries
const response = await fetch(`/api/v1/timetracking/customers/${customerId}/times`); const response = await fetch(`/api/v1/timetracking/customers/${customerId}/times`);
if (!response.ok) throw new Error('Failed to load time entries'); if (!response.ok) throw new Error('Failed to load time entries');
@ -964,6 +1050,185 @@
showToast(`Fejl ved opdatering: ${error.message}`, 'danger'); showToast(`Fejl ved opdatering: ${error.message}`, 'danger');
} }
} }
// Data Consistency Functions
function showConsistencyModal() {
if (!consistencyData) {
console.error('No consistency data available');
return;
}
const tbody = document.getElementById('consistencyTableBody');
tbody.innerHTML = '';
// Field labels in Danish
const fieldLabels = {
'name': 'Navn',
'cvr_number': 'CVR Nummer',
'address': 'Adresse',
'city': 'By',
'postal_code': 'Postnummer',
'country': 'Land',
'phone': 'Telefon',
'mobile_phone': 'Mobil',
'email': 'Email',
'website': 'Hjemmeside',
'invoice_email': 'Faktura Email'
};
// Only show fields with discrepancies
for (const [fieldName, fieldData] of Object.entries(consistencyData.discrepancies)) {
if (!fieldData.discrepancy) continue;
const row = document.createElement('tr');
row.className = 'table-warning';
// Field name
const fieldCell = document.createElement('td');
fieldCell.innerHTML = `<strong>${fieldLabels[fieldName] || fieldName}</strong>`;
row.appendChild(fieldCell);
// Hub value
const hubCell = document.createElement('td');
hubCell.innerHTML = `
<div class="form-check">
<input class="form-check-input" type="radio" name="field_${fieldName}"
id="hub_${fieldName}" value="hub" data-value="${fieldData.hub || ''}">
<label class="form-check-label" for="hub_${fieldName}">
${fieldData.hub || '<em class="text-muted">Tom</em>'}
</label>
</div>
`;
row.appendChild(hubCell);
// vTiger value
const vtigerCell = document.createElement('td');
if (consistencyData.systems_available.vtiger) {
vtigerCell.innerHTML = `
<div class="form-check">
<input class="form-check-input" type="radio" name="field_${fieldName}"
id="vtiger_${fieldName}" value="vtiger" data-value="${fieldData.vtiger || ''}">
<label class="form-check-label" for="vtiger_${fieldName}">
${fieldData.vtiger || '<em class="text-muted">Tom</em>'}
</label>
</div>
`;
} else {
vtigerCell.innerHTML = '<em class="text-muted">Ikke tilgængelig</em>';
}
row.appendChild(vtigerCell);
// e-conomic value
const economicCell = document.createElement('td');
if (consistencyData.systems_available.economic) {
economicCell.innerHTML = `
<div class="form-check">
<input class="form-check-input" type="radio" name="field_${fieldName}"
id="economic_${fieldName}" value="economic" data-value="${fieldData.economic || ''}">
<label class="form-check-label" for="economic_${fieldName}">
${fieldData.economic || '<em class="text-muted">Tom</em>'}
</label>
</div>
`;
} else {
economicCell.innerHTML = '<em class="text-muted">Ikke tilgængelig</em>';
}
row.appendChild(economicCell);
// Action cell (which system to use)
const actionCell = document.createElement('td');
actionCell.innerHTML = '<span class="text-muted">← Vælg</span>';
row.appendChild(actionCell);
tbody.appendChild(row);
}
const modal = new bootstrap.Modal(document.getElementById('consistencyModal'));
modal.show();
}
async function syncSelectedFields() {
const selections = [];
// Gather all selected values
const radioButtons = document.querySelectorAll('#consistencyTableBody input[type="radio"]:checked');
if (radioButtons.length === 0) {
alert('Vælg venligst mindst ét felt at synkronisere, eller klik "Spring Over"');
return;
}
radioButtons.forEach(radio => {
const fieldName = radio.name.replace('field_', '');
const sourceSystem = radio.value;
const sourceValue = radio.dataset.value;
selections.push({
field_name: fieldName,
source_system: sourceSystem,
source_value: sourceValue
});
});
// Confirm action
if (!confirm(`Du er ved at synkronisere ${selections.length} felt(er) på tværs af alle systemer. Fortsæt?`)) {
return;
}
// Sync each field
let successCount = 0;
let failCount = 0;
for (const selection of selections) {
try {
const response = await fetch(
`/api/v1/timetracking/customers/${pendingOrderCustomerId}/sync-field`,
{
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(selection)
}
);
if (response.ok) {
successCount++;
} else {
failCount++;
console.error(`Failed to sync ${selection.field_name}`);
}
} catch (error) {
failCount++;
console.error(`Error syncing ${selection.field_name}:`, error);
}
}
// Close consistency modal
const consistencyModal = bootstrap.Modal.getInstance(document.getElementById('consistencyModal'));
consistencyModal.hide();
// Show result
if (failCount === 0) {
showToast(`✓ ${successCount} felt(er) synkroniseret succesfuldt!`, 'success');
} else {
showToast(`⚠️ ${successCount} felt(er) synkroniseret, ${failCount} fejlede`, 'warning');
}
// Now proceed with order creation - reopen order modal and load preview
const customerName = allCustomers.find(c => c.id === pendingOrderCustomerId)?.name || 'Kunde';
const orderModal = new bootstrap.Modal(document.getElementById('createOrderModal'));
document.getElementById('order-customer-name').textContent = customerName;
orderModal.show();
await loadOrderPreview(pendingOrderCustomerId, customerName);
}
function skipConsistencyCheck() {
// User chose to skip consistency check and proceed with order anyway
const customerName = allCustomers.find(c => c.id === pendingOrderCustomerId)?.name || 'Kunde';
const orderModal = new bootstrap.Modal(document.getElementById('createOrderModal'));
document.getElementById('order-customer-name').textContent = customerName;
orderModal.show();
loadOrderPreview(pendingOrderCustomerId, customerName);
}
</script> </script>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,273 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Tidsregistreringer - BMC Hub{% endblock %}
{% block extra_css %}
<style>
/* Clean Filters */
.filter-card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.03);
}
/* Table Styling */
.registrations-table {
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
overflow: hidden;
border: 1px solid #e2e8f0;
}
.registrations-table th {
background-color: #f8f9fa;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.5px;
padding: 1rem;
border-bottom: 2px solid #e9ecef;
color: #64748b;
}
.registrations-table td {
vertical-align: middle;
padding: 1rem;
border-bottom: 1px solid #f1f5f9;
font-size: 0.9rem;
}
.registrations-table tr:hover {
background-color: #f8fafc;
}
.status-badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
text-transform: uppercase;
font-weight: 600;
}
.status-pending { background: #fff3cd; color: #856404; }
.status-approved { background: #d1e7dd; color: #0f5132; }
.status-rejected { background: #f8d7da; color: #842029; }
.status-billed { background: #cfe2ff; color: #084298; }
</style>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
{% endblock %}
{% block content %}
<div class="container-fluid py-4 px-4 m-0" style="max-width: 1600px; margin: 0 auto;">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="mb-1">Tidsregistreringer</h1>
<p class="text-muted mb-0">Søg og filtrer i alle registreringer</p>
</div>
<button class="btn btn-outline-primary" onclick="loadData()">
<i class="bi bi-arrow-clockwise"></i> Opdater
</button>
</div>
<!-- Filters -->
<div class="filter-card mb-4">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label small text-muted text-uppercase fw-bold">Søgning</label>
<div class="input-group">
<span class="input-group-text bg-white"><i class="bi bi-search"></i></span>
<input type="text" id="filter-search" class="form-control" placeholder="Kunde, beskrivelse, case..." onkeyup="debounceLoad()">
</div>
</div>
<div class="col-md-2">
<label class="form-label small text-muted text-uppercase fw-bold">Status</label>
<select id="filter-status" class="form-select" onchange="loadData()">
<option value="">Alle</option>
<option value="pending">Afventer</option>
<option value="approved">Godkendt</option>
<option value="billed">Faktureret</option>
<option value="rejected">Afvist</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label small text-muted text-uppercase fw-bold">Tekniker</label>
<input type="text" id="filter-user" class="form-control" placeholder="Navn..." onkeyup="debounceLoad()">
</div>
</div>
</div>
<!-- Table -->
<div class="registrations-table">
<div class="table-responsive">
<table class="table mb-0">
<thead>
<tr>
<th>Dato</th>
<th style="width: 20%;">Kunde</th>
<th style="width: 25%;">Beskrivelse / Case</th>
<th>Tekniker</th>
<th class="text-center">Timer</th>
<th class="text-center">Fakt.</th>
<th>Status</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="table-body">
<tr><td colspan="8" class="text-center py-5 text-muted">Henter data...</td></tr>
</tbody>
</table>
</div>
<!-- Pagination logic could go here -->
</div>
</div>
<!-- Details Modal -->
<div class="modal fade" id="detailsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Detaljer</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="details-content">
<!-- Content -->
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script>
let debounceTimer;
document.addEventListener('DOMContentLoaded', () => {
loadData();
});
function debounceLoad() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(loadData, 500);
}
async function loadData() {
const tbody = document.getElementById('table-body');
const search = document.getElementById('filter-search').value;
const status = document.getElementById('filter-status').value;
const user = document.getElementById('filter-user').value;
// Build URL
const params = new URLSearchParams({
limit: 100, // Hardcoded limit for now
offset: 0
});
if (search) params.append('search', search);
if (status) params.append('status', status);
if (user) params.append('user_name', user);
try {
const response = await fetch(`/api/v1/timetracking/times?${params.toString()}`);
const data = await response.json();
if (data.times.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-5">Ingen resultater fundet</td></tr>`;
return;
}
tbody.innerHTML = data.times.map(t => `
<tr>
<td>
<div class="fw-bold">${formatDate(t.worked_date)}</div>
<div class="small text-muted">${t.created_at ? new Date(t.created_at).toLocaleTimeString().slice(0,5) : ''}</div>
</td>
<td>
<div class="fw-bold text-dark">${t.customer_name || 'Ukendt'}</div>
</td>
<td>
<div class="small fw-bold text-primary mb-1">
${t.case_vtiger_id ? `<a href="https://bmcnetworks.od2.vtiger.com/index.php?module=HelpDesk&view=Detail&record=${t.case_vtiger_id.replace('39x','')}" target="_blank">${t.case_title || 'Ingen Case'}</a>` : (t.case_title || 'Ingen Case')}
</div>
<div class="text-secondary small" style="max-height: 3em; overflow: hidden; text-overflow: ellipsis;">
${t.description || '-'}
</div>
</td>
<td>
${t.user_name || '-'}
</td>
<td class="text-center">
<span class="badge bg-light text-dark border">${parseFloat(t.original_hours).toFixed(2)}</span>
</td>
<td class="text-center">
${t.billable ? '<i class="bi bi-check-circle-fill text-success"></i>' : '<i class="bi bi-dash-circle text-muted"></i>'}
</td>
<td>
<span class="status-badge status-${t.status}">${getStatusLabel(t.status)}</span>
</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary" onclick="showDetails(${t.id})">
<i class="bi bi-eye"></i>
</button>
<a href="/timetracking/wizard2?customer_id=${t.customer_id}&time_id=${t.id}" class="btn btn-sm btn-outline-primary" title="Gå til godkendelse">
<i class="bi bi-arrow-right"></i>
</a>
</td>
</tr>
`).join('');
} catch (error) {
console.error(error);
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-5 text-danger">Fejl ved hentning af data</td></tr>`;
}
}
function formatDate(dateStr) {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString('da-DK');
}
function getStatusLabel(status) {
const labels = {
'pending': 'Afventer',
'approved': 'Godkendt',
'rejected': 'Afvist',
'billed': 'Faktureret'
};
return labels[status] || status;
}
async function showDetails(id) {
const modal = new bootstrap.Modal(document.getElementById('detailsModal'));
const content = document.getElementById('details-content');
content.innerHTML = '<div class="text-center"><div class="spinner-border"></div></div>';
modal.show();
try {
const res = await fetch(`/api/v1/timetracking/times/${id}`);
const t = await res.json();
content.innerHTML = `
<table class="table table-bordered">
<tr><th>ID</th><td>${t.id}</td></tr>
<tr><th>Kunde</th><td>${t.customer_name}</td></tr>
<tr><th>Case</th><td>${t.case_title}</td></tr>
<tr><th>Beskrivelse</th><td>${t.description}</td></tr>
<tr><th>Timer</th><td>${t.original_hours}</td></tr>
<tr><th>Status</th><td>${t.status}</td></tr>
<tr><th>Raw Data</th><td><pre class="bg-light p-2 small">${JSON.stringify(t, null, 2)}</pre></td></tr>
</table>
`;
} catch (e) {
content.innerHTML = `<div class="alert alert-danger">Fejl: ${e.message}</div>`;
}
}
</script>
{% endblock %}

View File

@ -28,6 +28,18 @@ async def timetracking_wizard(request: Request):
return templates.TemplateResponse("timetracking/frontend/wizard.html", {"request": request}) return templates.TemplateResponse("timetracking/frontend/wizard.html", {"request": request})
@router.get("/timetracking/wizard2", response_class=HTMLResponse, name="timetracking_wizard_v2")
async def timetracking_wizard_v2(request: Request):
"""Time Tracking Wizard V2 - simplified approval"""
return templates.TemplateResponse("timetracking/frontend/wizard2.html", {"request": request})
@router.get("/timetracking/registrations", response_class=HTMLResponse, name="timetracking_registrations")
async def timetracking_registrations(request: Request):
"""Time Tracking Registrations - list view"""
return templates.TemplateResponse("timetracking/frontend/registrations.html", {"request": request})
@router.get("/timetracking/customers", response_class=HTMLResponse, name="timetracking_customers") @router.get("/timetracking/customers", response_class=HTMLResponse, name="timetracking_customers")
async def timetracking_customers(request: Request): async def timetracking_customers(request: Request):
"""Time Tracking Customers - manage hourly rates""" """Time Tracking Customers - manage hourly rates"""

View File

@ -725,6 +725,12 @@
<i class="bi bi-car-front"></i> Indeholder kørsel <i class="bi bi-car-front"></i> Indeholder kørsel
</label> </label>
</div> </div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="billable-${e.id}" ${e.billable !== false ? 'checked' : ''}>
<label class="form-check-label" for="billable-${e.id}">
<i class="bi bi-cash-coin"></i> Fakturerbar
</label>
</div>
</div> </div>
<div class="mt-3"> <div class="mt-3">
@ -1156,6 +1162,10 @@
const travelCheckbox = document.getElementById(`travel-${entryId}`); const travelCheckbox = document.getElementById(`travel-${entryId}`);
const isTravel = travelCheckbox ? travelCheckbox.checked : false; const isTravel = travelCheckbox ? travelCheckbox.checked : false;
// Get billable checkbox state
const billableCheckbox = document.getElementById(`billable-${entryId}`);
const isBillable = billableCheckbox ? billableCheckbox.checked : true;
// Get approval note // Get approval note
const approvalNoteField = document.getElementById(`approval-note-${entryId}`); const approvalNoteField = document.getElementById(`approval-note-${entryId}`);
const approvalNote = approvalNoteField ? approvalNoteField.value.trim() : ''; const approvalNote = approvalNoteField ? approvalNoteField.value.trim() : '';
@ -1170,6 +1180,7 @@
billable_hours: billableHours, billable_hours: billableHours,
hourly_rate: hourlyRate, hourly_rate: hourlyRate,
is_travel: isTravel, is_travel: isTravel,
billable: isBillable,
approval_note: approvalNote || null approval_note: approvalNote || null
}) })
}); });

View File

@ -0,0 +1,784 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Godkend Tider V2 - BMC Hub{% endblock %}
{% block extra_css %}
<style>
/* Clean Table Design */
.approval-table {
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
overflow: hidden;
margin-bottom: 3rem !important; /* Increased spacing */
border: 1px solid #e2e8f0;
}
.approval-table th {
background-color: #f8f9fa;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.5px;
padding: 1rem;
border-bottom: 2px solid #e9ecef;
color: #64748b;
}
.approval-table td {
vertical-align: top; /* Align to top for better readability with long descriptions */
padding: 1.25rem 1rem;
border-bottom: 1px solid #f1f5f9;
}
.approval-table tbody tr:last-child td {
border-bottom: none;
}
.case-group-header {
background-color: #f1f5f9 !important; /* Lighter background */
border-left: 6px solid var(--accent); /* Thicker accent */
padding: 1.5rem !important;
border-bottom: 1px solid #e2e8f0;
}
.case-title {
font-weight: 800;
color: #1e293b;
font-size: 1.25rem;
letter-spacing: -0.5px;
}
.case-description-box {
background-color: white;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 1rem;
margin-top: 0.75rem;
font-size: 0.9rem;
color: #475569;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.case-meta {
font-size: 0.85rem;
color: var(--text-secondary);
}
.entry-row:hover {
background-color: #f8fafc;
}
/* Input controls */
.hours-input {
width: 80px;
text-align: center;
font-weight: 600;
}
.description-cell {
max-width: 400px;
position: relative;
}
.description-text {
white-space: pre-wrap;
font-size: 0.95rem;
}
/* Floating Action Bar */
.action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
padding: 1rem;
box-shadow: 0 -4px 10px rgba(0,0,0,0.1);
z-index: 1000;
transform: translateY(100%);
transition: transform 0.3s ease-in-out;
}
.action-bar.visible {
transform: translateY(0);
}
/* Status Badges */
.badge-soft-warning {
background-color: rgba(255, 193, 7, 0.15);
color: #856404;
}
.badge-soft-success {
background-color: rgba(40, 167, 69, 0.15);
color: #155724;
}
/* Billable Toggle */
.billable-toggle {
cursor: pointer;
opacity: 0.5;
transition: opacity 0.2s;
}
.billable-toggle.active {
opacity: 1;
color: var(--success);
}
.billable-toggle:not(.active) {
color: var(--secondary);
}
/* Travel Toggle */
.travel-toggle {
cursor: pointer;
opacity: 0.5;
transition: opacity 0.2s;
color: #6c757d;
}
.travel-toggle.active {
opacity: 1;
color: #fd7e14; /* Orange for travel */
}
/* Animation */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in {
animation: fadeIn 0.4s ease-out forwards;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4 px-4 m-0" style="max-width: 1600px; margin: 0 auto;">
<!-- Header Area -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="mb-1">
<i class="bi bi-check-all text-primary"></i> Godkend Tider (V2)
</h1>
<p class="text-muted mb-0">Hurtig godkendelse af tidsregistreringer pr. kunde</p>
</div>
<div>
<div class="d-flex gap-2">
<select id="customer-select" class="form-select" style="min-width: 300px;" onchange="changeCustomer(this.value)">
<option value="">Vælg kunde...</option>
</select>
<a href="/timetracking" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Tilbage
</a>
</div>
</div>
</div>
<!-- Loading State -->
<div id="loading-container" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="mt-2 text-muted">Henter tidsregistreringer...</p>
</div>
<!-- Empty State -->
<div id="empty-state" class="d-none text-center py-5">
<div class="display-1 text-muted mb-3"><i class="bi bi-check-circle"></i></div>
<h3>Alt godkendt!</h3>
<p class="text-muted">Ingen afventende tidsregistreringer for denne kunde.</p>
<button class="btn btn-outline-primary mt-2" onclick="loadCustomerList()">Opdater liste</button>
</div>
<!-- Main Content -->
<div id="main-content" class="d-none animate-in">
<!-- Summary Card -->
<div class="card mb-4 border-0 shadow-sm bg-primary text-white">
<div class="card-body p-4">
<div class="row align-items-center">
<div class="col-md-6">
<h2 id="customer-name" class="fw-bold mb-1">-</h2>
<div class="d-flex gap-3 text-white-50">
<span><i class="bi bi-tag"></i> Timepris: <span id="hourly-rate" class="fw-bold text-white">-</span> DKK</span>
<span><i class="bi bi-clock"></i> Afventer: <span id="pending-count" class="fw-bold text-white">-</span> stk</span>
</div>
</div>
<div class="col-md-6 text-end">
<div class="display-6 fw-bold"><span id="total-value">0,00</span> DKK</div>
<div class="text-white-50">Total værdi til godkendelse</div>
</div>
</div>
</div>
</div>
<!-- Group Actions -->
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" onclick="expandAll()">Fold alt ud</button>
<button class="btn btn-outline-secondary btn-sm" onclick="collapseAll()">Fold alt sammen</button>
</div>
<div class="d-flex gap-2">
<button class="btn btn-danger" onclick="rejectSelected()">
<i class="bi bi-x-circle"></i> Afvis Valgte
</button>
<button class="btn btn-success" onclick="approveAll()">
<i class="bi bi-check-circle"></i> Godkend Alle
</button>
</div>
</div>
<!-- Approval Table -->
<div id="entries-container">
<!-- Populated by JS -->
</div>
</div>
</div>
<!-- Sticky Action Bar -->
<div id="selection-bar" class="action-bar d-flex justify-content-between align-items-center">
<div>
<span class="fw-bold"><span id="selected-count">0</span> valgte</span>
<span class="text-muted mx-2">|</span>
<span class="text-primary fw-bold"><span id="selected-value">0,00</span> DKK</span>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary" onclick="clearSelection()">Annuller</button>
<button class="btn btn-danger" onclick="rejectSelected()">Afvis</button>
<button class="btn btn-success" onclick="approveSelected()">Godkend</button>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let currentCustomerId = new URLSearchParams(window.location.search).get('customer_id');
let currentTimeId = new URLSearchParams(window.location.search).get('time_id');
let currentCustomerData = null;
let customerList = [];
let pendingEntries = [];
let selectedEntries = new Set();
// Config
const DEFAULT_RATE = 1200;
document.addEventListener('DOMContentLoaded', () => {
loadCustomerList();
if (currentCustomerId) {
loadCustomerEntries(currentCustomerId);
}
});
async function loadCustomerList() {
try {
// Fetch stats to know which customers have pending entries
const response = await fetch('/api/v1/timetracking/wizard/stats');
const stats = await response.json();
customerList = stats.filter(c => c.pending_entries > 0);
const select = document.getElementById('customer-select');
select.innerHTML = '<option value="">Vælg kunde...</option>';
customerList.forEach(c => {
const option = document.createElement('option');
option.value = c.customer_id;
option.textContent = `${c.customer_name} (${c.pending_entries})`;
if (parseInt(currentCustomerId) === c.customer_id) {
option.selected = true;
}
select.appendChild(option);
});
if (!currentCustomerId && customerList.length > 0) {
// Determine auto-select logic?
// For now, let user pick
} else if (!currentCustomerId) {
document.getElementById('loading-container').innerHTML = `
<div class="mt-5">
<i class="bi bi-check-circle-fill text-success display-1"></i>
<h3 class="mt-3">Alt er ajour!</h3>
<p class="text-muted">Ingen kunder afventer godkendelse lige nu.</p>
</div>
`;
}
} catch (error) {
console.error('Error loading customers:', error);
}
}
function changeCustomer(customerId) {
if (!customerId) return;
window.history.pushState({}, '', `?customer_id=${customerId}`);
currentCustomerId = customerId;
loadCustomerEntries(customerId);
}
async function loadCustomerEntries(customerId) {
document.getElementById('loading-container').classList.remove('d-none');
document.getElementById('main-content').classList.add('d-none');
document.getElementById('empty-state').classList.add('d-none');
try {
// First get customer details for rate
const custResponse = await fetch('/api/v1/timetracking/wizard/stats');
const allStats = await custResponse.json();
currentCustomerData = allStats.find(c => c.customer_id == customerId);
if (!currentCustomerData) {
// Might happen if there are no pending stats but we force-navigated?
// Fallback fetch
}
// Fetch entries. Since we don't have a direct "get all pending for customer" endpoint,
// we might need to iterate or create a new endpoint.
// But wait, the existing wizard.html fetches entries ONE BY ONE or by case.
// We need a way to get ALL pending entries for a customer.
// Let's use the router endpoint: /api/v1/timetracking/customers/{id}/times (but filter for pending)
const timesResponse = await fetch(`/api/v1/timetracking/customers/${customerId}/times`);
const timesData = await timesResponse.json();
// Filter only pending
// The endpoint returns ALL times. We filter in JS for now.
pendingEntries = timesData.times.filter(t => t.status === 'pending');
if (pendingEntries.length === 0) {
document.getElementById('loading-container').classList.add('d-none');
document.getElementById('empty-state').classList.remove('d-none');
return;
}
// Organize by Case
renderEntries();
updateSummary();
document.getElementById('loading-container').classList.add('d-none');
document.getElementById('main-content').classList.remove('d-none');
} catch (error) {
console.error('Error loading entries:', error);
document.getElementById('loading-container').innerHTML =
`<div class="alert alert-danger">Fejl ved indlæsning: ${error.message}</div>`;
}
}
function renderEntries() {
const container = document.getElementById('entries-container');
container.innerHTML = '';
// Group by Case ID
const groups = {};
pendingEntries.forEach(entry => {
const caseId = entry.case_id || 'no_case';
if (!groups[caseId]) {
groups[caseId] = {
title: entry.case_title || 'Ingen Case / Diverse',
meta: entry, // store for header info
entries: []
};
}
groups[caseId].entries.push(entry);
});
// Render each group
Object.entries(groups).forEach(([caseId, group]) => {
const groupDiv = document.createElement('div');
groupDiv.className = 'approval-table mb-4 animate-in';
// Header
const meta = group.meta;
const caseInfo = [];
if (meta.case_type) caseInfo.push(`<span class="badge bg-light text-dark border">${meta.case_type}</span>`);
if (meta.case_priority) caseInfo.push(`<span class="badge bg-light text-dark border">${meta.case_priority}</span>`);
const contactName = getContactName(meta); // Helper needed?
const headerHtml = `
<div class="case-group-header p-3">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="case-title d-flex align-items-center gap-2">
${caseId === 'no_case' ? '<i class="bi bi-person-workspace text-secondary"></i>' : '<i class="bi bi-folder-fill text-primary"></i>'}
${meta.case_vtiger_id ? `<a href="https://bmcnetworks.od2.vtiger.com/index.php?module=HelpDesk&view=Detail&record=${meta.case_vtiger_id.replace('39x', '')}" target="_blank" class="text-decoration-none text-dark">${group.title} <i class="bi bi-box-arrow-up-right small ms-1"></i></a>` : `<span>${group.title}</span>`}
<span class="badge bg-white text-dark border ms-2">${meta.case_type || 'Support'}</span>
<span class="badge ${getPriorityBadgeClass(meta.case_priority)} ms-1">${meta.case_priority || 'Normal'}</span>
</div>
<!-- Case Description -->
${meta.case_description ? `
<div class="case-description-box">
<div class="fw-bold text-dark mb-1" style="font-size: 0.8rem; text-transform: uppercase;">Opgavebeskrivelse</div>
${truncateText(stripHtml(meta.case_description), 300)}
</div>
` : ''}
<div class="case-meta mt-2 text-muted small">
${contactName ? `<i class="bi bi-person me-1"></i> ${contactName}` : ''}
</div>
</div>
<div class="text-end">
<span class="badge bg-white text-primary border fs-6">${group.entries.length} poster</span>
</div>
</div>
</div>
`;
// Table
let rowsHtml = '';
group.entries.forEach(entry => {
rowsHtml += `
<tr class="entry-row" id="row-${entry.id}">
<td style="width: 40px;">
<input type="checkbox" class="form-check-input entry-checkbox"
data-case-id="${caseId}"
value="${entry.id}" onchange="toggleSelection(${entry.id})">
</td>
<td style="width: 100px; white-space: nowrap;">
<div class="fw-bold">${formatDate(entry.worked_date)}</div>
<div class="small text-muted">${entry.user_name || 'Ukendt'}</div>
</td>
<td class="description-cell">
<div class="description-text">${entry.description || '<em class="text-muted">Ingen beskrivelse</em>'}</div>
</td>
<td style="width: 100px;" class="text-center">
<i class="bi bi-check-circle-fill fs-4 billable-toggle ${entry.billable !== false ? 'active' : ''}"
onclick="toggleBillable(${entry.id})"
title="Fakturerbar?"></i>
</td>
<td style="width: 40px;" class="text-center">
<i class="bi bi-car-front fs-5 travel-toggle ${entry.is_travel ? 'active' : ''}"
onclick="toggleTravel(${entry.id})"
title="Kørsel?"></i>
</td>
<td style="width: 150px;">
<div class="small text-muted mb-1">Registreret: ${formatHoursMinutes(entry.original_hours)}</div>
<div class="input-group input-group-sm">
<input type="number" class="form-control hours-input"
id="hours-${entry.id}"
value="${roundUpToQuarter(entry.original_hours)}"
step="0.25" min="0.25"
onchange="updateRowTotal(${entry.id})">
<span class="input-group-text">t</span>
</div>
</td>
<td style="width: 120px;" class="text-end fw-bold">
<span id="total-${entry.id}">-</span>
</td>
<td style="width: 100px;" class="text-end">
<button class="btn btn-sm btn-outline-success" onclick="approveOne(${entry.id})" title="Godkend">
<i class="bi bi-check-lg"></i>
</button>
</td>
</tr>
`;
});
groupDiv.innerHTML = `
${headerHtml}
<div class="table-responsive">
<table class="table mb-0">
<thead class="table-light">
<tr>
<th><input type="checkbox" class="form-check-input" onchange="toggleGroupSelection(this, '${caseId}')"></th>
<th>Dato</th>
<th>Beskrivelse</th>
<th class="text-center">Fakt.</th>
<th class="text-center">Kørsel</th>
<th>Timer</th>
<th class="text-end">Total (DKK)</th>
<th></th>
</tr>
</thead>
<tbody>
${rowsHtml}
</tbody>
</table>
</div>
`;
container.appendChild(groupDiv);
// Note: We used to update totals here, but that crashed updateSummary loop
// because not all groups were rendered yet.
});
// Init totals AFTER all rows are in the DOM
pendingEntries.forEach(e => updateRowTotal(e.id));
// Highlight specific time_id if present
if (currentTimeId) {
const row = document.getElementById(`row-${currentTimeId}`);
if (row) {
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
row.classList.add('table-warning'); // Bootstrap highlight
setTimeout(() => row.classList.remove('table-warning'), 3000);
}
}
}
function updateSummary() {
const name = currentCustomerData?.customer_name || 'Kunde';
const rate = currentCustomerData?.customer_rate || DEFAULT_RATE;
document.getElementById('customer-name').textContent = name;
document.getElementById('hourly-rate').textContent = parseFloat(rate).toFixed(2);
document.getElementById('pending-count').textContent = pendingEntries.length;
let totalValue = 0;
pendingEntries.forEach(entry => {
const hoursInput = document.getElementById(`hours-${entry.id}`);
if (!hoursInput) return; // Skip if not rendered yet
const hours = parseFloat(hoursInput.value || entry.original_hours);
// Only count if billable
const toggle = document.querySelector(`#row-${entry.id} .billable-toggle`);
const isBillable = toggle && toggle.classList.contains('active');
if (isBillable) {
totalValue += hours * rate;
}
});
document.getElementById('total-value').textContent = totalValue.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function updateRowTotal(entryId) {
const row = document.getElementById(`row-${entryId}`);
if (!row) return;
const rate = parseFloat(currentCustomerData?.customer_rate || DEFAULT_RATE);
const hoursInput = document.getElementById(`hours-${entryId}`);
const hours = parseFloat(hoursInput.value || 0);
const toggle = document.querySelector(`#row-${entryId} .billable-toggle`);
const isBillable = toggle && toggle.classList.contains('active');
const totalElem = document.getElementById(`total-${entryId}`);
if (isBillable) {
const total = hours * rate;
totalElem.textContent = total.toLocaleString('da-DK', { minimumFractionDigits: 2 });
totalElem.classList.remove('text-muted', 'text-decoration-line-through');
row.style.opacity = '1';
} else {
totalElem.textContent = '0,00';
totalElem.classList.add('text-muted', 'text-decoration-line-through');
// Visual feedback for non-billable
row.style.opacity = '0.7';
}
updateSummary();
updateSelectionBar();
}
function toggleBillable(entryId) {
const toggle = document.querySelector(`#row-${entryId} .billable-toggle`);
toggle.classList.toggle('active');
updateRowTotal(entryId);
}
function toggleTravel(entryId) {
const toggle = document.querySelector(`#row-${entryId} .travel-toggle`);
toggle.classList.toggle('active');
}
function formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit' });
}
function formatHoursMinutes(decimalHours) {
if (!decimalHours) return '0t 0m';
const hours = Math.floor(decimalHours);
const minutes = Math.floor((decimalHours - hours) * 60);
if (hours === 0) {
return `${minutes}m`;
} else if (minutes === 0) {
return `${hours}t`;
} else {
return `${hours}t ${minutes}m`;
}
}
function roundUpToQuarter(hours) {
// Round up to nearest 0.5 (30 minutes)
return Math.ceil(hours * 2) / 2;
}
function getContactName(meta) {
// vtiger data extraction if needed
return null;
}
// --- Selection Logic ---
function getPriorityBadgeClass(priority) {
if (!priority) return 'bg-light text-dark border';
const p = priority.toLowerCase();
if (p.includes('høj') || p.includes('urgent') || p.includes('high') || p.includes('critical')) return 'bg-danger text-white';
if (p.includes('mellem') || p.includes('medium')) return 'bg-warning text-dark';
if (p.includes('lav') || p.includes('low')) return 'bg-success text-white';
return 'bg-light text-dark border';
}
function stripHtml(html) {
if (!html) return '';
const tmp = document.createElement("DIV");
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || "";
}
function truncateText(text, length) {
if (!text) return '';
if (text.length <= length) return text;
return text.substring(0, length) + '...';
}
function toggleSelection(entryId) {
if (selectedEntries.has(entryId)) {
selectedEntries.delete(entryId);
} else {
selectedEntries.add(entryId);
}
updateSelectionBar();
}
function toggleGroupSelection(checkbox, caseId) {
const checkboxes = document.querySelectorAll(`.entry-checkbox[data-case-id="${caseId}"]`);
checkboxes.forEach(cb => {
if (cb.checked !== checkbox.checked) {
cb.checked = checkbox.checked;
// Update selection set via toggleSelection logic
// Since toggleSelection expects the ID and relies on current state,
// we can just call it if the state mismatch.
// However, toggleSelection toggles based on set presence.
// It's safer to manually manipulate the set here.
const id = parseInt(cb.value);
if (checkbox.checked) {
selectedEntries.add(id);
} else {
selectedEntries.delete(id);
}
}
});
updateSelectionBar();
}
function updateSelectionBar() {
const bar = document.getElementById('selection-bar');
const count = selectedEntries.size;
if (count > 0) {
bar.classList.add('visible');
document.getElementById('selected-count').textContent = count;
// Calc value of selection
let val = 0;
const rate = parseFloat(currentCustomerData?.customer_rate || DEFAULT_RATE);
selectedEntries.forEach(id => {
const hours = parseFloat(document.getElementById(`hours-${id}`).value || 0);
const isBillable = document.querySelector(`#row-${id} .billable-toggle`).classList.contains('active');
if (isBillable) val += hours * rate;
});
document.getElementById('selected-value').textContent = val.toLocaleString('da-DK', {minimumFractionDigits: 2});
} else {
bar.classList.remove('visible');
}
}
function clearSelection() {
selectedEntries.clear();
document.querySelectorAll('.entry-checkbox').forEach(cb => cb.checked = false);
updateSelectionBar();
}
// --- Actions ---
async function approveOne(entryId) {
await processApproval([entryId]);
}
async function approveSelected() {
await processApproval(Array.from(selectedEntries));
}
async function approveAll() {
const allIds = pendingEntries.map(e => e.id);
if (confirm(`Er du sikker på du vil godkende alle ${allIds.length} tidsregistreringer?`)) {
await processApproval(allIds);
}
}
async function processApproval(ids) {
// Prepare payload with current values (hours, billable state)
const items = ids.map(id => {
return {
id: id,
billable_hours: parseFloat(document.getElementById(`hours-${id}`).value),
hourly_rate: currentCustomerData?.customer_rate || DEFAULT_RATE,
billable: document.querySelector(`#row-${id} .billable-toggle`).classList.contains('active'),
is_travel: document.querySelector(`#row-${id} .travel-toggle`).classList.contains('active')
};
});
// We accept list approval via a loop or new bulk endpoint.
// Let's loop for now to reuse existing endpoint or create a bulk one.
// It's safer to implement a bulk endpoint in backend, but for speed let's iterate.
// Actually, let's just make a specialized bulk endpoint or reuse the loop in JS
let successCount = 0;
// Show loading overlay
document.getElementById('loading-container').classList.remove('d-none');
document.getElementById('loading-container').innerHTML = '<div class="spinner-border text-primary"></div><p>Behandler godkendelser...</p>';
document.getElementById('main-content').classList.add('d-none');
try {
for (const item of items) {
await fetch(`/api/v1/timetracking/wizard/approve/${item.id}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(item)
});
successCount++;
}
// Reload
loadCustomerEntries(currentCustomerId);
// Also refresh stats
loadCustomerList();
clearSelection();
} catch (e) {
alert('Fejl under godkendelse: ' + e.message);
loadCustomerEntries(currentCustomerId); // Refresh anyway
}
}
async function rejectSelected() {
const ids = Array.from(selectedEntries);
if (ids.length === 0) return;
const note = prompt("Begrundelse for afvisning:");
if (note === null) return;
// Loop reject
for (const id of ids) {
await fetch(`/api/v1/timetracking/wizard/reject/${id}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ rejection_note: note })
});
}
loadCustomerEntries(currentCustomerId);
clearSelection();
}
</script>
{% endblock %}

View File

@ -48,6 +48,10 @@ else
exit 1 exit 1
fi fi
# Clean up old images (keep last 14 days)
echo "🧹 Cleaning up images older than 14 days..."
ssh $PROD_SERVER "sudo podman image prune -a -f --filter 'until=336h'"
echo "" echo ""
echo "🎉 Production is now running version $VERSION" echo "🎉 Production is now running version $VERSION"
echo " http://172.16.31.183:8000" echo " http://172.16.31.183:8000"