feat(timetracking): Implement time tracking module with frontend views, HTML templates, and database migrations

- Added FastAPI router for time tracking views including dashboard, wizard, and orders.
- Created HTML templates for the time tracking wizard with responsive design and Bootstrap integration.
- Developed SQL migration script for the time tracking module, including tables for customers, cases, time entries, orders, and audit logs.
- Introduced a script to list all registered routes, focusing on time tracking routes.
- Added test script to verify route registration and specifically check for time tracking routes.
This commit is contained in:
Christian 2025-12-09 22:46:30 +01:00
parent 3a8288f5a1
commit 34555d1e36
19 changed files with 5016 additions and 0 deletions

View File

@ -33,6 +33,27 @@ class Settings(BaseSettings):
ECONOMIC_READ_ONLY: bool = True
ECONOMIC_DRY_RUN: bool = True
# vTiger CRM Integration
VTIGER_URL: str = ""
VTIGER_USERNAME: str = ""
VTIGER_API_KEY: str = ""
VTIGER_PASSWORD: str = "" # Fallback hvis API key ikke virker
# Time Tracking Module - vTiger Integration (Isoleret)
TIMETRACKING_VTIGER_READ_ONLY: bool = True # 🚨 SAFETY: Bloker ALLE skrivninger til vTiger
TIMETRACKING_VTIGER_DRY_RUN: bool = True # 🚨 SAFETY: Log uden at synkronisere
# Time Tracking Module - e-conomic Integration (Isoleret)
TIMETRACKING_ECONOMIC_READ_ONLY: bool = True # 🚨 SAFETY: Bloker ALLE skrivninger til e-conomic
TIMETRACKING_ECONOMIC_DRY_RUN: bool = True # 🚨 SAFETY: Log uden at eksportere
TIMETRACKING_EXPORT_TYPE: str = "draft" # draft|booked (draft er sikrest)
# Time Tracking Module - Business Logic
TIMETRACKING_DEFAULT_HOURLY_RATE: float = 850.00 # DKK pr. time (fallback)
TIMETRACKING_AUTO_ROUND: bool = False # Auto-afrund til nærmeste 0.5 time
TIMETRACKING_ROUND_INCREMENT: float = 0.5 # Afrundingsinterval (0.25, 0.5, 1.0)
TIMETRACKING_REQUIRE_APPROVAL: bool = True # Kræv manuel godkendelse (ikke auto-approve)
# Ollama AI Integration
OLLAMA_ENDPOINT: str = "http://ai_direct.cs.blaahund.dk"
OLLAMA_MODEL: str = "qwen2.5-coder:7b" # qwen2.5-coder fungerer bedre til JSON udtrækning

View File

@ -0,0 +1,24 @@
"""
Time Tracking & Billing Module (Isoleret)
==========================================
Dette modul er 100% isoleret fra resten af BMC Hub.
- Alle data gemmes i tmodule_* tabeller
- Ingen ændringer sker eksisterende Hub-data
- Modulet kan slettes fuldstændigt uden sideeffekter
Formål:
- Importere tidsregistreringer fra vTiger (read-only)
- Manuel godkendelse via wizard
- Generere ordrer fra godkendte tider
- Eksportere til e-conomic som draft orders
Safety Flags (altid aktiveret som standard):
- TIMETRACKING_VTIGER_READ_ONLY = True
- TIMETRACKING_VTIGER_DRY_RUN = True
- TIMETRACKING_ECONOMIC_READ_ONLY = True
- TIMETRACKING_ECONOMIC_DRY_RUN = True
"""
__version__ = "1.0.0"
__author__ = "BMC Hub Development Team"

View File

@ -0,0 +1,5 @@
"""Time Tracking Module - Backend"""
from .router import router
__all__ = ["router"]

View File

@ -0,0 +1,280 @@
"""
Audit Logging Service for Time Tracking Module
===============================================
Fuld sporbarhed af alle handlinger i modulet.
Alle events logges til tmodule_sync_log for compliance.
"""
import logging
import json
from datetime import datetime
from typing import Optional, Dict, Any
from app.core.database import execute_insert
from app.timetracking.backend.models import TModuleSyncLogCreate
logger = logging.getLogger(__name__)
class AuditLogger:
"""
Service til at logge alle module events.
Event Types:
- sync_started, sync_completed, sync_failed
- approval, rejection, bulk_approval
- order_created, order_updated, order_cancelled
- export_started, export_completed, export_failed
- module_installed, module_uninstalled
"""
@staticmethod
def log_event(
event_type: str,
entity_type: Optional[str] = None,
entity_id: Optional[int] = None,
user_id: Optional[int] = None,
details: Optional[Dict[str, Any]] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None
) -> int:
"""
Log en event til tmodule_sync_log.
Args:
event_type: Type af event (sync_started, approval, etc.)
entity_type: Type af entitet (time_entry, order, customer, case)
entity_id: ID entiteten
user_id: ID brugeren der foretog handlingen
details: Ekstra detaljer som JSON
ip_address: IP-adresse
user_agent: User agent string
Returns:
ID den oprettede log-entry
"""
try:
# Konverter details til JSON-string hvis det er en dict
details_json = json.dumps(details) if details else None
query = """
INSERT INTO tmodule_sync_log
(event_type, entity_type, entity_id, user_id, details, ip_address, user_agent)
VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s)
RETURNING id
"""
log_id = execute_insert(
query,
(event_type, entity_type, entity_id, user_id, details_json, ip_address, user_agent)
)
logger.debug(f"📝 Logged event: {event_type} (ID: {log_id})")
return log_id
except Exception as e:
logger.error(f"❌ Failed to log event {event_type}: {e}")
# Don't raise - audit logging failure shouldn't break main flow
return 0
@staticmethod
def log_sync_started(user_id: Optional[int] = None) -> int:
"""Log start af vTiger sync"""
return AuditLogger.log_event(
event_type="sync_started",
user_id=user_id,
details={"timestamp": datetime.now().isoformat()}
)
@staticmethod
def log_sync_completed(
stats: Dict[str, Any],
user_id: Optional[int] = None
) -> int:
"""Log succesfuld sync med statistik"""
return AuditLogger.log_event(
event_type="sync_completed",
user_id=user_id,
details=stats
)
@staticmethod
def log_sync_failed(
error: str,
user_id: Optional[int] = None
) -> int:
"""Log fejlet sync"""
return AuditLogger.log_event(
event_type="sync_failed",
user_id=user_id,
details={"error": error, "timestamp": datetime.now().isoformat()}
)
@staticmethod
def log_approval(
time_id: int,
original_hours: float,
approved_hours: float,
rounded_to: Optional[float],
note: Optional[str],
user_id: Optional[int] = None
) -> int:
"""Log godkendelse af tidsregistrering"""
return AuditLogger.log_event(
event_type="approval",
entity_type="time_entry",
entity_id=time_id,
user_id=user_id,
details={
"original_hours": original_hours,
"approved_hours": approved_hours,
"rounded_to": rounded_to,
"note": note,
"timestamp": datetime.now().isoformat()
}
)
@staticmethod
def log_rejection(
time_id: int,
reason: Optional[str],
user_id: Optional[int] = None
) -> int:
"""Log afvisning af tidsregistrering"""
return AuditLogger.log_event(
event_type="rejection",
entity_type="time_entry",
entity_id=time_id,
user_id=user_id,
details={
"reason": reason,
"timestamp": datetime.now().isoformat()
}
)
@staticmethod
def log_order_created(
order_id: int,
customer_id: int,
total_hours: float,
total_amount: float,
line_count: int,
user_id: Optional[int] = None
) -> int:
"""Log oprettelse af ordre"""
return AuditLogger.log_event(
event_type="order_created",
entity_type="order",
entity_id=order_id,
user_id=user_id,
details={
"customer_id": customer_id,
"total_hours": total_hours,
"total_amount": total_amount,
"line_count": line_count,
"timestamp": datetime.now().isoformat()
}
)
@staticmethod
def log_order_updated(
order_id: int,
changes: Dict[str, Any],
user_id: Optional[int] = None
) -> int:
"""Log opdatering af ordre"""
return AuditLogger.log_event(
event_type="order_updated",
entity_type="order",
entity_id=order_id,
user_id=user_id,
details={
"changes": changes,
"timestamp": datetime.now().isoformat()
}
)
@staticmethod
def log_order_cancelled(
order_id: int,
reason: Optional[str],
user_id: Optional[int] = None
) -> int:
"""Log annullering af ordre"""
return AuditLogger.log_event(
event_type="order_cancelled",
entity_type="order",
entity_id=order_id,
user_id=user_id,
details={
"reason": reason,
"timestamp": datetime.now().isoformat()
}
)
@staticmethod
def log_export_started(
order_id: int,
user_id: Optional[int] = None
) -> int:
"""Log start af e-conomic export"""
return AuditLogger.log_event(
event_type="export_started",
entity_type="order",
entity_id=order_id,
user_id=user_id,
details={"timestamp": datetime.now().isoformat()}
)
@staticmethod
def log_export_completed(
order_id: int,
economic_draft_id: Optional[int],
economic_order_number: Optional[str],
dry_run: bool,
user_id: Optional[int] = None
) -> int:
"""Log succesfuld export til e-conomic"""
return AuditLogger.log_event(
event_type="export_completed",
entity_type="order",
entity_id=order_id,
user_id=user_id,
details={
"economic_draft_id": economic_draft_id,
"economic_order_number": economic_order_number,
"dry_run": dry_run,
"timestamp": datetime.now().isoformat()
}
)
@staticmethod
def log_export_failed(
order_id: int,
error: str,
user_id: Optional[int] = None
) -> int:
"""Log fejlet export til e-conomic"""
return AuditLogger.log_event(
event_type="export_failed",
entity_type="order",
entity_id=order_id,
user_id=user_id,
details={
"error": error,
"timestamp": datetime.now().isoformat()
}
)
@staticmethod
def log_module_uninstalled(user_id: Optional[int] = None) -> int:
"""Log modul-uninstall"""
return AuditLogger.log_event(
event_type="module_uninstalled",
user_id=user_id,
details={"timestamp": datetime.now().isoformat()}
)
# Singleton instance
audit = AuditLogger()

View File

@ -0,0 +1,300 @@
"""
e-conomic Export Service for Time Tracking Module
==================================================
🚨 KRITISK: Denne service skal respektere safety flags.
Eksporterer ordrer til e-conomic som draft orders.
Safety Flags:
- TIMETRACKING_ECONOMIC_READ_ONLY = True (default)
- TIMETRACKING_ECONOMIC_DRY_RUN = True (default)
"""
import logging
from typing import Optional, Dict, Any
import aiohttp
from fastapi import HTTPException
from app.core.config import settings
from app.core.database import execute_query, execute_update
from app.timetracking.backend.models import (
TModuleEconomicExportRequest,
TModuleEconomicExportResult
)
from app.timetracking.backend.audit import audit
logger = logging.getLogger(__name__)
class EconomicExportService:
"""
e-conomic integration for Time Tracking Module.
🔒 SAFETY-FIRST service - all writes controlled by flags.
"""
def __init__(self):
self.api_url = settings.ECONOMIC_API_URL
self.app_secret_token = settings.ECONOMIC_APP_SECRET_TOKEN
self.agreement_grant_token = settings.ECONOMIC_AGREEMENT_GRANT_TOKEN
# Safety flags
self.read_only = settings.TIMETRACKING_ECONOMIC_READ_ONLY
self.dry_run = settings.TIMETRACKING_ECONOMIC_DRY_RUN
self.export_type = settings.TIMETRACKING_EXPORT_TYPE
# Log safety status
if self.read_only:
logger.warning("🔒 TIMETRACKING e-conomic READ-ONLY mode: Enabled")
if self.dry_run:
logger.warning("🏃 TIMETRACKING e-conomic DRY-RUN mode: Enabled")
if not self.read_only:
logger.error("⚠️ WARNING: TIMETRACKING e-conomic READ-ONLY disabled!")
def _get_headers(self) -> Dict[str, str]:
"""Get e-conomic API headers"""
return {
'X-AppSecretToken': self.app_secret_token,
'X-AgreementGrantToken': self.agreement_grant_token,
'Content-Type': 'application/json'
}
def _check_write_permission(self, operation: str) -> bool:
"""
Check om write operation er tilladt.
Returns:
True hvis operationen udføres, False hvis blokeret
"""
if self.read_only:
logger.error(f"🚫 BLOCKED: {operation} - READ_ONLY mode enabled")
return False
if self.dry_run:
logger.warning(f"🏃 DRY-RUN: {operation} - Would execute but not sending")
return False
logger.warning(f"⚠️ EXECUTING WRITE: {operation}")
return True
async def test_connection(self) -> bool:
"""Test e-conomic connection"""
try:
logger.info("🔍 Testing e-conomic connection...")
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.api_url}/self",
headers=self._get_headers(),
timeout=aiohttp.ClientTimeout(total=10)
) as response:
if response.status == 200:
logger.info("✅ e-conomic connection successful")
return True
else:
logger.error(f"❌ e-conomic connection failed: {response.status}")
return False
except Exception as e:
logger.error(f"❌ e-conomic connection error: {e}")
return False
async def export_order(
self,
request: TModuleEconomicExportRequest,
user_id: Optional[int] = None
) -> TModuleEconomicExportResult:
"""
Eksporter ordre til e-conomic som draft order.
Args:
request: Export request med order_id
user_id: ID brugeren der eksporterer
Returns:
Export result med success status
"""
try:
# Hent ordre med linjer
order_query = """
SELECT o.*, c.name as customer_name, c.vtiger_id as customer_vtiger_id
FROM tmodule_orders o
JOIN tmodule_customers c ON o.customer_id = c.id
WHERE o.id = %s
"""
order = execute_query(order_query, (request.order_id,), fetchone=True)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
# Check if already exported
if order['economic_draft_id'] and not request.force:
raise HTTPException(
status_code=400,
detail=f"Order already exported (draft ID: {order['economic_draft_id']}). Use force=true to re-export."
)
# Hent ordre-linjer
lines_query = """
SELECT * FROM tmodule_order_lines
WHERE order_id = %s
ORDER BY line_number
"""
lines = execute_query(lines_query, (request.order_id,))
if not lines:
raise HTTPException(
status_code=400,
detail="Order has no lines"
)
# Log export start
audit.log_export_started(
order_id=request.order_id,
user_id=user_id
)
# Check safety flags
operation = f"Export order {request.order_id} to e-conomic"
if not self._check_write_permission(operation):
# Dry-run or read-only mode
result = TModuleEconomicExportResult(
success=True,
dry_run=True,
order_id=request.order_id,
economic_draft_id=None,
economic_order_number=None,
message=f"DRY-RUN: Would export order {order['order_number']} to e-conomic",
details={
"order_number": order['order_number'],
"customer_name": order['customer_name'],
"total_amount": float(order['total_amount']),
"line_count": len(lines),
"read_only": self.read_only,
"dry_run": self.dry_run
}
)
# Log dry-run completion
audit.log_export_completed(
order_id=request.order_id,
economic_draft_id=None,
economic_order_number=None,
dry_run=True,
user_id=user_id
)
return result
# REAL EXPORT (kun hvis safety flags er disabled)
logger.warning(f"⚠️ REAL EXPORT STARTING for order {request.order_id}")
# TODO: Implementer rigtig e-conomic API call her
# Denne kode vil kun køre hvis READ_ONLY og DRY_RUN begge er False
# Build e-conomic draft order payload
economic_payload = {
"date": order['order_date'].isoformat(),
"currency": "DKK",
"exchangeRate": 100,
"netAmount": float(order['subtotal']),
"grossAmount": float(order['total_amount']),
"vatAmount": float(order['vat_amount']),
"notes": {
"heading": f"Tidsregistrering - {order['order_number']}",
"textLine1": order.get('notes', '')
},
"lines": [
{
"lineNumber": line['line_number'],
"description": line['description'],
"quantity": float(line['quantity']),
"unitNetPrice": float(line['unit_price']),
"totalNetAmount": float(line['line_total'])
}
for line in lines
]
}
# Call e-conomic API
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.api_url}/orders/drafts",
headers=self._get_headers(),
json=economic_payload,
timeout=aiohttp.ClientTimeout(total=30)
) as response:
if response.status not in [200, 201]:
error_text = await response.text()
logger.error(f"❌ e-conomic export failed: {response.status} - {error_text}")
# Log failed export
audit.log_export_failed(
order_id=request.order_id,
error=error_text,
user_id=user_id
)
raise HTTPException(
status_code=response.status,
detail=f"e-conomic API error: {error_text}"
)
result_data = await response.json()
economic_draft_id = result_data.get('draftOrderNumber')
economic_order_number = result_data.get('orderNumber', str(economic_draft_id))
# Update order med e-conomic IDs
execute_update(
"""UPDATE tmodule_orders
SET economic_draft_id = %s,
economic_order_number = %s,
exported_at = CURRENT_TIMESTAMP,
exported_by = %s,
status = 'exported'
WHERE id = %s""",
(economic_draft_id, economic_order_number, user_id, request.order_id)
)
# Log successful export
audit.log_export_completed(
order_id=request.order_id,
economic_draft_id=economic_draft_id,
economic_order_number=economic_order_number,
dry_run=False,
user_id=user_id
)
logger.info(f"✅ Exported order {request.order_id} → e-conomic draft {economic_draft_id}")
return TModuleEconomicExportResult(
success=True,
dry_run=False,
order_id=request.order_id,
economic_draft_id=economic_draft_id,
economic_order_number=economic_order_number,
message=f"Successfully exported to e-conomic draft {economic_draft_id}",
details=result_data
)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error exporting order: {e}")
# Log failed export
audit.log_export_failed(
order_id=request.order_id,
error=str(e),
user_id=user_id
)
raise HTTPException(status_code=500, detail=str(e))
# Singleton instance
economic_service = EconomicExportService()

View File

@ -0,0 +1,410 @@
"""
Pydantic Models for Time Tracking Module
=========================================
Alle models repræsenterer data fra tmodule_* tabeller.
Ingen afhængigheder til eksisterende Hub-models.
"""
from datetime import date, datetime
from decimal import Decimal
from typing import List, Optional
from pydantic import BaseModel, Field, field_validator
# ============================================================================
# KUNDE MODELS
# ============================================================================
class TModuleCustomerBase(BaseModel):
"""Base model for customer"""
vtiger_id: str = Field(..., description="vTiger Account ID")
name: str = Field(..., min_length=1, max_length=255)
email: Optional[str] = Field(None, max_length=255)
hourly_rate: Optional[Decimal] = Field(None, ge=0, description="DKK pr. time")
hub_customer_id: Optional[int] = Field(None, description="Reference til customers.id (read-only)")
class TModuleCustomerCreate(TModuleCustomerBase):
"""Model for creating a customer"""
vtiger_data: Optional[dict] = Field(default_factory=dict)
class TModuleCustomer(TModuleCustomerBase):
"""Full customer model with DB fields"""
id: int
sync_hash: Optional[str] = None
created_at: datetime
updated_at: Optional[datetime] = None
last_synced_at: datetime
class Config:
from_attributes = True
# ============================================================================
# CASE MODELS
# ============================================================================
class TModuleCaseBase(BaseModel):
"""Base model for case/project"""
vtiger_id: str = Field(..., description="vTiger HelpDesk/ProjectTask ID")
customer_id: int = Field(..., gt=0)
title: str = Field(..., min_length=1, max_length=500)
description: Optional[str] = None
status: Optional[str] = Field(None, max_length=50)
priority: Optional[str] = Field(None, max_length=50)
module_type: Optional[str] = Field(None, max_length=50, description="HelpDesk, ProjectTask, etc.")
class TModuleCaseCreate(TModuleCaseBase):
"""Model for creating a case"""
vtiger_data: Optional[dict] = Field(default_factory=dict)
class TModuleCase(TModuleCaseBase):
"""Full case model with DB fields"""
id: int
sync_hash: Optional[str] = None
created_at: datetime
updated_at: Optional[datetime] = None
last_synced_at: datetime
class Config:
from_attributes = True
# ============================================================================
# TIDSREGISTRERING MODELS
# ============================================================================
class TModuleTimeBase(BaseModel):
"""Base model for time entry"""
vtiger_id: str = Field(..., description="vTiger ModComments ID")
case_id: int = Field(..., gt=0)
customer_id: int = Field(..., gt=0)
description: Optional[str] = None
original_hours: Decimal = Field(..., gt=0, description="Original timer fra vTiger")
worked_date: Optional[date] = None
user_name: Optional[str] = Field(None, max_length=255, description="vTiger bruger")
class TModuleTimeCreate(TModuleTimeBase):
"""Model for creating a time entry"""
vtiger_data: Optional[dict] = Field(default_factory=dict)
class TModuleTimeUpdate(BaseModel):
"""Model for updating time entry (godkendelse)"""
approved_hours: Optional[Decimal] = Field(None, gt=0)
rounded_to: Optional[Decimal] = Field(None, ge=0.25, description="Afrundingsinterval")
approval_note: Optional[str] = None
billable: Optional[bool] = None
status: Optional[str] = Field(None, pattern="^(pending|approved|rejected|billed)$")
class TModuleTimeApproval(BaseModel):
"""Model for wizard approval action"""
time_id: int = Field(..., gt=0)
approved_hours: Decimal = Field(..., gt=0, description="Timer efter godkendelse")
rounded_to: Optional[Decimal] = Field(None, ge=0.25, description="Afrundingsinterval brugt")
approval_note: Optional[str] = Field(None, description="Brugerens note")
billable: bool = Field(True, description="Skal faktureres?")
@field_validator('approved_hours')
@classmethod
def validate_approved_hours(cls, v: Decimal) -> Decimal:
if v <= 0:
raise ValueError("Approved hours must be positive")
# Max 24 timer pr. dag er rimeligt
if v > 24:
raise ValueError("Approved hours cannot exceed 24 per entry")
return v
class TModuleTime(TModuleTimeBase):
"""Full time entry model with DB fields"""
id: int
status: str = Field("pending", pattern="^(pending|approved|rejected|billed)$")
approved_hours: Optional[Decimal] = None
rounded_to: Optional[Decimal] = None
approval_note: Optional[str] = None
billable: bool = True
approved_at: Optional[datetime] = None
approved_by: Optional[int] = None
sync_hash: Optional[str] = None
created_at: datetime
updated_at: Optional[datetime] = None
last_synced_at: datetime
class Config:
from_attributes = True
class TModuleTimeWithContext(TModuleTime):
"""Time entry with case and customer context (for wizard)"""
case_title: str
case_status: Optional[str] = None
customer_name: str
customer_rate: Optional[Decimal] = None
# ============================================================================
# ORDRE MODELS
# ============================================================================
class TModuleOrderLineBase(BaseModel):
"""Base model for order line"""
line_number: int = Field(..., gt=0)
description: str = Field(..., min_length=1)
quantity: Decimal = Field(..., gt=0, description="Timer")
unit_price: Decimal = Field(..., ge=0, description="DKK pr. time")
line_total: Decimal = Field(..., ge=0, description="Total for linje")
case_id: Optional[int] = Field(None, gt=0)
time_entry_ids: List[int] = Field(default_factory=list)
product_number: Optional[str] = Field(None, max_length=50)
account_number: Optional[str] = Field(None, max_length=50)
class TModuleOrderLineCreate(TModuleOrderLineBase):
"""Model for creating order line"""
pass
class TModuleOrderLine(TModuleOrderLineBase):
"""Full order line model"""
id: int
order_id: int
created_at: datetime
class Config:
from_attributes = True
class TModuleOrderBase(BaseModel):
"""Base model for order"""
customer_id: int = Field(..., gt=0)
hub_customer_id: Optional[int] = Field(None, gt=0)
order_date: date = Field(default_factory=date.today)
total_hours: Decimal = Field(0, ge=0)
hourly_rate: Decimal = Field(..., gt=0, description="DKK pr. time")
subtotal: Decimal = Field(0, ge=0)
vat_rate: Decimal = Field(Decimal("25.00"), ge=0, le=100, description="Moms %")
vat_amount: Decimal = Field(0, ge=0)
total_amount: Decimal = Field(0, ge=0)
notes: Optional[str] = None
class TModuleOrderCreate(TModuleOrderBase):
"""Model for creating order"""
lines: List[TModuleOrderLineCreate] = Field(default_factory=list)
class TModuleOrderUpdate(BaseModel):
"""Model for updating order"""
status: Optional[str] = Field(None, pattern="^(draft|exported|sent|cancelled)$")
notes: Optional[str] = None
class TModuleOrder(TModuleOrderBase):
"""Full order model with DB fields"""
id: int
order_number: Optional[str] = None
status: str = Field("draft", pattern="^(draft|exported|sent|cancelled)$")
economic_draft_id: Optional[int] = None
economic_order_number: Optional[str] = None
exported_at: Optional[datetime] = None
exported_by: Optional[int] = None
created_at: datetime
updated_at: Optional[datetime] = None
created_by: Optional[int] = None
class Config:
from_attributes = True
class TModuleOrderWithLines(TModuleOrder):
"""Order with lines included"""
lines: List[TModuleOrderLine] = Field(default_factory=list)
class TModuleOrderDetails(BaseModel):
"""Order details from view (aggregated data)"""
order_id: int
order_number: Optional[str] = None
order_date: date
order_status: str
total_hours: Decimal
total_amount: Decimal
economic_draft_id: Optional[int] = None
customer_name: str
customer_vtiger_id: str
line_count: int
time_entry_count: int
class Config:
from_attributes = True
# ============================================================================
# STATISTIK & WIZARD MODELS
# ============================================================================
class TModuleApprovalStats(BaseModel):
"""Approval statistics per customer (from view)"""
customer_id: int
customer_name: str
customer_vtiger_id: str
total_entries: int
pending_count: int
approved_count: int
rejected_count: int
billed_count: int
total_original_hours: Optional[Decimal] = None
total_approved_hours: Optional[Decimal] = None
latest_work_date: Optional[date] = None
last_sync: Optional[datetime] = None
class Config:
from_attributes = True
class TModuleWizardProgress(BaseModel):
"""Progress for wizard flow"""
customer_id: int
customer_name: str
total_entries: int
approved_entries: int
pending_entries: int
rejected_entries: int
current_case_id: Optional[int] = None
current_case_title: Optional[str] = None
progress_percent: float = Field(0, ge=0, le=100)
@field_validator('progress_percent', mode='before')
@classmethod
def calculate_progress(cls, v, info):
"""Auto-calculate progress if not provided"""
if v == 0 and 'total_entries' in info.data and info.data['total_entries'] > 0:
approved = info.data.get('approved_entries', 0)
rejected = info.data.get('rejected_entries', 0)
total = info.data['total_entries']
return round(((approved + rejected) / total) * 100, 2)
return v
class TModuleWizardNextEntry(BaseModel):
"""Next entry for wizard approval"""
has_next: bool
time_entry: Optional[TModuleTimeWithContext] = None
progress: Optional[TModuleWizardProgress] = None
# ============================================================================
# SYNC & AUDIT MODELS
# ============================================================================
class TModuleSyncStats(BaseModel):
"""Statistics from sync operation"""
customers_imported: int = 0
customers_updated: int = 0
customers_skipped: int = 0
cases_imported: int = 0
cases_updated: int = 0
cases_skipped: int = 0
times_imported: int = 0
times_updated: int = 0
times_skipped: int = 0
errors: int = 0
duration_seconds: float = 0.0
started_at: datetime
completed_at: Optional[datetime] = None
class TModuleSyncLogCreate(BaseModel):
"""Model for creating audit log entry"""
event_type: str = Field(..., max_length=50)
entity_type: Optional[str] = Field(None, max_length=50)
entity_id: Optional[int] = None
user_id: Optional[int] = None
details: Optional[dict] = Field(default_factory=dict)
ip_address: Optional[str] = None
user_agent: Optional[str] = None
class TModuleSyncLog(TModuleSyncLogCreate):
"""Full audit log model"""
id: int
created_at: datetime
class Config:
from_attributes = True
# ============================================================================
# e-conomic EXPORT MODELS
# ============================================================================
class TModuleEconomicExportRequest(BaseModel):
"""Request for exporting order to e-conomic"""
order_id: int = Field(..., gt=0)
force: bool = Field(False, description="Force export even if already exported")
class TModuleEconomicExportResult(BaseModel):
"""Result of e-conomic export"""
success: bool
dry_run: bool = False
order_id: int
economic_draft_id: Optional[int] = None
economic_order_number: Optional[str] = None
message: str
details: Optional[dict] = None
# ============================================================================
# MODULE METADATA
# ============================================================================
class TModuleMetadata(BaseModel):
"""Module metadata"""
id: int
module_version: str
installed_at: datetime
installed_by: Optional[int] = None
last_sync_at: Optional[datetime] = None
is_active: bool
settings: dict = Field(default_factory=dict)
class Config:
from_attributes = True
class TModuleUninstallRequest(BaseModel):
"""Request for module uninstall"""
confirm: bool = Field(..., description="Must be True to proceed")
delete_all_data: bool = Field(..., description="Confirm deletion of ALL module data")
@field_validator('confirm')
@classmethod
def validate_confirm(cls, v: bool) -> bool:
if not v:
raise ValueError("You must confirm uninstall by setting confirm=True")
return v
@field_validator('delete_all_data')
@classmethod
def validate_delete(cls, v: bool) -> bool:
if not v:
raise ValueError("You must confirm data deletion by setting delete_all_data=True")
return v
class TModuleUninstallResult(BaseModel):
"""Result of module uninstall"""
success: bool
message: str
tables_dropped: List[str] = Field(default_factory=list)
views_dropped: List[str] = Field(default_factory=list)
functions_dropped: List[str] = Field(default_factory=list)
rows_deleted: int = 0

View File

@ -0,0 +1,403 @@
"""
Order Generation Service for Time Tracking Module
==================================================
Aggreger godkendte tidsregistreringer til customer orders.
Beregn totals, moms, og opret order lines.
"""
import logging
from typing import List, Optional
from decimal import Decimal
from datetime import date
from fastapi import HTTPException
from app.core.config import settings
from app.core.database import execute_query, execute_insert, execute_update
from app.timetracking.backend.models import (
TModuleOrder,
TModuleOrderWithLines,
TModuleOrderLine,
TModuleOrderCreate,
TModuleOrderLineCreate
)
from app.timetracking.backend.audit import audit
logger = logging.getLogger(__name__)
class OrderService:
"""Service for generating orders from approved time entries"""
@staticmethod
def _get_hourly_rate(customer_id: int, hub_customer_id: Optional[int]) -> Decimal:
"""
Hent timepris for kunde.
Prioritet:
1. customer_id (tmodule_customers.hourly_rate)
2. hub_customer_id (customers.hourly_rate)
3. Default fra settings
"""
try:
# Check module customer
query = "SELECT hourly_rate FROM tmodule_customers WHERE id = %s"
result = execute_query(query, (customer_id,), fetchone=True)
if result and result.get('hourly_rate'):
rate = result['hourly_rate']
logger.info(f"✅ Using tmodule customer rate: {rate} DKK")
return Decimal(str(rate))
# Check Hub customer if linked
if hub_customer_id:
query = "SELECT hourly_rate FROM customers WHERE id = %s"
result = execute_query(query, (hub_customer_id,), fetchone=True)
if result and result.get('hourly_rate'):
rate = result['hourly_rate']
logger.info(f"✅ Using Hub customer rate: {rate} DKK")
return Decimal(str(rate))
# Fallback to default
default_rate = Decimal(str(settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
logger.warning(f"⚠️ No customer rate found, using default: {default_rate} DKK")
return default_rate
except Exception as e:
logger.error(f"❌ Error getting hourly rate: {e}")
# Safe fallback
return Decimal(str(settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
@staticmethod
def generate_order_for_customer(
customer_id: int,
user_id: Optional[int] = None
) -> TModuleOrderWithLines:
"""
Generer ordre for alle godkendte tider for en kunde.
Args:
customer_id: ID fra tmodule_customers
user_id: ID brugeren der opretter
Returns:
Order med lines
"""
try:
# Hent customer info
customer = execute_query(
"SELECT * FROM tmodule_customers WHERE id = %s",
(customer_id,),
fetchone=True
)
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
# Hent godkendte tider for kunden
query = """
SELECT t.*, c.title as case_title
FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id
WHERE t.customer_id = %s
AND t.status = 'approved'
AND t.billable = true
ORDER BY c.id, t.worked_date
"""
approved_times = execute_query(query, (customer_id,))
if not approved_times:
raise HTTPException(
status_code=400,
detail="No approved billable time entries found for customer"
)
logger.info(f"📊 Found {len(approved_times)} approved time entries")
# Get hourly rate
hourly_rate = OrderService._get_hourly_rate(
customer_id,
customer.get('hub_customer_id')
)
# Group by case
case_groups = {}
for time_entry in approved_times:
case_id = time_entry['case_id']
if case_id not in case_groups:
case_groups[case_id] = {
'case_title': time_entry['case_title'],
'entries': []
}
case_groups[case_id]['entries'].append(time_entry)
# Build order lines
order_lines = []
line_number = 1
total_hours = Decimal('0')
for case_id, group in case_groups.items():
# Sum hours for this case
case_hours = sum(
Decimal(str(entry['approved_hours']))
for entry in group['entries']
)
# Build description
entry_count = len(group['entries'])
description = f"{group['case_title']} ({entry_count} tidsregistreringer)"
# Calculate line total
line_total = case_hours * hourly_rate
# Collect time entry IDs
time_entry_ids = [entry['id'] for entry in group['entries']]
order_lines.append(TModuleOrderLineCreate(
line_number=line_number,
description=description,
quantity=case_hours,
unit_price=hourly_rate,
line_total=line_total,
case_id=case_id,
time_entry_ids=time_entry_ids
))
total_hours += case_hours
line_number += 1
# Calculate totals
subtotal = total_hours * hourly_rate
vat_rate = Decimal('25.00') # Danish VAT
vat_amount = (subtotal * vat_rate / Decimal('100')).quantize(Decimal('0.01'))
total_amount = subtotal + vat_amount
logger.info(f"💰 Order totals: {total_hours}h × {hourly_rate} = {subtotal} + {vat_amount} moms = {total_amount} DKK")
# Create order
order_id = execute_insert(
"""INSERT INTO tmodule_orders
(customer_id, hub_customer_id, order_date, total_hours, hourly_rate,
subtotal, vat_rate, vat_amount, total_amount, status, created_by)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'draft', %s)""",
(
customer_id,
customer.get('hub_customer_id'),
date.today(),
total_hours,
hourly_rate,
subtotal,
vat_rate,
vat_amount,
total_amount,
user_id
)
)
logger.info(f"✅ Created order {order_id}")
# Create order lines
created_lines = []
for line in order_lines:
line_id = execute_insert(
"""INSERT INTO tmodule_order_lines
(order_id, case_id, line_number, description, quantity, unit_price,
line_total, time_entry_ids)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
(
order_id,
line.case_id,
line.line_number,
line.description,
line.quantity,
line.unit_price,
line.line_total,
line.time_entry_ids
)
)
created_lines.append(line_id)
logger.info(f"✅ Created {len(created_lines)} order lines")
# Update time entries to 'billed' status
time_entry_ids = [
entry_id
for line in order_lines
for entry_id in line.time_entry_ids
]
if time_entry_ids:
placeholders = ','.join(['%s'] * len(time_entry_ids))
execute_update(
f"""UPDATE tmodule_times
SET status = 'billed'
WHERE id IN ({placeholders})""",
time_entry_ids
)
logger.info(f"✅ Marked {len(time_entry_ids)} time entries as billed")
# Log order creation
audit.log_order_created(
order_id=order_id,
customer_id=customer_id,
total_hours=float(total_hours),
total_amount=float(total_amount),
line_count=len(order_lines),
user_id=user_id
)
# Return full order with lines
return OrderService.get_order_with_lines(order_id)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error generating order: {e}")
raise HTTPException(status_code=500, detail=str(e))
@staticmethod
def get_order_with_lines(order_id: int) -> TModuleOrderWithLines:
"""Hent ordre med linjer"""
try:
# Get order
order_query = "SELECT * FROM tmodule_orders WHERE id = %s"
order = execute_query(order_query, (order_id,), fetchone=True)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
# Get lines
lines_query = """
SELECT * FROM tmodule_order_lines
WHERE order_id = %s
ORDER BY line_number
"""
lines = execute_query(lines_query, (order_id,))
return TModuleOrderWithLines(
**order,
lines=[TModuleOrderLine(**line) for line in lines]
)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error getting order: {e}")
raise HTTPException(status_code=500, detail=str(e))
@staticmethod
def list_orders(
customer_id: Optional[int] = None,
status: Optional[str] = None,
limit: int = 100
) -> List[TModuleOrder]:
"""List orders med filtrering"""
try:
conditions = []
params = []
if customer_id:
conditions.append("customer_id = %s")
params.append(customer_id)
if status:
conditions.append("status = %s")
params.append(status)
where_clause = " WHERE " + " AND ".join(conditions) if conditions else ""
query = f"""
SELECT * FROM tmodule_orders
{where_clause}
ORDER BY order_date DESC, id DESC
LIMIT %s
"""
params.append(limit)
orders = execute_query(query, params if params else None)
return [TModuleOrder(**order) for order in orders]
except Exception as e:
logger.error(f"❌ Error listing orders: {e}")
raise HTTPException(status_code=500, detail=str(e))
@staticmethod
def cancel_order(
order_id: int,
reason: Optional[str] = None,
user_id: Optional[int] = None
) -> TModuleOrder:
"""Annuller en ordre"""
try:
# Check order exists and is not exported
order = execute_query(
"SELECT * FROM tmodule_orders WHERE id = %s",
(order_id,),
fetchone=True
)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if order['status'] == 'cancelled':
raise HTTPException(status_code=400, detail="Order already cancelled")
if order['status'] == 'exported':
raise HTTPException(
status_code=400,
detail="Cannot cancel exported order"
)
# Update status
execute_update(
"UPDATE tmodule_orders SET status = 'cancelled', notes = %s WHERE id = %s",
(reason, order_id)
)
# Reset time entries back to approved
lines = execute_query(
"SELECT time_entry_ids FROM tmodule_order_lines WHERE order_id = %s",
(order_id,)
)
all_time_ids = []
for line in lines:
if line.get('time_entry_ids'):
all_time_ids.extend(line['time_entry_ids'])
if all_time_ids:
placeholders = ','.join(['%s'] * len(all_time_ids))
execute_update(
f"UPDATE tmodule_times SET status = 'approved' WHERE id IN ({placeholders})",
all_time_ids
)
# Log cancellation
audit.log_order_cancelled(
order_id=order_id,
reason=reason,
user_id=user_id
)
logger.info(f"❌ Cancelled order {order_id}")
# Return updated order
updated = execute_query(
"SELECT * FROM tmodule_orders WHERE id = %s",
(order_id,),
fetchone=True
)
return TModuleOrder(**updated)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error cancelling order: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Singleton instance
order_service = OrderService()

View File

@ -0,0 +1,489 @@
"""
Main API Router for Time Tracking Module
=========================================
Samler alle endpoints for modulet.
Isoleret routing uden påvirkning af existing Hub endpoints.
"""
import logging
from typing import Optional, List
from fastapi import APIRouter, HTTPException, Depends
from fastapi.responses import JSONResponse
from app.core.database import execute_query, execute_update
from app.timetracking.backend.models import (
TModuleSyncStats,
TModuleApprovalStats,
TModuleWizardNextEntry,
TModuleWizardProgress,
TModuleTimeApproval,
TModuleTimeWithContext,
TModuleOrder,
TModuleOrderWithLines,
TModuleEconomicExportRequest,
TModuleEconomicExportResult,
TModuleMetadata,
TModuleUninstallRequest,
TModuleUninstallResult
)
from app.timetracking.backend.vtiger_sync import vtiger_service
from app.timetracking.backend.wizard import wizard
from app.timetracking.backend.order_service import order_service
from app.timetracking.backend.economic_export import economic_service
from app.timetracking.backend.audit import audit
logger = logging.getLogger(__name__)
router = APIRouter()
# ============================================================================
# SYNC ENDPOINTS
# ============================================================================
@router.post("/sync", response_model=TModuleSyncStats, tags=["Sync"])
async def sync_from_vtiger(user_id: Optional[int] = None):
"""
🔍 Synkroniser data fra vTiger (READ-ONLY).
Henter:
- Accounts (kunder)
- HelpDesk (cases)
- ModComments (tidsregistreringer)
Gemmes i tmodule_* tabeller (isoleret).
"""
try:
logger.info("🚀 Starting vTiger sync...")
result = await vtiger_service.full_sync(user_id=user_id)
return result
except Exception as e:
logger.error(f"❌ Sync failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/sync/test-connection", tags=["Sync"])
async def test_vtiger_connection():
"""Test forbindelse til vTiger"""
try:
is_connected = await vtiger_service.test_connection()
return {
"connected": is_connected,
"service": "vTiger CRM",
"read_only": vtiger_service.read_only,
"dry_run": vtiger_service.dry_run
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# WIZARD / APPROVAL ENDPOINTS
# ============================================================================
@router.get("/wizard/stats", response_model=List[TModuleApprovalStats], tags=["Wizard"])
async def get_all_customer_stats():
"""Hent approval statistik for alle kunder"""
try:
return wizard.get_all_customers_stats()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/wizard/next", response_model=TModuleWizardNextEntry, tags=["Wizard"])
async def get_next_pending_entry(customer_id: Optional[int] = None):
"""
Hent næste pending tidsregistrering til godkendelse.
Query params:
- customer_id: Valgfri - filtrer til specifik kunde
"""
try:
return wizard.get_next_pending_entry(customer_id=customer_id)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/wizard/approve", response_model=TModuleTimeWithContext, tags=["Wizard"])
async def approve_time_entry(
approval: TModuleTimeApproval,
user_id: Optional[int] = None
):
"""
Godkend en tidsregistrering.
Body:
- time_id: ID tidsregistreringen
- approved_hours: Timer efter godkendelse (kan være afrundet)
- rounded_to: Afrundingsinterval (0.5, 1.0, etc.)
- approval_note: Valgfri note
- billable: Skal faktureres? (default: true)
"""
try:
return wizard.approve_time_entry(approval, user_id=user_id)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/wizard/reject/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"])
async def reject_time_entry(
time_id: int,
reason: Optional[str] = None,
user_id: Optional[int] = None
):
"""Afvis en tidsregistrering"""
try:
return wizard.reject_time_entry(time_id, reason=reason, user_id=user_id)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/wizard/progress/{customer_id}", response_model=TModuleWizardProgress, tags=["Wizard"])
async def get_customer_progress(customer_id: int):
"""Hent wizard progress for en kunde"""
try:
return wizard.get_customer_progress(customer_id)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# ORDER ENDPOINTS
# ============================================================================
@router.post("/orders/generate/{customer_id}", response_model=TModuleOrderWithLines, tags=["Orders"])
async def generate_order(customer_id: int, user_id: Optional[int] = None):
"""
Generer ordre for alle godkendte tider for en kunde.
Aggregerer:
- Alle godkendte tidsregistreringer
- Grupperet efter case
- Beregner totals med moms
Markerer tidsregistreringer som 'billed'.
"""
try:
return order_service.generate_order_for_customer(customer_id, user_id=user_id)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/orders", response_model=List[TModuleOrder], tags=["Orders"])
async def list_orders(
customer_id: Optional[int] = None,
status: Optional[str] = None,
limit: int = 100
):
"""
List ordrer med filtrering.
Query params:
- customer_id: Filtrer til specifik kunde
- status: Filtrer status (draft, exported, sent, cancelled)
- limit: Max antal resultater (default: 100)
"""
try:
return order_service.list_orders(
customer_id=customer_id,
status=status,
limit=limit
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/orders/{order_id}", response_model=TModuleOrderWithLines, tags=["Orders"])
async def get_order(order_id: int):
"""Hent ordre med linjer"""
try:
return order_service.get_order_with_lines(order_id)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/orders/{order_id}/cancel", response_model=TModuleOrder, tags=["Orders"])
async def cancel_order(
order_id: int,
reason: Optional[str] = None,
user_id: Optional[int] = None
):
"""
Annuller en ordre.
Kun muligt for draft orders (ikke exported).
Resetter tidsregistreringer tilbage til 'approved'.
"""
try:
return order_service.cancel_order(order_id, reason=reason, user_id=user_id)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# e-conomic EXPORT ENDPOINTS
# ============================================================================
@router.post("/export", response_model=TModuleEconomicExportResult, tags=["Export"])
async def export_to_economic(
request: TModuleEconomicExportRequest,
user_id: Optional[int] = None
):
"""
🚨 Eksporter ordre til e-conomic som draft order.
SAFETY FLAGS:
- TIMETRACKING_ECONOMIC_READ_ONLY (default: True)
- TIMETRACKING_ECONOMIC_DRY_RUN (default: True)
Hvis begge er enabled, køres kun dry-run simulation.
Body:
- order_id: ID ordren
- force: Re-eksporter selvom allerede eksporteret (default: false)
"""
try:
return await economic_service.export_order(request, user_id=user_id)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/export/test-connection", tags=["Export"])
async def test_economic_connection():
"""Test forbindelse til e-conomic"""
try:
is_connected = await economic_service.test_connection()
return {
"connected": is_connected,
"service": "e-conomic",
"read_only": economic_service.read_only,
"dry_run": economic_service.dry_run,
"export_type": economic_service.export_type
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# MODULE METADATA & ADMIN ENDPOINTS
# ============================================================================
@router.get("/metadata", response_model=TModuleMetadata, tags=["Admin"])
async def get_module_metadata():
"""Hent modul metadata"""
try:
result = execute_query(
"SELECT * FROM tmodule_metadata ORDER BY id DESC LIMIT 1",
fetchone=True
)
if not result:
raise HTTPException(status_code=404, detail="Module metadata not found")
return TModuleMetadata(**result)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/health", tags=["Admin"])
async def module_health():
"""Module health check"""
try:
# Check database tables exist
tables_query = """
SELECT COUNT(*) as count FROM information_schema.tables
WHERE table_name LIKE 'tmodule_%'
"""
result = execute_query(tables_query, fetchone=True)
table_count = result['count'] if result else 0
# Get stats - count each table separately
try:
stats = {
"customers": 0,
"cases": 0,
"times": 0,
"orders": 0
}
for table_name in ["customers", "cases", "times", "orders"]:
count_result = execute_query(
f"SELECT COUNT(*) as count FROM tmodule_{table_name}",
fetchone=True
)
stats[table_name] = count_result['count'] if count_result else 0
except Exception as e:
stats = {"error": str(e)}
return {
"status": "healthy" if table_count >= 6 else "degraded",
"module": "Time Tracking & Billing",
"version": "1.0.0",
"tables": table_count,
"statistics": stats,
"safety": {
"vtiger_read_only": vtiger_service.read_only,
"vtiger_dry_run": vtiger_service.dry_run,
"economic_read_only": economic_service.read_only,
"economic_dry_run": economic_service.dry_run
}
}
except Exception as e:
logger.error(f"Health check error: {e}")
return JSONResponse(
status_code=503,
content={
"status": "unhealthy",
"error": str(e)
}
)
@router.delete("/admin/uninstall", response_model=TModuleUninstallResult, tags=["Admin"])
async def uninstall_module(
request: TModuleUninstallRequest,
user_id: Optional[int] = None
):
"""
🚨 SLET MODULET FULDSTÆNDIGT.
ADVARSEL: Dette sletter ALLE data i modulet!
Kan ikke fortrydes.
Body:
- confirm: SKAL være true
- delete_all_data: SKAL være true
"""
try:
# Validate request (Pydantic validators already check this)
if not request.confirm or not request.delete_all_data:
raise HTTPException(
status_code=400,
detail="You must confirm uninstall and data deletion"
)
logger.warning(f"⚠️ UNINSTALLING TIME TRACKING MODULE (user_id: {user_id})")
# Log uninstall
audit.log_module_uninstalled(user_id=user_id)
# Execute DROP script
uninstall_script = """
DROP VIEW IF EXISTS tmodule_order_details CASCADE;
DROP VIEW IF EXISTS tmodule_next_pending CASCADE;
DROP VIEW IF EXISTS tmodule_approval_stats CASCADE;
DROP TRIGGER IF EXISTS tmodule_orders_generate_number ON tmodule_orders;
DROP TRIGGER IF EXISTS tmodule_orders_update ON tmodule_orders;
DROP TRIGGER IF EXISTS tmodule_times_update ON tmodule_times;
DROP TRIGGER IF EXISTS tmodule_cases_update ON tmodule_cases;
DROP TRIGGER IF EXISTS tmodule_customers_update ON tmodule_customers;
DROP FUNCTION IF EXISTS tmodule_generate_order_number() CASCADE;
DROP FUNCTION IF EXISTS tmodule_update_timestamp() CASCADE;
DROP TABLE IF EXISTS tmodule_sync_log CASCADE;
DROP TABLE IF EXISTS tmodule_order_lines CASCADE;
DROP TABLE IF EXISTS tmodule_orders CASCADE;
DROP TABLE IF EXISTS tmodule_times CASCADE;
DROP TABLE IF EXISTS tmodule_cases CASCADE;
DROP TABLE IF EXISTS tmodule_customers CASCADE;
DROP TABLE IF EXISTS tmodule_metadata CASCADE;
"""
# Count rows before deletion
try:
count_query = """
SELECT
(SELECT COUNT(*) FROM tmodule_customers) +
(SELECT COUNT(*) FROM tmodule_cases) +
(SELECT COUNT(*) FROM tmodule_times) +
(SELECT COUNT(*) FROM tmodule_orders) +
(SELECT COUNT(*) FROM tmodule_order_lines) +
(SELECT COUNT(*) FROM tmodule_sync_log) as total
"""
count_result = execute_query(count_query, fetchone=True)
total_rows = count_result['total'] if count_result else 0
except:
total_rows = 0
# Execute uninstall (split into separate statements)
from app.core.database import get_db_connection
import psycopg2
conn = get_db_connection()
cursor = conn.cursor()
dropped_items = {
"views": [],
"triggers": [],
"functions": [],
"tables": []
}
try:
# Drop views
for view in ["tmodule_order_details", "tmodule_next_pending", "tmodule_approval_stats"]:
cursor.execute(f"DROP VIEW IF EXISTS {view} CASCADE")
dropped_items["views"].append(view)
# Drop triggers
triggers = [
("tmodule_orders_generate_number", "tmodule_orders"),
("tmodule_orders_update", "tmodule_orders"),
("tmodule_times_update", "tmodule_times"),
("tmodule_cases_update", "tmodule_cases"),
("tmodule_customers_update", "tmodule_customers")
]
for trigger_name, table_name in triggers:
cursor.execute(f"DROP TRIGGER IF EXISTS {trigger_name} ON {table_name}")
dropped_items["triggers"].append(trigger_name)
# Drop functions
for func in ["tmodule_generate_order_number", "tmodule_update_timestamp"]:
cursor.execute(f"DROP FUNCTION IF EXISTS {func}() CASCADE")
dropped_items["functions"].append(func)
# Drop tables
for table in [
"tmodule_sync_log",
"tmodule_order_lines",
"tmodule_orders",
"tmodule_times",
"tmodule_cases",
"tmodule_customers",
"tmodule_metadata"
]:
cursor.execute(f"DROP TABLE IF EXISTS {table} CASCADE")
dropped_items["tables"].append(table)
conn.commit()
logger.warning(f"✅ Module uninstalled - deleted {total_rows} rows")
return TModuleUninstallResult(
success=True,
message=f"Time Tracking Module successfully uninstalled. Deleted {total_rows} rows.",
tables_dropped=dropped_items["tables"],
views_dropped=dropped_items["views"],
functions_dropped=dropped_items["functions"],
rows_deleted=total_rows
)
except Exception as e:
conn.rollback()
raise e
finally:
cursor.close()
conn.close()
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Uninstall failed: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@ -0,0 +1,673 @@
"""
vTiger Sync Service for Time Tracking Module
=============================================
🚨 KRITISK: Denne service KUN læse data fra vTiger.
Ingen opdateringer, ingen statusændringer, ingen skrivninger.
Formål:
- Hent ModComments (tidsregistreringer) fra vTiger
- Hent HelpDesk/ProjectTask (cases) fra vTiger
- Hent Accounts (kunder) fra vTiger
- Gem alt i tmodule_* tabeller (isoleret)
Safety Flags:
- TIMETRACKING_VTIGER_READ_ONLY = True (default)
- TIMETRACKING_VTIGER_DRY_RUN = True (default)
"""
import logging
import hashlib
import json
from datetime import datetime
from typing import List, Dict, Optional, Any
from decimal import Decimal
import aiohttp
from fastapi import HTTPException
from app.core.config import settings
from app.core.database import execute_query, execute_insert, execute_update
from app.timetracking.backend.models import TModuleSyncStats
from app.timetracking.backend.audit import audit
logger = logging.getLogger(__name__)
class TimeTrackingVTigerService:
"""
vTiger integration for Time Tracking Module.
🔒 READ-ONLY service - ingen skrivninger til vTiger tilladt.
"""
def __init__(self):
self.base_url = settings.VTIGER_URL
self.username = settings.VTIGER_USERNAME
self.api_key = settings.VTIGER_API_KEY
self.password = settings.VTIGER_PASSWORD
self.rest_endpoint = f"{self.base_url}/restapi/v1/vtiger/default"
# Safety flags
self.read_only = settings.TIMETRACKING_VTIGER_READ_ONLY
self.dry_run = settings.TIMETRACKING_VTIGER_DRY_RUN
# Log safety status
if self.read_only:
logger.warning("🔒 TIMETRACKING vTiger READ-ONLY mode: Enabled")
if self.dry_run:
logger.warning("🏃 TIMETRACKING vTiger DRY-RUN mode: Enabled")
if not self.read_only:
logger.error("⚠️ WARNING: TIMETRACKING vTiger READ-ONLY disabled! This violates module isolation!")
def _get_auth(self) -> aiohttp.BasicAuth:
"""Get HTTP Basic Auth"""
# Prefer API key over password
auth_value = self.api_key if self.api_key else self.password
return aiohttp.BasicAuth(self.username, auth_value)
def _calculate_hash(self, data: Dict[str, Any]) -> str:
"""Calculate SHA256 hash of data for change detection"""
# Sort keys for consistent hashing
json_str = json.dumps(data, sort_keys=True)
return hashlib.sha256(json_str.encode()).hexdigest()
async def _retrieve(self, record_id: str) -> Dict:
"""
Retrieve full record details via vTiger REST API.
This gets ALL fields including relationships that query doesn't return.
🔍 READ-ONLY operation
"""
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.rest_endpoint}/retrieve",
params={"id": record_id},
auth=self._get_auth(),
timeout=aiohttp.ClientTimeout(total=30)
) as response:
text = await response.text()
if response.status != 200:
logger.error(f"❌ vTiger retrieve failed: HTTP {response.status} for {record_id}")
return {}
try:
data = json.loads(text)
except json.JSONDecodeError:
logger.error(f"❌ Invalid JSON in retrieve response for {record_id}")
return {}
if not data.get('success'):
logger.error(f"❌ vTiger retrieve failed for {record_id}: {data.get('error', {})}")
return {}
return data.get('result', {})
except Exception as e:
logger.error(f"❌ Error retrieving {record_id}: {e}")
return {}
async def _query(self, query_string: str) -> List[Dict]:
"""
Execute SQL-like query against vTiger REST API.
🔍 READ-ONLY operation
"""
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.rest_endpoint}/query",
params={"query": query_string},
auth=self._get_auth(),
timeout=aiohttp.ClientTimeout(total=60)
) as response:
text = await response.text()
if response.status != 200:
logger.error(f"❌ vTiger query failed: HTTP {response.status}")
logger.error(f"Query: {query_string}")
logger.error(f"Response body: {text[:1000]}")
logger.error(f"Response headers: {dict(response.headers)}")
raise HTTPException(
status_code=response.status,
detail=f"vTiger API error: {text[:200]}"
)
# vTiger returns text/json, not application/json
try:
data = json.loads(text)
except json.JSONDecodeError as e:
logger.error(f"❌ Invalid JSON in query response: {text[:200]}")
raise HTTPException(status_code=500, detail="Invalid JSON from vTiger")
# Check vTiger success flag
if not data.get('success'):
error_msg = data.get('error', {}).get('message', 'Unknown error')
logger.error(f"❌ vTiger query failed: {error_msg}")
logger.error(f"Query: {query_string}")
raise HTTPException(status_code=400, detail=f"vTiger error: {error_msg}")
result = data.get('result', [])
logger.info(f"✅ Query returned {len(result)} records")
return result
except aiohttp.ClientError as e:
logger.error(f"❌ vTiger connection error: {e}")
raise HTTPException(status_code=503, detail=f"Cannot connect to vTiger: {str(e)}")
async def _retrieve(self, record_id: str) -> Dict:
"""
Retrieve single record by ID.
🔍 READ-ONLY operation
"""
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.rest_endpoint}/retrieve",
params={"id": record_id},
auth=self._get_auth(),
timeout=aiohttp.ClientTimeout(total=30)
) as response:
if response.status != 200:
error_text = await response.text()
logger.error(f"❌ vTiger retrieve failed: {response.status} - {error_text}")
return {}
data = json.loads(await response.text())
if not data.get('success', False):
return {}
return data.get('result', {})
except aiohttp.ClientError as e:
logger.error(f"❌ vTiger retrieve error: {e}")
return {}
async def test_connection(self) -> bool:
"""Test vTiger connection"""
try:
logger.info("🔍 Testing vTiger connection...")
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.rest_endpoint}/me",
auth=self._get_auth(),
timeout=aiohttp.ClientTimeout(total=10)
) as response:
if response.status == 200:
logger.info("✅ vTiger connection successful")
return True
else:
logger.error(f"❌ vTiger connection failed: {response.status}")
return False
except Exception as e:
logger.error(f"❌ vTiger connection error: {e}")
return False
async def sync_customers(self, limit: int = 1000) -> Dict[str, int]:
"""
Sync Accounts (customers) from vTiger to tmodule_customers.
Returns: {imported: X, updated: Y, skipped: Z}
"""
logger.info("🔍 Syncing customers from vTiger...")
stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0}
try:
# Query vTiger for active accounts
# Start with simplest query to debug
query = "SELECT * FROM Accounts;"
accounts = await self._query(query)
logger.info(f"📥 Fetched {len(accounts)} accounts from vTiger")
for account in accounts:
try:
vtiger_id = account.get('id', '')
if not vtiger_id:
logger.warning("⚠️ Skipping account without ID")
stats["skipped"] += 1
continue
# Calculate hash for change detection
data_hash = self._calculate_hash(account)
# Check if exists
existing = execute_query(
"SELECT id, sync_hash FROM tmodule_customers WHERE vtiger_id = %s",
(vtiger_id,),
fetchone=True
)
if existing:
# Check if data changed
if existing['sync_hash'] == data_hash:
logger.debug(f"⏭️ No changes for customer {vtiger_id}")
stats["skipped"] += 1
continue
# Update existing
execute_update(
"""UPDATE tmodule_customers
SET name = %s, email = %s, vtiger_data = %s::jsonb,
sync_hash = %s, last_synced_at = CURRENT_TIMESTAMP
WHERE vtiger_id = %s""",
(
account.get('accountname', 'Unknown'),
account.get('email1', None),
json.dumps(account),
data_hash,
vtiger_id
)
)
logger.debug(f"✏️ Updated customer {vtiger_id}")
stats["updated"] += 1
else:
# Insert new
execute_insert(
"""INSERT INTO tmodule_customers
(vtiger_id, name, email, vtiger_data, sync_hash, last_synced_at)
VALUES (%s, %s, %s, %s::jsonb, %s, CURRENT_TIMESTAMP)""",
(
vtiger_id,
account.get('accountname', 'Unknown'),
account.get('email1', None),
json.dumps(account),
data_hash
)
)
logger.debug(f" Imported customer {vtiger_id}")
stats["imported"] += 1
except Exception as e:
logger.error(f"❌ Error processing account {account.get('id', 'unknown')}: {e}")
stats["errors"] += 1
logger.info(f"✅ Customer sync complete: {stats}")
return stats
except Exception as e:
logger.error(f"❌ Customer sync failed: {e}")
raise
async def sync_cases(self, limit: int = 5000) -> Dict[str, int]:
"""
Sync HelpDesk tickets (cases) from vTiger to tmodule_cases.
Returns: {imported: X, updated: Y, skipped: Z}
"""
logger.info(f"🔍 Syncing up to {limit} cases from vTiger...")
stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0}
try:
# vTiger API doesn't support OFFSET - use id-based pagination instead
all_tickets = []
last_id = "0x0" # Start from beginning
batch_size = 100 # Conservative batch size to avoid timeouts
max_batches = limit // batch_size + 1
for batch_num in range(max_batches):
# Use id > last_id for pagination (vTiger format: 39x1234)
query = f"SELECT * FROM Cases WHERE id > '{last_id}' ORDER BY id LIMIT {batch_size};"
batch = await self._query(query)
if not batch: # No more records
break
all_tickets.extend(batch)
last_id = batch[-1].get('id', last_id) # Get last ID for next iteration
logger.info(f"📥 Fetched {len(batch)} cases (total: {len(all_tickets)}, last_id: {last_id})")
if len(batch) < batch_size: # Last batch
break
if len(all_tickets) >= limit: # Reached limit
break
tickets = all_tickets[:limit] # Trim to requested limit
logger.info(f"✅ Total fetched: {len(tickets)} HelpDesk tickets from vTiger")
for ticket in tickets:
try:
vtiger_id = ticket.get('id', '')
if not vtiger_id:
stats["skipped"] += 1
continue
# Get related account (customer)
account_id = ticket.get('parent_id', '')
if not account_id:
logger.warning(f"⚠️ HelpDesk {vtiger_id} has no parent account")
stats["skipped"] += 1
continue
# Find customer in our DB
customer = execute_query(
"SELECT id FROM tmodule_customers WHERE vtiger_id = %s",
(account_id,),
fetchone=True
)
if not customer:
logger.warning(f"⚠️ Customer {account_id} not found - sync customers first")
stats["skipped"] += 1
continue
customer_id = customer['id']
data_hash = self._calculate_hash(ticket)
# Check if exists
existing = execute_query(
"SELECT id, sync_hash FROM tmodule_cases WHERE vtiger_id = %s",
(vtiger_id,),
fetchone=True
)
if existing:
if existing['sync_hash'] == data_hash:
stats["skipped"] += 1
continue
# Update
execute_update(
"""UPDATE tmodule_cases
SET customer_id = %s, title = %s, description = %s,
status = %s, priority = %s, module_type = %s,
vtiger_data = %s::jsonb, sync_hash = %s,
last_synced_at = CURRENT_TIMESTAMP
WHERE vtiger_id = %s""",
(
customer_id,
ticket.get('ticket_title', 'No Title'),
ticket.get('description', None),
ticket.get('ticketstatus', None),
ticket.get('ticketpriorities', None),
'HelpDesk',
json.dumps(ticket),
data_hash,
vtiger_id
)
)
stats["updated"] += 1
else:
# Insert
case_id = execute_insert(
"""INSERT INTO tmodule_cases
(vtiger_id, customer_id, title, description, status,
priority, module_type, vtiger_data, sync_hash, last_synced_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, CURRENT_TIMESTAMP)""",
(
vtiger_id,
customer_id,
ticket.get('ticket_title', 'No Title'),
ticket.get('description', None),
ticket.get('ticketstatus', None),
ticket.get('ticketpriorities', None),
'HelpDesk',
json.dumps(ticket),
data_hash
)
)
stats["imported"] += 1
except Exception as e:
logger.error(f"❌ Error processing case {ticket.get('id', 'unknown')}: {e}")
stats["errors"] += 1
logger.info(f"✅ Case sync complete: {stats}")
return stats
except Exception as e:
logger.error(f"❌ Case sync failed: {e}")
raise
async def sync_time_entries(self, limit: int = 3000) -> Dict[str, int]:
"""
Sync time entries from vTiger Timelog module to tmodule_times.
vTiger's Timelog module contains detailed time entries with:
- timelognumber: Unique ID (TL1234)
- duration: Time in seconds
- relatedto: Reference to Case/Account
- isbillable: Billable flag
"""
logger.info(f"🔍 Syncing up to {limit} time entries from vTiger Timelog...")
stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0}
try:
# vTiger API doesn't support OFFSET - use id-based pagination instead
all_timelogs = []
last_id = "0x0" # Start from beginning
batch_size = 100 # Conservative batch size
max_batches = limit // batch_size + 1
for batch_num in range(max_batches):
# Use id > last_id for pagination (vTiger format: 43x1234)
query = f"SELECT * FROM Timelog WHERE timelog_status = 'Completed' AND id > '{last_id}' ORDER BY id LIMIT {batch_size};"
batch = await self._query(query)
if not batch: # No more records
break
all_timelogs.extend(batch)
last_id = batch[-1].get('id', last_id) # Get last ID for next iteration
logger.info(f"📥 Fetched {len(batch)} timelogs (total: {len(all_timelogs)}, last_id: {last_id})")
if len(batch) < batch_size: # Last batch
break
if len(all_timelogs) >= limit: # Reached limit
break
timelogs = all_timelogs[:limit] # Trim to requested limit
logger.info(f"✅ Total fetched: {len(timelogs)} Timelog entries from vTiger")
# NOTE: retrieve API is too slow for batch operations (1500+ individual calls)
# We'll work with query data and accept that relatedto might be empty for some
for timelog in timelogs:
try:
vtiger_id = timelog.get('id', '')
if not vtiger_id:
stats["skipped"] += 1
continue
# Get duration in hours (stored as seconds in vTiger)
duration_seconds = float(timelog.get('duration', 0) or 0)
if duration_seconds <= 0:
logger.debug(f"⏭️ Skipping timelog {vtiger_id} - no duration")
stats["skipped"] += 1
continue
hours = Decimal(str(duration_seconds / 3600.0)) # Convert seconds to hours
# Get related entity (Case or Account)
related_to = timelog.get('relatedto', '')
if not related_to:
logger.warning(f"⚠️ Timelog {vtiger_id} has no relatedto - RAW DATA: {timelog}")
stats["skipped"] += 1
continue
# Try to find case first, then account
case = execute_query(
"SELECT id, customer_id FROM tmodule_cases WHERE vtiger_id = %s",
(related_to,),
fetchone=True
)
if case:
case_id = case['id']
customer_id = case['customer_id']
else:
# Try to find customer directly
customer = execute_query(
"SELECT id FROM tmodule_customers WHERE vtiger_id = %s",
(related_to,),
fetchone=True
)
if not customer:
logger.debug(f"⏭️ Related entity {related_to} not found")
stats["skipped"] += 1
continue
customer_id = customer['id']
case_id = None # No specific case, just customer
data_hash = self._calculate_hash(timelog)
# Check if exists
existing = execute_query(
"SELECT id, sync_hash FROM tmodule_times WHERE vtiger_id = %s",
(vtiger_id,),
fetchone=True
)
if existing:
if existing['sync_hash'] == data_hash:
stats["skipped"] += 1
continue
# Update only if NOT yet approved
result = execute_update(
"""UPDATE tmodule_times
SET description = %s, original_hours = %s, worked_date = %s,
user_name = %s, billable = %s, vtiger_data = %s::jsonb,
sync_hash = %s, last_synced_at = CURRENT_TIMESTAMP
WHERE vtiger_id = %s AND status = 'pending'""",
(
timelog.get('name', ''),
hours,
timelog.get('startedon', None),
timelog.get('assigned_user_id', None),
timelog.get('isbillable', '0') == '1',
json.dumps(timelog),
data_hash,
vtiger_id
)
)
if result > 0:
stats["updated"] += 1
else:
logger.debug(f"⏭️ Time entry {vtiger_id} already approved")
stats["skipped"] += 1
else:
# Insert new
execute_insert(
"""INSERT INTO tmodule_times
(vtiger_id, case_id, customer_id, description, original_hours,
worked_date, user_name, billable, vtiger_data, sync_hash,
status, last_synced_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, 'pending', CURRENT_TIMESTAMP)""",
(
vtiger_id,
case_id,
customer_id,
timelog.get('name', ''),
hours,
timelog.get('startedon', None),
timelog.get('assigned_user_id', None),
timelog.get('isbillable', '0') == '1',
json.dumps(timelog),
data_hash
)
)
stats["imported"] += 1
except Exception as e:
logger.error(f"❌ Error processing timelog {timelog.get('id', 'unknown')}: {e}")
stats["errors"] += 1
logger.info(f"✅ Time entry sync complete: {stats}")
return stats
except Exception as e:
logger.error(f"❌ Time entry sync failed: {e}")
raise
async def full_sync(
self,
user_id: Optional[int] = None
) -> TModuleSyncStats:
"""
Perform full sync of all data from vTiger.
Order: Customers -> Cases -> Time Entries (dependencies)
"""
logger.info("🚀 Starting FULL vTiger sync...")
start_time = datetime.now()
# Log sync started
audit.log_sync_started(user_id=user_id)
try:
# Test connection first
if not await self.test_connection():
raise HTTPException(
status_code=503,
detail="Cannot connect to vTiger - check credentials"
)
# Sync in order of dependencies
customer_stats = await self.sync_customers()
case_stats = await self.sync_cases()
time_stats = await self.sync_time_entries()
end_time = datetime.now()
duration = (end_time - start_time).total_seconds()
# Build result
result = TModuleSyncStats(
customers_imported=customer_stats["imported"],
customers_updated=customer_stats["updated"],
customers_skipped=customer_stats["skipped"],
cases_imported=case_stats["imported"],
cases_updated=case_stats["updated"],
cases_skipped=case_stats["skipped"],
times_imported=time_stats["imported"],
times_updated=time_stats["updated"],
times_skipped=time_stats["skipped"],
errors=(
customer_stats["errors"] +
case_stats["errors"] +
time_stats["errors"]
),
duration_seconds=duration,
started_at=start_time,
completed_at=end_time
)
# Log completion
audit.log_sync_completed(
stats=result.model_dump(),
user_id=user_id
)
logger.info(f"✅ Full sync completed in {duration:.2f}s")
logger.info(f"📊 Customers: {customer_stats['imported']} new, {customer_stats['updated']} updated")
logger.info(f"📊 Cases: {case_stats['imported']} new, {case_stats['updated']} updated")
logger.info(f"📊 Times: {time_stats['imported']} new, {time_stats['updated']} updated")
return result
except Exception as e:
logger.error(f"❌ Full sync failed: {e}")
audit.log_sync_failed(error=str(e), user_id=user_id)
raise
# Singleton instance
vtiger_service = TimeTrackingVTigerService()

View File

@ -0,0 +1,330 @@
"""
Wizard Service for Time Tracking Module
========================================
Step-by-step approval flow for time entries.
Brugeren godkender én tidsregistrering ad gangen.
"""
import logging
from typing import Optional
from decimal import Decimal
from datetime import datetime
from fastapi import HTTPException
from app.core.database import execute_query, execute_update
from app.timetracking.backend.models import (
TModuleTimeWithContext,
TModuleTimeApproval,
TModuleWizardProgress,
TModuleWizardNextEntry,
TModuleApprovalStats
)
from app.timetracking.backend.audit import audit
logger = logging.getLogger(__name__)
class WizardService:
"""Service for managing wizard-based approval flow"""
@staticmethod
def get_customer_stats(customer_id: int) -> Optional[TModuleApprovalStats]:
"""Hent approval statistics for en kunde"""
try:
query = """
SELECT * FROM tmodule_approval_stats
WHERE customer_id = %s
"""
result = execute_query(query, (customer_id,), fetchone=True)
if not result:
return None
return TModuleApprovalStats(**result)
except Exception as e:
logger.error(f"❌ Error getting customer stats: {e}")
return None
@staticmethod
def get_all_customers_stats() -> list[TModuleApprovalStats]:
"""Hent approval statistics for alle kunder"""
try:
query = "SELECT * FROM tmodule_approval_stats ORDER BY customer_name"
results = execute_query(query)
return [TModuleApprovalStats(**row) for row in results]
except Exception as e:
logger.error(f"❌ Error getting all customer stats: {e}")
return []
@staticmethod
def get_next_pending_entry(
customer_id: Optional[int] = None
) -> TModuleWizardNextEntry:
"""
Hent næste pending tidsregistrering til godkendelse.
Args:
customer_id: Valgfri - filtrer til specifik kunde
Returns:
TModuleWizardNextEntry med has_next=True hvis der er flere
"""
try:
if customer_id:
# Hent næste for specifik kunde
query = """
SELECT * FROM tmodule_next_pending
WHERE customer_id = %s
LIMIT 1
"""
result = execute_query(query, (customer_id,), fetchone=True)
else:
# Hent næste generelt
query = "SELECT * FROM tmodule_next_pending LIMIT 1"
result = execute_query(query, fetchone=True)
if not result:
# Ingen flere entries
return TModuleWizardNextEntry(
has_next=False,
time_entry=None,
progress=None
)
# Build entry with context
entry = TModuleTimeWithContext(**result)
# Get progress if customer_id known
progress = None
cust_id = customer_id or entry.customer_id
if cust_id:
stats = WizardService.get_customer_stats(cust_id)
if stats:
progress = TModuleWizardProgress(
customer_id=stats.customer_id,
customer_name=stats.customer_name,
total_entries=stats.total_entries,
approved_entries=stats.approved_count,
pending_entries=stats.pending_count,
rejected_entries=stats.rejected_count,
current_case_id=entry.case_id,
current_case_title=entry.case_title
)
return TModuleWizardNextEntry(
has_next=True,
time_entry=entry,
progress=progress
)
except Exception as e:
logger.error(f"❌ Error getting next entry: {e}")
raise HTTPException(status_code=500, detail=str(e))
@staticmethod
def approve_time_entry(
approval: TModuleTimeApproval,
user_id: Optional[int] = None
) -> TModuleTimeWithContext:
"""
Godkend en tidsregistrering.
Args:
approval: Approval data med time_id og approved_hours
user_id: ID brugeren der godkender
Returns:
Opdateret tidsregistrering
"""
try:
# Hent original entry
query = """
SELECT t.*, c.title as case_title, c.status as case_status,
cust.name as customer_name, cust.hourly_rate as customer_rate
FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id
JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE t.id = %s
"""
entry = execute_query(query, (approval.time_id,), fetchone=True)
if not entry:
raise HTTPException(status_code=404, detail="Time entry not found")
if entry['status'] != 'pending':
raise HTTPException(
status_code=400,
detail=f"Time entry already {entry['status']}"
)
# Update entry
update_query = """
UPDATE tmodule_times
SET status = 'approved',
approved_hours = %s,
rounded_to = %s,
approval_note = %s,
billable = %s,
approved_at = CURRENT_TIMESTAMP,
approved_by = %s
WHERE id = %s
"""
execute_update(
update_query,
(
approval.approved_hours,
approval.rounded_to,
approval.approval_note,
approval.billable,
user_id,
approval.time_id
)
)
# Log approval
audit.log_approval(
time_id=approval.time_id,
original_hours=float(entry['original_hours']),
approved_hours=float(approval.approved_hours),
rounded_to=float(approval.rounded_to) if approval.rounded_to else None,
note=approval.approval_note,
user_id=user_id
)
logger.info(
f"✅ Approved time entry {approval.time_id}: "
f"{entry['original_hours']}h → {approval.approved_hours}h"
)
# Return updated entry
updated = execute_query(query, (approval.time_id,), fetchone=True)
return TModuleTimeWithContext(**updated)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error approving time entry: {e}")
raise HTTPException(status_code=500, detail=str(e))
@staticmethod
def reject_time_entry(
time_id: int,
reason: Optional[str] = None,
user_id: Optional[int] = None
) -> TModuleTimeWithContext:
"""
Afvis en tidsregistrering.
Args:
time_id: ID tidsregistreringen
reason: Årsag til afvisning
user_id: ID brugeren der afviser
Returns:
Opdateret tidsregistrering
"""
try:
# Check exists
query = """
SELECT t.*, c.title as case_title, c.status as case_status,
cust.name as customer_name, cust.hourly_rate as customer_rate
FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id
JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE t.id = %s
"""
entry = execute_query(query, (time_id,), fetchone=True)
if not entry:
raise HTTPException(status_code=404, detail="Time entry not found")
if entry['status'] != 'pending':
raise HTTPException(
status_code=400,
detail=f"Time entry already {entry['status']}"
)
# Update to rejected
update_query = """
UPDATE tmodule_times
SET status = 'rejected',
approval_note = %s,
billable = false,
approved_at = CURRENT_TIMESTAMP,
approved_by = %s
WHERE id = %s
"""
execute_update(update_query, (reason, user_id, time_id))
# Log rejection
audit.log_rejection(
time_id=time_id,
reason=reason,
user_id=user_id
)
logger.info(f"❌ Rejected time entry {time_id}: {reason}")
# Return updated
updated = execute_query(query, (time_id,), fetchone=True)
return TModuleTimeWithContext(**updated)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error rejecting time entry: {e}")
raise HTTPException(status_code=500, detail=str(e))
@staticmethod
def get_customer_progress(customer_id: int) -> TModuleWizardProgress:
"""Hent wizard progress for en kunde"""
try:
stats = WizardService.get_customer_stats(customer_id)
if not stats:
raise HTTPException(status_code=404, detail="Customer not found")
# Get current case if any pending entries
current_case_id = None
current_case_title = None
if stats.pending_count > 0:
query = """
SELECT DISTINCT c.id, c.title
FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id
WHERE t.customer_id = %s AND t.status = 'pending'
ORDER BY t.worked_date
LIMIT 1
"""
case = execute_query(query, (customer_id,), fetchone=True)
if case:
current_case_id = case['id']
current_case_title = case['title']
return TModuleWizardProgress(
customer_id=stats.customer_id,
customer_name=stats.customer_name,
total_entries=stats.total_entries,
approved_entries=stats.approved_count,
pending_entries=stats.pending_count,
rejected_entries=stats.rejected_count,
current_case_id=current_case_id,
current_case_title=current_case_title
)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error getting customer progress: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Singleton instance
wizard = WizardService()

View File

@ -0,0 +1 @@
"""Time Tracking Module - Frontend"""

View File

@ -0,0 +1,479 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<!-- Version: 2025-12-09-22:00 - FORCE RELOAD MED CMD+SHIFT+R / CTRL+SHIFT+R -->
<title>{{ page_title }} - BMC Hub</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
:root {
--bg-body: #f8f9fa;
--bg-card: #ffffff;
--text-primary: #2c3e50;
--text-secondary: #6c757d;
--accent: #0f4c75;
--accent-light: #eef2f5;
--success: #28a745;
--warning: #ffc107;
--danger: #dc3545;
--border-radius: 12px;
}
[data-theme="dark"] {
--bg-body: #1a1a1a;
--bg-card: #2d2d2d;
--text-primary: #e4e4e4;
--text-secondary: #a0a0a0;
--accent-light: #1e3a52;
}
body {
background-color: var(--bg-body);
color: var(--text-primary);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
padding-top: 80px;
transition: background-color 0.3s, color 0.3s;
}
.navbar {
background: var(--bg-card);
box-shadow: 0 2px 15px rgba(0,0,0,0.1);
padding: 1rem 0;
}
.navbar-brand {
font-weight: 700;
color: var(--accent);
font-size: 1.25rem;
}
.nav-link {
color: var(--text-secondary);
padding: 0.6rem 1.2rem !important;
border-radius: var(--border-radius);
transition: all 0.2s;
}
.nav-link:hover, .nav-link.active {
background-color: var(--accent-light);
color: var(--accent);
}
.card {
border: none;
border-radius: var(--border-radius);
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
background: var(--bg-card);
margin-bottom: 1.5rem;
transition: transform 0.2s;
}
.card:hover {
transform: translateY(-2px);
}
.stat-card {
text-align: center;
padding: 1.5rem;
}
.stat-card h3 {
font-size: 2.5rem;
font-weight: 700;
color: var(--accent);
margin-bottom: 0.5rem;
}
.stat-card p {
color: var(--text-secondary);
font-size: 0.9rem;
margin: 0;
}
.btn-primary {
background-color: var(--accent);
border-color: var(--accent);
padding: 0.6rem 1.5rem;
border-radius: 8px;
}
.badge {
padding: 0.4rem 0.8rem;
border-radius: 6px;
font-weight: 500;
}
.table {
background: var(--bg-card);
border-radius: var(--border-radius);
}
.table th {
font-weight: 600;
color: var(--text-secondary);
font-size: 0.85rem;
text-transform: uppercase;
}
.sync-status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 8px;
background: var(--accent-light);
color: var(--accent);
font-size: 0.9rem;
}
.spinner-border-sm {
width: 1rem;
height: 1rem;
}
</style>
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg fixed-top">
<div class="container-fluid">
<a class="navbar-brand" href="/dashboard">
<i class="bi bi-grid-3x3-gap-fill"></i> BMC Hub
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/dashboard">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/customers">Kunder</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/timetracking">
<i class="bi bi-clock-history"></i> Tidsregistrering
</a>
</li>
</ul>
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<button class="btn btn-link nav-link" onclick="toggleTheme()">
<i class="bi bi-moon-fill" id="theme-icon"></i>
</button>
</li>
</ul>
</div>
</div>
</nav>
<!-- Main Content -->
<div class="container-fluid py-4">
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<h1 class="mb-1">
<i class="bi bi-clock-history text-primary"></i> Tidsregistrering
</h1>
<p class="text-muted">Synkroniser, godkend og fakturer tidsregistreringer fra vTiger</p>
</div>
</div>
<!-- Safety Status Banner -->
<div class="row mb-4">
<div class="col-12">
<div class="alert alert-info d-flex align-items-center" role="alert">
<i class="bi bi-shield-lock-fill me-2"></i>
<div>
<strong>Safety Mode Aktiv</strong> -
Modulet kører i read-only mode. Ingen ændringer sker i vTiger eller e-conomic.
<small class="d-block mt-1">
<span class="badge bg-success">vTiger: READ-ONLY</span>
<span class="badge bg-success ms-1">e-conomic: DRY-RUN</span>
</small>
</div>
</div>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card stat-card">
<h3 id="stat-customers">-</h3>
<p>Kunder med tider</p>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<h3 id="stat-pending" class="text-warning">-</h3>
<p>Afventer godkendelse</p>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<h3 id="stat-approved" class="text-success">-</h3>
<p>Godkendte</p>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card">
<h3 id="stat-hours">-</h3>
<p>Timer godkendt</p>
</div>
</div>
</div>
<!-- Actions Row -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h5 class="mb-1">Synkronisering</h5>
<p class="text-muted mb-0 small">Hent nye tidsregistreringer fra vTiger</p>
</div>
<div>
<button class="btn btn-primary" onclick="syncFromVTiger()" id="sync-btn">
<i class="bi bi-arrow-repeat"></i> Synkroniser
</button>
</div>
</div>
<div id="sync-status" class="mt-3 d-none"></div>
</div>
</div>
</div>
</div>
<!-- Customer List -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-white">
<h5 class="mb-0">Kunder med åbne tidsregistreringer</h5>
</div>
<div class="card-body">
<div id="loading" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
</div>
<div id="customer-table" class="d-none">
<table class="table table-hover">
<thead>
<tr>
<th>Kunde</th>
<th>Total Timer</th>
<th class="text-center">Afventer</th>
<th class="text-center">Godkendt</th>
<th class="text-center">Afvist</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="customer-tbody">
</tbody>
</table>
</div>
<div id="no-data" class="text-center py-4 d-none">
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-3">Ingen tidsregistreringer fundet</p>
<button class="btn btn-primary" onclick="syncFromVTiger()">
<i class="bi bi-arrow-repeat"></i> Synkroniser fra vTiger
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Theme toggle
function toggleTheme() {
const html = document.documentElement;
const icon = document.getElementById('theme-icon');
if (html.getAttribute('data-theme') === 'dark') {
html.removeAttribute('data-theme');
icon.className = 'bi bi-moon-fill';
localStorage.setItem('theme', 'light');
} else {
html.setAttribute('data-theme', 'dark');
icon.className = 'bi bi-sun-fill';
localStorage.setItem('theme', 'dark');
}
}
// Load saved theme
if (localStorage.getItem('theme') === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
document.getElementById('theme-icon').className = 'bi bi-sun-fill';
}
// Load customer stats
async function loadCustomerStats() {
try {
const response = await fetch('/api/v1/timetracking/wizard/stats');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const customers = await response.json();
// Valider at vi fik et array
if (!Array.isArray(customers)) {
console.error('Invalid response format:', customers);
throw new Error('Uventet dataformat fra server');
}
// Filtrer kunder uden tidsregistreringer eller kun med godkendte/afviste
const activeCustomers = customers.filter(c =>
c.pending_count > 0 || c.approved_count > 0
);
if (activeCustomers.length === 0) {
document.getElementById('loading').classList.add('d-none');
document.getElementById('no-data').classList.remove('d-none');
return;
}
// Calculate totals (kun aktive kunder)
let totalCustomers = activeCustomers.length;
let totalPending = activeCustomers.reduce((sum, c) => sum + (c.pending_count || 0), 0);
let totalApproved = activeCustomers.reduce((sum, c) => sum + (c.approved_count || 0), 0);
let totalHours = activeCustomers.reduce((sum, c) => sum + (parseFloat(c.total_approved_hours) || 0), 0);
// Update stat cards
document.getElementById('stat-customers').textContent = totalCustomers;
document.getElementById('stat-pending').textContent = totalPending;
document.getElementById('stat-approved').textContent = totalApproved;
document.getElementById('stat-hours').textContent = totalHours.toFixed(1) + 'h';
// Build table (kun aktive kunder)
const tbody = document.getElementById('customer-tbody');
tbody.innerHTML = activeCustomers.map(customer => `
<tr>
<td>
<strong>${customer.customer_name || 'Ukendt kunde'}</strong>
<br><small class="text-muted">${customer.total_entries || 0} registreringer</small>
</td>
<td>${parseFloat(customer.total_original_hours || 0).toFixed(1)}h</td>
<td class="text-center">
<span class="badge bg-warning">${customer.pending_count || 0}</span>
</td>
<td class="text-center">
<span class="badge bg-success">${customer.approved_count || 0}</span>
</td>
<td class="text-center">
<span class="badge bg-danger">${customer.rejected_count || 0}</span>
</td>
<td class="text-end">
${(customer.pending_count || 0) > 0 ? `
<a href="/timetracking/wizard?customer_id=${customer.customer_id}"
class="btn btn-sm btn-primary">
<i class="bi bi-check-circle"></i> Godkend
</a>
` : ''}
${(customer.approved_count || 0) > 0 ? `
<button class="btn btn-sm btn-success"
onclick="generateOrder(${customer.customer_id})">
<i class="bi bi-receipt"></i> Opret ordre
</button>
` : ''}
</td>
</tr>
`).join('');
document.getElementById('loading').classList.add('d-none');
document.getElementById('customer-table').classList.remove('d-none');
} catch (error) {
console.error('Error loading stats:', error);
console.error('Error stack:', error.stack);
document.getElementById('loading').innerHTML = `
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle"></i>
<strong>Fejl ved indlæsning:</strong> ${error.message}
<br><small class="text-muted">Prøv at genindlæse siden med Cmd+Shift+R (Mac) eller Ctrl+Shift+F5 (Windows)</small>
</div>
`;
}
}
// Sync from vTiger
async function syncFromVTiger() {
const btn = document.getElementById('sync-btn');
const statusDiv = document.getElementById('sync-status');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Synkroniserer...';
statusDiv.classList.remove('d-none');
statusDiv.innerHTML = '<div class="alert alert-info mb-0"><i class="bi bi-hourglass-split"></i> Synkronisering i gang...</div>';
try {
const response = await fetch('/api/v1/timetracking/sync', {
method: 'POST'
});
const result = await response.json();
statusDiv.innerHTML = `
<div class="alert alert-success mb-0">
<strong><i class="bi bi-check-circle"></i> Synkronisering fuldført!</strong>
<ul class="mb-0 mt-2">
<li>Kunder: ${result.customers_imported || 0} nye, ${result.customers_updated || 0} opdateret</li>
<li>Cases: ${result.cases_imported || 0} nye, ${result.cases_updated || 0} opdateret</li>
<li>Tidsregistreringer: ${result.times_imported || 0} nye, ${result.times_updated || 0} opdateret</li>
</ul>
${result.duration_seconds ? `<small class="text-muted d-block mt-2">Varighed: ${result.duration_seconds.toFixed(1)}s</small>` : ''}
</div>
`;
// Reload data
setTimeout(() => {
location.reload();
}, 2000);
} catch (error) {
statusDiv.innerHTML = `
<div class="alert alert-danger mb-0">
<i class="bi bi-x-circle"></i> Synkronisering fejlede: ${error.message}
</div>
`;
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Synkroniser';
}
}
// Generate order for customer
async function generateOrder(customerId) {
if (!confirm('Opret ordre for alle godkendte tidsregistreringer?')) {
return;
}
try {
const response = await fetch(`/api/v1/timetracking/orders/generate/${customerId}`, {
method: 'POST'
});
const order = await response.json();
alert(`Ordre oprettet: ${order.order_number}\nTotal: ${order.total_amount} DKK`);
location.reload();
} catch (error) {
alert('Fejl ved oprettelse af ordre: ' + error.message);
}
}
// Load data on page load
loadCustomerStats();
</script>
</body>
</html>

View File

@ -0,0 +1,469 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ordrer - BMC Hub</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
:root {
--bg-body: #f8f9fa;
--bg-card: #ffffff;
--text-primary: #2c3e50;
--text-secondary: #6c757d;
--accent: #0f4c75;
--accent-light: #eef2f5;
--success: #28a745;
--warning: #ffc107;
--danger: #dc3545;
--border-radius: 12px;
}
[data-theme="dark"] {
--bg-body: #1a1a1a;
--bg-card: #2d2d2d;
--text-primary: #e4e4e4;
--text-secondary: #a0a0a0;
--accent-light: #1e3a52;
}
body {
background-color: var(--bg-body);
color: var(--text-primary);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
padding-top: 80px;
transition: background-color 0.3s, color 0.3s;
}
.navbar {
background: var(--bg-card);
box-shadow: 0 2px 15px rgba(0,0,0,0.1);
padding: 1rem 0;
}
.navbar-brand {
font-weight: 700;
color: var(--accent);
font-size: 1.25rem;
}
.nav-link {
color: var(--text-secondary);
padding: 0.6rem 1.2rem !important;
border-radius: var(--border-radius);
transition: all 0.2s;
}
.nav-link:hover, .nav-link.active {
background-color: var(--accent-light);
color: var(--accent);
}
.card {
border: none;
border-radius: var(--border-radius);
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
background: var(--bg-card);
margin-bottom: 1.5rem;
}
.table {
background: var(--bg-card);
}
.table th {
font-weight: 600;
color: var(--text-secondary);
font-size: 0.85rem;
text-transform: uppercase;
border-bottom: 2px solid var(--accent-light);
}
.order-row {
cursor: pointer;
transition: background-color 0.2s;
}
.order-row:hover {
background-color: var(--accent-light);
}
.order-details {
background: var(--accent-light);
border-radius: var(--border-radius);
padding: 1.5rem;
margin-top: 1rem;
}
.line-item {
padding: 0.75rem;
background: var(--bg-card);
border-radius: 8px;
margin-bottom: 0.5rem;
}
.modal-body .info-row {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid var(--accent-light);
}
.modal-body .info-row:last-child {
border-bottom: none;
}
</style>
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg fixed-top">
<div class="container-fluid">
<a class="navbar-brand" href="/dashboard">
<i class="bi bi-grid-3x3-gap-fill"></i> BMC Hub
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/dashboard">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/customers">Kunder</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/timetracking">
<i class="bi bi-clock-history"></i> Tidsregistrering
</a>
</li>
</ul>
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<button class="btn btn-link nav-link" onclick="toggleTheme()">
<i class="bi bi-moon-fill" id="theme-icon"></i>
</button>
</li>
</ul>
</div>
</div>
</nav>
<!-- Main Content -->
<div class="container-fluid py-4">
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="mb-1">
<i class="bi bi-receipt text-primary"></i> Ordrer
</h1>
<p class="text-muted mb-0">Oversigt over genererede ordrer og eksport til e-conomic</p>
</div>
<a href="/timetracking" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Tilbage
</a>
</div>
</div>
</div>
<!-- Safety Banner -->
<div class="row mb-4">
<div class="col-12">
<div class="alert alert-warning d-flex align-items-center" role="alert">
<i class="bi bi-shield-exclamation me-2"></i>
<div>
<strong>DRY-RUN Mode Aktiv</strong> -
Eksport til e-conomic er i test-mode. Fakturaer oprettes ikke i e-conomic.
</div>
</div>
</div>
</div>
<!-- Orders Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">Alle Ordrer</h5>
<button class="btn btn-sm btn-outline-primary" onclick="loadOrders()">
<i class="bi bi-arrow-clockwise"></i> Opdater
</button>
</div>
<div class="card-body">
<div id="loading" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
</div>
<div id="orders-table" class="d-none">
<table class="table table-hover">
<thead>
<tr>
<th>Ordrenr.</th>
<th>Kunde</th>
<th>Dato</th>
<th class="text-center">Linjer</th>
<th class="text-end">Total</th>
<th class="text-center">Status</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="orders-tbody">
</tbody>
</table>
</div>
<div id="no-orders" class="text-center py-5 d-none">
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-3">Ingen ordrer endnu</p>
<a href="/timetracking" class="btn btn-primary">
<i class="bi bi-arrow-left"></i> Godkend tider først
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Order Details Modal -->
<div class="modal fade" id="orderModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-receipt"></i> Ordre Detaljer
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="order-details-content">
<!-- Will be populated dynamically -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-success" id="export-order-btn" onclick="exportCurrentOrder()">
<i class="bi bi-cloud-upload"></i> Eksporter til e-conomic
</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script>
let currentOrderId = null;
let orderModal = null;
// Theme toggle
function toggleTheme() {
const html = document.documentElement;
const icon = document.getElementById('theme-icon');
if (html.getAttribute('data-theme') === 'dark') {
html.removeAttribute('data-theme');
icon.className = 'bi bi-moon-fill';
localStorage.setItem('theme', 'light');
} else {
html.setAttribute('data-theme', 'dark');
icon.className = 'bi bi-sun-fill';
localStorage.setItem('theme', 'dark');
}
}
// Load saved theme
if (localStorage.getItem('theme') === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
document.getElementById('theme-icon').className = 'bi bi-sun-fill';
}
// Initialize modal
document.addEventListener('DOMContentLoaded', function() {
orderModal = new bootstrap.Modal(document.getElementById('orderModal'));
loadOrders();
});
// Load all orders
async function loadOrders() {
document.getElementById('loading').classList.remove('d-none');
document.getElementById('orders-table').classList.add('d-none');
document.getElementById('no-orders').classList.add('d-none');
try {
const response = await fetch('/api/v1/timetracking/orders');
const orders = await response.json();
if (orders.length === 0) {
document.getElementById('loading').classList.add('d-none');
document.getElementById('no-orders').classList.remove('d-none');
return;
}
const tbody = document.getElementById('orders-tbody');
tbody.innerHTML = orders.map(order => {
const statusBadge = getStatusBadge(order);
const exportedIcon = order.exported_to_economic
? '<i class="bi bi-check-circle text-success" title="Eksporteret"></i>'
: '';
return `
<tr class="order-row" onclick="viewOrder(${order.id})">
<td>
<strong>${order.order_number}</strong>
${exportedIcon}
</td>
<td>${order.customer_name}</td>
<td>${new Date(order.order_date).toLocaleDateString('da-DK')}</td>
<td class="text-center">${order.line_count || 0}</td>
<td class="text-end"><strong>${parseFloat(order.total_amount).toFixed(2)} DKK</strong></td>
<td class="text-center">${statusBadge}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation(); viewOrder(${order.id})">
<i class="bi bi-eye"></i>
</button>
${!order.exported_to_economic ? `
<button class="btn btn-sm btn-success"
onclick="event.stopPropagation(); exportOrder(${order.id})">
<i class="bi bi-cloud-upload"></i>
</button>
` : ''}
</td>
</tr>
`;
}).join('');
document.getElementById('loading').classList.add('d-none');
document.getElementById('orders-table').classList.remove('d-none');
} catch (error) {
console.error('Error loading orders:', error);
document.getElementById('loading').innerHTML = `
<div class="alert alert-danger">
Fejl ved indlæsning: ${error.message}
</div>
`;
}
}
// Get status badge
function getStatusBadge(order) {
if (order.cancelled_at) {
return '<span class="badge bg-danger">Annulleret</span>';
}
if (order.exported_to_economic) {
return '<span class="badge bg-success">Eksporteret</span>';
}
return '<span class="badge bg-warning">Pending</span>';
}
// View order details
async function viewOrder(orderId) {
currentOrderId = orderId;
try {
const response = await fetch(`/api/v1/timetracking/orders/${orderId}`);
const order = await response.json();
const content = document.getElementById('order-details-content');
content.innerHTML = `
<div class="info-row">
<span class="fw-bold">Ordrenummer:</span>
<span>${order.order_number}</span>
</div>
<div class="info-row">
<span class="fw-bold">Kunde:</span>
<span>${order.customer_name}</span>
</div>
<div class="info-row">
<span class="fw-bold">Dato:</span>
<span>${new Date(order.order_date).toLocaleDateString('da-DK')}</span>
</div>
<div class="info-row">
<span class="fw-bold">Total:</span>
<span class="fs-5 fw-bold text-primary">${parseFloat(order.total_amount).toFixed(2)} DKK</span>
</div>
<hr class="my-3">
<h6 class="mb-3">Ordrelinjer:</h6>
${order.lines.map(line => `
<div class="line-item">
<div class="d-flex justify-content-between mb-1">
<strong>${line.description}</strong>
<strong>${parseFloat(line.line_total).toFixed(2)} DKK</strong>
</div>
<div class="d-flex justify-content-between text-muted small">
<span>${line.quantity} timer × ${parseFloat(line.unit_price).toFixed(2)} DKK</span>
<span>${new Date(line.time_date).toLocaleDateString('da-DK')}</span>
</div>
</div>
`).join('')}
${order.exported_to_economic ? `
<div class="alert alert-success mt-3 mb-0">
<i class="bi bi-check-circle"></i>
Eksporteret til e-conomic den ${new Date(order.exported_at).toLocaleDateString('da-DK')}
${order.economic_draft_invoice_number ? `<br>Kladde nr.: ${order.economic_draft_invoice_number}` : ''}
</div>
` : ''}
`;
// Update export button
const exportBtn = document.getElementById('export-order-btn');
if (order.exported_to_economic) {
exportBtn.disabled = true;
exportBtn.innerHTML = '<i class="bi bi-check-circle"></i> Allerede eksporteret';
} else {
exportBtn.disabled = false;
exportBtn.innerHTML = '<i class="bi bi-cloud-upload"></i> Eksporter til e-conomic';
}
orderModal.show();
} catch (error) {
alert('Fejl ved indlæsning af ordre: ' + error.message);
}
}
// Export order
async function exportOrder(orderId) {
if (!confirm('Eksporter ordre til e-conomic?\n\nDette opretter en kladde-faktura i e-conomic.')) {
return;
}
try {
const response = await fetch(`/api/v1/timetracking/export/${orderId}`, {
method: 'POST'
});
const result = await response.json();
if (result.dry_run) {
alert(`DRY-RUN MODE:\n\nFakturaen ville blive oprettet med:\n- Kladde nr.: ${result.draft_invoice_number}\n- Total: ${result.total_amount} DKK\n\nIngen ændringer er foretaget i e-conomic.`);
} else {
alert(`Ordre eksporteret!\n\nKladde nr.: ${result.draft_invoice_number}\nTotal: ${result.total_amount} DKK`);
}
loadOrders();
if (orderModal._isShown) {
orderModal.hide();
}
} catch (error) {
alert('Fejl ved eksport: ' + error.message);
}
}
// Export current order from modal
function exportCurrentOrder() {
if (currentOrderId) {
exportOrder(currentOrderId);
}
}
</script>
</body>
</html>

View File

@ -0,0 +1,62 @@
"""
Frontend Views Router for Time Tracking Module
===============================================
HTML page handlers for time tracking UI.
"""
import logging
import os
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, FileResponse
logger = logging.getLogger(__name__)
router = APIRouter()
# Path to templates - use absolute path from environment
BASE_DIR = Path(os.getenv("APP_ROOT", "/app"))
TEMPLATE_DIR = BASE_DIR / "app" / "timetracking" / "frontend"
@router.get("/timetracking", response_class=HTMLResponse, name="timetracking_dashboard")
async def timetracking_dashboard(request: Request):
"""Time Tracking Dashboard - oversigt og sync"""
template_path = TEMPLATE_DIR / "dashboard.html"
logger.info(f"Serving dashboard from: {template_path}")
# Force no-cache headers to prevent browser caching
response = FileResponse(template_path)
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
@router.get("/timetracking/wizard", response_class=HTMLResponse, name="timetracking_wizard")
async def timetracking_wizard(request: Request):
"""Time Tracking Wizard - step-by-step approval"""
template_path = TEMPLATE_DIR / "wizard.html"
logger.info(f"Serving wizard from: {template_path}")
# Force no-cache headers
response = FileResponse(template_path)
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
@router.get("/timetracking/orders", response_class=HTMLResponse, name="timetracking_orders")
async def timetracking_orders(request: Request):
"""Order oversigt"""
template_path = TEMPLATE_DIR / "orders.html"
logger.info(f"Serving orders from: {template_path}")
# Force no-cache headers
response = FileResponse(template_path)
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response

View File

@ -0,0 +1,617 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Godkend Tider - BMC Hub</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
:root {
--bg-body: #f8f9fa;
--bg-card: #ffffff;
--text-primary: #2c3e50;
--text-secondary: #6c757d;
--accent: #0f4c75;
--accent-light: #eef2f5;
--success: #28a745;
--warning: #ffc107;
--danger: #dc3545;
--border-radius: 12px;
}
[data-theme="dark"] {
--bg-body: #1a1a1a;
--bg-card: #2d2d2d;
--text-primary: #e4e4e4;
--text-secondary: #a0a0a0;
--accent-light: #1e3a52;
}
body {
background-color: var(--bg-body);
color: var(--text-primary);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
padding-top: 80px;
transition: background-color 0.3s, color 0.3s;
}
.navbar {
background: var(--bg-card);
box-shadow: 0 2px 15px rgba(0,0,0,0.1);
padding: 1rem 0;
}
.navbar-brand {
font-weight: 700;
color: var(--accent);
font-size: 1.25rem;
}
.nav-link {
color: var(--text-secondary);
padding: 0.6rem 1.2rem !important;
border-radius: var(--border-radius);
transition: all 0.2s;
}
.nav-link:hover, .nav-link.active {
background-color: var(--accent-light);
color: var(--accent);
}
.card {
border: none;
border-radius: var(--border-radius);
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
background: var(--bg-card);
margin-bottom: 1.5rem;
}
.progress-container {
position: relative;
padding: 1.5rem 0;
}
.progress {
height: 8px;
border-radius: 10px;
background-color: var(--accent-light);
}
.progress-bar {
background-color: var(--accent);
}
.time-entry-card {
border-left: 4px solid var(--accent);
}
.time-entry-card .card-body {
padding: 2rem;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid var(--accent-light);
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
color: var(--text-secondary);
font-weight: 500;
font-size: 0.9rem;
}
.info-value {
font-weight: 600;
color: var(--text-primary);
}
.rounding-controls {
background: var(--accent-light);
padding: 1.5rem;
border-radius: var(--border-radius);
margin-top: 1.5rem;
}
.btn-action {
padding: 0.8rem 2rem;
font-weight: 600;
border-radius: 8px;
min-width: 150px;
}
.completion-card {
text-align: center;
padding: 3rem 2rem;
}
.completion-card i {
font-size: 4rem;
margin-bottom: 1.5rem;
}
.badge-large {
font-size: 1rem;
padding: 0.6rem 1.2rem;
border-radius: 8px;
}
.description-box {
background: var(--accent-light);
padding: 1rem;
border-radius: 8px;
margin-top: 1rem;
font-family: monospace;
white-space: pre-wrap;
}
</style>
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg fixed-top">
<div class="container-fluid">
<a class="navbar-brand" href="/dashboard">
<i class="bi bi-grid-3x3-gap-fill"></i> BMC Hub
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/dashboard">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/customers">Kunder</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/timetracking">
<i class="bi bi-clock-history"></i> Tidsregistrering
</a>
</li>
</ul>
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<button class="btn btn-link nav-link" onclick="toggleTheme()">
<i class="bi bi-moon-fill" id="theme-icon"></i>
</button>
</li>
</ul>
</div>
</div>
</nav>
<!-- Main Content -->
<div class="container py-4">
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="mb-1">
<i class="bi bi-check2-circle text-primary"></i> Godkend Tidsregistreringer
</h1>
<p class="text-muted mb-0">Gennemgå og godkend tider én ad gangen</p>
</div>
<a href="/timetracking" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Tilbage til oversigt
</a>
</div>
</div>
</div>
<!-- Progress Bar -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0" id="progress-title">Indlæser...</h6>
<span class="badge bg-primary" id="progress-badge">0 / 0</span>
</div>
<div class="progress">
<div class="progress-bar" role="progressbar" id="progress-bar"
style="width: 0%"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Loading State -->
<div id="loading-state" class="row">
<div class="col-12">
<div class="card">
<div class="card-body text-center py-5">
<div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">Indlæser...</span>
</div>
<p class="text-muted">Henter næste tidsregistrering...</p>
</div>
</div>
</div>
</div>
<!-- Time Entry Card -->
<div id="time-entry-container" class="row d-none">
<div class="col-lg-8">
<div class="card time-entry-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<h4 class="mb-0" id="entry-title">Tidsregistrering</h4>
<span class="badge badge-large bg-info" id="entry-status">Afventer godkendelse</span>
</div>
<div class="info-row">
<span class="info-label">
<i class="bi bi-building"></i> Kunde
</span>
<span class="info-value" id="entry-customer">-</span>
</div>
<div class="info-row">
<span class="info-label">
<i class="bi bi-folder"></i> Case
</span>
<span class="info-value" id="entry-case">-</span>
</div>
<div class="info-row">
<span class="info-label">
<i class="bi bi-calendar-event"></i> Dato
</span>
<span class="info-value" id="entry-date">-</span>
</div>
<div class="info-row">
<span class="info-label">
<i class="bi bi-clock"></i> Original Timer
</span>
<span class="info-value" id="entry-hours-original">-</span>
</div>
<div class="info-row">
<span class="info-label">
<i class="bi bi-person"></i> Udført af
</span>
<span class="info-value" id="entry-user">-</span>
</div>
<div class="mt-3">
<label class="info-label d-block mb-2">
<i class="bi bi-file-text"></i> Beskrivelse
</label>
<div class="description-box" id="entry-description">-</div>
</div>
<!-- Rounding Controls -->
<div class="rounding-controls">
<h6 class="mb-3">
<i class="bi bi-calculator"></i> Afrunding
</h6>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Metode</label>
<select class="form-select" id="rounding-method">
<option value="none">Ingen afrunding</option>
<option value="nearest_quarter" selected>Nærmeste 0.25 time</option>
<option value="nearest_half">Nærmeste 0.5 time</option>
<option value="up_quarter">Afrund op til 0.25</option>
<option value="up_half">Afrund op til 0.5</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Minimum timer</label>
<input type="number" class="form-control" id="minimum-hours"
value="0" min="0" step="0.25">
</div>
</div>
<div class="mt-3 p-3 bg-white rounded">
<div class="d-flex justify-content-between align-items-center">
<span class="fw-bold">Fakturerbare timer:</span>
<span class="fs-4 fw-bold text-primary" id="billable-hours">-</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="col-lg-4">
<div class="card">
<div class="card-body">
<h6 class="mb-3">Handlinger</h6>
<button class="btn btn-success btn-action w-100 mb-3"
onclick="approveEntry()">
<i class="bi bi-check-circle"></i> Godkend
</button>
<button class="btn btn-danger btn-action w-100 mb-3"
onclick="rejectEntry()">
<i class="bi bi-x-circle"></i> Afvis
</button>
<hr>
<div class="text-muted small">
<p class="mb-2">
<i class="bi bi-info-circle"></i>
Godkend for at inkludere i fakturering
</p>
<p class="mb-0">
<i class="bi bi-exclamation-triangle"></i>
Afvisning kan ikke fortrydes
</p>
</div>
</div>
</div>
<!-- Customer Context -->
<div class="card">
<div class="card-body">
<h6 class="mb-3">
<i class="bi bi-graph-up"></i> Kunde Status
</h6>
<div id="customer-context">
<div class="mb-2">
<small class="text-muted">Timepris:</small>
<div class="fw-bold" id="context-hourly-rate">-</div>
</div>
<div class="mb-2">
<small class="text-muted">Afventer godkendelse:</small>
<div class="fw-bold" id="context-pending">-</div>
</div>
<div>
<small class="text-muted">Godkendte timer:</small>
<div class="fw-bold text-success" id="context-approved">-</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Completion State -->
<div id="completion-state" class="row d-none">
<div class="col-12">
<div class="card">
<div class="card-body completion-card">
<i class="bi bi-check-circle text-success"></i>
<h3 class="mb-3">Alle tider gennemgået!</h3>
<p class="text-muted mb-4">
Der er ingen flere tidsregistreringer der afventer godkendelse.
</p>
<div class="d-flex gap-3 justify-content-center">
<a href="/timetracking" class="btn btn-primary btn-lg">
<i class="bi bi-house"></i> Tilbage til Dashboard
</a>
<a href="/timetracking/orders" class="btn btn-success btn-lg">
<i class="bi bi-receipt"></i> Opret Ordrer
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script>
let currentEntry = null;
let currentCustomerId = null;
// Theme toggle
function toggleTheme() {
const html = document.documentElement;
const icon = document.getElementById('theme-icon');
if (html.getAttribute('data-theme') === 'dark') {
html.removeAttribute('data-theme');
icon.className = 'bi bi-moon-fill';
localStorage.setItem('theme', 'light');
} else {
html.setAttribute('data-theme', 'dark');
icon.className = 'bi bi-sun-fill';
localStorage.setItem('theme', 'dark');
}
}
// Load saved theme
if (localStorage.getItem('theme') === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
document.getElementById('theme-icon').className = 'bi bi-sun-fill';
}
// Get URL parameters
const urlParams = new URLSearchParams(window.location.search);
currentCustomerId = urlParams.get('customer_id');
// Load next entry
async function loadNextEntry() {
document.getElementById('loading-state').classList.remove('d-none');
document.getElementById('time-entry-container').classList.add('d-none');
document.getElementById('completion-state').classList.add('d-none');
try {
const url = currentCustomerId
? `/api/v1/timetracking/wizard/next?customer_id=${currentCustomerId}`
: '/api/v1/timetracking/wizard/next';
const response = await fetch(url);
if (response.status === 404) {
// No more entries
showCompletion();
return;
}
const data = await response.json();
currentEntry = data.time_entry;
displayEntry(data);
await loadCustomerContext();
calculateBillableHours();
document.getElementById('loading-state').classList.add('d-none');
document.getElementById('time-entry-container').classList.remove('d-none');
} catch (error) {
console.error('Error loading entry:', error);
alert('Fejl ved indlæsning: ' + error.message);
}
}
// Display entry
function displayEntry(data) {
const entry = data.time_entry;
document.getElementById('entry-customer').textContent = entry.customer_name;
document.getElementById('entry-case').textContent = entry.case_subject || 'Ingen case';
document.getElementById('entry-date').textContent = new Date(entry.time_date).toLocaleDateString('da-DK');
document.getElementById('entry-hours-original').textContent = entry.original_hours + ' timer';
document.getElementById('entry-user').textContent = entry.time_user_name || 'Ukendt';
document.getElementById('entry-description').textContent = entry.description || '(Ingen beskrivelse)';
// Progress
const total = data.customer_progress.total_entries;
const processed = data.customer_progress.approved_count + data.customer_progress.rejected_count;
const percent = Math.round((processed / total) * 100);
document.getElementById('progress-title').textContent = entry.customer_name;
document.getElementById('progress-badge').textContent = `${processed} / ${total}`;
document.getElementById('progress-bar').style.width = percent + '%';
}
// Load customer context
async function loadCustomerContext() {
try {
const response = await fetch(`/api/v1/timetracking/wizard/progress/${currentEntry.customer_id}`);
const progress = await response.json();
const hourlyRate = currentEntry.customer_hourly_rate || 850.00;
document.getElementById('context-hourly-rate').textContent = hourlyRate + ' DKK';
document.getElementById('context-pending').textContent = progress.pending_count + ' timer';
document.getElementById('context-approved').textContent =
progress.approved_count + ' (' + parseFloat(progress.total_approved_hours || 0).toFixed(1) + 'h)';
} catch (error) {
console.error('Error loading customer context:', error);
}
}
// Calculate billable hours
function calculateBillableHours() {
const method = document.getElementById('rounding-method').value;
const minHours = parseFloat(document.getElementById('minimum-hours').value) || 0;
const original = currentEntry.original_hours;
let billable = original;
// Apply rounding
if (method === 'nearest_quarter') {
billable = Math.round(billable * 4) / 4;
} else if (method === 'nearest_half') {
billable = Math.round(billable * 2) / 2;
} else if (method === 'up_quarter') {
billable = Math.ceil(billable * 4) / 4;
} else if (method === 'up_half') {
billable = Math.ceil(billable * 2) / 2;
}
// Apply minimum
billable = Math.max(billable, minHours);
document.getElementById('billable-hours').textContent = billable.toFixed(2) + ' timer';
currentEntry.calculated_billable_hours = billable;
}
// Approve entry
async function approveEntry() {
const billableHours = currentEntry.calculated_billable_hours;
const roundingMethod = document.getElementById('rounding-method').value;
try {
const response = await fetch(`/api/v1/timetracking/wizard/approve/${currentEntry.id}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
billable_hours: billableHours,
rounding_method: roundingMethod
})
});
if (!response.ok) throw new Error('Godkendelse fejlede');
// Load next
await loadNextEntry();
} catch (error) {
alert('Fejl ved godkendelse: ' + error.message);
}
}
// Reject entry
async function rejectEntry() {
if (!confirm('Er du sikker på at du vil afvise denne tidsregistrering?')) {
return;
}
const reason = prompt('Årsag til afvisning (valgfrit):');
try {
const response = await fetch(`/api/v1/timetracking/wizard/reject/${currentEntry.id}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
rejection_reason: reason
})
});
if (!response.ok) throw new Error('Afvisning fejlede');
// Load next
await loadNextEntry();
} catch (error) {
alert('Fejl ved afvisning: ' + error.message);
}
}
// Show completion
function showCompletion() {
document.getElementById('loading-state').classList.add('d-none');
document.getElementById('time-entry-container').classList.add('d-none');
document.getElementById('completion-state').classList.remove('d-none');
}
// Event listeners
document.getElementById('rounding-method').addEventListener('change', calculateBillableHours);
document.getElementById('minimum-hours').addEventListener('input', calculateBillableHours);
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (currentEntry && !e.target.matches('input, select, textarea')) {
if (e.key === 'a' || e.key === 'A') {
e.preventDefault();
approveEntry();
} else if (e.key === 'r' || e.key === 'R') {
e.preventDefault();
rejectEntry();
}
}
});
// Load first entry
loadNextEntry();
</script>
</body>
</html>

16
list_routes.py Normal file
View File

@ -0,0 +1,16 @@
#!/usr/bin/env python3
import main
print("=" * 80)
print("ALL REGISTERED ROUTES")
print("=" * 80)
for i, route in enumerate(main.app.routes):
if hasattr(route, 'path'):
print(f"{i+1:3}. {route.path:60}")
if 'time' in route.path.lower():
print(f" ^^^ TIMETRACKING ROUTE ^^^")
else:
print(f"{i+1:3}. {route}")
print(f"\n Total routes: {len(main.app.routes)}")

View File

@ -32,6 +32,8 @@ from app.dashboard.backend import views as dashboard_views
from app.dashboard.backend import router as dashboard_api
from app.devportal.backend import router as devportal_api
from app.devportal.backend import views as devportal_views
from app.timetracking.backend import router as timetracking_api
from app.timetracking.frontend import views as timetracking_views
# Configure logging
logging.basicConfig(
@ -103,6 +105,7 @@ app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"])
app.include_router(system_api.router, prefix="/api/v1", tags=["System"])
app.include_router(dashboard_api.router, prefix="/api/v1/dashboard", tags=["Dashboard"])
app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["DEV Portal"])
app.include_router(timetracking_api, prefix="/api/v1/timetracking", tags=["Time Tracking"])
# Frontend Routers
app.include_router(auth_views.router, tags=["Frontend"])
@ -113,6 +116,7 @@ app.include_router(vendors_views.router, tags=["Frontend"])
app.include_router(billing_views.router, tags=["Frontend"])
app.include_router(settings_views.router, tags=["Frontend"])
app.include_router(devportal_views.router, tags=["Frontend"])
app.include_router(timetracking_views.router, tags=["Frontend"])
# Serve static files (UI)
app.mount("/static", StaticFiles(directory="static", html=True), name="static")

View File

@ -0,0 +1,416 @@
-- ============================================================================
-- Migration 013: Tidsregistrering & Faktureringsmodul (Isoleret)
-- ============================================================================
-- Dette modul er 100% isoleret og kan slettes uden at påvirke eksisterende data.
-- Alle tabeller har prefix 'tmodule_' for at markere tilhørsforhold til modulet.
-- Ved uninstall køres DROP-scriptet i bunden af denne fil.
-- ============================================================================
-- Metadata tabel til at tracke modulets tilstand
CREATE TABLE IF NOT EXISTS tmodule_metadata (
id SERIAL PRIMARY KEY,
module_version VARCHAR(20) NOT NULL DEFAULT '1.0.0',
installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
installed_by INTEGER, -- Reference til users.id (read-only, ingen FK)
last_sync_at TIMESTAMP,
is_active BOOLEAN DEFAULT true,
settings JSONB DEFAULT '{}'::jsonb
);
-- Indsæt initial metadata
INSERT INTO tmodule_metadata (module_version, is_active)
VALUES ('1.0.0', true)
ON CONFLICT DO NOTHING;
-- ============================================================================
-- KUNDE-CACHE (read-only kopi fra vTiger for isolation)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tmodule_customers (
id SERIAL PRIMARY KEY,
vtiger_id VARCHAR(50) UNIQUE NOT NULL, -- vTiger Account ID
name VARCHAR(255) NOT NULL,
email VARCHAR(255),
hub_customer_id INTEGER, -- Reference til customers.id (OPTIONAL, read-only)
hourly_rate DECIMAL(10,2), -- Kan override Hub-rate
vtiger_data JSONB, -- Original vTiger data for reference
sync_hash VARCHAR(64), -- SHA256 af data for change detection
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
last_synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_tmodule_customers_vtiger ON tmodule_customers(vtiger_id);
CREATE INDEX idx_tmodule_customers_hub ON tmodule_customers(hub_customer_id);
CREATE INDEX idx_tmodule_customers_synced ON tmodule_customers(last_synced_at);
-- ============================================================================
-- CASE-CACHE (read-only kopi fra vTiger HelpDesk/ProjectTask)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tmodule_cases (
id SERIAL PRIMARY KEY,
vtiger_id VARCHAR(50) UNIQUE NOT NULL, -- vTiger HelpDesk/ProjectTask ID
customer_id INTEGER NOT NULL REFERENCES tmodule_customers(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
description TEXT,
status VARCHAR(50),
priority VARCHAR(50),
module_type VARCHAR(50), -- HelpDesk, ProjectTask, etc.
vtiger_data JSONB,
sync_hash VARCHAR(64),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
last_synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_tmodule_cases_vtiger ON tmodule_cases(vtiger_id);
CREATE INDEX idx_tmodule_cases_customer ON tmodule_cases(customer_id);
CREATE INDEX idx_tmodule_cases_status ON tmodule_cases(status);
CREATE INDEX idx_tmodule_cases_synced ON tmodule_cases(last_synced_at);
-- ============================================================================
-- TIDSREGISTRERINGER (read-only kopi fra vTiger ModComments)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tmodule_times (
id SERIAL PRIMARY KEY,
vtiger_id VARCHAR(50) UNIQUE NOT NULL, -- vTiger ModComments ID
case_id INTEGER NOT NULL REFERENCES tmodule_cases(id) ON DELETE CASCADE,
customer_id INTEGER NOT NULL REFERENCES tmodule_customers(id) ON DELETE CASCADE,
-- Original vTiger data
description TEXT,
original_hours DECIMAL(5,2) NOT NULL,
worked_date DATE,
user_name VARCHAR(255), -- vTiger user (read-only)
-- Godkendelsesdata (ændres kun i modulet)
status VARCHAR(20) DEFAULT 'pending', -- pending|approved|rejected|billed
approved_hours DECIMAL(5,2),
rounded_to DECIMAL(3,1), -- 0.5, 1.0, etc.
approval_note TEXT,
billable BOOLEAN DEFAULT true,
approved_at TIMESTAMP,
approved_by INTEGER, -- Reference til users.id (read-only)
-- Metadata
vtiger_data JSONB,
sync_hash VARCHAR(64),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
last_synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Constraints
CONSTRAINT check_hours_positive CHECK (original_hours > 0),
CONSTRAINT check_approved_hours CHECK (approved_hours IS NULL OR approved_hours > 0),
CONSTRAINT check_status CHECK (status IN ('pending', 'approved', 'rejected', 'billed'))
);
CREATE INDEX idx_tmodule_times_vtiger ON tmodule_times(vtiger_id);
CREATE INDEX idx_tmodule_times_case ON tmodule_times(case_id);
CREATE INDEX idx_tmodule_times_customer ON tmodule_times(customer_id);
CREATE INDEX idx_tmodule_times_status ON tmodule_times(status);
CREATE INDEX idx_tmodule_times_date ON tmodule_times(worked_date);
CREATE INDEX idx_tmodule_times_approved_by ON tmodule_times(approved_by);
-- ============================================================================
-- ORDRER (genereret fra godkendte tider)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tmodule_orders (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES tmodule_customers(id) ON DELETE CASCADE,
hub_customer_id INTEGER, -- Reference til customers.id (read-only)
-- Order metadata
order_number VARCHAR(50), -- Auto-generated: TT-YYYYMMDD-XXX
order_date DATE DEFAULT CURRENT_DATE,
status VARCHAR(20) DEFAULT 'draft', -- draft|exported|sent|cancelled
-- Beløb
total_hours DECIMAL(8,2) NOT NULL DEFAULT 0,
hourly_rate DECIMAL(10,2) NOT NULL,
subtotal DECIMAL(12,2) NOT NULL DEFAULT 0,
vat_rate DECIMAL(5,2) DEFAULT 25.00, -- Danish VAT
vat_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
total_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
-- e-conomic integration
economic_draft_id INTEGER,
economic_order_number VARCHAR(50),
exported_at TIMESTAMP,
exported_by INTEGER, -- Reference til users.id (read-only)
-- Metadata
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
created_by INTEGER, -- Reference til users.id (read-only)
CONSTRAINT check_total_hours CHECK (total_hours >= 0),
CONSTRAINT check_amounts CHECK (subtotal >= 0 AND vat_amount >= 0 AND total_amount >= 0),
CONSTRAINT check_status CHECK (status IN ('draft', 'exported', 'sent', 'cancelled'))
);
CREATE INDEX idx_tmodule_orders_customer ON tmodule_orders(customer_id);
CREATE INDEX idx_tmodule_orders_status ON tmodule_orders(status);
CREATE INDEX idx_tmodule_orders_date ON tmodule_orders(order_date);
CREATE INDEX idx_tmodule_orders_economic ON tmodule_orders(economic_draft_id);
CREATE UNIQUE INDEX idx_tmodule_orders_number ON tmodule_orders(order_number) WHERE order_number IS NOT NULL;
-- ============================================================================
-- ORDRE-LINJER (detaljer pr. case eller gruppering)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tmodule_order_lines (
id SERIAL PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES tmodule_orders(id) ON DELETE CASCADE,
case_id INTEGER REFERENCES tmodule_cases(id) ON DELETE SET NULL,
-- Linje-detaljer
line_number INTEGER NOT NULL,
description TEXT NOT NULL,
quantity DECIMAL(8,2) NOT NULL, -- Timer
unit_price DECIMAL(10,2) NOT NULL,
line_total DECIMAL(12,2) NOT NULL,
-- Reference til tidsregistreringer
time_entry_ids INTEGER[], -- Array af tmodule_times.id
-- e-conomic mapping
product_number VARCHAR(50),
account_number VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT check_line_number CHECK (line_number > 0),
CONSTRAINT check_quantity CHECK (quantity > 0),
CONSTRAINT check_amounts_line CHECK (unit_price >= 0 AND line_total >= 0)
);
CREATE INDEX idx_tmodule_order_lines_order ON tmodule_order_lines(order_id);
CREATE INDEX idx_tmodule_order_lines_case ON tmodule_order_lines(case_id);
-- ============================================================================
-- AUDIT LOG (fuld sporbarhed af alle handlinger)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tmodule_sync_log (
id SERIAL PRIMARY KEY,
event_type VARCHAR(50) NOT NULL, -- sync_started|sync_completed|approval|rejection|export|uninstall
entity_type VARCHAR(50), -- time_entry|order|customer|case
entity_id INTEGER,
user_id INTEGER, -- Reference til users.id (read-only)
-- Event-specifik data
details JSONB,
-- Metadata
ip_address INET,
user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT check_event_type CHECK (event_type IN (
'sync_started', 'sync_completed', 'sync_failed',
'approval', 'rejection', 'bulk_approval',
'order_created', 'order_updated', 'order_cancelled',
'export_started', 'export_completed', 'export_failed',
'module_installed', 'module_uninstalled'
))
);
CREATE INDEX idx_tmodule_sync_log_event ON tmodule_sync_log(event_type);
CREATE INDEX idx_tmodule_sync_log_entity ON tmodule_sync_log(entity_type, entity_id);
CREATE INDEX idx_tmodule_sync_log_user ON tmodule_sync_log(user_id);
CREATE INDEX idx_tmodule_sync_log_created ON tmodule_sync_log(created_at DESC);
-- ============================================================================
-- TRIGGERS FOR AUTO-UPDATE TIMESTAMPS
-- ============================================================================
CREATE OR REPLACE FUNCTION tmodule_update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tmodule_customers_update
BEFORE UPDATE ON tmodule_customers
FOR EACH ROW EXECUTE FUNCTION tmodule_update_timestamp();
CREATE TRIGGER tmodule_cases_update
BEFORE UPDATE ON tmodule_cases
FOR EACH ROW EXECUTE FUNCTION tmodule_update_timestamp();
CREATE TRIGGER tmodule_times_update
BEFORE UPDATE ON tmodule_times
FOR EACH ROW EXECUTE FUNCTION tmodule_update_timestamp();
CREATE TRIGGER tmodule_orders_update
BEFORE UPDATE ON tmodule_orders
FOR EACH ROW EXECUTE FUNCTION tmodule_update_timestamp();
-- ============================================================================
-- AUTO-GENERATE ORDER NUMBERS
-- ============================================================================
CREATE OR REPLACE FUNCTION tmodule_generate_order_number()
RETURNS TRIGGER AS $$
DECLARE
date_prefix VARCHAR(8);
seq_num INTEGER;
new_number VARCHAR(50);
BEGIN
IF NEW.order_number IS NULL THEN
-- Format: TT-YYYYMMDD-XXX
date_prefix := TO_CHAR(CURRENT_DATE, 'YYYYMMDD');
-- Find næste sekvensnummer for dagen
SELECT COALESCE(MAX(
CAST(SUBSTRING(order_number FROM 'TT-\d{8}-(\d+)') AS INTEGER)
), 0) + 1
INTO seq_num
FROM tmodule_orders
WHERE order_number LIKE 'TT-' || date_prefix || '-%';
new_number := 'TT-' || date_prefix || '-' || LPAD(seq_num::TEXT, 3, '0');
NEW.order_number := new_number;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tmodule_orders_generate_number
BEFORE INSERT ON tmodule_orders
FOR EACH ROW EXECUTE FUNCTION tmodule_generate_order_number();
-- ============================================================================
-- VIEWS FOR COMMON QUERIES
-- ============================================================================
-- Oversigt over godkendelsesstatus pr. kunde
CREATE OR REPLACE VIEW tmodule_approval_stats AS
SELECT
c.id AS customer_id,
c.name AS customer_name,
c.vtiger_id AS customer_vtiger_id,
COUNT(t.id) AS total_entries,
COUNT(t.id) FILTER (WHERE t.status = 'pending') AS pending_count,
COUNT(t.id) FILTER (WHERE t.status = 'approved') AS approved_count,
COUNT(t.id) FILTER (WHERE t.status = 'rejected') AS rejected_count,
COUNT(t.id) FILTER (WHERE t.status = 'billed') AS billed_count,
SUM(t.original_hours) AS total_original_hours,
SUM(t.approved_hours) FILTER (WHERE t.status = 'approved') AS total_approved_hours,
MAX(t.worked_date) AS latest_work_date,
MAX(t.last_synced_at) AS last_sync
FROM tmodule_customers c
LEFT JOIN tmodule_times t ON c.id = t.customer_id
GROUP BY c.id, c.name, c.vtiger_id;
-- Næste tid der skal godkendes (wizard helper)
CREATE OR REPLACE VIEW tmodule_next_pending AS
SELECT
t.*,
c.title AS case_title,
c.status AS case_status,
cust.name AS customer_name,
cust.hourly_rate AS customer_rate
FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id
JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE t.status = 'pending'
ORDER BY cust.name, c.title, t.worked_date;
-- Order summary med linjer
CREATE OR REPLACE VIEW tmodule_order_details AS
SELECT
o.id AS order_id,
o.order_number,
o.order_date,
o.status AS order_status,
o.total_hours,
o.total_amount,
o.economic_draft_id,
c.name AS customer_name,
c.vtiger_id AS customer_vtiger_id,
COUNT(DISTINCT l.id) AS line_count,
COUNT(DISTINCT t.id) AS time_entry_count
FROM tmodule_orders o
JOIN tmodule_customers c ON o.customer_id = c.id
LEFT JOIN tmodule_order_lines l ON o.id = l.order_id
LEFT JOIN tmodule_times t ON t.id = ANY(l.time_entry_ids)
GROUP BY o.id, o.order_number, o.order_date, o.status, o.total_hours,
o.total_amount, o.economic_draft_id, c.name, c.vtiger_id;
-- ============================================================================
-- COMMENTS FOR DOCUMENTATION
-- ============================================================================
COMMENT ON TABLE tmodule_metadata IS 'Metadata og konfiguration for tidsregistreringsmodulet';
COMMENT ON TABLE tmodule_customers IS 'Read-only cache af vTiger kunder (isoleret kopi)';
COMMENT ON TABLE tmodule_cases IS 'Read-only cache af vTiger cases/projekter (isoleret kopi)';
COMMENT ON TABLE tmodule_times IS 'Tidsregistreringer importeret fra vTiger med godkendelsesstatus';
COMMENT ON TABLE tmodule_orders IS 'Genererede ordrer fra godkendte tider';
COMMENT ON TABLE tmodule_order_lines IS 'Ordre-linjer med reference til tidsregistreringer';
COMMENT ON TABLE tmodule_sync_log IS 'Fuld audit log af alle modulhandlinger';
COMMENT ON COLUMN tmodule_times.status IS 'pending=Afventer godkendelse, approved=Godkendt, rejected=Afvist, billed=Faktureret';
COMMENT ON COLUMN tmodule_times.approved_hours IS 'Timer efter brugerens godkendelse og evt. afrunding';
COMMENT ON COLUMN tmodule_times.rounded_to IS 'Afrundingsinterval brugt (0.5, 1.0, etc.)';
COMMENT ON COLUMN tmodule_orders.status IS 'draft=Kladde, exported=Sendt til e-conomic, sent=Sendt til kunde, cancelled=Annulleret';
-- ============================================================================
-- INITIAL DATA LOG
-- ============================================================================
INSERT INTO tmodule_sync_log (event_type, details)
VALUES (
'module_installed',
jsonb_build_object(
'version', '1.0.0',
'migration', '013_timetracking_module.sql',
'timestamp', CURRENT_TIMESTAMP
)
);
-- ============================================================================
-- UNINSTALL SCRIPT (bruges ved modul-sletning)
-- ============================================================================
-- ADVARSEL: Dette script sletter ALLE data i modulet!
-- Kør kun hvis modulet skal fjernes fuldstændigt.
--
-- For at uninstalle, kør følgende kommandoer i rækkefølge:
--
-- DROP VIEW IF EXISTS tmodule_order_details CASCADE;
-- DROP VIEW IF EXISTS tmodule_next_pending CASCADE;
-- DROP VIEW IF EXISTS tmodule_approval_stats CASCADE;
--
-- DROP TRIGGER IF EXISTS tmodule_orders_generate_number ON tmodule_orders;
-- DROP TRIGGER IF EXISTS tmodule_orders_update ON tmodule_orders;
-- DROP TRIGGER IF EXISTS tmodule_times_update ON tmodule_times;
-- DROP TRIGGER IF EXISTS tmodule_cases_update ON tmodule_cases;
-- DROP TRIGGER IF EXISTS tmodule_customers_update ON tmodule_customers;
--
-- DROP FUNCTION IF EXISTS tmodule_generate_order_number() CASCADE;
-- DROP FUNCTION IF EXISTS tmodule_update_timestamp() CASCADE;
--
-- DROP TABLE IF EXISTS tmodule_sync_log CASCADE;
-- DROP TABLE IF EXISTS tmodule_order_lines CASCADE;
-- DROP TABLE IF EXISTS tmodule_orders CASCADE;
-- DROP TABLE IF EXISTS tmodule_times CASCADE;
-- DROP TABLE IF EXISTS tmodule_cases CASCADE;
-- DROP TABLE IF EXISTS tmodule_customers CASCADE;
-- DROP TABLE IF EXISTS tmodule_metadata CASCADE;
--
-- -- Log uninstall i system log hvis muligt
-- -- (Dette vil fejle hvis tmodule_sync_log er droppet, men det er OK)
-- DO $$
-- BEGIN
-- IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'tmodule_sync_log') THEN
-- INSERT INTO tmodule_sync_log (event_type, details)
-- VALUES ('module_uninstalled', jsonb_build_object('timestamp', CURRENT_TIMESTAMP));
-- END IF;
-- EXCEPTION WHEN OTHERS THEN
-- -- Ignorer fejl - tabellen er måske allerede slettet
-- NULL;
-- END $$;
--
-- ============================================================================

17
test_routes.py Normal file
View File

@ -0,0 +1,17 @@
#!/usr/bin/env python3
"""Test route registration"""
import main
frontend_routes = [r for r in main.app.routes if hasattr(r, 'path') and not r.path.startswith('/api')]
print(f"Found {len(frontend_routes)} frontend routes:")
for r in frontend_routes[:30]:
path = r.path if hasattr(r, 'path') else str(r)
endpoint_name = r.endpoint.__name__ if hasattr(r, 'endpoint') else 'N/A'
print(f" {path:50} -> {endpoint_name}")
# Check timetracking specifically
timetracking_routes = [r for r in main.app.routes if hasattr(r, 'path') and 'timetracking' in r.path]
print(f"\nTimetracking routes: {len(timetracking_routes)}")
for r in timetracking_routes:
print(f" {r.path}")