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 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:
|
||||||
|
|||||||
@ -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 = ""
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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__)
|
||||||
|
|||||||
@ -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__)
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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.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")
|
||||||
|
|||||||
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