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 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:

View File

@ -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 = ""

View File

@ -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))

View File

@ -363,6 +363,36 @@
</button>
</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 class="text-center py-5">
@ -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';
}
</script>
{% endblock %}

View File

@ -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__)

View File

@ -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__)

View File

@ -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 = {

View File

@ -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

View File

@ -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:

View File

@ -352,7 +352,8 @@
<script>
let currentEntry = 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
async function loadConfig() {
@ -402,6 +403,7 @@
}
currentEntry = data.time_entry;
currentCaseId = currentEntry.case_id;
// Fetch ALL pending timelogs in this case
if (currentEntry.case_id) {
@ -658,6 +660,14 @@
</label>
</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 class="mt-3 d-flex gap-2">
@ -985,7 +995,7 @@
const method = methodSelect.value;
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;
let billable = original;
@ -1076,6 +1086,10 @@
// Get travel checkbox state
const travelCheckbox = document.getElementById(`travel-${entryId}`);
const isTravel = travelCheckbox ? travelCheckbox.checked : false;
// Get approval note
const approvalNoteField = document.getElementById(`approval-note-${entryId}`);
const approvalNote = approvalNoteField ? approvalNoteField.value.trim() : '';
try {
const response = await fetch(`/api/v1/timetracking/wizard/approve/${entryId}`, {
@ -1086,7 +1100,8 @@
body: JSON.stringify({
billable_hours: billableHours,
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.hardware.backend import router as hardware_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.dashboard.backend import views as dashboard_views
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.vendors.backend import router as vendors_api
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
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(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"])
app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
app.include_router(timetracking_api, prefix="/api/v1", tags=["Time Tracking"])
# Frontend Routers
app.include_router(dashboard_views.router, tags=["Frontend"])
app.include_router(customers_views.router, tags=["Frontend"])
app.include_router(prepaid_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(contacts_views.router, tags=["Frontend"])
# Serve static files (UI)
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);