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:
parent
19827d03a8
commit
a1d4696005
@ -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>
|
||||||
|
|||||||
@ -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']),
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
273
app/timetracking/frontend/registrations.html
Normal file
273
app/timetracking/frontend/registrations.html
Normal 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 %}
|
||||||
@ -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"""
|
||||||
|
|||||||
@ -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
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
784
app/timetracking/frontend/wizard2.html
Normal file
784
app/timetracking/frontend/wizard2.html
Normal 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 %}
|
||||||
@ -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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user