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:
parent
3a8288f5a1
commit
34555d1e36
@ -33,6 +33,27 @@ class Settings(BaseSettings):
|
|||||||
ECONOMIC_READ_ONLY: bool = True
|
ECONOMIC_READ_ONLY: bool = True
|
||||||
ECONOMIC_DRY_RUN: 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 AI Integration
|
||||||
OLLAMA_ENDPOINT: str = "http://ai_direct.cs.blaahund.dk"
|
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
|
OLLAMA_MODEL: str = "qwen2.5-coder:7b" # qwen2.5-coder fungerer bedre til JSON udtrækning
|
||||||
|
|||||||
24
app/timetracking/__init__.py
Normal file
24
app/timetracking/__init__.py
Normal 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 på 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"
|
||||||
5
app/timetracking/backend/__init__.py
Normal file
5
app/timetracking/backend/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""Time Tracking Module - Backend"""
|
||||||
|
|
||||||
|
from .router import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
280
app/timetracking/backend/audit.py
Normal file
280
app/timetracking/backend/audit.py
Normal 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 på entiteten
|
||||||
|
user_id: ID på brugeren der foretog handlingen
|
||||||
|
details: Ekstra detaljer som JSON
|
||||||
|
ip_address: IP-adresse
|
||||||
|
user_agent: User agent string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ID på 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()
|
||||||
300
app/timetracking/backend/economic_export.py
Normal file
300
app/timetracking/backend/economic_export.py
Normal 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 må 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 på 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()
|
||||||
410
app/timetracking/backend/models.py
Normal file
410
app/timetracking/backend/models.py
Normal 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
|
||||||
403
app/timetracking/backend/order_service.py
Normal file
403
app/timetracking/backend/order_service.py
Normal 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 på 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()
|
||||||
489
app/timetracking/backend/router.py
Normal file
489
app/timetracking/backend/router.py
Normal 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 på 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 på 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 på 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))
|
||||||
673
app/timetracking/backend/vtiger_sync.py
Normal file
673
app/timetracking/backend/vtiger_sync.py
Normal file
@ -0,0 +1,673 @@
|
|||||||
|
"""
|
||||||
|
vTiger Sync Service for Time Tracking Module
|
||||||
|
=============================================
|
||||||
|
|
||||||
|
🚨 KRITISK: Denne service må 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()
|
||||||
330
app/timetracking/backend/wizard.py
Normal file
330
app/timetracking/backend/wizard.py
Normal 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 på 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 på tidsregistreringen
|
||||||
|
reason: Årsag til afvisning
|
||||||
|
user_id: ID på 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()
|
||||||
1
app/timetracking/frontend/__init__.py
Normal file
1
app/timetracking/frontend/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Time Tracking Module - Frontend"""
|
||||||
479
app/timetracking/frontend/dashboard.html
Normal file
479
app/timetracking/frontend/dashboard.html
Normal 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>
|
||||||
469
app/timetracking/frontend/orders.html
Normal file
469
app/timetracking/frontend/orders.html
Normal 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>
|
||||||
62
app/timetracking/frontend/views.py
Normal file
62
app/timetracking/frontend/views.py
Normal 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
|
||||||
617
app/timetracking/frontend/wizard.html
Normal file
617
app/timetracking/frontend/wizard.html
Normal 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
16
list_routes.py
Normal 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)}")
|
||||||
4
main.py
4
main.py
@ -32,6 +32,8 @@ from app.dashboard.backend import views as dashboard_views
|
|||||||
from app.dashboard.backend import router as dashboard_api
|
from app.dashboard.backend import router as dashboard_api
|
||||||
from app.devportal.backend import router as devportal_api
|
from app.devportal.backend import router as devportal_api
|
||||||
from app.devportal.backend import views as devportal_views
|
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
|
# Configure logging
|
||||||
logging.basicConfig(
|
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(system_api.router, prefix="/api/v1", tags=["System"])
|
||||||
app.include_router(dashboard_api.router, prefix="/api/v1/dashboard", tags=["Dashboard"])
|
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(devportal_api.router, prefix="/api/v1/devportal", tags=["DEV Portal"])
|
||||||
|
app.include_router(timetracking_api, prefix="/api/v1/timetracking", tags=["Time Tracking"])
|
||||||
|
|
||||||
# Frontend Routers
|
# Frontend Routers
|
||||||
app.include_router(auth_views.router, tags=["Frontend"])
|
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(billing_views.router, tags=["Frontend"])
|
||||||
app.include_router(settings_views.router, tags=["Frontend"])
|
app.include_router(settings_views.router, tags=["Frontend"])
|
||||||
app.include_router(devportal_views.router, tags=["Frontend"])
|
app.include_router(devportal_views.router, tags=["Frontend"])
|
||||||
|
app.include_router(timetracking_views.router, tags=["Frontend"])
|
||||||
|
|
||||||
# Serve static files (UI)
|
# Serve static files (UI)
|
||||||
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
|
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
|
||||||
|
|||||||
416
migrations/013_timetracking_module.sql
Normal file
416
migrations/013_timetracking_module.sql
Normal 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
17
test_routes.py
Normal 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}")
|
||||||
Loading…
Reference in New Issue
Block a user