From fadf7258de8d16e519a598b7bb14c5ccd521f39f Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 16 Dec 2025 22:07:20 +0100 Subject: [PATCH] feat: Implement internal comments for customer subscriptions with database support --- app/billing/backend/supplier_invoices.py | 4 +- app/core/config.py | 13 ++ app/customers/backend/router.py | 82 +++++++++++++ app/customers/frontend/customer_detail.html | 128 ++++++++++++++++++++ app/timetracking/backend/economic_export.py | 4 +- app/timetracking/backend/order_service.py | 4 +- app/timetracking/backend/router.py | 8 +- app/timetracking/backend/vtiger_sync.py | 3 +- app/timetracking/backend/wizard.py | 10 +- app/timetracking/frontend/wizard.html | 21 +++- main.py | 8 ++ migrations/027_customer_notes.sql | 17 +++ 12 files changed, 282 insertions(+), 20 deletions(-) create mode 100644 migrations/027_customer_notes.sql 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 @@