feat: Implement internal comments for customer subscriptions with database support

This commit is contained in:
Christian 2025-12-16 22:07:20 +01:00
parent ffb3d335bc
commit fadf7258de
12 changed files with 282 additions and 20 deletions

View File

@ -9,7 +9,7 @@ from typing import List, Dict, Optional
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
from decimal import Decimal from decimal import Decimal
from pathlib import Path 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.core.config import settings
from app.services.economic_service import get_economic_service from app.services.economic_service import get_economic_service
from app.services.ollama_service import ollama_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" 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 # Add lines to each invoice
for invoice in invoices: for invoice in invoices:

View File

@ -42,6 +42,19 @@ class Settings(BaseSettings):
VTIGER_USERNAME: str = "" VTIGER_USERNAME: str = ""
VTIGER_API_KEY: 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) # Simply-CRM (Old vTiger On-Premise)
OLD_VTIGER_URL: str = "" OLD_VTIGER_URL: str = ""
OLD_VTIGER_USERNAME: str = "" OLD_VTIGER_USERNAME: str = ""

View File

@ -713,3 +713,85 @@ async def delete_subscription(subscription_id: str, customer_id: int = None):
except Exception as e: except Exception as e:
logger.error(f"❌ Error deleting subscription: {e}") logger.error(f"❌ Error deleting subscription: {e}")
raise HTTPException(status_code=500, detail=str(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))

View File

@ -364,6 +364,36 @@
</div> </div>
</div> </div>
<!-- Internal Comment Box -->
<div class="card mb-4" style="border-left: 4px solid var(--accent);">
<div class="card-body">
<h6 class="fw-bold mb-3">
<i class="bi bi-shield-lock me-2"></i>Intern Kommentar
<small class="text-muted fw-normal">(kun synlig for medarbejdere)</small>
</h6>
<div id="internalCommentDisplay" class="mb-3" style="display: none;">
<div class="alert alert-light mb-2">
<div style="white-space: pre-wrap;" id="commentText"></div>
</div>
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted" id="commentMeta"></small>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="editInternalComment()" title="Rediger kommentar">
<i class="bi bi-pencil me-1"></i>Rediger
</button>
</div>
</div>
<div id="internalCommentEdit">
<textarea class="form-control mb-2" id="internalCommentInput" rows="3"
placeholder="Skriv intern note om kundens abonnementer..."></textarea>
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-sm btn-primary" onclick="saveInternalComment()">
<i class="bi bi-save me-1"></i>Gem Kommentar
</button>
</div>
</div>
</div>
</div>
<div id="subscriptionsContainer"> <div id="subscriptionsContainer">
<div class="text-center py-5"> <div class="text-center py-5">
<div class="spinner-border text-primary"></div> <div class="spinner-border text-primary"></div>
@ -483,6 +513,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (subscriptionsTab) { if (subscriptionsTab) {
subscriptionsTab.addEventListener('shown.bs.tab', () => { subscriptionsTab.addEventListener('shown.bs.tab', () => {
loadSubscriptions(); loadSubscriptions();
loadInternalComment();
}, { once: false }); }, { once: false });
} }
@ -1409,5 +1440,102 @@ async function toggleSubscriptionsLock() {
alert('Fejl: ' + error.message); 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';
}
</script> </script>
{% endblock %} {% endblock %}

View File

@ -18,11 +18,11 @@ import aiohttp
from fastapi import HTTPException from fastapi import HTTPException
from app.core.config import settings 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 ( from app.timetracking.backend.models import (
TModuleEconomicExportRequest, TModuleEconomicExportRequest,
TModuleEconomicExportResult TModuleEconomicExportResult
, execute_query_single) )
from app.timetracking.backend.audit import audit from app.timetracking.backend.audit import audit
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -13,14 +13,14 @@ from datetime import date
from fastapi import HTTPException from fastapi import HTTPException
from app.core.config import settings 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 ( from app.timetracking.backend.models import (
TModuleOrder, TModuleOrder,
TModuleOrderWithLines, TModuleOrderWithLines,
TModuleOrderLine, TModuleOrderLine,
TModuleOrderCreate, TModuleOrderCreate,
TModuleOrderLineCreate TModuleOrderLineCreate
, execute_query_single) )
from app.timetracking.backend.audit import audit from app.timetracking.backend.audit import audit
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -12,7 +12,7 @@ from typing import Optional, List
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends
from fastapi.responses import JSONResponse 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 ( from app.timetracking.backend.models import (
TModuleSyncStats, TModuleSyncStats,
TModuleApprovalStats, TModuleApprovalStats,
@ -27,7 +27,7 @@ from app.timetracking.backend.models import (
TModuleMetadata, TModuleMetadata,
TModuleUninstallRequest, TModuleUninstallRequest,
TModuleUninstallResult TModuleUninstallResult
, execute_query_single) )
from app.timetracking.backend.vtiger_sync import vtiger_service from app.timetracking.backend.vtiger_sync import vtiger_service
from app.timetracking.backend.wizard import wizard from app.timetracking.backend.wizard import wizard
from app.timetracking.backend.order_service import order_service from app.timetracking.backend.order_service import order_service
@ -36,7 +36,7 @@ from app.timetracking.backend.audit import audit
logger = logging.getLogger(__name__) 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 from app.core.database import get_db_connection
import psycopg2 import psycopg2
conn = get_db_connection(, execute_query_single) conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
dropped_items = { dropped_items = {

View File

@ -28,7 +28,7 @@ import aiohttp
from fastapi import HTTPException from fastapi import HTTPException
from app.core.config import settings 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.models import TModuleSyncStats
from app.timetracking.backend.audit import audit from app.timetracking.backend.audit import audit
@ -46,7 +46,6 @@ class TimeTrackingVTigerService:
self.base_url = settings.VTIGER_URL self.base_url = settings.VTIGER_URL
self.username = settings.VTIGER_USERNAME self.username = settings.VTIGER_USERNAME
self.api_key = settings.VTIGER_API_KEY self.api_key = settings.VTIGER_API_KEY
self.password = settings.VTIGER_PASSWORD
self.rest_endpoint = f"{self.base_url}/restapi/v1/vtiger/default" self.rest_endpoint = f"{self.base_url}/restapi/v1/vtiger/default"
# Safety flags # Safety flags

View File

@ -12,14 +12,14 @@ from decimal import Decimal
from datetime import datetime from datetime import datetime
from fastapi import HTTPException 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 ( from app.timetracking.backend.models import (
TModuleTimeWithContext, TModuleTimeWithContext,
TModuleTimeApproval, TModuleTimeApproval,
TModuleWizardProgress, TModuleWizardProgress,
TModuleWizardNextEntry, TModuleWizardNextEntry,
TModuleApprovalStats TModuleApprovalStats
, execute_query_single) )
from app.timetracking.backend.audit import audit from app.timetracking.backend.audit import audit
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -52,7 +52,7 @@ class WizardService:
"""Hent approval statistics for alle kunder""" """Hent approval statistics for alle kunder"""
try: try:
query = "SELECT * FROM tmodule_approval_stats ORDER BY customer_name" 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] return [TModuleApprovalStats(**row) for row in results]
@ -83,7 +83,7 @@ class WizardService:
WHERE customer_id = %s WHERE customer_id = %s
LIMIT 1 LIMIT 1
""" """
result = execute_query(query, (customer_id,)) result = execute_query_single(query, (customer_id,))
else: else:
# Hent næste generelt # Hent næste generelt
if exclude_time_card: if exclude_time_card:
@ -585,7 +585,7 @@ class WizardService:
ORDER BY t.worked_date, t.id 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] return [TModuleTimeWithContext(**row) for row in results]
except Exception as e: except Exception as e:

View File

@ -352,7 +352,8 @@
<script> <script>
let currentEntry = null; let currentEntry = null;
let currentCustomerId = null; let currentCustomerId = null;
let defaultHourlyRate = 850.00; // Fallback værdi, hentes fra API let currentCaseId = null;
let defaultHourlyRate = 1200.00; // Fallback værdi, hentes fra API
// Load config from API // Load config from API
async function loadConfig() { async function loadConfig() {
@ -402,6 +403,7 @@
} }
currentEntry = data.time_entry; currentEntry = data.time_entry;
currentCaseId = currentEntry.case_id;
// Fetch ALL pending timelogs in this case // Fetch ALL pending timelogs in this case
if (currentEntry.case_id) { if (currentEntry.case_id) {
@ -658,6 +660,14 @@
</label> </label>
</div> </div>
</div> </div>
<div class="mt-3">
<label class="form-label">
<i class="bi bi-pencil"></i> Godkendelsesnote (valgfri)
</label>
<textarea class="form-control" id="approval-note-${e.id}" rows="2"
placeholder="Tilføj evt. note til denne godkendelse..."></textarea>
</div>
</div> </div>
<div class="mt-3 d-flex gap-2"> <div class="mt-3 d-flex gap-2">
@ -985,7 +995,7 @@
const method = methodSelect.value; const method = methodSelect.value;
const minimum = parseFloat(minimumInput.value) || 0; const minimum = parseFloat(minimumInput.value) || 0;
const hourlyRate = parseFloat(hourlyRateInput?.value) || 850; const hourlyRate = parseFloat(hourlyRateInput?.value) || 1200;
const original = parseFloat(entry.original_hours) || 0; const original = parseFloat(entry.original_hours) || 0;
let billable = original; let billable = original;
@ -1077,6 +1087,10 @@
const travelCheckbox = document.getElementById(`travel-${entryId}`); const travelCheckbox = document.getElementById(`travel-${entryId}`);
const isTravel = travelCheckbox ? travelCheckbox.checked : false; const isTravel = travelCheckbox ? travelCheckbox.checked : false;
// Get approval note
const approvalNoteField = document.getElementById(`approval-note-${entryId}`);
const approvalNote = approvalNoteField ? approvalNoteField.value.trim() : '';
try { try {
const response = await fetch(`/api/v1/timetracking/wizard/approve/${entryId}`, { const response = await fetch(`/api/v1/timetracking/wizard/approve/${entryId}`, {
method: 'POST', method: 'POST',
@ -1086,7 +1100,8 @@
body: JSON.stringify({ body: JSON.stringify({
billable_hours: billableHours, billable_hours: billableHours,
hourly_rate: hourlyRate, hourly_rate: hourlyRate,
is_travel: isTravel is_travel: isTravel,
approval_note: approvalNote || null
}) })
}); });

View File

@ -18,6 +18,7 @@ from app.customers.backend import router as customers_api
from app.customers.backend import views as customers_views from app.customers.backend import views as customers_views
from app.hardware.backend import router as hardware_api from app.hardware.backend import router as hardware_api
from app.billing.backend import router as billing_api from app.billing.backend import router as billing_api
from app.billing.frontend import views as billing_views
from app.system.backend import router as system_api from app.system.backend import router as system_api
from app.dashboard.backend import views as dashboard_views from app.dashboard.backend import views as dashboard_views
from app.prepaid.backend import router as prepaid_api from app.prepaid.backend import router as prepaid_api
@ -26,6 +27,9 @@ from app.ticket.backend import router as ticket_api
from app.ticket.frontend import views as ticket_views from app.ticket.frontend import views as ticket_views
from app.vendors.backend import router as vendors_api from app.vendors.backend import router as vendors_api
from app.vendors.backend import views as vendors_views from app.vendors.backend import views as vendors_views
from app.timetracking.backend import router as timetracking_api
from app.timetracking.frontend import views as timetracking_views
from app.contacts.backend import views as contacts_views
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@ -89,13 +93,17 @@ app.include_router(system_api.router, prefix="/api/v1", tags=["System"])
app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"]) app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"])
app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"]) app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"])
app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"]) app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
app.include_router(timetracking_api, prefix="/api/v1", tags=["Time Tracking"])
# Frontend Routers # Frontend Routers
app.include_router(dashboard_views.router, tags=["Frontend"]) app.include_router(dashboard_views.router, tags=["Frontend"])
app.include_router(customers_views.router, tags=["Frontend"]) app.include_router(customers_views.router, tags=["Frontend"])
app.include_router(prepaid_views.router, tags=["Frontend"]) app.include_router(prepaid_views.router, tags=["Frontend"])
app.include_router(vendors_views.router, tags=["Frontend"]) app.include_router(vendors_views.router, tags=["Frontend"])
app.include_router(timetracking_views.router, tags=["Frontend"])
app.include_router(billing_views.router, tags=["Frontend"])
app.include_router(ticket_views.router, prefix="/ticket", tags=["Frontend"]) app.include_router(ticket_views.router, prefix="/ticket", tags=["Frontend"])
app.include_router(contacts_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")

View File

@ -0,0 +1,17 @@
-- Migration 027: Customer Notes Table
-- Add table for storing internal notes about customers
-- Including subscription comments and other note types
CREATE TABLE IF NOT EXISTS customer_notes (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
note_type VARCHAR(50) NOT NULL, -- 'subscription_comment', 'general', 'support', etc.
note TEXT NOT NULL,
created_by VARCHAR(100),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Create index for efficient queries
CREATE INDEX IF NOT EXISTS idx_customer_notes_customer_id ON customer_notes(customer_id);
CREATE INDEX IF NOT EXISTS idx_customer_notes_type ON customer_notes(note_type);