feat: Implement internal comments for customer subscriptions with database support
This commit is contained in:
parent
ffb3d335bc
commit
fadf7258de
@ -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:
|
||||
|
||||
@ -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 = ""
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -364,6 +364,36 @@
|
||||
</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">
|
||||
<div class="spinner-border text-primary"></div>
|
||||
@ -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 %}
|
||||
|
||||
@ -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__)
|
||||
|
||||
@ -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__)
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
@ -1077,6 +1087,10 @@
|
||||
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}`, {
|
||||
method: 'POST',
|
||||
@ -1086,7 +1100,8 @@
|
||||
body: JSON.stringify({
|
||||
billable_hours: billableHours,
|
||||
hourly_rate: hourlyRate,
|
||||
is_travel: isTravel
|
||||
is_travel: isTravel,
|
||||
approval_note: approvalNote || null
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
8
main.py
8
main.py
@ -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")
|
||||
|
||||
17
migrations/027_customer_notes.sql
Normal file
17
migrations/027_customer_notes.sql
Normal 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);
|
||||
Loading…
Reference in New Issue
Block a user