diff --git a/app/billing/backend/supplier_invoices.py b/app/billing/backend/supplier_invoices.py
index 47db4b6..732bae2 100644
--- a/app/billing/backend/supplier_invoices.py
+++ b/app/billing/backend/supplier_invoices.py
@@ -9,7 +9,7 @@ from typing import List, Dict, Optional
from datetime import datetime, date, timedelta
from decimal import Decimal
from pathlib import Path
-from app.core.database import execute_query, execute_insert, execute_update
+from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.core.config import settings
from app.services.economic_service import get_economic_service
from app.services.ollama_service import ollama_service
@@ -203,7 +203,7 @@ async def list_supplier_invoices(
query += " ORDER BY si.due_date ASC, si.invoice_date DESC"
- invoices = execute_query_single(query, tuple(params) if params else ())
+ invoices = execute_query(query, tuple(params) if params else ())
# Add lines to each invoice
for invoice in invoices:
diff --git a/app/core/config.py b/app/core/config.py
index 90c6451..905e26d 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -42,6 +42,19 @@ class Settings(BaseSettings):
VTIGER_USERNAME: str = ""
VTIGER_API_KEY: str = ""
+ # Time Tracking Module Settings
+ TIMETRACKING_DEFAULT_HOURLY_RATE: float = 1200.00
+ TIMETRACKING_AUTO_ROUND: bool = True
+ TIMETRACKING_ROUND_INCREMENT: float = 0.5
+ TIMETRACKING_ROUND_METHOD: str = "up" # "up", "down", "nearest"
+
+ # Time Tracking Module Safety Flags
+ TIMETRACKING_VTIGER_READ_ONLY: bool = True
+ TIMETRACKING_VTIGER_DRY_RUN: bool = True
+ TIMETRACKING_ECONOMIC_READ_ONLY: bool = True
+ TIMETRACKING_ECONOMIC_DRY_RUN: bool = True
+ TIMETRACKING_EXPORT_TYPE: str = "draft" # "draft" or "booked"
+
# Simply-CRM (Old vTiger On-Premise)
OLD_VTIGER_URL: str = ""
OLD_VTIGER_USERNAME: str = ""
diff --git a/app/customers/backend/router.py b/app/customers/backend/router.py
index 2dbc32b..87385a3 100644
--- a/app/customers/backend/router.py
+++ b/app/customers/backend/router.py
@@ -713,3 +713,85 @@ async def delete_subscription(subscription_id: str, customer_id: int = None):
except Exception as e:
logger.error(f"❌ Error deleting subscription: {e}")
raise HTTPException(status_code=500, detail=str(e))
+
+
+# Subscription Internal Comment Endpoints
+class SubscriptionComment(BaseModel):
+ comment: str
+
+
+@router.post("/customers/{customer_id}/subscription-comment")
+async def save_subscription_comment(customer_id: int, data: SubscriptionComment):
+ """Save internal comment about customer subscriptions"""
+ try:
+ # Check if customer exists
+ customer = execute_query_single(
+ "SELECT id FROM customers WHERE id = %s",
+ (customer_id,)
+ )
+ if not customer:
+ raise HTTPException(status_code=404, detail="Customer not found")
+
+ # Delete existing comment if any and insert new one in a single query
+ result = execute_query(
+ """
+ WITH deleted AS (
+ DELETE FROM customer_notes
+ WHERE customer_id = %s AND note_type = 'subscription_comment'
+ )
+ INSERT INTO customer_notes (customer_id, note_type, note, created_by, created_at)
+ VALUES (%s, 'subscription_comment', %s, 'System', NOW())
+ RETURNING id, note, created_by, created_at
+ """,
+ (customer_id, customer_id, data.comment)
+ )
+
+ if not result or len(result) == 0:
+ raise Exception("Failed to insert comment")
+
+ row = result[0]
+ logger.info(f"✅ Saved subscription comment for customer {customer_id}")
+ return {
+ "id": row['id'],
+ "comment": row['note'],
+ "created_by": row['created_by'],
+ "created_at": row['created_at'].isoformat()
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error saving subscription comment: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/customers/{customer_id}/subscription-comment")
+async def get_subscription_comment(customer_id: int):
+ """Get internal comment about customer subscriptions"""
+ try:
+ result = execute_query_single(
+ """
+ SELECT id, note, created_by, created_at
+ FROM customer_notes
+ WHERE customer_id = %s AND note_type = 'subscription_comment'
+ ORDER BY created_at DESC
+ LIMIT 1
+ """,
+ (customer_id,)
+ )
+
+ if not result:
+ raise HTTPException(status_code=404, detail="No comment found")
+
+ return {
+ "id": result['id'],
+ "comment": result['note'],
+ "created_by": result['created_by'],
+ "created_at": result['created_at'].isoformat()
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error fetching subscription comment: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html
index 6f0de4b..a09732e 100644
--- a/app/customers/frontend/customer_detail.html
+++ b/app/customers/frontend/customer_detail.html
@@ -363,6 +363,36 @@
+
+
+
+
+
+ Intern Kommentar
+ (kun synlig for medarbejdere)
+
+
+
+
+
@@ -483,6 +513,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (subscriptionsTab) {
subscriptionsTab.addEventListener('shown.bs.tab', () => {
loadSubscriptions();
+ loadInternalComment();
}, { once: false });
}
@@ -1409,5 +1440,102 @@ async function toggleSubscriptionsLock() {
alert('Fejl: ' + error.message);
}
}
+
+async function saveInternalComment() {
+ const commentInput = document.getElementById('internalCommentInput');
+ const commentText = commentInput.value.trim();
+
+ if (!commentText) {
+ alert('Indtast venligst en kommentar');
+ return;
+ }
+
+ try {
+ const response = await fetch(`/api/v1/customers/${customerId}/subscription-comment`, {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({ comment: commentText })
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.detail || 'Kunne ikke gemme kommentar');
+ }
+
+ const result = await response.json();
+
+ // Clear input
+ commentInput.value = '';
+
+ // Show saved comment
+ displayInternalComment(result);
+
+ alert('✓ Kommentar gemt');
+
+ } catch (error) {
+ console.error('Error saving comment:', error);
+ alert('Fejl: ' + error.message);
+ }
+}
+
+async function loadInternalComment() {
+ try {
+ const response = await fetch(`/api/v1/customers/${customerId}/subscription-comment`);
+
+ if (response.status === 404) {
+ // No comment yet, that's fine
+ return;
+ }
+
+ if (!response.ok) {
+ throw new Error('Kunne ikke hente kommentar');
+ }
+
+ const result = await response.json();
+ displayInternalComment(result);
+
+ } catch (error) {
+ console.error('Error loading comment:', error);
+ }
+}
+
+function displayInternalComment(data) {
+ const displayDiv = document.getElementById('internalCommentDisplay');
+ const editDiv = document.getElementById('internalCommentEdit');
+ const commentMeta = document.getElementById('commentMeta');
+ const commentText = document.getElementById('commentText');
+
+ if (data && data.comment) {
+ commentText.textContent = data.comment;
+
+ // Format timestamp
+ const timestamp = new Date(data.created_at).toLocaleString('da-DK', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+
+ commentMeta.textContent = `Oprettet af ${data.created_by || 'System'} • ${timestamp}`;
+ displayDiv.style.display = 'block';
+ editDiv.style.display = 'none';
+ } else {
+ displayDiv.style.display = 'none';
+ editDiv.style.display = 'block';
+ }
+}
+
+function editInternalComment() {
+ const commentText = document.getElementById('commentText').textContent;
+ const commentInput = document.getElementById('internalCommentInput');
+ const displayDiv = document.getElementById('internalCommentDisplay');
+ const editDiv = document.getElementById('internalCommentEdit');
+
+ // Show input, populate with existing text
+ commentInput.value = commentText;
+ editDiv.style.display = 'block';
+ displayDiv.style.display = 'none';
+}
{% endblock %}
diff --git a/app/timetracking/backend/economic_export.py b/app/timetracking/backend/economic_export.py
index cde8282..e33f68f 100644
--- a/app/timetracking/backend/economic_export.py
+++ b/app/timetracking/backend/economic_export.py
@@ -18,11 +18,11 @@ import aiohttp
from fastapi import HTTPException
from app.core.config import settings
-from app.core.database import execute_query, execute_update
+from app.core.database import execute_query, execute_update, execute_query_single
from app.timetracking.backend.models import (
TModuleEconomicExportRequest,
TModuleEconomicExportResult
-, execute_query_single)
+)
from app.timetracking.backend.audit import audit
logger = logging.getLogger(__name__)
diff --git a/app/timetracking/backend/order_service.py b/app/timetracking/backend/order_service.py
index b0055b6..e3b0e17 100644
--- a/app/timetracking/backend/order_service.py
+++ b/app/timetracking/backend/order_service.py
@@ -13,14 +13,14 @@ 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.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.timetracking.backend.models import (
TModuleOrder,
TModuleOrderWithLines,
TModuleOrderLine,
TModuleOrderCreate,
TModuleOrderLineCreate
-, execute_query_single)
+)
from app.timetracking.backend.audit import audit
logger = logging.getLogger(__name__)
diff --git a/app/timetracking/backend/router.py b/app/timetracking/backend/router.py
index 7d7652c..3297b54 100644
--- a/app/timetracking/backend/router.py
+++ b/app/timetracking/backend/router.py
@@ -12,7 +12,7 @@ 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.core.database import execute_query, execute_update, execute_query_single
from app.timetracking.backend.models import (
TModuleSyncStats,
TModuleApprovalStats,
@@ -27,7 +27,7 @@ from app.timetracking.backend.models import (
TModuleMetadata,
TModuleUninstallRequest,
TModuleUninstallResult
-, execute_query_single)
+)
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
@@ -36,7 +36,7 @@ from app.timetracking.backend.audit import audit
logger = logging.getLogger(__name__)
-router = APIRouter()
+router = APIRouter(prefix="/timetracking")
# ============================================================================
@@ -892,7 +892,7 @@ async def uninstall_module(
from app.core.database import get_db_connection
import psycopg2
- conn = get_db_connection(, execute_query_single)
+ conn = get_db_connection()
cursor = conn.cursor()
dropped_items = {
diff --git a/app/timetracking/backend/vtiger_sync.py b/app/timetracking/backend/vtiger_sync.py
index e8ed18c..4ff77f4 100644
--- a/app/timetracking/backend/vtiger_sync.py
+++ b/app/timetracking/backend/vtiger_sync.py
@@ -28,7 +28,7 @@ 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.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.timetracking.backend.models import TModuleSyncStats
from app.timetracking.backend.audit import audit
@@ -46,7 +46,6 @@ class TimeTrackingVTigerService:
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
diff --git a/app/timetracking/backend/wizard.py b/app/timetracking/backend/wizard.py
index bac7aa4..75310e1 100644
--- a/app/timetracking/backend/wizard.py
+++ b/app/timetracking/backend/wizard.py
@@ -12,14 +12,14 @@ from decimal import Decimal
from datetime import datetime
from fastapi import HTTPException
-from app.core.database import execute_query, execute_update
+from app.core.database import execute_query, execute_update, execute_query_single
from app.timetracking.backend.models import (
TModuleTimeWithContext,
TModuleTimeApproval,
TModuleWizardProgress,
TModuleWizardNextEntry,
TModuleApprovalStats
-, execute_query_single)
+)
from app.timetracking.backend.audit import audit
logger = logging.getLogger(__name__)
@@ -52,7 +52,7 @@ class WizardService:
"""Hent approval statistics for alle kunder"""
try:
query = "SELECT * FROM tmodule_approval_stats ORDER BY customer_name"
- results = execute_query_single(query)
+ results = execute_query(query)
return [TModuleApprovalStats(**row) for row in results]
@@ -83,7 +83,7 @@ class WizardService:
WHERE customer_id = %s
LIMIT 1
"""
- result = execute_query(query, (customer_id,))
+ result = execute_query_single(query, (customer_id,))
else:
# Hent næste generelt
if exclude_time_card:
@@ -585,7 +585,7 @@ class WizardService:
ORDER BY t.worked_date, t.id
"""
- results = execute_query_single(query, (case_id,))
+ results = execute_query(query, (case_id,))
return [TModuleTimeWithContext(**row) for row in results]
except Exception as e:
diff --git a/app/timetracking/frontend/wizard.html b/app/timetracking/frontend/wizard.html
index 17b6a3b..92c75ae 100644
--- a/app/timetracking/frontend/wizard.html
+++ b/app/timetracking/frontend/wizard.html
@@ -352,7 +352,8 @@