feat: Add Simply-CRM integration setup documentation and configuration details

docs: Create vTiger & Simply-CRM integration setup guide with credential requirements

feat: Implement ticket system enhancements including relations, calendar events, templates, and AI suggestions

refactor: Update ticket system migration to include audit logging and enhanced email metadata
This commit is contained in:
Christian 2025-12-16 15:36:11 +01:00
parent 3806c7d011
commit ffb3d335bc
62 changed files with 5233 additions and 2056 deletions

View File

@ -45,77 +45,18 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer # 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
# ===================================================== # =====================================================
# vTiger CRM Integration (Optional) # vTiger Cloud Integration (Required for Subscriptions)
# ===================================================== # =====================================================
# Get credentials from vTiger Cloud -> Settings -> Integration -> Webservices
VTIGER_URL=https://your-instance.od2.vtiger.com VTIGER_URL=https://your-instance.od2.vtiger.com
VTIGER_USERNAME=your_username@yourdomain.com VTIGER_USERNAME=your_vtiger_username
VTIGER_API_KEY=your_api_key_or_access_key VTIGER_API_KEY=your_vtiger_api_key
VTIGER_PASSWORD=your_password_if_using_basic_auth
# ===================================================== # =====================================================
# TIME TRACKING MODULE - Isolated Settings # Simply-CRM / Old vTiger On-Premise (Legacy)
# ===================================================== # =====================================================
# Old vTiger installation (if different from cloud)
# vTiger Integration Safety Flags OLD_VTIGER_URL=http://your-old-vtiger-server.com
TIMETRACKING_VTIGER_READ_ONLY=true # 🚨 Bloker ALLE skrivninger til vTiger OLD_VTIGER_USERNAME=your_old_username
TIMETRACKING_VTIGER_DRY_RUN=true # 🚨 Log uden at synkronisere OLD_VTIGER_API_KEY=your_old_api_key
# e-conomic Integration Safety Flags
TIMETRACKING_ECONOMIC_READ_ONLY=true # 🚨 Bloker ALLE skrivninger til e-conomic
TIMETRACKING_ECONOMIC_DRY_RUN=true # 🚨 Log uden at eksportere
TIMETRACKING_EXPORT_TYPE=draft # draft|booked (draft er sikrest)
# Business Logic Settings
TIMETRACKING_DEFAULT_HOURLY_RATE=850.00 # DKK pr. time (fallback hvis kunde ikke har rate)
TIMETRACKING_AUTO_ROUND=true # Auto-afrund til nærmeste interval
TIMETRACKING_ROUND_INCREMENT=0.5 # Afrundingsinterval (0.25, 0.5, 1.0)
TIMETRACKING_ROUND_METHOD=up # up (op til), nearest (nærmeste), down (ned til)
TIMETRACKING_REQUIRE_APPROVAL=true # Kræv manuel godkendelse
# Order Management Security
TIMETRACKING_ADMIN_UNLOCK_CODE= # 🔐 Admin kode til at låse eksporterede ordrer op (sæt en stærk kode!)
# =====================================================
# OLLAMA AI Integration (Optional - for document extraction)
# =====================================================
OLLAMA_ENDPOINT=http://ai_direct.cs.blaahund.dk
OLLAMA_MODEL=qwen2.5-coder:7b
# =====================================================
# COMPANY INFO
# =====================================================
OWN_CVR=29522790 # BMC Denmark ApS - ignore when detecting vendors
# =====================================================
# FILE UPLOAD
# =====================================================
UPLOAD_DIR=uploads
MAX_FILE_SIZE_MB=50
# =====================================================
# MODULE SYSTEM - Dynamic Feature Loading
# =====================================================
# Enable/disable entire module system
MODULES_ENABLED=true
# Directory for dynamic modules (default: app/modules)
MODULES_DIR=app/modules
# Auto-reload modules on changes (dev only, requires restart)
MODULES_AUTO_RELOAD=true
# =====================================================
# MODULE-SPECIFIC CONFIGURATION
# =====================================================
# Pattern: MODULES__{MODULE_NAME}__{KEY}
# Example module configuration:
# MODULES__INVOICE_OCR__READ_ONLY=true
# MODULES__INVOICE_OCR__DRY_RUN=true
# MODULES__INVOICE_OCR__API_KEY=secret123
# MODULES__MY_FEATURE__READ_ONLY=false
# MODULES__MY_FEATURE__DRY_RUN=false
# MODULES__MY_FEATURE__SOME_SETTING=value

View File

@ -8,10 +8,6 @@ RUN apt-get update && apt-get install -y \
git \ git \
libpq-dev \ libpq-dev \
gcc \ gcc \
postgresql-client \
tesseract-ocr \
tesseract-ocr-dan \
tesseract-ocr-eng \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Build arguments for GitHub release deployment # Build arguments for GitHub release deployment

View File

@ -7,12 +7,6 @@ Et centralt management system til BMC Networks - håndterer kunder, services, ha
## 🌟 Features ## 🌟 Features
- **Customer Management**: Komplet kundedatabase med CRM integration - **Customer Management**: Komplet kundedatabase med CRM integration
- **Time Tracking Module**: vTiger integration med tidsregistrering og fakturering
- Automatisk sync fra vTiger (billable timelogs)
- Step-by-step godkendelses-wizard
- Auto-afrunding til 0.5 timer
- Klippekort-funktionalitet
- e-conomic export (draft orders)
- **Hardware Tracking**: Registrering og sporing af kundeudstyr - **Hardware Tracking**: Registrering og sporing af kundeudstyr
- **Service Management**: Håndtering af services og abonnementer - **Service Management**: Håndtering af services og abonnementer
- **Billing Integration**: Automatisk fakturering via e-conomic - **Billing Integration**: Automatisk fakturering via e-conomic
@ -129,43 +123,12 @@ bmc_hub/
## 🔌 API Endpoints ## 🔌 API Endpoints
### Main API
- `GET /api/v1/customers` - List customers - `GET /api/v1/customers` - List customers
- `GET /api/v1/hardware` - List hardware - `GET /api/v1/hardware` - List hardware
- `GET /api/v1/billing/invoices` - List invoices - `GET /api/v1/billing/invoices` - List invoices
- `GET /health` - Health check - `GET /health` - Health check
### Time Tracking Module Se fuld dokumentation: http://localhost:8000/api/docs
- `POST /api/v1/timetracking/sync` - Sync from vTiger (read-only)
- `GET /api/v1/timetracking/wizard/next` - Get next pending timelog
- `POST /api/v1/timetracking/wizard/approve/{id}` - Approve timelog
- `POST /api/v1/timetracking/orders/generate` - Generate invoice order
- `POST /api/v1/timetracking/export` - Export to e-conomic (with safety flags)
- `GET /api/v1/timetracking/export/test-connection` - Test e-conomic connection
Se fuld dokumentation: http://localhost:8001/api/docs
## 🚨 e-conomic Write Mode
Time Tracking modulet kan eksportere ordrer til e-conomic med **safety-first approach**:
### Safety Flags (default: SAFE)
```bash
TIMETRACKING_ECONOMIC_READ_ONLY=true # Block all writes
TIMETRACKING_ECONOMIC_DRY_RUN=true # Simulate writes (log only)
```
### Enable Write Mode
Se detaljeret guide: [docs/ECONOMIC_WRITE_MODE.md](docs/ECONOMIC_WRITE_MODE.md)
**Quick steps:**
1. Test connection: `GET /api/v1/timetracking/export/test-connection`
2. Test dry-run: Set `READ_ONLY=false`, keep `DRY_RUN=true`
3. Export test order: `POST /api/v1/timetracking/export`
4. Enable production: Set **both** flags to `false`
5. Verify first order in e-conomic before bulk operations
**CRITICAL**: All customers must have `economic_customer_number` (synced from vTiger `cf_854` field).
## 🧪 Testing ## 🧪 Testing

View File

@ -161,7 +161,7 @@ async def list_backups(
query += " ORDER BY created_at DESC LIMIT %s OFFSET %s" query += " ORDER BY created_at DESC LIMIT %s OFFSET %s"
params.extend([limit, offset]) params.extend([limit, offset])
backups = execute_query(query, tuple(params)) backups = execute_query_single(query, tuple(params))
return backups if backups else [] return backups if backups else []
@ -171,9 +171,7 @@ async def get_backup(job_id: int):
"""Get details of a specific backup job""" """Get details of a specific backup job"""
backup = execute_query( backup = execute_query(
"SELECT * FROM backup_jobs WHERE id = %s", "SELECT * FROM backup_jobs WHERE id = %s",
(job_id,), (job_id,))
fetchone=True
)
if not backup: if not backup:
raise HTTPException(status_code=404, detail=f"Backup job {job_id} not found") raise HTTPException(status_code=404, detail=f"Backup job {job_id} not found")
@ -305,11 +303,9 @@ async def restore_backup(job_id: int, request: RestoreRequest):
) )
# Get backup job # Get backup job
backup = execute_query( backup = execute_query_single(
"SELECT * FROM backup_jobs WHERE id = %s", "SELECT * FROM backup_jobs WHERE id = %s",
(job_id,), (job_id,))
fetchone=True
)
if not backup: if not backup:
raise HTTPException(status_code=404, detail=f"Backup job {job_id} not found") raise HTTPException(status_code=404, detail=f"Backup job {job_id} not found")
@ -359,11 +355,9 @@ async def delete_backup(job_id: int):
Delete a backup job and its associated file Delete a backup job and its associated file
""" """
# Get backup job # Get backup job
backup = execute_query( backup = execute_query_single(
"SELECT * FROM backup_jobs WHERE id = %s", "SELECT * FROM backup_jobs WHERE id = %s",
(job_id,), (job_id,))
fetchone=True
)
if not backup: if not backup:
raise HTTPException(status_code=404, detail=f"Backup job {job_id} not found") raise HTTPException(status_code=404, detail=f"Backup job {job_id} not found")
@ -419,10 +413,8 @@ async def get_maintenance_status():
Used by frontend to display maintenance overlay Used by frontend to display maintenance overlay
""" """
status = execute_query( status = execute_query_single(
"SELECT * FROM system_status WHERE id = 1", "SELECT * FROM system_status WHERE id = 1")
fetchone=True
)
if not status: if not status:
# Return default status if not found # Return default status if not found

View File

@ -158,11 +158,9 @@ class BackupScheduler:
db_job_id, files_job_id, duration) db_job_id, files_job_id, duration)
# Send success notification for database backup # Send success notification for database backup
db_backup = execute_query( db_backup = execute_query_single(
"SELECT * FROM backup_jobs WHERE id = %s", "SELECT * FROM backup_jobs WHERE id = %s",
(db_job_id,), (db_job_id,))
fetchone=True
)
if db_backup: if db_backup:
await notifications.send_backup_success( await notifications.send_backup_success(
@ -217,11 +215,9 @@ class BackupScheduler:
db_job_id, files_job_id, duration) db_job_id, files_job_id, duration)
# Send success notification for database backup # Send success notification for database backup
db_backup = execute_query( db_backup = execute_query_single(
"SELECT * FROM backup_jobs WHERE id = %s", "SELECT * FROM backup_jobs WHERE id = %s",
(db_job_id,), (db_job_id,))
fetchone=True
)
if db_backup: if db_backup:
await notifications.send_backup_success( await notifications.send_backup_success(
@ -259,7 +255,7 @@ class BackupScheduler:
try: try:
# Find all completed backups not yet uploaded # Find all completed backups not yet uploaded
pending_backups = execute_query( pending_backups = execute_query_single(
"""SELECT * FROM backup_jobs """SELECT * FROM backup_jobs
WHERE status = 'completed' WHERE status = 'completed'
AND offsite_uploaded_at IS NULL AND offsite_uploaded_at IS NULL
@ -295,9 +291,7 @@ class BackupScheduler:
# Get updated retry count # Get updated retry count
updated_backup = execute_query( updated_backup = execute_query(
"SELECT offsite_retry_count FROM backup_jobs WHERE id = %s", "SELECT offsite_retry_count FROM backup_jobs WHERE id = %s",
(backup['id'],), (backup['id'],))
fetchone=True
)
# Send failure notification # Send failure notification
await notifications.send_offsite_failed( await notifications.send_offsite_failed(

View File

@ -285,7 +285,7 @@ class BackupService:
logger.info("🔄 Starting backup rotation") logger.info("🔄 Starting backup rotation")
# Find expired backups # Find expired backups
expired_backups = execute_query( expired_backups = execute_query_single(
"""SELECT id, file_path, is_monthly, retention_until """SELECT id, file_path, is_monthly, retention_until
FROM backup_jobs FROM backup_jobs
WHERE status = 'completed' WHERE status = 'completed'
@ -333,9 +333,7 @@ class BackupService:
# Get backup job # Get backup job
backup = execute_query( backup = execute_query(
"SELECT * FROM backup_jobs WHERE id = %s AND job_type = 'database'", "SELECT * FROM backup_jobs WHERE id = %s AND job_type = 'database'",
(job_id,), (job_id,))
fetchone=True
)
if not backup: if not backup:
logger.error("❌ Backup job not found: %s", job_id) logger.error("❌ Backup job not found: %s", job_id)
@ -442,11 +440,9 @@ class BackupService:
return False return False
# Get backup job # Get backup job
backup = execute_query( backup = execute_query_single(
"SELECT * FROM backup_jobs WHERE id = %s AND job_type = 'files'", "SELECT * FROM backup_jobs WHERE id = %s AND job_type = 'files'",
(job_id,), (job_id,))
fetchone=True
)
if not backup: if not backup:
logger.error("❌ Backup job not found: %s", job_id) logger.error("❌ Backup job not found: %s", job_id)
@ -516,11 +512,9 @@ class BackupService:
return False return False
# Get backup job # Get backup job
backup = execute_query( backup = execute_query_single(
"SELECT * FROM backup_jobs WHERE id = %s", "SELECT * FROM backup_jobs WHERE id = %s",
(job_id,), (job_id,))
fetchone=True
)
if not backup: if not backup:
logger.error("❌ Backup job not found: %s", job_id) logger.error("❌ Backup job not found: %s", job_id)

View File

@ -380,6 +380,12 @@
// Load backups list // Load backups list
async function loadBackups() { async function loadBackups() {
// TODO: Implement /api/v1/backups/jobs endpoint
console.warn('⚠️ Backups API ikke implementeret endnu');
document.getElementById('backups-table').innerHTML = '<tr><td colspan="8" class="text-center text-warning"><i class="bi bi-exclamation-triangle me-2"></i>Backup API er ikke implementeret endnu</td></tr>';
return;
/* Disabled until API implemented:
try { try {
const response = await fetch('/api/v1/backups/jobs?limit=50'); const response = await fetch('/api/v1/backups/jobs?limit=50');
const backups = await response.json(); const backups = await response.json();
@ -433,6 +439,10 @@
// Load storage stats // Load storage stats
async function loadStorageStats() { async function loadStorageStats() {
// TODO: Implement /api/v1/backups/storage endpoint
return;
/* Disabled until API implemented:
try { try {
const response = await fetch('/api/v1/backups/storage'); const response = await fetch('/api/v1/backups/storage');
const stats = await response.json(); const stats = await response.json();
@ -464,6 +474,10 @@
// Load notifications // Load notifications
async function loadNotifications() { async function loadNotifications() {
// TODO: Implement /api/v1/backups/notifications endpoint
return;
/* Disabled until API implemented:
try { try {
const response = await fetch('/api/v1/backups/notifications?limit=10'); const response = await fetch('/api/v1/backups/notifications?limit=10');
const notifications = await response.json(); const notifications = await response.json();
@ -493,6 +507,10 @@
// Load scheduler status // Load scheduler status
async function loadSchedulerStatus() { async function loadSchedulerStatus() {
// TODO: Implement /api/v1/backups/scheduler/status endpoint
return;
/* Disabled until API implemented:
try { try {
const response = await fetch('/api/v1/backups/scheduler/status'); const response = await fetch('/api/v1/backups/scheduler/status');
const status = await response.json(); const status = await response.json();
@ -528,9 +546,13 @@
async function createBackup(event) { async function createBackup(event) {
event.preventDefault(); event.preventDefault();
const resultDiv = document.getElementById('backup-result');
resultDiv.innerHTML = '<div class="alert alert-warning"><i class="bi bi-exclamation-triangle me-2"></i>Backup API er ikke implementeret endnu</div>';
return;
/* Disabled until API implemented:
const type = document.getElementById('backup-type').value; const type = document.getElementById('backup-type').value;
const isMonthly = document.getElementById('is-monthly').checked; const isMonthly = document.getElementById('is-monthly').checked;
const resultDiv = document.getElementById('backup-result');
resultDiv.innerHTML = '<div class="alert alert-info"><i class="bi bi-hourglass-split"></i> Creating backup...</div>'; resultDiv.innerHTML = '<div class="alert alert-info"><i class="bi bi-hourglass-split"></i> Creating backup...</div>';
@ -558,10 +580,14 @@
async function uploadBackup(event) { async function uploadBackup(event) {
event.preventDefault(); event.preventDefault();
const resultDiv = document.getElementById('upload-result');
resultDiv.innerHTML = '<div class="alert alert-warning"><i class="bi bi-exclamation-triangle me-2"></i>Backup upload API er ikke implementeret endnu</div>';
return;
/* Disabled until API implemented:
const fileInput = document.getElementById('backup-file'); const fileInput = document.getElementById('backup-file');
const type = document.getElementById('upload-type').value; const type = document.getElementById('upload-type').value;
const isMonthly = document.getElementById('upload-monthly').checked; const isMonthly = document.getElementById('upload-monthly').checked;
const resultDiv = document.getElementById('upload-result');
if (!fileInput.files || fileInput.files.length === 0) { if (!fileInput.files || fileInput.files.length === 0) {
resultDiv.innerHTML = '<div class="alert alert-danger">Please select a file</div>'; resultDiv.innerHTML = '<div class="alert alert-danger">Please select a file</div>';
@ -613,6 +639,10 @@
// Confirm restore // Confirm restore
async function confirmRestore() { async function confirmRestore() {
alert('⚠️ Restore API er ikke implementeret endnu');
return;
/* Disabled until API implemented:
if (!selectedJobId) return; if (!selectedJobId) return;
try { try {
@ -639,6 +669,10 @@
// Upload to offsite // Upload to offsite
async function uploadOffsite(jobId) { async function uploadOffsite(jobId) {
alert('⚠️ Offsite upload API er ikke implementeret endnu');
return;
/* Disabled until API implemented:
if (!confirm('Upload this backup to offsite storage?')) return; if (!confirm('Upload this backup to offsite storage?')) return;
try { try {
@ -658,6 +692,10 @@
// Delete backup // Delete backup
async function deleteBackup(jobId) { async function deleteBackup(jobId) {
alert('⚠️ Delete backup API er ikke implementeret endnu');
return;
/* Disabled until API implemented:
if (!confirm('Delete this backup? This cannot be undone.')) return; if (!confirm('Delete this backup? This cannot be undone.')) return;
try { try {
@ -676,6 +714,10 @@
// Acknowledge notification // Acknowledge notification
async function acknowledgeNotification(notificationId) { async function acknowledgeNotification(notificationId) {
console.warn('⚠️ Notification API ikke implementeret');
return;
/* Disabled until API implemented:
try { try {
await fetch(`/api/v1/backups/notifications/${notificationId}/acknowledge`, {method: 'POST'}); await fetch(`/api/v1/backups/notifications/${notificationId}/acknowledge`, {method: 'POST'});
loadNotifications(); loadNotifications();

View File

@ -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(query, tuple(params) if params else ()) invoices = execute_query_single(query, tuple(params) if params else ())
# Add lines to each invoice # Add lines to each invoice
for invoice in invoices: for invoice in invoices:
@ -324,9 +324,7 @@ async def get_file_pdf_text(file_id: int):
# Get file info # Get file info
file_info = execute_query( file_info = execute_query(
"SELECT file_path, filename FROM incoming_files WHERE file_id = %s", "SELECT file_path, filename FROM incoming_files WHERE file_id = %s",
(file_id,), (file_id,))
fetchone=True
)
if not file_info: if not file_info:
raise HTTPException(status_code=404, detail="Fil ikke fundet") raise HTTPException(status_code=404, detail="Fil ikke fundet")
@ -357,21 +355,17 @@ async def get_file_extracted_data(file_id: int):
"""Hent AI-extracted data fra en uploaded fil""" """Hent AI-extracted data fra en uploaded fil"""
try: try:
# Get file info # Get file info
file_info = execute_query( file_info = execute_query_single(
"SELECT * FROM incoming_files WHERE file_id = %s", "SELECT * FROM incoming_files WHERE file_id = %s",
(file_id,), (file_id,))
fetchone=True
)
if not file_info: if not file_info:
raise HTTPException(status_code=404, detail="Fil ikke fundet") raise HTTPException(status_code=404, detail="Fil ikke fundet")
# Get extraction results if exists # Get extraction results if exists
extraction = execute_query( extraction = execute_query_single(
"SELECT * FROM extractions WHERE file_id = %s ORDER BY created_at DESC LIMIT 1", "SELECT * FROM extractions WHERE file_id = %s ORDER BY created_at DESC LIMIT 1",
(file_id,), (file_id,))
fetchone=True
)
# Parse llm_response_json if it exists (from AI or template extraction) # Parse llm_response_json if it exists (from AI or template extraction)
llm_json_data = None llm_json_data = None
@ -386,7 +380,7 @@ async def get_file_extracted_data(file_id: int):
# Get extraction lines if exist # Get extraction lines if exist
extraction_lines = [] extraction_lines = []
if extraction: if extraction:
extraction_lines = execute_query( extraction_lines = execute_query_single(
"""SELECT * FROM extraction_lines """SELECT * FROM extraction_lines
WHERE extraction_id = %s WHERE extraction_id = %s
ORDER BY line_number""", ORDER BY line_number""",
@ -493,9 +487,7 @@ async def download_pending_file(file_id: int):
# Get file info # Get file info
file_info = execute_query( file_info = execute_query(
"SELECT * FROM incoming_files WHERE file_id = %s", "SELECT * FROM incoming_files WHERE file_id = %s",
(file_id,), (file_id,))
fetchone=True
)
if not file_info: if not file_info:
raise HTTPException(status_code=404, detail="Fil ikke fundet") raise HTTPException(status_code=404, detail="Fil ikke fundet")
@ -533,21 +525,17 @@ async def link_vendor_to_extraction(file_id: int, data: dict):
raise HTTPException(status_code=400, detail="vendor_id is required") raise HTTPException(status_code=400, detail="vendor_id is required")
# Verify vendor exists # Verify vendor exists
vendor = execute_query( vendor = execute_query_single(
"SELECT id, name FROM vendors WHERE id = %s", "SELECT id, name FROM vendors WHERE id = %s",
(vendor_id,), (vendor_id,))
fetchone=True
)
if not vendor: if not vendor:
raise HTTPException(status_code=404, detail="Leverandør ikke fundet") raise HTTPException(status_code=404, detail="Leverandør ikke fundet")
# Get latest extraction for this file # Get latest extraction for this file
extraction = execute_query( extraction = execute_query_single(
"SELECT extraction_id FROM extractions WHERE file_id = %s ORDER BY created_at DESC LIMIT 1", "SELECT extraction_id FROM extractions WHERE file_id = %s ORDER BY created_at DESC LIMIT 1",
(file_id,), (file_id,))
fetchone=True
)
if not extraction: if not extraction:
raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil") raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil")
@ -583,23 +571,19 @@ async def delete_pending_file_endpoint(file_id: int):
try: try:
# Get file info # Get file info
file_info = execute_query( file_info = execute_query_single(
"SELECT * FROM incoming_files WHERE file_id = %s", "SELECT * FROM incoming_files WHERE file_id = %s",
(file_id,), (file_id,))
fetchone=True
)
if not file_info: if not file_info:
raise HTTPException(status_code=404, detail="Fil ikke fundet") raise HTTPException(status_code=404, detail="Fil ikke fundet")
# Check if already converted to invoice # Check if already converted to invoice
invoice_exists = execute_query( invoice_exists = execute_query_single(
"""SELECT si.id FROM supplier_invoices si """SELECT si.id FROM supplier_invoices si
JOIN extractions e ON si.extraction_id = e.extraction_id JOIN extractions e ON si.extraction_id = e.extraction_id
WHERE e.file_id = %s""", WHERE e.file_id = %s""",
(file_id,), (file_id,))
fetchone=True
)
if invoice_exists: if invoice_exists:
raise HTTPException( raise HTTPException(
@ -665,21 +649,17 @@ async def link_vendor_to_extraction(file_id: int, data: dict):
raise HTTPException(status_code=400, detail="vendor_id er påkrævet") raise HTTPException(status_code=400, detail="vendor_id er påkrævet")
# Verify vendor exists # Verify vendor exists
vendor = execute_query( vendor = execute_query_single(
"SELECT id, name FROM vendors WHERE id = %s", "SELECT id, name FROM vendors WHERE id = %s",
(vendor_id,), (vendor_id,))
fetchone=True
)
if not vendor: if not vendor:
raise HTTPException(status_code=404, detail=f"Leverandør {vendor_id} ikke fundet") raise HTTPException(status_code=404, detail=f"Leverandør {vendor_id} ikke fundet")
# Get latest extraction for this file # Get latest extraction for this file
extraction = execute_query( extraction = execute_query_single(
"SELECT extraction_id FROM extractions WHERE file_id = %s ORDER BY created_at DESC LIMIT 1", "SELECT extraction_id FROM extractions WHERE file_id = %s ORDER BY created_at DESC LIMIT 1",
(file_id,), (file_id,))
fetchone=True
)
if not extraction: if not extraction:
raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil") raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil")
@ -711,16 +691,14 @@ async def create_invoice_from_extraction(file_id: int):
"""Opret leverandørfaktura fra extraction data""" """Opret leverandørfaktura fra extraction data"""
try: try:
# Get latest extraction for this file # Get latest extraction for this file
extraction = execute_query( extraction = execute_query_single(
"""SELECT e.*, v.name as vendor_name """SELECT e.*, v.name as vendor_name
FROM extractions e FROM extractions e
LEFT JOIN vendors v ON v.id = e.vendor_matched_id LEFT JOIN vendors v ON v.id = e.vendor_matched_id
WHERE e.file_id = %s WHERE e.file_id = %s
ORDER BY e.created_at DESC ORDER BY e.created_at DESC
LIMIT 1""", LIMIT 1""",
(file_id,), (file_id,))
fetchone=True
)
if not extraction: if not extraction:
raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil") raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil")
@ -733,17 +711,15 @@ async def create_invoice_from_extraction(file_id: int):
) )
# Check if invoice already exists # Check if invoice already exists
existing = execute_query( existing = execute_query_single(
"SELECT id FROM supplier_invoices WHERE extraction_id = %s", "SELECT id FROM supplier_invoices WHERE extraction_id = %s",
(extraction['extraction_id'],), (extraction['extraction_id'],))
fetchone=True
)
if existing: if existing:
raise HTTPException(status_code=400, detail="Faktura er allerede oprettet fra denne extraction") raise HTTPException(status_code=400, detail="Faktura er allerede oprettet fra denne extraction")
# Get extraction lines # Get extraction lines
lines = execute_query( lines = execute_query_single(
"""SELECT * FROM extraction_lines """SELECT * FROM extraction_lines
WHERE extraction_id = %s WHERE extraction_id = %s
ORDER BY line_number""", ORDER BY line_number""",
@ -892,9 +868,7 @@ async def list_templates():
if vendor_cvr: if vendor_cvr:
vendor = execute_query( vendor = execute_query(
"SELECT id, name FROM vendors WHERE cvr_number = %s", "SELECT id, name FROM vendors WHERE cvr_number = %s",
(vendor_cvr,), (vendor_cvr,))
fetchone=True
)
if vendor: if vendor:
vendor_id = vendor['id'] vendor_id = vendor['id']
vendor_name = vendor['name'] vendor_name = vendor['name']
@ -935,7 +909,7 @@ async def get_template(template_id: int):
LEFT JOIN vendors v ON t.vendor_id = v.id LEFT JOIN vendors v ON t.vendor_id = v.id
WHERE t.template_id = %s AND t.is_active = true WHERE t.template_id = %s AND t.is_active = true
""" """
template = execute_query(query, (template_id,), fetchone=True) template = execute_query_single(query, (template_id,))
if not template: if not template:
raise HTTPException(status_code=404, detail="Template not found") raise HTTPException(status_code=404, detail="Template not found")
@ -969,11 +943,9 @@ async def search_vendor_by_info(request: Dict):
# Search by CVR first (most accurate) # Search by CVR first (most accurate)
if vendor_cvr: if vendor_cvr:
vendor = execute_query( vendor = execute_query_single(
"SELECT id, name, cvr_number FROM vendors WHERE cvr_number = %s", "SELECT id, name, cvr_number FROM vendors WHERE cvr_number = %s",
(vendor_cvr,), (vendor_cvr,))
fetchone=True
)
if vendor: if vendor:
return { return {
"found": True, "found": True,
@ -984,7 +956,7 @@ async def search_vendor_by_info(request: Dict):
# Search by name (fuzzy) # Search by name (fuzzy)
if vendor_name: if vendor_name:
vendors = execute_query( vendors = execute_query_single(
"SELECT id, name, cvr_number FROM vendors WHERE LOWER(name) LIKE LOWER(%s) LIMIT 5", "SELECT id, name, cvr_number FROM vendors WHERE LOWER(name) LIKE LOWER(%s) LIMIT 5",
(f"%{vendor_name}%",) (f"%{vendor_name}%",)
) )
@ -1178,15 +1150,13 @@ async def get_supplier_invoice(invoice_id: int):
FROM supplier_invoices si FROM supplier_invoices si
LEFT JOIN vendors v ON si.vendor_id = v.id LEFT JOIN vendors v ON si.vendor_id = v.id
WHERE si.id = %s""", WHERE si.id = %s""",
(invoice_id,), (invoice_id,))
fetchone=True
)
if not invoice: if not invoice:
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found") raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
# Get lines # Get lines
lines = execute_query( lines = execute_query_single(
"SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id = %s ORDER BY line_number", "SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id = %s ORDER BY line_number",
(invoice_id,) (invoice_id,)
) )
@ -1313,9 +1283,7 @@ async def update_supplier_invoice(invoice_id: int, data: Dict):
# Check if invoice exists # Check if invoice exists
existing = execute_query( existing = execute_query(
"SELECT id, status FROM supplier_invoices WHERE id = %s", "SELECT id, status FROM supplier_invoices WHERE id = %s",
(invoice_id,), (invoice_id,))
fetchone=True
)
if not existing: if not existing:
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found") raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
@ -1368,11 +1336,9 @@ async def update_supplier_invoice(invoice_id: int, data: Dict):
async def delete_supplier_invoice(invoice_id: int): async def delete_supplier_invoice(invoice_id: int):
"""Delete supplier invoice (soft delete if integrated with e-conomic)""" """Delete supplier invoice (soft delete if integrated with e-conomic)"""
try: try:
invoice = execute_query( invoice = execute_query_single(
"SELECT id, invoice_number, economic_voucher_number FROM supplier_invoices WHERE id = %s", "SELECT id, invoice_number, economic_voucher_number FROM supplier_invoices WHERE id = %s",
(invoice_id,), (invoice_id,))
fetchone=True
)
if not invoice: if not invoice:
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found") raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
@ -1410,11 +1376,9 @@ class ApproveRequest(BaseModel):
async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest): async def approve_supplier_invoice(invoice_id: int, request: ApproveRequest):
"""Approve supplier invoice for payment""" """Approve supplier invoice for payment"""
try: try:
invoice = execute_query( invoice = execute_query_single(
"SELECT id, invoice_number, status FROM supplier_invoices WHERE id = %s", "SELECT id, invoice_number, status FROM supplier_invoices WHERE id = %s",
(invoice_id,), (invoice_id,))
fetchone=True
)
if not invoice: if not invoice:
raise HTTPException(status_code=404, detail=f"Faktura {invoice_id} ikke fundet") raise HTTPException(status_code=404, detail=f"Faktura {invoice_id} ikke fundet")
@ -1448,14 +1412,12 @@ async def send_to_economic(invoice_id: int):
""" """
try: try:
# Get invoice with lines # Get invoice with lines
invoice = execute_query( invoice = execute_query_single(
"""SELECT si.*, v.economic_supplier_number as vendor_economic_id, v.name as vendor_full_name """SELECT si.*, v.economic_supplier_number as vendor_economic_id, v.name as vendor_full_name
FROM supplier_invoices si FROM supplier_invoices si
LEFT JOIN vendors v ON si.vendor_id = v.id LEFT JOIN vendors v ON si.vendor_id = v.id
WHERE si.id = %s""", WHERE si.id = %s""",
(invoice_id,), (invoice_id,))
fetchone=True
)
if not invoice: if not invoice:
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found") raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
@ -1467,7 +1429,7 @@ async def send_to_economic(invoice_id: int):
raise HTTPException(status_code=400, detail="Invoice already sent to e-conomic") raise HTTPException(status_code=400, detail="Invoice already sent to e-conomic")
# Get lines # Get lines
lines = execute_query( lines = execute_query_single(
"SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id = %s ORDER BY line_number", "SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id = %s ORDER BY line_number",
(invoice_id,) (invoice_id,)
) )
@ -1505,9 +1467,7 @@ async def send_to_economic(invoice_id: int):
# Get default journal number from settings # Get default journal number from settings
journal_setting = execute_query( journal_setting = execute_query(
"SELECT setting_value FROM supplier_invoice_settings WHERE setting_key = 'economic_default_journal'", "SELECT setting_value FROM supplier_invoice_settings WHERE setting_key = 'economic_default_journal'")
fetchone=True
)
journal_number = int(journal_setting['setting_value']) if journal_setting else 1 journal_number = int(journal_setting['setting_value']) if journal_setting else 1
# Build VAT breakdown from lines # Build VAT breakdown from lines
@ -1634,7 +1594,7 @@ async def get_payment_overview():
try: try:
today = date.today().isoformat() today = date.today().isoformat()
stats = execute_query(""" stats = execute_query_single("""
SELECT SELECT
COUNT(*) as total_count, COUNT(*) as total_count,
SUM(CASE WHEN paid_date IS NOT NULL THEN 1 ELSE 0 END) as paid_count, SUM(CASE WHEN paid_date IS NOT NULL THEN 1 ELSE 0 END) as paid_count,
@ -1647,7 +1607,7 @@ async def get_payment_overview():
SUM(CASE WHEN paid_date IS NULL AND due_date < %s THEN total_amount ELSE 0 END) as overdue_amount SUM(CASE WHEN paid_date IS NULL AND due_date < %s THEN total_amount ELSE 0 END) as overdue_amount
FROM supplier_invoices FROM supplier_invoices
WHERE status != 'cancelled' WHERE status != 'cancelled'
""", (today, today, today, today, today), fetchone=True) """, (today, today, today, today, today))
return { return {
"total_invoices": stats.get('total_count', 0) if stats else 0, "total_invoices": stats.get('total_count', 0) if stats else 0,
@ -1670,7 +1630,7 @@ async def get_payment_overview():
async def get_stats_by_vendor(): async def get_stats_by_vendor():
"""Get supplier invoice statistics grouped by vendor""" """Get supplier invoice statistics grouped by vendor"""
try: try:
stats = execute_query(""" stats = execute_query_single("""
SELECT SELECT
v.id as vendor_id, v.id as vendor_id,
v.name as vendor_name, v.name as vendor_name,
@ -1762,22 +1722,18 @@ async def upload_supplier_invoice(file: UploadFile = File(...)):
# Check for duplicate file # Check for duplicate file
existing_file = execute_query( existing_file = execute_query(
"SELECT file_id, status FROM incoming_files WHERE checksum = %s", "SELECT file_id, status FROM incoming_files WHERE checksum = %s",
(checksum,), (checksum,))
fetchone=True
)
if existing_file: if existing_file:
temp_path.unlink(missing_ok=True) temp_path.unlink(missing_ok=True)
logger.warning(f"⚠️ Duplicate file detected: {checksum[:16]}...") logger.warning(f"⚠️ Duplicate file detected: {checksum[:16]}...")
# Get existing invoice if linked # Get existing invoice if linked
existing_invoice = execute_query( existing_invoice = execute_query_single(
"""SELECT si.* FROM supplier_invoices si """SELECT si.* FROM supplier_invoices si
JOIN extractions e ON si.extraction_id = e.extraction_id JOIN extractions e ON si.extraction_id = e.extraction_id
WHERE e.file_id = %s""", WHERE e.file_id = %s""",
(existing_file['file_id'],), (existing_file['file_id'],))
fetchone=True
)
return { return {
"status": "duplicate", "status": "duplicate",
@ -1797,14 +1753,12 @@ async def upload_supplier_invoice(file: UploadFile = File(...)):
logger.info(f"💾 Saved file as: {final_path.name}") logger.info(f"💾 Saved file as: {final_path.name}")
# Insert file record # Insert file record
file_record = execute_query( file_record = execute_query_single(
"""INSERT INTO incoming_files """INSERT INTO incoming_files
(filename, original_filename, file_path, file_size, mime_type, checksum, status) (filename, original_filename, file_path, file_size, mime_type, checksum, status)
VALUES (%s, %s, %s, %s, %s, %s, 'processing') RETURNING file_id""", VALUES (%s, %s, %s, %s, %s, %s, 'processing') RETURNING file_id""",
(final_path.name, file.filename, str(final_path), total_size, (final_path.name, file.filename, str(final_path), total_size,
ollama_service._get_mime_type(final_path), checksum), ollama_service._get_mime_type(final_path), checksum))
fetchone=True
)
file_id = file_record['file_id'] file_id = file_record['file_id']
# Extract text from file # Extract text from file
@ -1843,16 +1797,14 @@ async def upload_supplier_invoice(file: UploadFile = File(...)):
logger.info(f"🔍 Checking for duplicate invoice number: {document_number}") logger.info(f"🔍 Checking for duplicate invoice number: {document_number}")
# Check 1: Search in local database (supplier_invoices table) # Check 1: Search in local database (supplier_invoices table)
existing_invoice = execute_query( existing_invoice = execute_query_single(
"""SELECT si.id, si.invoice_number, si.created_at, v.name as vendor_name """SELECT si.id, si.invoice_number, si.created_at, v.name as vendor_name
FROM supplier_invoices si FROM supplier_invoices si
LEFT JOIN vendors v ON v.id = si.vendor_id LEFT JOIN vendors v ON v.id = si.vendor_id
WHERE si.invoice_number = %s WHERE si.invoice_number = %s
ORDER BY si.created_at DESC ORDER BY si.created_at DESC
LIMIT 1""", LIMIT 1""",
(document_number,), (document_number,))
fetchone=True
)
if existing_invoice: if existing_invoice:
# DUPLICATE FOUND IN DATABASE # DUPLICATE FOUND IN DATABASE
@ -2055,11 +2007,9 @@ async def reprocess_uploaded_file(file_id: int):
try: try:
# Get file record # Get file record
file_record = execute_query( file_record = execute_query_single(
"SELECT * FROM incoming_files WHERE file_id = %s", "SELECT * FROM incoming_files WHERE file_id = %s",
(file_id,), (file_id,))
fetchone=True
)
if not file_record: if not file_record:
raise HTTPException(status_code=404, detail=f"Fil {file_id} ikke fundet") raise HTTPException(status_code=404, detail=f"Fil {file_id} ikke fundet")
@ -2120,11 +2070,9 @@ async def reprocess_uploaded_file(file_id: int):
logger.info(f"📋 Using invoice2data template") logger.info(f"📋 Using invoice2data template")
# Try to find vendor from extracted CVR # Try to find vendor from extracted CVR
if extracted_fields.get('vendor_vat'): if extracted_fields.get('vendor_vat'):
vendor = execute_query( vendor = execute_query_single(
"SELECT id FROM vendors WHERE cvr_number = %s", "SELECT id FROM vendors WHERE cvr_number = %s",
(extracted_fields['vendor_vat'],), (extracted_fields['vendor_vat'],))
fetchone=True
)
if vendor: if vendor:
vendor_id = vendor['id'] vendor_id = vendor['id']
@ -2134,11 +2082,9 @@ async def reprocess_uploaded_file(file_id: int):
# Fallback: match by issuer name # Fallback: match by issuer name
if vendor_id is None and extracted_fields.get('issuer'): if vendor_id is None and extracted_fields.get('issuer'):
vendor = execute_query( vendor = execute_query_single(
"SELECT id FROM vendors WHERE name ILIKE %s ORDER BY id LIMIT 1", "SELECT id FROM vendors WHERE name ILIKE %s ORDER BY id LIMIT 1",
(extracted_fields['issuer'],), (extracted_fields['issuer'],))
fetchone=True
)
if vendor: if vendor:
vendor_id = vendor['id'] vendor_id = vendor['id']
@ -2301,11 +2247,9 @@ async def reprocess_uploaded_file(file_id: int):
# Add warning if no template exists # Add warning if no template exists
if not template_id and vendor_id: if not template_id and vendor_id:
vendor = execute_query( vendor = execute_query_single(
"SELECT name FROM vendors WHERE id = %s", "SELECT name FROM vendors WHERE id = %s",
(vendor_id,), (vendor_id,))
fetchone=True
)
if vendor: if vendor:
result["warning"] = f"⚠️ Ingen template fundet for {vendor['name']} - brugte AI extraction (langsommere)" result["warning"] = f"⚠️ Ingen template fundet for {vendor['name']} - brugte AI extraction (langsommere)"

View File

@ -49,7 +49,7 @@ async def get_contacts(
FROM contacts c FROM contacts c
{where_sql} {where_sql}
""" """
count_result = execute_query(count_query, tuple(params), fetchone=True) count_result = execute_query_single(count_query, tuple(params))
total = count_result['count'] if count_result else 0 total = count_result['count'] if count_result else 0
# Get contacts with company count # Get contacts with company count
@ -71,7 +71,7 @@ async def get_contacts(
""" """
params.extend([limit, offset]) params.extend([limit, offset])
contacts = execute_query(query, tuple(params)) # Default is fetchall contacts = execute_query_single(query, tuple(params)) # Default is fetchall
return { return {
"contacts": contacts or [], "contacts": contacts or [],
@ -99,7 +99,7 @@ async def get_contact(contact_id: int):
FROM contacts FROM contacts
WHERE id = %s WHERE id = %s
""" """
contact = execute_query(contact_query, (contact_id,), fetchone=True) contact = execute_query(contact_query, (contact_id,))
if not contact: if not contact:
raise HTTPException(status_code=404, detail="Contact not found") raise HTTPException(status_code=404, detail="Contact not found")
@ -114,7 +114,7 @@ async def get_contact(contact_id: int):
WHERE cc.contact_id = %s WHERE cc.contact_id = %s
ORDER BY cc.is_primary DESC, cu.name ORDER BY cc.is_primary DESC, cu.name
""" """
companies = execute_query(companies_query, (contact_id,)) # Default is fetchall companies = execute_query_single(companies_query, (contact_id,)) # Default is fetchall
contact['companies'] = companies or [] contact['companies'] = companies or []
return contact return contact
@ -171,7 +171,7 @@ async def update_contact(contact_id: int, contact: ContactUpdate):
""" """
try: try:
# Check if contact exists # Check if contact exists
existing = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,), fetchone=True) existing = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
if not existing: if not existing:
raise HTTPException(status_code=404, detail="Contact not found") raise HTTPException(status_code=404, detail="Contact not found")
@ -258,12 +258,12 @@ async def link_contact_to_company(contact_id: int, link: ContactCompanyLink):
""" """
try: try:
# Check if contact exists # Check if contact exists
contact = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,), fetchone=True) contact = execute_query_single("SELECT id FROM contacts WHERE id = %s", (contact_id,))
if not contact: if not contact:
raise HTTPException(status_code=404, detail="Contact not found") raise HTTPException(status_code=404, detail="Contact not found")
# Check if company exists # Check if company exists
customer = execute_query("SELECT id FROM customers WHERE id = %s", (link.customer_id,), fetchone=True) customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (link.customer_id,))
if not customer: if not customer:
raise HTTPException(status_code=404, detail="Customer not found") raise HTTPException(status_code=404, detail="Customer not found")

View File

@ -47,11 +47,9 @@ async def get_current_user(
# Get additional user details from database # Get additional user details from database
from app.core.database import execute_query from app.core.database import execute_query
user_details = execute_query( user_details = execute_query_single(
"SELECT email, full_name FROM users WHERE id = %s", "SELECT email, full_name FROM users WHERE id = %s",
(user_id,), (user_id,))
fetchone=True
)
return { return {
"id": user_id, "id": user_id,

View File

@ -85,11 +85,9 @@ class AuthService:
# Check if token is revoked # Check if token is revoked
jti = payload.get('jti') jti = payload.get('jti')
if jti: if jti:
session = execute_query( session = execute_query_single(
"SELECT revoked FROM sessions WHERE token_jti = %s", "SELECT revoked FROM sessions WHERE token_jti = %s",
(jti,), (jti,))
fetchone=True
)
if session and session.get('revoked'): if session and session.get('revoked'):
logger.warning(f"⚠️ Revoked token used: {jti[:10]}...") logger.warning(f"⚠️ Revoked token used: {jti[:10]}...")
return None return None
@ -117,14 +115,12 @@ class AuthService:
User dict if successful, None otherwise User dict if successful, None otherwise
""" """
# Get user # Get user
user = execute_query( user = execute_query_single(
"""SELECT id, username, email, password_hash, full_name, """SELECT id, username, email, password_hash, full_name,
is_active, is_superadmin, failed_login_attempts, locked_until is_active, is_superadmin, failed_login_attempts, locked_until
FROM users FROM users
WHERE username = %s OR email = %s""", WHERE username = %s OR email = %s""",
(username, username), (username, username))
fetchone=True
)
if not user: if not user:
logger.warning(f"❌ Login failed: User not found - {username}") logger.warning(f"❌ Login failed: User not found - {username}")
@ -213,15 +209,13 @@ class AuthService:
List of permission codes List of permission codes
""" """
# Check if user is superadmin first # Check if user is superadmin first
user = execute_query( user = execute_query_single(
"SELECT is_superadmin FROM users WHERE id = %s", "SELECT is_superadmin FROM users WHERE id = %s",
(user_id,), (user_id,))
fetchone=True
)
# Superadmins have all permissions # Superadmins have all permissions
if user and user['is_superadmin']: if user and user['is_superadmin']:
all_perms = execute_query("SELECT code FROM permissions") all_perms = execute_query_single("SELECT code FROM permissions")
return [p['code'] for p in all_perms] if all_perms else [] return [p['code'] for p in all_perms] if all_perms else []
# Get permissions through groups # Get permissions through groups
@ -250,21 +244,19 @@ class AuthService:
# Superadmins have all permissions # Superadmins have all permissions
user = execute_query( user = execute_query(
"SELECT is_superadmin FROM users WHERE id = %s", "SELECT is_superadmin FROM users WHERE id = %s",
(user_id,), (user_id,))
fetchone=True
)
if user and user['is_superadmin']: if user and user['is_superadmin']:
return True return True
# Check if user has permission through groups # Check if user has permission through groups
result = execute_query(""" result = execute_query_single("""
SELECT COUNT(*) as cnt SELECT COUNT(*) as cnt
FROM permissions p FROM permissions p
JOIN group_permissions gp ON p.id = gp.permission_id JOIN group_permissions gp ON p.id = gp.permission_id
JOIN user_groups ug ON gp.group_id = ug.group_id JOIN user_groups ug ON gp.group_id = ug.group_id
WHERE ug.user_id = %s AND p.code = %s WHERE ug.user_id = %s AND p.code = %s
""", (user_id, permission_code), fetchone=True) """, (user_id, permission_code))
return bool(result and result['cnt'] > 0) return bool(result and result['cnt'] > 0)

View File

@ -33,169 +33,28 @@ class Settings(BaseSettings):
ECONOMIC_READ_ONLY: bool = True ECONOMIC_READ_ONLY: bool = True
ECONOMIC_DRY_RUN: bool = True ECONOMIC_DRY_RUN: bool = True
# vTiger CRM Integration # Ollama LLM
OLLAMA_ENDPOINT: str = "http://localhost:11434"
OLLAMA_MODEL: str = "llama3.2:3b"
# vTiger Cloud Integration
VTIGER_URL: str = "" VTIGER_URL: str = ""
VTIGER_USERNAME: str = "" VTIGER_USERNAME: str = ""
VTIGER_API_KEY: str = "" VTIGER_API_KEY: str = ""
VTIGER_PASSWORD: str = "" # Fallback hvis API key ikke virker
# Simply-CRM Integration (Legacy System med CVR data) # Simply-CRM (Old vTiger On-Premise)
OLD_VTIGER_URL: str = "https://bmcnetworks.simply-crm.dk" OLD_VTIGER_URL: str = ""
OLD_VTIGER_USERNAME: str = "ct" OLD_VTIGER_USERNAME: str = ""
OLD_VTIGER_ACCESS_KEY: str = "b00ff2b7c08d591" OLD_VTIGER_API_KEY: str = ""
# Time Tracking Module - vTiger Integration (Isoleret) # Simply-CRM (Separate System)
TIMETRACKING_VTIGER_READ_ONLY: bool = True # 🚨 SAFETY: Bloker ALLE skrivninger til vTiger SIMPLYCRM_URL: str = ""
TIMETRACKING_VTIGER_DRY_RUN: bool = True # 🚨 SAFETY: Log uden at synkronisere SIMPLYCRM_USERNAME: str = ""
SIMPLYCRM_API_KEY: str = ""
# Time Tracking Module - Order Management
TIMETRACKING_ADMIN_UNLOCK_CODE: str = "" # Kode for at låse eksporterede ordrer op
# Time Tracking Module - e-conomic Integration (Isoleret)
TIMETRACKING_ECONOMIC_READ_ONLY: bool = True # 🚨 SAFETY: Bloker ALLE skrivninger til e-conomic
TIMETRACKING_ECONOMIC_DRY_RUN: bool = True # 🚨 SAFETY: Log uden at eksportere
TIMETRACKING_EXPORT_TYPE: str = "draft" # draft|booked (draft er sikrest)
# Time Tracking Module - Business Logic
TIMETRACKING_DEFAULT_HOURLY_RATE: float = 850.00 # DKK pr. time (fallback)
TIMETRACKING_AUTO_ROUND: bool = True # Auto-afrund til nærmeste 0.5 time
TIMETRACKING_ROUND_INCREMENT: float = 0.5 # Afrundingsinterval (0.25, 0.5, 1.0)
TIMETRACKING_ROUND_METHOD: str = "up" # up (op til), nearest (nærmeste), down (ned til)
TIMETRACKING_REQUIRE_APPROVAL: bool = True # Kræv manuel godkendelse (ikke auto-approve)
# Ollama AI Integration
OLLAMA_ENDPOINT: str = "http://ai_direct.cs.blaahund.dk"
OLLAMA_MODEL: str = "qwen2.5-coder:7b" # qwen2.5-coder fungerer bedre til JSON udtrækning
# Ticket System Module
TICKET_ENABLED: bool = True
TICKET_EMAIL_INTEGRATION: bool = False # 🚨 SAFETY: Disable email-to-ticket until configured
TICKET_AUTO_ASSIGN: bool = False # Auto-assign tickets based on rules
TICKET_DEFAULT_PRIORITY: str = "normal" # low|normal|high|urgent
TICKET_REQUIRE_CUSTOMER: bool = False # Allow tickets without customer link
TICKET_NOTIFICATION_ENABLED: bool = False # Notify on status changes
# Ticket System - e-conomic Integration
TICKET_ECONOMIC_READ_ONLY: bool = True # 🚨 SAFETY: Block all writes to e-conomic
TICKET_ECONOMIC_DRY_RUN: bool = True # 🚨 SAFETY: Log without executing
TICKET_ECONOMIC_AUTO_EXPORT: bool = False # Auto-export billable worklog
# Email System Configuration
EMAIL_TO_TICKET_ENABLED: bool = False # 🚨 SAFETY: Disable auto-processing until configured
# Email Fetching (IMAP)
USE_GRAPH_API: bool = False # Use Microsoft Graph API instead of IMAP (preferred)
IMAP_SERVER: str = "outlook.office365.com"
IMAP_PORT: int = 993
IMAP_USE_SSL: bool = True
IMAP_USERNAME: str = ""
IMAP_PASSWORD: str = ""
IMAP_FOLDER: str = "INBOX"
IMAP_READ_ONLY: bool = True # 🚨 SAFETY: Never mark emails as read or modify mailbox
# Microsoft Graph API (OAuth2)
GRAPH_TENANT_ID: str = ""
GRAPH_CLIENT_ID: str = ""
GRAPH_CLIENT_SECRET: str = ""
GRAPH_USER_EMAIL: str = "" # Email account to monitor
# Email Processing
EMAIL_PROCESS_INTERVAL_MINUTES: int = 5 # Background job frequency
EMAIL_MAX_FETCH_PER_RUN: int = 50 # Limit emails per processing cycle
EMAIL_RETENTION_DAYS: int = 90 # Days to keep emails before soft delete
# Email Classification (AI)
EMAIL_AI_ENABLED: bool = True
EMAIL_AI_CONFIDENCE_THRESHOLD: float = 0.7 # Minimum confidence for auto-processing
EMAIL_AUTO_CLASSIFY: bool = True # Run AI classification on new emails
# Email Rules Engine (DEPRECATED - Use workflows instead)
EMAIL_RULES_ENABLED: bool = False # 🚨 LEGACY: Disabled by default, use EMAIL_WORKFLOWS_ENABLED instead
EMAIL_RULES_AUTO_PROCESS: bool = False # 🚨 SAFETY: Require manual approval initially
# Email Workflows (RECOMMENDED)
EMAIL_WORKFLOWS_ENABLED: bool = True # Enable automated workflows based on classification (replaces rules)
# Company Info
OWN_CVR: str = "29522790" # BMC Denmark ApS - ignore when detecting vendors
# File Upload
UPLOAD_DIR: str = "uploads"
MAX_FILE_SIZE_MB: int = 50
ALLOWED_EXTENSIONS: List[str] = [".pdf", ".png", ".jpg", ".jpeg", ".txt", ".csv"]
# Module System Configuration
MODULES_ENABLED: bool = True # Enable/disable entire module system
MODULES_DIR: str = "app/modules" # Directory for dynamic modules
MODULES_AUTO_RELOAD: bool = True # Hot-reload modules on changes (dev only)
# Backup System Configuration
# Safety switches (default to safe mode)
BACKUP_ENABLED: bool = False # 🚨 SAFETY: Disable backups until explicitly enabled
BACKUP_DRY_RUN: bool = True # 🚨 SAFETY: Log operations without executing
BACKUP_READ_ONLY: bool = True # 🚨 SAFETY: Allow reads but block destructive operations
# Backup formats
DB_DAILY_FORMAT: str = "dump" # dump (compressed) or sql (plain text)
DB_MONTHLY_FORMAT: str = "sql" # Monthly backups use plain SQL for readability
# Backup scope
BACKUP_INCLUDE_UPLOADS: bool = True # Include uploads/ directory
BACKUP_INCLUDE_LOGS: bool = True # Include logs/ directory
BACKUP_INCLUDE_DATA: bool = True # Include data/ directory (templates, configs)
# Storage configuration
BACKUP_STORAGE_PATH: str = "/opt/backups" # Production: /opt/backups, Dev: ./backups
BACKUP_MAX_SIZE_GB: int = 50 # Maximum total backup storage size
STORAGE_WARNING_THRESHOLD_PCT: int = 80 # Warn when storage exceeds this percentage
# Rotation policy
RETENTION_DAYS: int = 30 # Keep daily backups for 30 days
MONTHLY_KEEP_MONTHS: int = 12 # Keep monthly backups for 12 months
# Offsite configuration (SFTP/SSH)
OFFSITE_ENABLED: bool = False # 🚨 SAFETY: Disable offsite uploads until configured
OFFSITE_WEEKLY_DAY: str = "sunday" # Day for weekly offsite upload (monday-sunday)
OFFSITE_RETRY_MAX_ATTEMPTS: int = 3 # Maximum retry attempts for failed uploads
OFFSITE_RETRY_DELAY_HOURS: int = 1 # Hours between retry attempts
SFTP_HOST: str = "" # SFTP server hostname or IP
SFTP_PORT: int = 22 # SFTP server port
SFTP_USER: str = "" # SFTP username
SFTP_PASSWORD: str = "" # SFTP password (if not using SSH key)
SSH_KEY_PATH: str = "" # Path to SSH private key (preferred over password)
SFTP_REMOTE_PATH: str = "/backups/bmc_hub" # Remote directory for backups
# Notification configuration (Mattermost)
MATTERMOST_ENABLED: bool = False # 🚨 SAFETY: Disable until webhook configured
MATTERMOST_WEBHOOK_URL: str = "" # Mattermost incoming webhook URL
MATTERMOST_CHANNEL: str = "backups" # Channel name for backup notifications
NOTIFY_ON_FAILURE: bool = True # Send notification on backup/offsite failures
NOTIFY_ON_SUCCESS_OFFSITE: bool = True # Send notification on successful offsite upload
class Config: class Config:
env_file = ".env" env_file = ".env"
case_sensitive = True case_sensitive = True
extra = "ignore" # Ignore extra fields from .env
settings = Settings() settings = Settings()
def get_module_config(module_name: str, key: str, default=None):
"""
Hent modul-specifik konfiguration fra miljøvariabel
Pattern: MODULES__{MODULE_NAME}__{KEY}
Eksempel: MODULES__MY_MODULE__API_KEY
Args:
module_name: Navn modul (fx "my_module")
key: Config key (fx "API_KEY")
default: Default værdi hvis ikke sat
Returns:
Konfigurationsværdi eller default
"""
import os
env_key = f"MODULES__{module_name.upper()}__{key.upper()}"
return os.getenv(env_key, default)

View File

@ -55,37 +55,21 @@ def get_db():
release_db_connection(conn) release_db_connection(conn)
def execute_query(query: str, params: Optional[tuple] = None, fetchone: bool = False): def execute_query(query: str, params: tuple = None, fetch: bool = True):
""" """Execute a SQL query and return results"""
Execute a SQL query and return results
Args:
query: SQL query string
params: Query parameters tuple
fetchone: If True, return single row dict, otherwise list of dicts
Returns:
Single dict if fetchone=True, otherwise list of dicts
"""
conn = get_db_connection() conn = get_db_connection()
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor: with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(query, params or ()) cursor.execute(query, params)
# Check if this is a write operation (INSERT, UPDATE, DELETE) # Auto-detect write operations and commit
query_upper = query.strip().upper() query_upper = query.strip().upper()
is_write = any(query_upper.startswith(cmd) for cmd in ['INSERT', 'UPDATE', 'DELETE']) if query_upper.startswith(('INSERT', 'UPDATE', 'DELETE')):
conn.commit()
if fetchone: if fetch:
row = cursor.fetchone() return cursor.fetchall()
if is_write: return cursor.rowcount
conn.commit()
return dict(row) if row else None
else:
rows = cursor.fetchall()
if is_write:
conn.commit()
return [dict(row) for row in rows]
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
logger.error(f"Query error: {e}") logger.error(f"Query error: {e}")
@ -94,35 +78,15 @@ def execute_query(query: str, params: Optional[tuple] = None, fetchone: bool = F
release_db_connection(conn) release_db_connection(conn)
def execute_insert(query: str, params: tuple = ()) -> Optional[int]: def execute_insert(query: str, params: tuple = None):
""" """Execute INSERT query and return new ID"""
Execute an INSERT query and return last row id
Args:
query: SQL INSERT query (will add RETURNING id if not present)
params: Query parameters tuple
Returns:
Last inserted row ID or None
"""
conn = get_db_connection() conn = get_db_connection()
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor: with conn.cursor(cursor_factory=RealDictCursor) as cursor:
# PostgreSQL requires RETURNING clause
if "RETURNING" not in query.upper():
query = query.rstrip(";") + " RETURNING id"
cursor.execute(query, params) cursor.execute(query, params)
result = cursor.fetchone()
conn.commit() conn.commit()
result = cursor.fetchone()
# If result exists, return the first column value (typically ID) return result['id'] if result and 'id' in result else None
if result:
# If it's a dict, get first value
if isinstance(result, dict):
return list(result.values())[0]
# If it's a tuple/list, get first element
return result[0]
return None
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
logger.error(f"Insert error: {e}") logger.error(f"Insert error: {e}")
@ -131,24 +95,14 @@ def execute_insert(query: str, params: tuple = ()) -> Optional[int]:
release_db_connection(conn) release_db_connection(conn)
def execute_update(query: str, params: tuple = ()) -> int: def execute_update(query: str, params: tuple = None):
""" """Execute UPDATE/DELETE query and return affected rows"""
Execute an UPDATE/DELETE query and return affected rows
Args:
query: SQL UPDATE/DELETE query
params: Query parameters tuple
Returns:
Number of affected rows
"""
conn = get_db_connection() conn = get_db_connection()
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor: with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(query, params) cursor.execute(query, params)
rowcount = cursor.rowcount
conn.commit() conn.commit()
return rowcount return cursor.rowcount
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
logger.error(f"Update error: {e}") logger.error(f"Update error: {e}")
@ -157,66 +111,7 @@ def execute_update(query: str, params: tuple = ()) -> int:
release_db_connection(conn) release_db_connection(conn)
def execute_module_migration(module_name: str, migration_sql: str) -> bool: def execute_query_single(query: str, params: tuple = None):
""" """Execute query and return single row (backwards compatibility for fetchone=True)"""
Kør en migration for et specifikt modul result = execute_query(query, params)
return result[0] if result and len(result) > 0 else None
Args:
module_name: Navn modulet
migration_sql: SQL migration kode
Returns:
True hvis success, False ved fejl
"""
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
# Sikr at module_migrations tabel eksisterer
cursor.execute("""
CREATE TABLE IF NOT EXISTS module_migrations (
id SERIAL PRIMARY KEY,
module_name VARCHAR(100) NOT NULL,
migration_name VARCHAR(255) NOT NULL,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
success BOOLEAN DEFAULT TRUE,
error_message TEXT,
UNIQUE(module_name, migration_name)
)
""")
# Kør migration
cursor.execute(migration_sql)
conn.commit()
logger.info(f"✅ Migration for {module_name} success")
return True
except Exception as e:
conn.rollback()
logger.error(f"❌ Migration failed for {module_name}: {e}")
return False
finally:
release_db_connection(conn)
def check_module_table_exists(table_name: str) -> bool:
"""
Check om en modul tabel eksisterer
Args:
table_name: Tabel navn (fx "my_module_customers")
Returns:
True hvis tabellen eksisterer
"""
query = """
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = %s
)
"""
result = execute_query(query, (table_name,), fetchone=True)
if result and isinstance(result, dict):
return result.get('exists', False)
return False

View File

@ -9,7 +9,7 @@ from typing import List, Optional, Dict
from pydantic import BaseModel from pydantic import BaseModel
import logging import logging
from app.core.database import execute_query, execute_insert, execute_update from app.core.database import execute_query, execute_query_single
from app.services.cvr_service import get_cvr_service from app.services.cvr_service import get_cvr_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -82,13 +82,24 @@ async def list_customers(
source: Filter by source ('vtiger' or 'local') source: Filter by source ('vtiger' or 'local')
is_active: Filter by active status is_active: Filter by active status
""" """
# Build query # Build query with primary contact info
query = """ query = """
SELECT SELECT
c.*, c.*,
COUNT(DISTINCT cc.contact_id) as contact_count COUNT(DISTINCT cc.contact_id) as contact_count,
CONCAT(pc.first_name, ' ', pc.last_name) as contact_name,
pc.email as contact_email,
COALESCE(pc.mobile, pc.phone) as contact_phone
FROM customers c FROM customers c
LEFT JOIN contact_companies cc ON cc.customer_id = c.id LEFT JOIN contact_companies cc ON cc.customer_id = c.id
LEFT JOIN LATERAL (
SELECT con.first_name, con.last_name, con.email, con.phone, con.mobile
FROM contact_companies ccomp
JOIN contacts con ON ccomp.contact_id = con.id
WHERE ccomp.customer_id = c.id
ORDER BY ccomp.is_primary DESC, con.id ASC
LIMIT 1
) pc ON true
WHERE 1=1 WHERE 1=1
""" """
params = [] params = []
@ -117,7 +128,7 @@ async def list_customers(
params.append(is_active) params.append(is_active)
query += """ query += """
GROUP BY c.id GROUP BY c.id, pc.first_name, pc.last_name, pc.email, pc.phone, pc.mobile
ORDER BY c.name ORDER BY c.name
LIMIT %s OFFSET %s LIMIT %s OFFSET %s
""" """
@ -148,7 +159,7 @@ async def list_customers(
count_query += " AND is_active = %s" count_query += " AND is_active = %s"
count_params.append(is_active) count_params.append(is_active)
count_result = execute_query(count_query, tuple(count_params), fetchone=True) count_result = execute_query_single(count_query, tuple(count_params))
total = count_result['total'] if count_result else 0 total = count_result['total'] if count_result else 0
return { return {
@ -163,21 +174,17 @@ async def list_customers(
async def get_customer(customer_id: int): async def get_customer(customer_id: int):
"""Get single customer by ID with contact count and vTiger BMC Låst status""" """Get single customer by ID with contact count and vTiger BMC Låst status"""
# Get customer # Get customer
customer = execute_query( customer = execute_query_single(
"SELECT * FROM customers WHERE id = %s", "SELECT * FROM customers WHERE id = %s",
(customer_id,), (customer_id,))
fetchone=True
)
if not customer: if not customer:
raise HTTPException(status_code=404, detail="Customer not found") raise HTTPException(status_code=404, detail="Customer not found")
# Get contact count # Get contact count
contact_count_result = execute_query( contact_count_result = execute_query_single(
"SELECT COUNT(*) as count FROM contact_companies WHERE customer_id = %s", "SELECT COUNT(*) as count FROM contact_companies WHERE customer_id = %s",
(customer_id,), (customer_id,))
fetchone=True
)
contact_count = contact_count_result['count'] if contact_count_result else 0 contact_count = contact_count_result['count'] if contact_count_result else 0
@ -230,11 +237,9 @@ async def create_customer(customer: CustomerCreate):
logger.info(f"✅ Created customer {customer_id}: {customer.name}") logger.info(f"✅ Created customer {customer_id}: {customer.name}")
# Fetch and return created customer # Fetch and return created customer
created = execute_query( created = execute_query_single(
"SELECT * FROM customers WHERE id = %s", "SELECT * FROM customers WHERE id = %s",
(customer_id,), (customer_id,))
fetchone=True
)
return created return created
except Exception as e: except Exception as e:
@ -246,11 +251,9 @@ async def create_customer(customer: CustomerCreate):
async def update_customer(customer_id: int, update: CustomerUpdate): async def update_customer(customer_id: int, update: CustomerUpdate):
"""Update customer information""" """Update customer information"""
# Verify customer exists # Verify customer exists
existing = execute_query( existing = execute_query_single(
"SELECT id FROM customers WHERE id = %s", "SELECT id FROM customers WHERE id = %s",
(customer_id,), (customer_id,))
fetchone=True
)
if not existing: if not existing:
raise HTTPException(status_code=404, detail="Customer not found") raise HTTPException(status_code=404, detail="Customer not found")
@ -275,11 +278,9 @@ async def update_customer(customer_id: int, update: CustomerUpdate):
logger.info(f"✅ Updated customer {customer_id}") logger.info(f"✅ Updated customer {customer_id}")
# Fetch and return updated customer # Fetch and return updated customer
updated = execute_query( updated = execute_query_single(
"SELECT * FROM customers WHERE id = %s", "SELECT * FROM customers WHERE id = %s",
(customer_id,), (customer_id,))
fetchone=True
)
return updated return updated
except Exception as e: except Exception as e:
@ -294,11 +295,9 @@ async def lock_customer_subscriptions(customer_id: int, lock_request: dict):
locked = lock_request.get('locked', False) locked = lock_request.get('locked', False)
# Get customer # Get customer
customer = execute_query( customer = execute_query_single(
"SELECT id, name FROM customers WHERE id = %s", "SELECT id, name FROM customers WHERE id = %s",
(customer_id,), (customer_id,))
fetchone=True
)
if not customer: if not customer:
raise HTTPException(status_code=404, detail="Customer not found") raise HTTPException(status_code=404, detail="Customer not found")
@ -327,7 +326,7 @@ async def lock_customer_subscriptions(customer_id: int, lock_request: dict):
@router.get("/customers/{customer_id}/contacts") @router.get("/customers/{customer_id}/contacts")
async def get_customer_contacts(customer_id: int): async def get_customer_contacts(customer_id: int):
"""Get all contacts for a specific customer""" """Get all contacts for a specific customer"""
rows = execute_query(""" rows = execute_query_single("""
SELECT SELECT
c.*, c.*,
cc.is_primary, cc.is_primary,
@ -348,9 +347,7 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
# Verify customer exists # Verify customer exists
customer = execute_query( customer = execute_query(
"SELECT id FROM customers WHERE id = %s", "SELECT id FROM customers WHERE id = %s",
(customer_id,), (customer_id,))
fetchone=True
)
if not customer: if not customer:
raise HTTPException(status_code=404, detail="Customer not found") raise HTTPException(status_code=404, detail="Customer not found")
@ -383,11 +380,9 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
logger.info(f"✅ Created contact {contact_id} for customer {customer_id}") logger.info(f"✅ Created contact {contact_id} for customer {customer_id}")
# Fetch and return created contact # Fetch and return created contact
created = execute_query( created = execute_query_single(
"SELECT * FROM contacts WHERE id = %s", "SELECT * FROM contacts WHERE id = %s",
(contact_id,), (contact_id,))
fetchone=True
)
return created return created
except Exception as e: except Exception as e:
@ -420,11 +415,9 @@ async def get_customer_subscriptions(customer_id: int):
from app.services.vtiger_service import get_vtiger_service from app.services.vtiger_service import get_vtiger_service
# Get customer with vTiger ID # Get customer with vTiger ID
customer = execute_query( customer = execute_query_single(
"SELECT id, name, vtiger_id FROM customers WHERE id = %s", "SELECT id, name, vtiger_id FROM customers WHERE id = %s",
(customer_id,), (customer_id,))
fetchone=True
)
if not customer: if not customer:
raise HTTPException(status_code=404, detail="Customer not found") raise HTTPException(status_code=404, detail="Customer not found")
@ -506,20 +499,29 @@ async def get_customer_subscriptions(customer_id: int):
# Note: Simply-CRM returns one row per line item, so we need to group them # Note: Simply-CRM returns one row per line item, so we need to group them
query = f"SELECT * FROM SalesOrder WHERE account_id='{simplycrm_account_id}';" query = f"SELECT * FROM SalesOrder WHERE account_id='{simplycrm_account_id}';"
all_simplycrm_orders = await simplycrm.query(query) all_simplycrm_orders = await simplycrm.query(query)
logger.info(f"🔍 Simply-CRM raw query returned {len(all_simplycrm_orders or [])} orders for account {simplycrm_account_id}")
# Group line items by order ID # Group line items by order ID
# Filter: Only include orders with recurring_frequency (otherwise not subscription) # Filter: Only include orders with recurring_frequency (otherwise not subscription)
orders_dict = {} orders_dict = {}
filtered_closed = 0
filtered_no_freq = 0
for row in (all_simplycrm_orders or []): for row in (all_simplycrm_orders or []):
status = row.get('sostatus', '').lower() status = row.get('sostatus', '').lower()
if status in ['closed', 'cancelled']: if status in ['closed', 'cancelled']:
filtered_closed += 1
logger.debug(f" ⏭️ Skipping closed order: {row.get('subject', 'N/A')} ({status})")
continue continue
# MUST have recurring_frequency to be a subscription # MUST have recurring_frequency to be a subscription
recurring_frequency = row.get('recurring_frequency', '').strip() recurring_frequency = row.get('recurring_frequency', '').strip()
if not recurring_frequency: if not recurring_frequency:
filtered_no_freq += 1
logger.debug(f" ⏭️ Skipping order without frequency: {row.get('subject', 'N/A')}")
continue continue
logger.info(f" ✅ Including order: {row.get('subject', 'N/A')} - {recurring_frequency} ({status})")
order_id = row.get('id') order_id = row.get('id')
if order_id not in orders_dict: if order_id not in orders_dict:
# First occurrence - create order object # First occurrence - create order object
@ -548,7 +550,7 @@ async def get_customer_subscriptions(customer_id: int):
}) })
simplycrm_sales_orders = list(orders_dict.values()) simplycrm_sales_orders = list(orders_dict.values())
logger.info(f"📥 Found {len(simplycrm_sales_orders)} unique open sales orders in Simply-CRM") logger.info(f"📥 Found {len(simplycrm_sales_orders)} unique recurring orders in Simply-CRM (filtered out: {filtered_closed} closed, {filtered_no_freq} without frequency)")
else: else:
logger.info(f" No Simply-CRM account found for '{customer_name}'") logger.info(f" No Simply-CRM account found for '{customer_name}'")
except Exception as e: except Exception as e:
@ -608,9 +610,7 @@ async def create_subscription(customer_id: int, subscription: SubscriptionCreate
# Get customer's vTiger ID # Get customer's vTiger ID
customer = execute_query( customer = execute_query(
"SELECT vtiger_id FROM customers WHERE id = %s", "SELECT vtiger_id FROM customers WHERE id = %s",
(customer_id,), (customer_id,))
fetchone=True
)
if not customer or not customer.get('vtiger_id'): if not customer or not customer.get('vtiger_id'):
raise HTTPException(status_code=404, detail="Customer not linked to vTiger") raise HTTPException(status_code=404, detail="Customer not linked to vTiger")
@ -686,11 +686,9 @@ async def delete_subscription(subscription_id: str, customer_id: int = None):
try: try:
# Check if subscriptions are locked for this customer (if customer_id provided) # Check if subscriptions are locked for this customer (if customer_id provided)
if customer_id: if customer_id:
customer = execute_query( customer = execute_query_single(
"SELECT subscriptions_locked FROM customers WHERE id = %s", "SELECT subscriptions_locked FROM customers WHERE id = %s",
(customer_id,), (customer_id,))
fetchone=True
)
if customer and customer.get('subscriptions_locked'): if customer and customer.get('subscriptions_locked'):
raise HTTPException( raise HTTPException(
status_code=403, status_code=403,

View File

@ -8,7 +8,7 @@ templates = Jinja2Templates(directory="app")
@router.get("/customers", response_class=HTMLResponse) @router.get("/customers", response_class=HTMLResponse)
async def customers_page(request: Request): async def customers_page(request: Request):
""" """
Render the customers list page Render the customers page
""" """
return templates.TemplateResponse("customers/frontend/customers.html", {"request": request}) return templates.TemplateResponse("customers/frontend/customers.html", {"request": request})

View File

@ -215,7 +215,7 @@
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#subscriptions"> <a class="nav-link" data-bs-toggle="tab" href="#subscriptions">
<i class="bi bi-arrow-repeat"></i>Abonnementer <i class="bi bi-arrow-repeat"></i>Abonnnents tjek
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
@ -353,7 +353,7 @@
<!-- Subscriptions Tab --> <!-- Subscriptions Tab -->
<div class="tab-pane fade" id="subscriptions"> <div class="tab-pane fade" id="subscriptions">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold mb-0">Abonnementer & Salgsordre</h5> <h5 class="fw-bold mb-0">Abonnnents tjek</h5>
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-success btn-sm" onclick="showCreateSubscriptionModal()"> <button class="btn btn-success btn-sm" onclick="showCreateSubscriptionModal()">
<i class="bi bi-plus-circle me-2"></i>Opret Abonnement <i class="bi bi-plus-circle me-2"></i>Opret Abonnement
@ -860,11 +860,13 @@ function renderSalesOrdersList(orders) {
const lineItems = order.lineItems || []; const lineItems = order.lineItems || [];
const hasLineItems = Array.isArray(lineItems) && lineItems.length > 0; const hasLineItems = Array.isArray(lineItems) && lineItems.length > 0;
const total = parseFloat(order.hdnGrandTotal || 0); const total = parseFloat(order.hdnGrandTotal || 0);
const recordId = order.id.includes('x') ? order.id.split('x')[1] : order.id;
const simplycrmUrl = `https://bmcnetworks.simply-crm.dk/index.php?module=SalesOrder&view=Detail&record=${recordId}`;
return ` return `
<div class="subscription-item border rounded p-3 mb-3 bg-white shadow-sm"> <div class="subscription-item border rounded p-3 mb-3 bg-white shadow-sm">
<div class="d-flex justify-content-between align-items-start mb-2" style="cursor: pointer;" onclick="toggleLineItems('${itemId}')"> <div class="d-flex justify-content-between align-items-start mb-2">
<div class="flex-grow-1"> <div class="flex-grow-1" style="cursor: pointer;" onclick="toggleLineItems('${itemId}')">
<div class="fw-bold d-flex align-items-center"> <div class="fw-bold d-flex align-items-center">
<i class="bi bi-chevron-right me-2 text-success" id="${itemId}-icon" style="font-size: 0.8rem;"></i> <i class="bi bi-chevron-right me-2 text-success" id="${itemId}-icon" style="font-size: 0.8rem;"></i>
${escapeHtml(order.subject || order.salesorder_no || 'Unnamed')} ${escapeHtml(order.subject || order.salesorder_no || 'Unnamed')}
@ -874,14 +876,23 @@ function renderSalesOrdersList(orders) {
</div> </div>
</div> </div>
<div class="text-end ms-3"> <div class="text-end ms-3">
<div class="badge bg-${getStatusColor(order.sostatus)} mb-1">${escapeHtml(order.sostatus || 'Open')}</div> <div class="btn-group btn-group-sm mb-2" role="group">
<div class="fw-bold text-success">${total.toFixed(2)} DKK</div> <a href="${simplycrmUrl}" target="_blank" class="btn btn-outline-success" title="Åbn i Simply-CRM">
<i class="bi bi-box-arrow-up-right"></i> Simply-CRM
</a>
</div>
<div>
<div class="badge bg-${getStatusColor(order.sostatus)} mb-1">${escapeHtml(order.sostatus || 'Open')}</div>
<div class="fw-bold text-success">${total.toFixed(2)} DKK</div>
</div>
</div> </div>
</div> </div>
<div class="d-flex gap-2 flex-wrap small text-muted mt-2"> <div class="d-flex gap-2 flex-wrap small text-muted mt-2">
${order.recurring_frequency ? `<span class="badge bg-light text-dark"><i class="bi bi-arrow-repeat me-1"></i>${escapeHtml(order.recurring_frequency)}</span>` : ''} ${order.recurring_frequency ? `<span class="badge bg-light text-dark"><i class="bi bi-arrow-repeat me-1"></i>${escapeHtml(order.recurring_frequency)}</span>` : ''}
${order.last_recurring_date ? `<span class="badge bg-info text-dark"><i class="bi bi-calendar-check me-1"></i>Last: ${formatDate(order.last_recurring_date)}</span>` : ''} ${order.last_recurring_date ? `<span class="badge bg-info text-dark"><i class="bi bi-calendar-check me-1"></i>Sidste: ${formatDate(order.last_recurring_date)}</span>` : ''}
${order.start_period && order.end_period ? `<span class="badge bg-light text-dark"><i class="bi bi-calendar-range me-1"></i>${formatDate(order.start_period)} - ${formatDate(order.end_period)}</span>` : ''}
${order.start_period && !order.end_period ? `<span class="badge bg-light text-dark"><i class="bi bi-calendar-check me-1"></i>Start: ${formatDate(order.start_period)}</span>` : ''}
</div> </div>
${hasLineItems ? ` ${hasLineItems ? `

View File

@ -12,7 +12,6 @@
border-radius: 20px; border-radius: 20px;
font-size: 0.9rem; font-size: 0.9rem;
transition: all 0.2s; transition: all 0.2s;
cursor: pointer;
} }
.filter-btn:hover, .filter-btn.active { .filter-btn:hover, .filter-btn.active {
@ -20,38 +19,6 @@
color: white; color: white;
border-color: var(--accent); border-color: var(--accent);
} }
.customer-avatar {
width: 40px;
height: 40px;
border-radius: 8px;
background: var(--accent-light);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 0.9rem;
}
.pagination-btn {
border: 1px solid rgba(0,0,0,0.1);
padding: 0.5rem 1rem;
background: var(--bg-card);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
}
.pagination-btn:hover:not(:disabled) {
background: var(--accent-light);
border-color: var(--accent);
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style> </style>
{% endblock %} {% endblock %}
@ -62,29 +29,16 @@
<p class="text-muted mb-0">Administrer dine kunder</p> <p class="text-muted mb-0">Administrer dine kunder</p>
</div> </div>
<div class="d-flex gap-3"> <div class="d-flex gap-3">
<input type="text" id="searchInput" class="header-search" placeholder="Søg kunde, CVR, email..."> <input type="text" id="searchInput" class="header-search" placeholder="Søg kunde...">
<button class="btn btn-primary" onclick="showCreateCustomerModal()"> <button class="btn btn-primary"><i class="bi bi-plus-lg me-2"></i>Opret Kunde</button>
<i class="bi bi-plus-lg me-2"></i>Opret Kunde
</button>
</div> </div>
</div> </div>
<div class="mb-4 d-flex gap-2 flex-wrap"> <div class="mb-4 d-flex gap-2">
<button class="filter-btn active" data-filter="all" onclick="setFilter('all')"> <button class="filter-btn active">Alle Kunder</button>
Alle Kunder <span id="countAll" class="ms-1"></span> <button class="filter-btn">Aktive</button>
</button> <button class="filter-btn">Inaktive</button>
<button class="filter-btn" data-filter="active" onclick="setFilter('active')"> <button class="filter-btn">VIP</button>
Aktive <span id="countActive" class="ms-1"></span>
</button>
<button class="filter-btn" data-filter="inactive" onclick="setFilter('inactive')">
Inaktive <span id="countInactive" class="ms-1"></span>
</button>
<button class="filter-btn" data-filter="vtiger" onclick="setFilter('vtiger')">
<i class="bi bi-cloud me-1"></i>vTiger
</button>
<button class="filter-btn" data-filter="local" onclick="setFilter('local')">
<i class="bi bi-hdd me-1"></i>Lokal
</button>
</div> </div>
<div class="card p-4"> <div class="card p-4">
@ -93,17 +47,16 @@
<thead> <thead>
<tr> <tr>
<th>Virksomhed</th> <th>Virksomhed</th>
<th>Kontakt Info</th> <th>Kontakt</th>
<th>CVR</th> <th>CVR</th>
<th>Kilde</th>
<th>Status</th> <th>Status</th>
<th>Kontakter</th> <th>E-mail</th>
<th class="text-end">Handlinger</th> <th class="text-end">Handlinger</th>
</tr> </tr>
</thead> </thead>
<tbody id="customersTableBody"> <tbody id="customersTableBody">
<tr> <tr>
<td colspan="7" class="text-center py-5"> <td colspan="6" class="text-center py-5">
<div class="spinner-border text-primary" role="status"> <div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span> <span class="visually-hidden">Loading...</span>
</div> </div>
@ -112,407 +65,181 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="d-flex justify-content-between align-items-center mt-3">
<!-- Pagination --> <div class="text-muted" id="customerCount">Loading...</div>
<div class="d-flex justify-content-between align-items-center mt-4"> <nav>
<div class="text-muted small"> <ul class="pagination mb-0" id="pagination"></ul>
Viser <span id="showingStart">0</span>-<span id="showingEnd">0</span> af <span id="totalCount">0</span> kunder </nav>
</div>
<div class="d-flex gap-2">
<button class="pagination-btn" id="prevBtn" onclick="previousPage()">
<i class="bi bi-chevron-left"></i> Forrige
</button>
<button class="pagination-btn" id="nextBtn" onclick="nextPage()">
Næste <i class="bi bi-chevron-right"></i>
</button>
</div>
</div> </div>
</div> </div>
<!-- Create Customer Modal -->
<div class="modal fade" id="createCustomerModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Opret Ny Kunde</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="createCustomerForm">
<!-- CVR Lookup Section -->
<div class="mb-4">
<label class="form-label">CVR-nummer</label>
<div class="input-group">
<input type="text" class="form-control" id="cvrInput" placeholder="12345678" maxlength="8">
<button class="btn btn-primary" type="button" id="cvrLookupBtn" onclick="lookupCVR()">
<i class="bi bi-search me-2"></i>Søg CVR
</button>
</div>
<div class="form-text">Indtast CVR-nummer for automatisk udfyldning</div>
<div id="cvrLookupStatus" class="mt-2"></div>
</div>
<hr class="my-4">
<div class="row g-3">
<div class="col-md-12">
<label class="form-label">Virksomhedsnavn <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="nameInput" required>
</div>
<div class="col-md-8">
<label class="form-label">Adresse</label>
<input type="text" class="form-control" id="addressInput">
</div>
<div class="col-md-4">
<label class="form-label">Postnummer</label>
<input type="text" class="form-control" id="postalCodeInput">
</div>
<div class="col-md-6">
<label class="form-label">By</label>
<input type="text" class="form-control" id="cityInput">
</div>
<div class="col-md-6">
<label class="form-label">Email</label>
<input type="email" class="form-control" id="emailInput">
</div>
<div class="col-md-6">
<label class="form-label">Telefon</label>
<input type="text" class="form-control" id="phoneInput">
</div>
<div class="col-md-6">
<label class="form-label">Hjemmeside</label>
<input type="url" class="form-control" id="websiteInput" placeholder="https://">
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isActiveInput" checked>
<label class="form-check-label" for="isActiveInput">
Aktiv kunde
</label>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="createCustomer()">
<i class="bi bi-plus-lg me-2"></i>Opret Kunde
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script> <script>
let currentPage = 0; let currentPage = 1;
let pageSize = 20; const pageSize = 50;
let currentFilter = 'all';
let searchQuery = '';
let totalCustomers = 0; let totalCustomers = 0;
let searchTerm = '';
let searchTimeout = null;
// Load customers on page load // Load customers on page load
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadCustomers(); loadCustomers();
// Setup search with debounce
const searchInput = document.getElementById('searchInput'); const searchInput = document.getElementById('searchInput');
// Search with debounce
let searchTimeout;
searchInput.addEventListener('input', (e) => { searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => { searchTimeout = setTimeout(() => {
searchQuery = e.target.value; searchTerm = e.target.value;
currentPage = 0; loadCustomers(1);
console.log('🔍 Searching for:', searchQuery);
loadCustomers();
}, 300); }, 300);
}); });
}); });
// Cmd+K / Ctrl+K keyboard shortcut (outside DOMContentLoaded so it works everywhere) async function loadCustomers(page = 1) {
document.addEventListener('keydown', (e) => { currentPage = page;
if ((e.metaKey || e.ctrlKey) && e.key === 'k') { const offset = (page - 1) * pageSize;
e.preventDefault();
const searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.focus();
searchInput.select();
}
}
});
function setFilter(filter) {
currentFilter = filter;
currentPage = 0;
// Update active button
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.remove('active');
});
event.target.classList.add('active');
loadCustomers();
}
async function loadCustomers() {
const tbody = document.getElementById('customersTableBody');
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-5"><div class="spinner-border text-primary"></div></td></tr>';
try { try {
// Build query parameters let url = `/api/v1/customers?limit=${pageSize}&offset=${offset}`;
let params = new URLSearchParams({ if (searchTerm) {
limit: pageSize, url += `&search=${encodeURIComponent(searchTerm)}`;
offset: currentPage * pageSize
});
if (searchQuery) {
params.append('search', searchQuery);
console.log('📤 Sending search query:', searchQuery);
} }
const response = await fetch(url);
if (currentFilter === 'active') {
params.append('is_active', 'true');
} else if (currentFilter === 'inactive') {
params.append('is_active', 'false');
} else if (currentFilter === 'vtiger' || currentFilter === 'local') {
params.append('source', currentFilter);
}
const response = await fetch(`/api/v1/customers?${params}`);
const data = await response.json(); const data = await response.json();
totalCustomers = data.total; totalCustomers = data.total;
displayCustomers(data.customers); renderCustomers(data.customers);
updatePagination(data.total); renderPagination();
updateCount();
} catch (error) { } catch (error) {
console.error('Failed to load customers:', error); console.error('Error loading customers:', error);
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-5 text-danger">Kunne ikke indlæse kunder</td></tr>'; document.getElementById('customersTableBody').innerHTML = `
<tr><td colspan="6" class="text-center text-danger py-5">
❌ Fejl ved indlæsning: ${error.message}
</td></tr>
`;
} }
} }
function displayCustomers(customers) { function renderCustomers(customers) {
const tbody = document.getElementById('customersTableBody'); const tbody = document.getElementById('customersTableBody');
if (!customers || customers.length === 0) { if (!customers || customers.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-5 text-muted">Ingen kunder fundet</td></tr>'; tbody.innerHTML = `
<tr><td colspan="6" class="text-center text-muted py-5">
Ingen kunder fundet
</td></tr>
`;
return; return;
} }
tbody.innerHTML = customers.map(customer => { tbody.innerHTML = customers.map(customer => {
const initials = getInitials(customer.name); const initials = customer.name ? customer.name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase() : '??';
const statusBadge = customer.is_active const statusBadge = customer.is_active ?
? '<span class="badge bg-success bg-opacity-10 text-success">Aktiv</span>' '<span class="badge bg-success bg-opacity-10 text-success">Aktiv</span>' :
: '<span class="badge bg-secondary bg-opacity-10 text-secondary">Inaktiv</span>'; '<span class="badge bg-secondary bg-opacity-10 text-secondary">Inaktiv</span>';
const sourceBadge = customer.vtiger_id
? '<span class="badge bg-primary bg-opacity-10 text-primary"><i class="bi bi-cloud me-1"></i>vTiger</span>'
: '<span class="badge bg-secondary bg-opacity-10 text-secondary"><i class="bi bi-hdd me-1"></i>Lokal</span>';
const contactCount = customer.contact_count || 0;
return ` return `
<tr style="cursor: pointer;" onclick="viewCustomer(${customer.id})"> <tr onclick="window.location.href='/customers/${customer.id}'" style="cursor: pointer;">
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="customer-avatar me-3">${initials}</div> <div class="rounded bg-light d-flex align-items-center justify-content-center me-3 fw-bold"
style="width: 40px; height: 40px; color: var(--accent);">
${initials}
</div>
<div> <div>
<div class="fw-bold">${escapeHtml(customer.name)}</div> <div class="fw-bold">${customer.name || '-'}</div>
<div class="small text-muted">${customer.city || customer.address || '-'}</div> <div class="small text-muted">${customer.address || '-'}</div>
</div> </div>
</div> </div>
</td> </td>
<td> <td>
<div class="fw-medium">${customer.email || '-'}</div> <div class="fw-medium">${customer.contact_name || '-'}</div>
<div class="small text-muted">${customer.phone || '-'}</div> <div class="small text-muted">${customer.contact_phone || '-'}</div>
</td> </td>
<td class="text-muted">${customer.cvr_number || '-'}</td> <td class="text-muted">${customer.cvr_number || '-'}</td>
<td>${sourceBadge}</td>
<td>${statusBadge}</td> <td>${statusBadge}</td>
<td> <td class="text-muted">${customer.email || '-'}</td>
<span class="badge bg-light text-dark border">
<i class="bi bi-person me-1"></i>${contactCount}
</span>
</td>
<td class="text-end"> <td class="text-end">
<div class="btn-group"> <button class="btn btn-sm btn-outline-primary"
<button class="btn btn-sm btn-light" onclick="event.stopPropagation(); viewCustomer(${customer.id})"> onclick="event.stopPropagation(); window.location.href='/customers/${customer.id}'"
<i class="bi bi-eye"></i> title="Se detaljer">
</button> <i class="bi bi-arrow-right"></i>
<button class="btn btn-sm btn-light" onclick="event.stopPropagation(); editCustomer(${customer.id})"> </button>
<i class="bi bi-pencil"></i>
</button>
</div>
</td> </td>
</tr> </tr>
`; `;
}).join(''); }).join('');
} }
function updatePagination(total) { function renderPagination() {
const start = currentPage * pageSize + 1; const totalPages = Math.ceil(totalCustomers / pageSize);
const end = Math.min((currentPage + 1) * pageSize, total); const pagination = document.getElementById('pagination');
document.getElementById('showingStart').textContent = total > 0 ? start : 0; if (totalPages <= 1) {
document.getElementById('showingEnd').textContent = end; pagination.innerHTML = '';
document.getElementById('totalCount').textContent = total;
// Update buttons
document.getElementById('prevBtn').disabled = currentPage === 0;
document.getElementById('nextBtn').disabled = end >= total;
}
function previousPage() {
if (currentPage > 0) {
currentPage--;
loadCustomers();
}
}
function nextPage() {
if ((currentPage + 1) * pageSize < totalCustomers) {
currentPage++;
loadCustomers();
}
}
function viewCustomer(customerId) {
window.location.href = `/customers/${customerId}`;
}
function editCustomer(customerId) {
// TODO: Open edit modal
console.log('Edit customer:', customerId);
}
function showCreateCustomerModal() {
// Reset form
document.getElementById('createCustomerForm').reset();
document.getElementById('cvrLookupStatus').innerHTML = '';
document.getElementById('isActiveInput').checked = true;
// Show modal
const modal = new bootstrap.Modal(document.getElementById('createCustomerModal'));
modal.show();
}
async function lookupCVR() {
const cvrInput = document.getElementById('cvrInput');
const cvr = cvrInput.value.trim();
const statusDiv = document.getElementById('cvrLookupStatus');
const lookupBtn = document.getElementById('cvrLookupBtn');
if (!cvr || cvr.length !== 8) {
statusDiv.innerHTML = '<div class="alert alert-warning mb-0"><i class="bi bi-exclamation-triangle me-2"></i>Indtast et gyldigt 8-cifret CVR-nummer</div>';
return; return;
} }
// Show loading state let pages = [];
lookupBtn.disabled = true;
lookupBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Søger...';
statusDiv.innerHTML = '<div class="alert alert-info mb-0"><i class="bi bi-hourglass-split me-2"></i>Henter virksomhedsoplysninger...</div>';
try { // Previous button
const response = await fetch(`/api/v1/cvr/${cvr}`); pages.push(`
<li class="page-item ${currentPage === 1 ? 'disabled' : ''}">
if (!response.ok) { <a class="page-link" href="#" onclick="loadCustomers(${currentPage - 1}); return false;">
throw new Error('CVR ikke fundet'); <i class="bi bi-chevron-left"></i>
</a>
</li>
`);
// Page numbers (show max 7 pages)
let startPage = Math.max(1, currentPage - 3);
let endPage = Math.min(totalPages, startPage + 6);
if (endPage - startPage < 6) {
startPage = Math.max(1, endPage - 6);
}
if (startPage > 1) {
pages.push(`<li class="page-item"><a class="page-link" href="#" onclick="loadCustomers(1); return false;">1</a></li>`);
if (startPage > 2) {
pages.push(`<li class="page-item disabled"><span class="page-link">...</span></li>`);
} }
const data = await response.json();
// Auto-fill form fields
document.getElementById('nameInput').value = data.name || '';
document.getElementById('addressInput').value = data.address || '';
document.getElementById('postalCodeInput').value = data.postal_code || '';
document.getElementById('cityInput').value = data.city || '';
document.getElementById('phoneInput').value = data.phone || '';
document.getElementById('emailInput').value = data.email || '';
statusDiv.innerHTML = '<div class="alert alert-success mb-0"><i class="bi bi-check-circle me-2"></i>Virksomhedsoplysninger hentet fra CVR-registeret</div>';
} catch (error) {
console.error('CVR lookup failed:', error);
statusDiv.innerHTML = '<div class="alert alert-danger mb-0"><i class="bi bi-x-circle me-2"></i>Kunne ikke finde virksomhed med CVR-nummer ' + cvr + '</div>';
} finally {
lookupBtn.disabled = false;
lookupBtn.innerHTML = '<i class="bi bi-search me-2"></i>Søg CVR';
}
}
async function createCustomer() {
const name = document.getElementById('nameInput').value.trim();
if (!name) {
alert('Virksomhedsnavn er påkrævet');
return;
} }
const customerData = { for (let i = startPage; i <= endPage; i++) {
name: name, pages.push(`
cvr_number: document.getElementById('cvrInput').value.trim() || null, <li class="page-item ${i === currentPage ? 'active' : ''}">
address: document.getElementById('addressInput').value.trim() || null, <a class="page-link" href="#" onclick="loadCustomers(${i}); return false;">${i}</a>
postal_code: document.getElementById('postalCodeInput').value.trim() || null, </li>
city: document.getElementById('cityInput').value.trim() || null, `);
email: document.getElementById('emailInput').value.trim() || null, }
phone: document.getElementById('phoneInput').value.trim() || null,
website: document.getElementById('websiteInput').value.trim() || null,
is_active: document.getElementById('isActiveInput').checked
};
try { if (endPage < totalPages) {
const response = await fetch('/api/v1/customers', { if (endPage < totalPages - 1) {
method: 'POST', pages.push(`<li class="page-item disabled"><span class="page-link">...</span></li>`);
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(customerData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Kunne ikke oprette kunde');
} }
pages.push(`<li class="page-item"><a class="page-link" href="#" onclick="loadCustomers(${totalPages}); return false;">${totalPages}</a></li>`);
const newCustomer = await response.json();
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('createCustomerModal'));
modal.hide();
// Reload customer list
await loadCustomers();
// Show success message (optional)
alert('Kunde oprettet succesfuldt!');
} catch (error) {
console.error('Failed to create customer:', error);
alert('Fejl ved oprettelse af kunde: ' + error.message);
} }
// Next button
pages.push(`
<li class="page-item ${currentPage === totalPages ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="loadCustomers(${currentPage + 1}); return false;">
<i class="bi bi-chevron-right"></i>
</a>
</li>
`);
pagination.innerHTML = pages.join('');
} }
function getInitials(name) { function updateCount() {
if (!name) return '?'; const start = (currentPage - 1) * pageSize + 1;
const words = name.trim().split(' '); const end = Math.min(currentPage * pageSize, totalCustomers);
if (words.length === 1) return words[0].substring(0, 2).toUpperCase(); document.getElementById('customerCount').textContent =
return (words[0][0] + words[words.length - 1][0]).toUpperCase(); `Viser ${start}-${end} af ${totalCustomers} kunder`;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
} }
</script> </script>
{% endblock %} {% endblock %}

View File

@ -17,22 +17,22 @@ async def get_dashboard_stats():
# 1. Customer Counts # 1. Customer Counts
logger.info("Fetching customer count...") logger.info("Fetching customer count...")
customer_res = execute_query("SELECT COUNT(*) as count FROM customers WHERE deleted_at IS NULL", fetchone=True) customer_res = execute_query_single("SELECT COUNT(*) as count FROM customers WHERE deleted_at IS NULL")
customer_count = customer_res['count'] if customer_res else 0 customer_count = customer_res['count'] if customer_res else 0
# 2. Contact Counts # 2. Contact Counts
logger.info("Fetching contact count...") logger.info("Fetching contact count...")
contact_res = execute_query("SELECT COUNT(*) as count FROM contacts", fetchone=True) contact_res = execute_query_single("SELECT COUNT(*) as count FROM contacts")
contact_count = contact_res['count'] if contact_res else 0 contact_count = contact_res['count'] if contact_res else 0
# 3. Vendor Counts # 3. Vendor Counts
logger.info("Fetching vendor count...") logger.info("Fetching vendor count...")
vendor_res = execute_query("SELECT COUNT(*) as count FROM vendors", fetchone=True) vendor_res = execute_query_single("SELECT COUNT(*) as count FROM vendors")
vendor_count = vendor_res['count'] if vendor_res else 0 vendor_count = vendor_res['count'] if vendor_res else 0
# 4. Recent Customers (Real "Activity") # 4. Recent Customers (Real "Activity")
logger.info("Fetching recent customers...") logger.info("Fetching recent customers...")
recent_customers = execute_query(""" recent_customers = execute_query_single("""
SELECT id, name, created_at, 'customer' as type SELECT id, name, created_at, 'customer' as type
FROM customers FROM customers
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
@ -154,7 +154,7 @@ async def get_live_stats():
# Try to get real customer count as a demo # Try to get real customer count as a demo
try: try:
customer_count = execute_query("SELECT COUNT(*) as count FROM customers WHERE deleted_at IS NULL", fetchone=True) customer_count = execute_query("SELECT COUNT(*) as count FROM customers WHERE deleted_at IS NULL")
sales_stats["active_orders"] = customer_count.get('count', 0) if customer_count else 0 sales_stats["active_orders"] = customer_count.get('count', 0) if customer_count else 0
except: except:
pass pass

View File

@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="app") templates = Jinja2Templates(directory="app")
@router.get("/dashboard", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request): async def dashboard(request: Request):
""" """
Render the dashboard page Render the dashboard page

View File

@ -9,217 +9,123 @@
<p class="text-muted mb-0">Velkommen tilbage, Christian</p> <p class="text-muted mb-0">Velkommen tilbage, Christian</p>
</div> </div>
<div class="d-flex gap-3"> <div class="d-flex gap-3">
<div class="input-group"> <input type="text" class="header-search" placeholder="Søg...">
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search"></i></span> <button class="btn btn-primary"><i class="bi bi-plus-lg me-2"></i>Ny Opgave</button>
<input type="text" id="dashboardSearchInput" class="form-control border-start-0 ps-0" placeholder="Søg i alt... (⌘K)" style="max-width: 250px;" role="button">
</div>
<div class="dropdown">
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="bi bi-plus-lg me-2"></i>Ny Oprettelse
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/customers"><i class="bi bi-building me-2"></i>Ny Kunde</a></li>
<li><a class="dropdown-item" href="/contacts"><i class="bi bi-person me-2"></i>Ny Kontakt</a></li>
<li><a class="dropdown-item" href="/vendors"><i class="bi bi-shop me-2"></i>Ny Leverandør</a></li>
</ul>
</div>
</div> </div>
</div> </div>
<!-- 1. Live Metrics Cards -->
<div class="row g-4 mb-5"> <div class="row g-4 mb-5">
<div class="col-md-3"> <div class="col-md-3">
<div class="card stat-card p-4 h-100"> <div class="card stat-card p-4 h-100">
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between mb-2">
<p>Kunder</p> <p>Aktive Kunder</p>
<i class="bi bi-building text-primary" style="color: var(--accent) !important;"></i>
</div>
<h3 id="customerCount">-</h3>
<small class="text-success"><i class="bi bi-check-circle"></i> Aktive i systemet</small>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card p-4 h-100">
<div class="d-flex justify-content-between mb-2">
<p>Kontakter</p>
<i class="bi bi-people text-primary" style="color: var(--accent) !important;"></i> <i class="bi bi-people text-primary" style="color: var(--accent) !important;"></i>
</div> </div>
<h3 id="contactCount">-</h3> <h3>124</h3>
<small class="text-muted">Tilknyttede personer</small> <small class="text-success"><i class="bi bi-arrow-up-short"></i> 12% denne måned</small>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<div class="card stat-card p-4 h-100"> <div class="card stat-card p-4 h-100">
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between mb-2">
<p>Leverandører</p> <p>Hardware</p>
<i class="bi bi-shop text-primary" style="color: var(--accent) !important;"></i> <i class="bi bi-hdd text-primary" style="color: var(--accent) !important;"></i>
</div> </div>
<h3 id="vendorCount">-</h3> <h3>856</h3>
<small class="text-muted">Aktive leverandøraftaler</small> <small class="text-muted">Enheder online</small>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<div class="card stat-card p-4 h-100"> <div class="card stat-card p-4 h-100">
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between mb-2">
<p>System Status</p> <p>Support</p>
<i class="bi bi-cpu text-primary" style="color: var(--accent) !important;"></i> <i class="bi bi-ticket text-primary" style="color: var(--accent) !important;"></i>
</div> </div>
<h3 id="systemStatus" class="text-success">Online</h3> <h3>12</h3>
<small class="text-muted" id="systemVersion">v1.0.0</small> <small class="text-warning">3 kræver handling</small>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card p-4 h-100">
<div class="d-flex justify-content-between mb-2">
<p>Omsætning</p>
<i class="bi bi-currency-dollar text-primary" style="color: var(--accent) !important;"></i>
</div>
<h3>450k</h3>
<small class="text-success">Over budget</small>
</div> </div>
</div> </div>
</div> </div>
<div class="row g-4"> <div class="row g-4">
<!-- 2. Recent Activity (New Customers) -->
<div class="col-lg-8"> <div class="col-lg-8">
<div class="card p-4 h-100"> <div class="card p-4">
<div class="d-flex justify-content-between align-items-center mb-4"> <h5 class="fw-bold mb-4">Seneste Aktiviteter</h5>
<h5 class="fw-bold mb-0">Seneste Tilføjelser</h5>
<a href="/customers" class="btn btn-sm btn-light">Se alle</a>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle"> <table class="table table-hover align-middle">
<thead> <thead>
<tr> <tr>
<th>Navn</th> <th>Kunde</th>
<th>Type</th> <th>Handling</th>
<th>Oprettet</th> <th>Status</th>
<th class="text-end">Handling</th> <th class="text-end">Tid</th>
</tr> </tr>
</thead> </thead>
<tbody id="recentActivityTable"> <tbody>
<tr> <tr>
<td colspan="4" class="text-center py-4"> <td class="fw-bold">Advokatgruppen A/S</td>
<div class="spinner-border text-primary" role="status"></div> <td>Firewall konfiguration</td>
</td> <td><span class="badge bg-success bg-opacity-10 text-success">Fuldført</span></td>
<td class="text-end text-muted">10:23</td>
</tr>
<tr>
<td class="fw-bold">Byg & Bo ApS</td>
<td>Licens fornyelse</td>
<td><span class="badge bg-warning bg-opacity-10 text-warning">Afventer</span></td>
<td class="text-end text-muted">I går</td>
</tr>
<tr>
<td class="fw-bold">Cafe Møller</td>
<td>Netværksnedbrud</td>
<td><span class="badge bg-danger bg-opacity-10 text-danger">Kritisk</span></td>
<td class="text-end text-muted">I går</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
<!-- 3. Vendor Distribution & Quick Links -->
<div class="col-lg-4"> <div class="col-lg-4">
<div class="card p-4 mb-4"> <div class="card p-4 h-100">
<h5 class="fw-bold mb-4">Leverandør Fordeling</h5> <h5 class="fw-bold mb-4">System Status</h5>
<div id="vendorDistribution">
<div class="text-center py-3"> <div class="mb-4">
<div class="spinner-border text-primary" role="status"></div> <div class="d-flex justify-content-between mb-2">
<span class="small fw-bold text-muted">CPU LOAD</span>
<span class="small fw-bold">24%</span>
</div>
<div class="progress" style="height: 8px; background-color: var(--accent-light);">
<div class="progress-bar" style="width: 24%; background-color: var(--accent);"></div>
</div> </div>
</div> </div>
</div>
<!-- 4. Quick Actions / Shortcuts --> <div class="mb-4">
<div class="card p-4"> <div class="d-flex justify-content-between mb-2">
<h5 class="fw-bold mb-3">Genveje</h5> <span class="small fw-bold text-muted">MEMORY</span>
<div class="d-grid gap-2"> <span class="small fw-bold">56%</span>
<a href="/settings" class="btn btn-light text-start p-3 d-flex align-items-center"> </div>
<div class="bg-white p-2 rounded me-3 shadow-sm"> <div class="progress" style="height: 8px; background-color: var(--accent-light);">
<i class="bi bi-gear text-primary"></i> <div class="progress-bar" style="width: 56%; background-color: var(--accent);"></div>
</div> </div>
<div> </div>
<div class="fw-bold">Indstillinger</div>
<small class="text-muted">Konfigurer systemet</small> <div class="mt-auto p-3 rounded" style="background-color: var(--accent-light);">
</div> <div class="d-flex">
</a> <i class="bi bi-check-circle-fill text-success me-2"></i>
<a href="/vendors" class="btn btn-light text-start p-3 d-flex align-items-center"> <small class="fw-bold" style="color: var(--accent)">Alle systemer kører optimalt.</small>
<div class="bg-white p-2 rounded me-3 shadow-sm"> </div>
<i class="bi bi-truck text-success"></i>
</div>
<div>
<div class="fw-bold">Leverandører</div>
<small class="text-muted">Administrer aftaler</small>
</div>
</a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %}
<script>
async function loadDashboardStats() {
try {
const response = await fetch('/api/v1/dashboard/stats');
const data = await response.json();
// Update Counts
document.getElementById('customerCount').textContent = data.counts.customers;
document.getElementById('contactCount').textContent = data.counts.contacts;
document.getElementById('vendorCount').textContent = data.counts.vendors;
// Update Recent Activity
const activityTable = document.getElementById('recentActivityTable');
if (data.recent_activity && data.recent_activity.length > 0) {
activityTable.innerHTML = data.recent_activity.map(item => `
<tr>
<td class="fw-bold">
<div class="d-flex align-items-center">
<div class="rounded-circle bg-light d-flex align-items-center justify-content-center me-3" style="width: 32px; height: 32px;">
<i class="bi bi-building text-primary"></i>
</div>
${item.name}
</div>
</td>
<td><span class="badge bg-primary bg-opacity-10 text-primary">Kunde</span></td>
<td class="text-muted">${new Date(item.created_at).toLocaleDateString('da-DK')}</td>
<td class="text-end">
<a href="/customers/${item.id}" class="btn btn-sm btn-light"><i class="bi bi-arrow-right"></i></a>
</td>
</tr>
`).join('');
} else {
activityTable.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-4">Ingen nylig aktivitet</td></tr>';
}
// Update Vendor Distribution
const vendorDist = document.getElementById('vendorDistribution');
if (data.vendor_distribution && data.vendor_distribution.length > 0) {
const total = data.counts.vendors;
vendorDist.innerHTML = data.vendor_distribution.map(cat => {
const percentage = Math.round((cat.count / total) * 100);
return `
<div class="mb-3">
<div class="d-flex justify-content-between mb-1">
<span class="small fw-bold">${cat.category || 'Ukendt'}</span>
<span class="small text-muted">${cat.count}</span>
</div>
<div class="progress" style="height: 6px;">
<div class="progress-bar" role="progressbar" style="width: ${percentage}%" aria-valuenow="${percentage}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
`;
}).join('');
} else {
vendorDist.innerHTML = '<p class="text-muted text-center">Ingen leverandørdata</p>';
}
} catch (error) {
console.error('Error loading dashboard stats:', error);
}
}
document.addEventListener('DOMContentLoaded', loadDashboardStats);
// Connect dashboard search input to global search modal
document.getElementById('dashboardSearchInput').addEventListener('click', () => {
const modalEl = document.getElementById('globalSearchModal');
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
// Focus input when modal opens
modalEl.addEventListener('shown.bs.modal', () => {
document.getElementById('globalSearchInput').focus();
}, { once: true });
});
// Also handle focus (e.g. via tab navigation)
document.getElementById('dashboardSearchInput').addEventListener('focus', (e) => {
e.target.click();
e.target.blur(); // Remove focus from this input so we don't get stuck in a loop or keep cursor here
});
</script>
{% endblock %}

View File

@ -76,14 +76,14 @@ async def get_features(version: Optional[str] = None, status: Optional[str] = No
params.append(status) params.append(status)
query += " ORDER BY priority DESC, expected_date ASC" query += " ORDER BY priority DESC, expected_date ASC"
result = execute_query(query, tuple(params) if params else None) result = execute_query_single(query, tuple(params) if params else None)
return result or [] return result or []
@router.get("/features/{feature_id}", response_model=Feature) @router.get("/features/{feature_id}", response_model=Feature)
async def get_feature(feature_id: int): async def get_feature(feature_id: int):
"""Get a specific feature""" """Get a specific feature"""
result = execute_query("SELECT * FROM dev_features WHERE id = %s", (feature_id,), fetchone=True) result = execute_query("SELECT * FROM dev_features WHERE id = %s", (feature_id,))
if not result: if not result:
raise HTTPException(status_code=404, detail="Feature not found") raise HTTPException(status_code=404, detail="Feature not found")
return result return result
@ -97,10 +97,10 @@ async def create_feature(feature: FeatureCreate):
VALUES (%s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s)
RETURNING * RETURNING *
""" """
result = execute_query(query, ( result = execute_query_single(query, (
feature.title, feature.description, feature.version, feature.title, feature.description, feature.version,
feature.status, feature.priority, feature.expected_date feature.status, feature.priority, feature.expected_date
), fetchone=True) ))
logger.info(f"✅ Created feature: {feature.title}") logger.info(f"✅ Created feature: {feature.title}")
return result return result
@ -116,10 +116,10 @@ async def update_feature(feature_id: int, feature: FeatureCreate):
WHERE id = %s WHERE id = %s
RETURNING * RETURNING *
""" """
result = execute_query(query, ( result = execute_query_single(query, (
feature.title, feature.description, feature.version, feature.title, feature.description, feature.version,
feature.status, feature.priority, feature.expected_date, feature_id feature.status, feature.priority, feature.expected_date, feature_id
), fetchone=True) ))
if not result: if not result:
raise HTTPException(status_code=404, detail="Feature not found") raise HTTPException(status_code=404, detail="Feature not found")
@ -131,7 +131,7 @@ async def update_feature(feature_id: int, feature: FeatureCreate):
@router.delete("/features/{feature_id}") @router.delete("/features/{feature_id}")
async def delete_feature(feature_id: int): async def delete_feature(feature_id: int):
"""Delete a roadmap feature""" """Delete a roadmap feature"""
result = execute_query("DELETE FROM dev_features WHERE id = %s RETURNING id", (feature_id,), fetchone=True) result = execute_query_single("DELETE FROM dev_features WHERE id = %s RETURNING id", (feature_id,))
if not result: if not result:
raise HTTPException(status_code=404, detail="Feature not found") raise HTTPException(status_code=404, detail="Feature not found")
@ -151,7 +151,7 @@ async def get_ideas(category: Optional[str] = None):
params.append(category) params.append(category)
query += " ORDER BY votes DESC, created_at DESC" query += " ORDER BY votes DESC, created_at DESC"
result = execute_query(query, tuple(params) if params else None) result = execute_query_single(query, tuple(params) if params else None)
return result or [] return result or []
@ -163,7 +163,7 @@ async def create_idea(idea: IdeaCreate):
VALUES (%s, %s, %s) VALUES (%s, %s, %s)
RETURNING * RETURNING *
""" """
result = execute_query(query, (idea.title, idea.description, idea.category), fetchone=True) result = execute_query(query, (idea.title, idea.description, idea.category))
logger.info(f"✅ Created idea: {idea.title}") logger.info(f"✅ Created idea: {idea.title}")
return result return result
@ -178,7 +178,7 @@ async def vote_idea(idea_id: int):
WHERE id = %s WHERE id = %s
RETURNING * RETURNING *
""" """
result = execute_query(query, (idea_id,), fetchone=True) result = execute_query_single(query, (idea_id,))
if not result: if not result:
raise HTTPException(status_code=404, detail="Idea not found") raise HTTPException(status_code=404, detail="Idea not found")
@ -189,7 +189,7 @@ async def vote_idea(idea_id: int):
@router.delete("/ideas/{idea_id}") @router.delete("/ideas/{idea_id}")
async def delete_idea(idea_id: int): async def delete_idea(idea_id: int):
"""Delete an idea""" """Delete an idea"""
result = execute_query("DELETE FROM dev_ideas WHERE id = %s RETURNING id", (idea_id,), fetchone=True) result = execute_query_single("DELETE FROM dev_ideas WHERE id = %s RETURNING id", (idea_id,))
if not result: if not result:
raise HTTPException(status_code=404, detail="Idea not found") raise HTTPException(status_code=404, detail="Idea not found")
@ -209,14 +209,14 @@ async def get_workflows(category: Optional[str] = None):
params.append(category) params.append(category)
query += " ORDER BY created_at DESC" query += " ORDER BY created_at DESC"
result = execute_query(query, tuple(params) if params else None) result = execute_query_single(query, tuple(params) if params else None)
return result or [] return result or []
@router.get("/workflows/{workflow_id}", response_model=Workflow) @router.get("/workflows/{workflow_id}", response_model=Workflow)
async def get_workflow(workflow_id: int): async def get_workflow(workflow_id: int):
"""Get a specific workflow""" """Get a specific workflow"""
result = execute_query("SELECT * FROM dev_workflows WHERE id = %s", (workflow_id,), fetchone=True) result = execute_query("SELECT * FROM dev_workflows WHERE id = %s", (workflow_id,))
if not result: if not result:
raise HTTPException(status_code=404, detail="Workflow not found") raise HTTPException(status_code=404, detail="Workflow not found")
return result return result
@ -230,9 +230,9 @@ async def create_workflow(workflow: WorkflowCreate):
VALUES (%s, %s, %s, %s) VALUES (%s, %s, %s, %s)
RETURNING * RETURNING *
""" """
result = execute_query(query, ( result = execute_query_single(query, (
workflow.title, workflow.description, workflow.category, workflow.diagram_xml workflow.title, workflow.description, workflow.category, workflow.diagram_xml
), fetchone=True) ))
logger.info(f"✅ Created workflow: {workflow.title}") logger.info(f"✅ Created workflow: {workflow.title}")
return result return result
@ -247,10 +247,10 @@ async def update_workflow(workflow_id: int, workflow: WorkflowCreate):
WHERE id = %s WHERE id = %s
RETURNING * RETURNING *
""" """
result = execute_query(query, ( result = execute_query_single(query, (
workflow.title, workflow.description, workflow.category, workflow.title, workflow.description, workflow.category,
workflow.diagram_xml, workflow_id workflow.diagram_xml, workflow_id
), fetchone=True) ))
if not result: if not result:
raise HTTPException(status_code=404, detail="Workflow not found") raise HTTPException(status_code=404, detail="Workflow not found")
@ -262,7 +262,7 @@ async def update_workflow(workflow_id: int, workflow: WorkflowCreate):
@router.delete("/workflows/{workflow_id}") @router.delete("/workflows/{workflow_id}")
async def delete_workflow(workflow_id: int): async def delete_workflow(workflow_id: int):
"""Delete a workflow""" """Delete a workflow"""
result = execute_query("DELETE FROM dev_workflows WHERE id = %s RETURNING id", (workflow_id,), fetchone=True) result = execute_query_single("DELETE FROM dev_workflows WHERE id = %s RETURNING id", (workflow_id,))
if not result: if not result:
raise HTTPException(status_code=404, detail="Workflow not found") raise HTTPException(status_code=404, detail="Workflow not found")
@ -274,9 +274,9 @@ async def delete_workflow(workflow_id: int):
@router.get("/stats") @router.get("/stats")
async def get_devportal_stats(): async def get_devportal_stats():
"""Get DEV Portal statistics""" """Get DEV Portal statistics"""
features_count = execute_query("SELECT COUNT(*) as count FROM dev_features", fetchone=True) features_count = execute_query_single("SELECT COUNT(*) as count FROM dev_features")
ideas_count = execute_query("SELECT COUNT(*) as count FROM dev_ideas", fetchone=True) ideas_count = execute_query_single("SELECT COUNT(*) as count FROM dev_ideas")
workflows_count = execute_query("SELECT COUNT(*) as count FROM dev_workflows", fetchone=True) workflows_count = execute_query_single("SELECT COUNT(*) as count FROM dev_workflows")
features_by_status = execute_query(""" features_by_status = execute_query("""
SELECT status, COUNT(*) as count SELECT status, COUNT(*) as count

View File

@ -183,7 +183,7 @@ async def list_emails(
""" """
params.extend([limit, offset]) params.extend([limit, offset])
result = execute_query(query, tuple(params)) result = execute_query_single(query, tuple(params))
return result return result
@ -241,7 +241,7 @@ async def mark_email_processed(email_id: int):
WHERE id = %s AND deleted_at IS NULL WHERE id = %s AND deleted_at IS NULL
RETURNING id, folder, status RETURNING id, folder, status
""" """
result = execute_query(update_query, (email_id,), fetchone=True) result = execute_query(update_query, (email_id,))
if not result: if not result:
raise HTTPException(status_code=404, detail="Email not found") raise HTTPException(status_code=404, detail="Email not found")
@ -274,7 +274,7 @@ async def download_attachment(email_id: int, attachment_id: int):
JOIN email_messages e ON e.id = a.email_id JOIN email_messages e ON e.id = a.email_id
WHERE a.id = %s AND a.email_id = %s AND e.deleted_at IS NULL WHERE a.id = %s AND a.email_id = %s AND e.deleted_at IS NULL
""" """
result = execute_query(query, (attachment_id, email_id)) result = execute_query_single(query, (attachment_id, email_id))
if not result: if not result:
raise HTTPException(status_code=404, detail="Attachment not found") raise HTTPException(status_code=404, detail="Attachment not found")
@ -717,7 +717,7 @@ async def get_workflow(workflow_id: int):
"""Get specific workflow by ID""" """Get specific workflow by ID"""
try: try:
query = "SELECT * FROM email_workflows WHERE id = %s" query = "SELECT * FROM email_workflows WHERE id = %s"
result = execute_query(query, (workflow_id,), fetchone=True) result = execute_query(query, (workflow_id,))
if not result: if not result:
raise HTTPException(status_code=404, detail="Workflow not found") raise HTTPException(status_code=404, detail="Workflow not found")
@ -745,7 +745,7 @@ async def create_workflow(workflow: EmailWorkflow):
RETURNING * RETURNING *
""" """
result = execute_query(query, ( result = execute_query_single(query, (
workflow.name, workflow.name,
workflow.description, workflow.description,
workflow.classification_trigger, workflow.classification_trigger,
@ -756,7 +756,7 @@ async def create_workflow(workflow: EmailWorkflow):
workflow.priority, workflow.priority,
workflow.enabled, workflow.enabled,
workflow.stop_on_match workflow.stop_on_match
), fetchone=True) ))
if result: if result:
logger.info(f"✅ Created workflow: {workflow.name}") logger.info(f"✅ Created workflow: {workflow.name}")
@ -791,7 +791,7 @@ async def update_workflow(workflow_id: int, workflow: EmailWorkflow):
RETURNING * RETURNING *
""" """
result = execute_query(query, ( result = execute_query_single(query, (
workflow.name, workflow.name,
workflow.description, workflow.description,
workflow.classification_trigger, workflow.classification_trigger,
@ -803,7 +803,7 @@ async def update_workflow(workflow_id: int, workflow: EmailWorkflow):
workflow.enabled, workflow.enabled,
workflow.stop_on_match, workflow.stop_on_match,
workflow_id workflow_id
), fetchone=True) ))
if result: if result:
logger.info(f"✅ Updated workflow {workflow_id}") logger.info(f"✅ Updated workflow {workflow_id}")
@ -841,7 +841,7 @@ async def toggle_workflow(workflow_id: int):
WHERE id = %s WHERE id = %s
RETURNING enabled RETURNING enabled
""" """
result = execute_query(query, (workflow_id,), fetchone=True) result = execute_query_single(query, (workflow_id,))
if not result: if not result:
raise HTTPException(status_code=404, detail="Workflow not found") raise HTTPException(status_code=404, detail="Workflow not found")
@ -873,7 +873,7 @@ async def execute_workflows_for_email(email_id: int):
FROM email_messages FROM email_messages
WHERE id = %s AND deleted_at IS NULL WHERE id = %s AND deleted_at IS NULL
""" """
email_data = execute_query(query, (email_id,), fetchone=True) email_data = execute_query_single(query, (email_id,))
if not email_data: if not email_data:
raise HTTPException(status_code=404, detail="Email not found") raise HTTPException(status_code=404, detail="Email not found")

View File

@ -3,11 +3,10 @@ Pydantic Models and Schemas
""" """
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List from typing import Optional
from datetime import datetime from datetime import datetime
# Customer Schemas
class CustomerBase(BaseModel): class CustomerBase(BaseModel):
"""Base customer schema""" """Base customer schema"""
name: str name: str
@ -16,31 +15,9 @@ class CustomerBase(BaseModel):
address: Optional[str] = None address: Optional[str] = None
class CustomerCreate(BaseModel): class CustomerCreate(CustomerBase):
"""Schema for creating a customer""" """Schema for creating a customer"""
name: str pass
cvr_number: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
address: Optional[str] = None
postal_code: Optional[str] = None
city: Optional[str] = None
website: Optional[str] = None
is_active: bool = True
class CustomerUpdate(BaseModel):
"""Schema for updating a customer"""
name: Optional[str] = None
cvr_number: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
address: Optional[str] = None
postal_code: Optional[str] = None
city: Optional[str] = None
website: Optional[str] = None
is_active: Optional[bool] = None
subscriptions_locked: Optional[bool] = None
class Customer(CustomerBase): class Customer(CustomerBase):
@ -53,70 +30,6 @@ class Customer(CustomerBase):
from_attributes = True from_attributes = True
# Contact Schemas
class ContactBase(BaseModel):
"""Base contact schema"""
first_name: str
last_name: str
email: Optional[str] = None
phone: Optional[str] = None
mobile: Optional[str] = None
title: Optional[str] = None
department: Optional[str] = None
class ContactCreate(ContactBase):
"""Schema for creating a contact"""
company_ids: List[int] = [] # List of customer IDs to link to
is_primary: bool = False # Whether this is the primary contact for first company
role: Optional[str] = None
notes: Optional[str] = None
is_active: bool = True
class ContactUpdate(BaseModel):
"""Schema for updating a contact"""
first_name: Optional[str] = None
last_name: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
mobile: Optional[str] = None
title: Optional[str] = None
department: Optional[str] = None
is_active: Optional[bool] = None
class ContactCompanyLink(BaseModel):
"""Schema for linking/unlinking a contact to a company"""
customer_id: int
is_primary: bool = False
role: Optional[str] = None
notes: Optional[str] = None
class CompanyInfo(BaseModel):
"""Schema for company information in contact context"""
id: int
name: str
is_primary: bool
role: Optional[str] = None
notes: Optional[str] = None
class Contact(ContactBase):
"""Full contact schema"""
id: int
is_active: bool
vtiger_id: Optional[str] = None
created_at: datetime
updated_at: Optional[datetime] = None
companies: List[CompanyInfo] = [] # List of linked companies
class Config:
from_attributes = True
# Hardware Schemas
class HardwareBase(BaseModel): class HardwareBase(BaseModel):
"""Base hardware schema""" """Base hardware schema"""
serial_number: str serial_number: str
@ -138,46 +51,32 @@ class Hardware(HardwareBase):
from_attributes = True from_attributes = True
# Vendor Schemas
class VendorBase(BaseModel): class VendorBase(BaseModel):
"""Base vendor schema""" """Base vendor schema"""
name: str name: str
email: Optional[str] = None
phone: Optional[str] = None
class VendorCreate(BaseModel):
"""Schema for creating a vendor"""
name: str
cvr_number: Optional[str] = None cvr_number: Optional[str] = None
domain: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
phone: Optional[str] = None phone: Optional[str] = None
address: Optional[str] = None contact_person: Optional[str] = None
postal_code: Optional[str] = None category: Optional[str] = None
city: Optional[str] = None
website: Optional[str] = None
domain: Optional[str] = None
email_pattern: Optional[str] = None
category: str = 'general'
priority: int = 50
notes: Optional[str] = None notes: Optional[str] = None
is_active: bool = True
class VendorCreate(VendorBase):
"""Schema for creating a vendor"""
pass
class VendorUpdate(BaseModel): class VendorUpdate(BaseModel):
"""Schema for updating a vendor""" """Schema for updating a vendor"""
name: Optional[str] = None name: Optional[str] = None
cvr_number: Optional[str] = None cvr_number: Optional[str] = None
domain: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
phone: Optional[str] = None phone: Optional[str] = None
address: Optional[str] = None contact_person: Optional[str] = None
postal_code: Optional[str] = None
city: Optional[str] = None
website: Optional[str] = None
domain: Optional[str] = None
email_pattern: Optional[str] = None
category: Optional[str] = None category: Optional[str] = None
priority: Optional[int] = None
notes: Optional[str] = None notes: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
@ -185,20 +84,9 @@ class VendorUpdate(BaseModel):
class Vendor(VendorBase): class Vendor(VendorBase):
"""Full vendor schema""" """Full vendor schema"""
id: int id: int
cvr_number: Optional[str] = None is_active: bool = True
address: Optional[str] = None
postal_code: Optional[str] = None
city: Optional[str] = None
country: Optional[str] = None
website: Optional[str] = None
domain: Optional[str] = None
category: str
priority: int
notes: Optional[str] = None
is_active: bool
created_at: datetime created_at: datetime
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
class Config: class Config:
from_attributes = True from_attributes = True

View File

@ -29,7 +29,7 @@ async def get_items():
read_only = get_module_config("template_module", "READ_ONLY", "true") == "true" read_only = get_module_config("template_module", "READ_ONLY", "true") == "true"
# Hent items (bemærk table_prefix) # Hent items (bemærk table_prefix)
items = execute_query( items = execute_query_single(
"SELECT * FROM template_items ORDER BY created_at DESC" "SELECT * FROM template_items ORDER BY created_at DESC"
) )
@ -58,9 +58,7 @@ async def get_item(item_id: int):
try: try:
item = execute_query( item = execute_query(
"SELECT * FROM template_items WHERE id = %s", "SELECT * FROM template_items WHERE id = %s",
(item_id,), (item_id,))
fetchone=True
)
if not item: if not item:
raise HTTPException(status_code=404, detail="Item not found") raise HTTPException(status_code=404, detail="Item not found")
@ -245,7 +243,7 @@ async def health_check():
""" """
try: try:
# Test database connectivity # Test database connectivity
result = execute_query("SELECT 1 as test", fetchone=True) result = execute_query_single("SELECT 1 as test")
return { return {
"status": "healthy", "status": "healthy",

View File

@ -29,7 +29,7 @@ async def get_items():
read_only = get_module_config("test_module", "READ_ONLY", "true") == "true" read_only = get_module_config("test_module", "READ_ONLY", "true") == "true"
# Hent items (bemærk table_prefix) # Hent items (bemærk table_prefix)
items = execute_query( items = execute_query_single(
"SELECT * FROM test_module_items ORDER BY created_at DESC" "SELECT * FROM test_module_items ORDER BY created_at DESC"
) )
@ -58,9 +58,7 @@ async def get_item(item_id: int):
try: try:
item = execute_query( item = execute_query(
"SELECT * FROM test_module_items WHERE id = %s", "SELECT * FROM test_module_items WHERE id = %s",
(item_id,), (item_id,))
fetchone=True
)
if not item: if not item:
raise HTTPException(status_code=404, detail="Item not found") raise HTTPException(status_code=404, detail="Item not found")
@ -245,7 +243,7 @@ async def health_check():
""" """
try: try:
# Test database connectivity # Test database connectivity
result = execute_query("SELECT 1 as test", fetchone=True) result = execute_query_single("SELECT 1 as test")
return { return {
"status": "healthy", "status": "healthy",

View File

@ -0,0 +1,273 @@
from fastapi import APIRouter, HTTPException
from app.core.database import execute_query
from typing import List, Optional, Dict, Any
from pydantic import BaseModel
from datetime import datetime, date
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
# Pydantic Models
class PrepaidCard(BaseModel):
id: Optional[int] = None
card_number: str
customer_id: int
purchased_hours: float
used_hours: float
remaining_hours: float
price_per_hour: float
total_amount: float
status: str
purchased_at: Optional[datetime] = None
expires_at: Optional[datetime] = None
economic_invoice_number: Optional[str] = None
economic_product_number: Optional[str] = None
notes: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class PrepaidCardCreate(BaseModel):
customer_id: int
purchased_hours: float
price_per_hour: float
expires_at: Optional[date] = None
notes: Optional[str] = None
@router.get("/prepaid-cards", response_model=List[Dict[str, Any]])
async def get_prepaid_cards(status: Optional[str] = None, customer_id: Optional[int] = None):
"""
Get all prepaid cards with customer information
"""
try:
query = """
SELECT
pc.*,
c.name as customer_name,
c.email as customer_email,
(SELECT COUNT(*) FROM tticket_prepaid_transactions WHERE card_id = pc.id) as transaction_count
FROM tticket_prepaid_cards pc
LEFT JOIN customers c ON pc.customer_id = c.id
WHERE 1=1
"""
params = []
if status:
query += " AND pc.status = %s"
params.append(status)
if customer_id:
query += " AND pc.customer_id = %s"
params.append(customer_id)
query += " ORDER BY pc.created_at DESC"
cards = execute_query(query, tuple(params) if params else None)
logger.info(f"✅ Retrieved {len(cards) if cards else 0} prepaid cards")
return cards or []
except Exception as e:
logger.error(f"❌ Error fetching prepaid cards: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/prepaid-cards/{card_id}", response_model=Dict[str, Any])
async def get_prepaid_card(card_id: int):
"""
Get a specific prepaid card with transactions
"""
try:
result = execute_query("""
SELECT
pc.*,
c.name as customer_name,
c.email as customer_email
FROM tticket_prepaid_cards pc
LEFT JOIN customers c ON pc.customer_id = c.id
WHERE pc.id = %s
""", (card_id,))
if not result or len(result) == 0:
raise HTTPException(status_code=404, detail="Prepaid card not found")
card = result[0]
# Get transactions
transactions = execute_query("""
SELECT
pt.*,
w.ticket_id,
t.subject as ticket_title
FROM tticket_prepaid_transactions pt
LEFT JOIN tticket_worklog w ON pt.worklog_id = w.id
LEFT JOIN tticket_tickets t ON w.ticket_id = t.id
WHERE pt.card_id = %s
ORDER BY pt.created_at DESC
""", (card_id,))
card['transactions'] = transactions or []
return card
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error fetching prepaid card {card_id}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/prepaid-cards", response_model=Dict[str, Any])
async def create_prepaid_card(card: PrepaidCardCreate):
"""
Create a new prepaid card
"""
try:
# Calculate total amount
total_amount = card.purchased_hours * card.price_per_hour
# Check if customer already has active card
existing = execute_query("""
SELECT id FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active'
""", (card.customer_id,))
if existing and len(existing) > 0:
raise HTTPException(
status_code=400,
detail="Customer already has an active prepaid card"
)
# Create card (need to use fetch=False for INSERT RETURNING)
conn = None
try:
from app.core.database import get_db_connection, release_db_connection
from psycopg2.extras import RealDictCursor
conn = get_db_connection()
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute("""
INSERT INTO tticket_prepaid_cards
(customer_id, purchased_hours, price_per_hour, total_amount, expires_at, notes)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING *
""", (
card.customer_id,
card.purchased_hours,
card.price_per_hour,
total_amount,
card.expires_at,
card.notes
))
conn.commit()
result = cursor.fetchone()
logger.info(f"✅ Created prepaid card: {result['card_number']}")
return result
finally:
if conn:
release_db_connection(conn)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error creating prepaid card: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.put("/prepaid-cards/{card_id}/status")
async def update_card_status(card_id: int, status: str):
"""
Update prepaid card status (cancel, reactivate)
"""
try:
if status not in ['active', 'cancelled']:
raise HTTPException(status_code=400, detail="Invalid status")
conn = None
try:
from app.core.database import get_db_connection, release_db_connection
from psycopg2.extras import RealDictCursor
conn = get_db_connection()
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute("""
UPDATE tticket_prepaid_cards
SET status = %s
WHERE id = %s
RETURNING *
""", (status, card_id))
conn.commit()
result = cursor.fetchone()
if not result:
raise HTTPException(status_code=404, detail="Card not found")
logger.info(f"✅ Updated card {card_id} status to {status}")
return result
finally:
if conn:
release_db_connection(conn)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating card status: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/prepaid-cards/{card_id}")
async def delete_prepaid_card(card_id: int):
"""
Delete a prepaid card (only if no transactions)
"""
try:
# Check for transactions
transactions = execute_query("""
SELECT COUNT(*) as count FROM tticket_prepaid_transactions
WHERE card_id = %s
""", (card_id,))
if transactions and len(transactions) > 0 and transactions[0]['count'] > 0:
raise HTTPException(
status_code=400,
detail="Cannot delete card with transactions"
)
execute_query("DELETE FROM tticket_prepaid_cards WHERE id = %s", (card_id,), fetch=False)
logger.info(f"✅ Deleted prepaid card {card_id}")
return {"message": "Card deleted successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error deleting card: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/prepaid-cards/stats/summary", response_model=Dict[str, Any])
async def get_prepaid_stats():
"""
Get prepaid cards statistics
"""
try:
result = execute_query("""
SELECT
COUNT(*) FILTER (WHERE status = 'active') as active_count,
COUNT(*) FILTER (WHERE status = 'depleted') as depleted_count,
COUNT(*) FILTER (WHERE status = 'expired') as expired_count,
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled_count,
COALESCE(SUM(remaining_hours) FILTER (WHERE status = 'active'), 0) as total_remaining_hours,
COALESCE(SUM(used_hours), 0) as total_used_hours,
COALESCE(SUM(purchased_hours), 0) as total_purchased_hours,
COALESCE(SUM(total_amount), 0) as total_revenue
FROM tticket_prepaid_cards
""")
return result[0] if result and len(result) > 0 else {}
except Exception as e:
logger.error(f"❌ Error fetching prepaid stats: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))

View File

@ -0,0 +1,34 @@
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
templates = Jinja2Templates(directory=["app/prepaid/frontend", "app/shared/frontend"])
@router.get("/prepaid-cards", response_class=HTMLResponse)
async def prepaid_cards_page(request: Request):
"""
Prepaid cards overview page
"""
logger.info("🔍 Rendering prepaid cards page")
return templates.TemplateResponse("index.html", {
"request": request,
"page_title": "Prepaid Cards"
})
@router.get("/prepaid-cards/{card_id}", response_class=HTMLResponse)
async def prepaid_card_detail(request: Request, card_id: int):
"""
Prepaid card detail page
"""
logger.info(f"🔍 Rendering prepaid card detail: {card_id}")
return templates.TemplateResponse("detail.html", {
"request": request,
"page_title": "Card Details",
"card_id": card_id
})

View File

@ -0,0 +1,313 @@
{% extends "base.html" %}
{% block title %}Card Details - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="row mb-4">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/prepaid-cards">Prepaid Cards</a></li>
<li class="breadcrumb-item active" aria-current="page" id="cardNumber">Loading...</li>
</ol>
</nav>
<h1 class="h3 mb-0" id="pageTitle">💳 Loading...</h1>
</div>
<div class="col-auto">
<button class="btn btn-outline-secondary" onclick="window.location.href='/prepaid-cards'">
<i class="bi bi-arrow-left"></i> Tilbage
</button>
</div>
</div>
<!-- Card Info -->
<div class="row g-4 mb-4">
<div class="col-md-8">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0">Kort Information</h5>
</div>
<div class="card-body">
<div class="row g-3" id="cardInfo">
<div class="col-12 text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<!-- Stats Card -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0">Oversigt</h5>
</div>
<div class="card-body">
<div class="mb-3 pb-3 border-bottom">
<small class="text-muted d-block mb-1">Købte Timer</small>
<h4 class="mb-0" id="statPurchased">-</h4>
</div>
<div class="mb-3 pb-3 border-bottom">
<small class="text-muted d-block mb-1">Brugte Timer</small>
<h4 class="mb-0 text-info" id="statUsed">-</h4>
</div>
<div class="mb-3 pb-3 border-bottom">
<small class="text-muted d-block mb-1">Tilbage</small>
<h4 class="mb-0 text-success" id="statRemaining">-</h4>
</div>
<div>
<small class="text-muted d-block mb-1">Total Beløb</small>
<h4 class="mb-0 text-primary" id="statTotal">-</h4>
</div>
</div>
</div>
<!-- Actions Card -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0">Handlinger</h5>
</div>
<div class="card-body">
<div id="actionButtons">
<!-- Will be populated -->
</div>
</div>
</div>
</div>
</div>
<!-- Transactions -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0">Transaktioner</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle" id="transactionsTable">
<thead class="table-light">
<tr>
<th>Dato</th>
<th>Ticket</th>
<th>Beskrivelse</th>
<th class="text-end">Timer</th>
<th class="text-end">Beløb</th>
</tr>
</thead>
<tbody id="transactionsBody">
<tr>
<td colspan="5" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
const cardId = {{ card_id }};
// Load card details
document.addEventListener('DOMContentLoaded', () => {
loadCardDetails();
});
async function loadCardDetails() {
try {
const response = await fetch(`/api/v1/prepaid-cards/${cardId}`);
if (!response.ok) {
throw new Error('Card not found');
}
const card = await response.json();
// Update header
document.getElementById('cardNumber').textContent = card.card_number;
document.getElementById('pageTitle').textContent = `💳 ${card.card_number}`;
// Update stats
document.getElementById('statPurchased').textContent =
parseFloat(card.purchased_hours).toFixed(1) + ' t';
document.getElementById('statUsed').textContent =
parseFloat(card.used_hours).toFixed(1) + ' t';
document.getElementById('statRemaining').textContent =
parseFloat(card.remaining_hours).toFixed(1) + ' t';
document.getElementById('statTotal').textContent =
new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: 'DKK'
}).format(parseFloat(card.total_amount));
// Update card info
const statusBadge = getStatusBadge(card.status);
const expiresAt = card.expires_at ?
new Date(card.expires_at).toLocaleDateString('da-DK', {
year: 'numeric',
month: 'long',
day: 'numeric'
}) : 'Ingen udløbsdato';
document.getElementById('cardInfo').innerHTML = `
<div class="col-md-6">
<label class="small text-muted">Kortnummer</label>
<p class="mb-0"><strong>${card.card_number}</strong></p>
</div>
<div class="col-md-6">
<label class="small text-muted">Status</label>
<p class="mb-0">${statusBadge}</p>
</div>
<div class="col-md-6">
<label class="small text-muted">Kunde</label>
<p class="mb-0">
<a href="/customers/${card.customer_id}" class="text-decoration-none">
${card.customer_name || '-'}
</a>
</p>
<small class="text-muted">${card.customer_email || ''}</small>
</div>
<div class="col-md-6">
<label class="small text-muted">Pris pr. Time</label>
<p class="mb-0"><strong>${parseFloat(card.price_per_hour).toFixed(2)} kr</strong></p>
</div>
<div class="col-md-6">
<label class="small text-muted">Købt Dato</label>
<p class="mb-0">${new Date(card.purchased_at).toLocaleDateString('da-DK')}</p>
</div>
<div class="col-md-6">
<label class="small text-muted">Udløber</label>
<p class="mb-0">${expiresAt}</p>
</div>
${card.economic_invoice_number ? `
<div class="col-md-6">
<label class="small text-muted">e-conomic Fakturanr.</label>
<p class="mb-0">${card.economic_invoice_number}</p>
</div>
` : ''}
${card.notes ? `
<div class="col-12">
<label class="small text-muted">Bemærkninger</label>
<p class="mb-0">${card.notes}</p>
</div>
` : ''}
`;
// Update action buttons
const actions = [];
if (card.status === 'active') {
actions.push(`
<button class="btn btn-warning w-100 mb-2" onclick="cancelCard()">
<i class="bi bi-x-circle"></i> Annuller Kort
</button>
`);
}
document.getElementById('actionButtons').innerHTML = actions.join('') ||
'<p class="text-muted text-center mb-0">Ingen handlinger tilgængelige</p>';
// Render transactions
renderTransactions(card.transactions || []);
} catch (error) {
console.error('Error loading card:', error);
document.getElementById('cardInfo').innerHTML = `
<div class="col-12 text-center text-danger py-5">
<i class="bi bi-exclamation-circle fs-1 mb-3"></i>
<p>❌ Fejl ved indlæsning: ${error.message}</p>
<button class="btn btn-primary" onclick="window.location.href='/prepaid-cards'">
Tilbage til oversigt
</button>
</div>
`;
}
}
function renderTransactions(transactions) {
const tbody = document.getElementById('transactionsBody');
if (!transactions || transactions.length === 0) {
tbody.innerHTML = `
<tr><td colspan="5" class="text-center text-muted py-5">
Ingen transaktioner endnu
</td></tr>
`;
return;
}
tbody.innerHTML = transactions.map(t => `
<tr>
<td>${new Date(t.created_at).toLocaleDateString('da-DK')}</td>
<td>
${t.ticket_id ?
`<a href="/ticket/tickets/${t.ticket_id}" class="text-decoration-none">
#${t.ticket_id} - ${t.ticket_title || 'Ticket'}
</a>` : '-'}
</td>
<td>${t.description || '-'}</td>
<td class="text-end">${parseFloat(t.hours_used).toFixed(2)} t</td>
<td class="text-end">${parseFloat(t.amount).toFixed(2)} kr</td>
</tr>
`).join('');
}
function getStatusBadge(status) {
const badges = {
'active': '<span class="badge bg-success">Aktiv</span>',
'depleted': '<span class="badge bg-secondary">Opbrugt</span>',
'expired': '<span class="badge bg-danger">Udløbet</span>',
'cancelled': '<span class="badge bg-warning">Annulleret</span>'
};
return badges[status] || status;
}
async function cancelCard() {
if (!confirm('Er du sikker på at du vil annullere dette kort?')) {
return;
}
try {
const response = await fetch(`/api/v1/prepaid-cards/${cardId}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'cancelled' })
});
if (!response.ok) throw new Error('Fejl ved annullering');
alert('✅ Kort annulleret');
loadCardDetails(); // Reload
} catch (error) {
console.error('Error cancelling card:', error);
alert('❌ Fejl: ' + error.message);
}
}
</script>
<style>
.breadcrumb {
background: transparent;
padding: 0;
}
.card-header {
font-weight: 600;
}
.table th {
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--bs-secondary);
}
</style>
{% endblock %}

View File

@ -0,0 +1,461 @@
{% extends "base.html" %}
{% block title %}Prepaid Cards - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="row mb-4">
<div class="col">
<h1 class="h3 mb-0">💳 Prepaid Cards</h1>
<p class="text-muted">Oversigt og kontrol af kunders timekort</p>
</div>
<div class="col-auto">
<button class="btn btn-primary" onclick="openCreateModal()">
<i class="bi bi-plus-circle"></i> Opret Nyt Kort
</button>
</div>
</div>
<!-- Stats Row -->
<div class="row g-3 mb-4" id="statsCards">
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle bg-success bg-opacity-10 p-3">
<i class="bi bi-credit-card-2-front text-success fs-4"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<p class="text-muted small mb-1">Aktive Kort</p>
<h3 class="mb-0" id="activeCount">-</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle bg-primary bg-opacity-10 p-3">
<i class="bi bi-clock-history text-primary fs-4"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<p class="text-muted small mb-1">Tilbageværende Timer</p>
<h3 class="mb-0" id="remainingHours">-</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle bg-info bg-opacity-10 p-3">
<i class="bi bi-hourglass-split text-info fs-4"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<p class="text-muted small mb-1">Brugte Timer</p>
<h3 class="mb-0" id="usedHours">-</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="rounded-circle bg-warning bg-opacity-10 p-3">
<i class="bi bi-currency-dollar text-warning fs-4"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<p class="text-muted small mb-1">Total Omsætning</p>
<h3 class="mb-0" id="totalRevenue">-</h3>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filter Bar -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label small text-muted">Status</label>
<select class="form-select" id="statusFilter" onchange="loadCards()">
<option value="">Alle</option>
<option value="active">Aktive</option>
<option value="depleted">Opbrugt</option>
<option value="expired">Udløbet</option>
<option value="cancelled">Annulleret</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label small text-muted">Søg Kunde</label>
<input type="text" class="form-control" id="customerSearch"
placeholder="Søg efter kundenavn eller email...">
</div>
<div class="col-md-3 d-flex align-items-end">
<button class="btn btn-outline-secondary w-100" onclick="resetFilters()">
<i class="bi bi-x-circle"></i> Nulstil
</button>
</div>
</div>
</div>
</div>
<!-- Cards Table -->
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle" id="cardsTable">
<thead class="table-light">
<tr>
<th>Kortnummer</th>
<th>Kunde</th>
<th class="text-end">Købte Timer</th>
<th class="text-end">Brugte Timer</th>
<th class="text-end">Tilbage</th>
<th class="text-end">Pris/Time</th>
<th class="text-end">Total</th>
<th>Status</th>
<th>Udløber</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="cardsTableBody">
<tr>
<td colspan="10" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Create Card Modal -->
<div class="modal fade" id="createCardModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">💳 Opret Nyt Prepaid Kort</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="createCardForm">
<div class="mb-3">
<label class="form-label">Kunde *</label>
<select class="form-select" id="customerId" required>
<option value="">Vælg kunde...</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Antal Timer *</label>
<input type="number" class="form-control" id="purchasedHours"
step="0.5" min="1" required>
</div>
<div class="mb-3">
<label class="form-label">Pris pr. Time (DKK) *</label>
<input type="number" class="form-control" id="pricePerHour"
step="0.01" min="0" required>
</div>
<div class="mb-3">
<label class="form-label">Udløbsdato (valgfri)</label>
<input type="date" class="form-control" id="expiresAt">
</div>
<div class="mb-3">
<label class="form-label">Bemærkninger</label>
<textarea class="form-control" id="notes" rows="3"></textarea>
</div>
<div class="alert alert-info small">
<i class="bi bi-info-circle"></i>
Kortnummeret bliver automatisk genereret
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="createCard()">
<i class="bi bi-plus-circle"></i> Opret Kort
</button>
</div>
</div>
</div>
</div>
<script>
let createCardModal;
// Initialize
document.addEventListener('DOMContentLoaded', () => {
createCardModal = new bootstrap.Modal(document.getElementById('createCardModal'));
loadStats();
loadCards();
loadCustomers();
});
// Load Statistics
async function loadStats() {
try {
const response = await fetch('/api/v1/prepaid-cards/stats/summary');
const stats = await response.json();
document.getElementById('activeCount').textContent = stats.active_count || 0;
document.getElementById('remainingHours').textContent =
parseFloat(stats.total_remaining_hours || 0).toFixed(1) + ' t';
document.getElementById('usedHours').textContent =
parseFloat(stats.total_used_hours || 0).toFixed(1) + ' t';
document.getElementById('totalRevenue').textContent =
new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: 'DKK'
}).format(parseFloat(stats.total_revenue || 0));
} catch (error) {
console.error('Error loading stats:', error);
}
}
// Load Cards
async function loadCards() {
const status = document.getElementById('statusFilter').value;
const search = document.getElementById('customerSearch').value.toLowerCase();
try {
let url = '/api/v1/prepaid-cards';
if (status) url += `?status=${status}`;
const response = await fetch(url);
const cards = await response.json();
// Filter by search
const filtered = cards.filter(card => {
if (!search) return true;
const name = (card.customer_name || '').toLowerCase();
const email = (card.customer_email || '').toLowerCase();
return name.includes(search) || email.includes(search);
});
renderCards(filtered);
} catch (error) {
console.error('Error loading cards:', error);
document.getElementById('cardsTableBody').innerHTML = `
<tr><td colspan="10" class="text-center text-danger">
❌ Fejl ved indlæsning: ${error.message}
</td></tr>
`;
}
}
// Render Cards Table
function renderCards(cards) {
const tbody = document.getElementById('cardsTableBody');
if (!cards || cards.length === 0) {
tbody.innerHTML = `
<tr><td colspan="10" class="text-center text-muted py-5">
Ingen kort fundet
</td></tr>
`;
return;
}
tbody.innerHTML = cards.map(card => {
const statusBadge = getStatusBadge(card.status);
const expiresAt = card.expires_at ?
new Date(card.expires_at).toLocaleDateString('da-DK') : '-';
// Parse decimal strings to numbers
const purchasedHours = parseFloat(card.purchased_hours);
const usedHours = parseFloat(card.used_hours);
const remainingHours = parseFloat(card.remaining_hours);
const pricePerHour = parseFloat(card.price_per_hour);
const totalAmount = parseFloat(card.total_amount);
return `
<tr>
<td>
<a href="/prepaid-cards/${card.id}" class="text-decoration-none">
<strong>${card.card_number}</strong>
</a>
</td>
<td>
<div>${card.customer_name || '-'}</div>
<small class="text-muted">${card.customer_email || ''}</small>
</td>
<td class="text-end">${purchasedHours.toFixed(1)} t</td>
<td class="text-end">${usedHours.toFixed(1)} t</td>
<td class="text-end">
<strong class="${remainingHours < 5 ? 'text-danger' : 'text-success'}">
${remainingHours.toFixed(1)} t
</strong>
</td>
<td class="text-end">${pricePerHour.toFixed(2)} kr</td>
<td class="text-end"><strong>${totalAmount.toFixed(2)} kr</strong></td>
<td>${statusBadge}</td>
<td>${expiresAt}</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<a href="/prepaid-cards/${card.id}" class="btn btn-outline-primary"
title="Se detaljer">
<i class="bi bi-eye"></i>
</a>
${card.status === 'active' ? `
<button class="btn btn-outline-warning"
onclick="cancelCard(${card.id})" title="Annuller">
<i class="bi bi-x-circle"></i>
</button>
` : ''}
</div>
</td>
</tr>
`;
}).join('');
}
// Get Status Badge
function getStatusBadge(status) {
const badges = {
'active': '<span class="badge bg-success">Aktiv</span>',
'depleted': '<span class="badge bg-secondary">Opbrugt</span>',
'expired': '<span class="badge bg-danger">Udløbet</span>',
'cancelled': '<span class="badge bg-warning">Annulleret</span>'
};
return badges[status] || status;
}
// Load Customers for Dropdown
async function loadCustomers() {
try {
const response = await fetch('/api/v1/customers');
const customers = await response.json();
const select = document.getElementById('customerId');
select.innerHTML = '<option value="">Vælg kunde...</option>' +
customers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
} catch (error) {
console.error('Error loading customers:', error);
}
}
// Open Create Modal
function openCreateModal() {
document.getElementById('createCardForm').reset();
createCardModal.show();
}
// Create Card
async function createCard() {
const form = document.getElementById('createCardForm');
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const data = {
customer_id: parseInt(document.getElementById('customerId').value),
purchased_hours: parseFloat(document.getElementById('purchasedHours').value),
price_per_hour: parseFloat(document.getElementById('pricePerHour').value),
expires_at: document.getElementById('expiresAt').value || null,
notes: document.getElementById('notes').value || null
};
try {
const response = await fetch('/api/v1/prepaid-cards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Fejl ved oprettelse');
}
createCardModal.hide();
loadStats();
loadCards();
alert('✅ Prepaid kort oprettet!');
} catch (error) {
console.error('Error creating card:', error);
alert('❌ Fejl: ' + error.message);
}
}
// Cancel Card
async function cancelCard(cardId) {
if (!confirm('Er du sikker på at du vil annullere dette kort?')) {
return;
}
try {
const response = await fetch(`/api/v1/prepaid-cards/${cardId}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'cancelled' })
});
if (!response.ok) throw new Error('Fejl ved annullering');
loadStats();
loadCards();
alert('✅ Kort annulleret');
} catch (error) {
console.error('Error cancelling card:', error);
alert('❌ Fejl: ' + error.message);
}
}
// Reset Filters
function resetFilters() {
document.getElementById('statusFilter').value = '';
document.getElementById('customerSearch').value = '';
loadCards();
}
// Live search on customer input
document.getElementById('customerSearch').addEventListener('input', loadCards);
</script>
<style>
.table th {
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--bs-secondary);
}
.card {
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
.btn-group-sm .btn {
padding: 0.25rem 0.5rem;
}
</style>
{% endblock %}

View File

@ -97,10 +97,9 @@ class EmailProcessorService:
# Step 5: Match against rules (legacy support) - skip if workflow already processed # Step 5: Match against rules (legacy support) - skip if workflow already processed
if self.rules_enabled and not workflow_processed: if self.rules_enabled and not workflow_processed:
# Check if workflow already processed this email # Check if workflow already processed this email
existing_execution = execute_query( existing_execution = execute_query_single(
"SELECT id FROM email_workflow_executions WHERE email_id = %s AND status = 'completed' LIMIT 1", "SELECT id FROM email_workflow_executions WHERE email_id = %s AND status = 'completed' LIMIT 1",
(email_id,), fetchone=True (email_id,))
)
if existing_execution: if existing_execution:
logger.info(f"⏭️ Email {email_id} already processed by workflow, skipping rules") logger.info(f"⏭️ Email {email_id} already processed by workflow, skipping rules")

View File

@ -104,7 +104,7 @@ class EmailWorkflowService:
ORDER BY priority ASC ORDER BY priority ASC
""" """
workflows = execute_query(query, (classification, confidence)) workflows = execute_query_single(query, (classification, confidence))
# Filter by additional patterns # Filter by additional patterns
matching = [] matching = []
@ -400,16 +400,15 @@ class EmailWorkflowService:
# Find vendor by email # Find vendor by email
query = "SELECT id, name FROM vendors WHERE email = %s LIMIT 1" query = "SELECT id, name FROM vendors WHERE email = %s LIMIT 1"
result = execute_query(query, (sender_email,), fetchone=True) result = execute_query(query, (sender_email,))
if result: if result:
vendor_id = result['id'] vendor_id = result['id']
# Check if already linked to avoid duplicate updates # Check if already linked to avoid duplicate updates
current_vendor = execute_query( current_vendor = execute_query_single(
"SELECT supplier_id FROM email_messages WHERE id = %s", "SELECT supplier_id FROM email_messages WHERE id = %s",
(email_data['id'],), fetchone=True (email_data['id'],))
)
if current_vendor and current_vendor.get('supplier_id') == vendor_id: if current_vendor and current_vendor.get('supplier_id') == vendor_id:
logger.info(f"⏭️ Email already linked to vendor {vendor_id}, skipping duplicate update") logger.info(f"⏭️ Email already linked to vendor {vendor_id}, skipping duplicate update")
@ -458,7 +457,7 @@ class EmailWorkflowService:
vendor_id = email_data.get('supplier_id') vendor_id = email_data.get('supplier_id')
# Get PDF attachments from email # Get PDF attachments from email
attachments = execute_query( attachments = execute_query_single(
"""SELECT filename, file_path, size_bytes, content_type """SELECT filename, file_path, size_bytes, content_type
FROM email_attachments FROM email_attachments
WHERE email_id = %s AND content_type = 'application/pdf'""", WHERE email_id = %s AND content_type = 'application/pdf'""",
@ -515,9 +514,7 @@ class EmailWorkflowService:
# Check if file already exists # Check if file already exists
existing = execute_query( existing = execute_query(
"SELECT file_id FROM incoming_files WHERE checksum = %s", "SELECT file_id FROM incoming_files WHERE checksum = %s",
(checksum,), (checksum,))
fetchone=True
)
if existing: if existing:
logger.info(f"⚠️ File already exists: {attachment['filename']}") logger.info(f"⚠️ File already exists: {attachment['filename']}")

View File

@ -12,7 +12,7 @@ from datetime import datetime
import re import re
from app.core.config import settings from app.core.config import settings
from app.core.database import execute_insert, execute_query, execute_update from app.core.database import execute_insert, execute_query, execute_update, execute_query_single
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -582,11 +582,9 @@ Output: {
return None return None
# Search vendors table # Search vendors table
vendor = execute_query( vendor = execute_query_single(
"SELECT * FROM vendors WHERE cvr_number = %s", "SELECT * FROM vendors WHERE cvr_number = %s",
(cvr_clean,), (cvr_clean,))
fetchone=True
)
if vendor: if vendor:
logger.info(f"✅ Matched vendor: {vendor['name']} (CVR: {cvr_clean})") logger.info(f"✅ Matched vendor: {vendor['name']} (CVR: {cvr_clean})")

View File

@ -22,16 +22,16 @@ class SimplyCRMService:
"""Service for integrating with Simply-CRM via webservice.php (VTiger fork)""" """Service for integrating with Simply-CRM via webservice.php (VTiger fork)"""
def __init__(self): def __init__(self):
# Simply-CRM bruger OLD_VTIGER settings # Try SIMPLYCRM_* first, fallback to OLD_VTIGER_* for backward compatibility
self.base_url = getattr(settings, 'OLD_VTIGER_URL', None) self.base_url = getattr(settings, 'SIMPLYCRM_URL', None) or getattr(settings, 'OLD_VTIGER_URL', None)
self.username = getattr(settings, 'OLD_VTIGER_USERNAME', None) self.username = getattr(settings, 'SIMPLYCRM_USERNAME', None) or getattr(settings, 'OLD_VTIGER_USERNAME', None)
self.access_key = getattr(settings, 'OLD_VTIGER_ACCESS_KEY', None) self.access_key = getattr(settings, 'SIMPLYCRM_API_KEY', None) or getattr(settings, 'OLD_VTIGER_API_KEY', None)
self.session_name: Optional[str] = None self.session_name: Optional[str] = None
self.session: Optional[aiohttp.ClientSession] = None self.session: Optional[aiohttp.ClientSession] = None
if not all([self.base_url, self.username, self.access_key]): if not all([self.base_url, self.username, self.access_key]):
logger.warning("⚠️ Simply-CRM credentials not configured (OLD_VTIGER_* settings)") logger.warning("⚠️ Simply-CRM credentials not configured (SIMPLYCRM_* or OLD_VTIGER_* settings)")
async def __aenter__(self): async def __aenter__(self):
"""Context manager entry - create session and login""" """Context manager entry - create session and login"""

View File

@ -208,9 +208,10 @@
<h5 class="fw-bold mb-2">📦 Modul System</h5> <h5 class="fw-bold mb-2">📦 Modul System</h5>
<p class="text-muted mb-0">Dynamisk feature loading - udvikl moduler isoleret fra core systemet</p> <p class="text-muted mb-0">Dynamisk feature loading - udvikl moduler isoleret fra core systemet</p>
</div> </div>
<a href="/api/v1/modules" target="_blank" class="btn btn-sm btn-outline-primary"> <!-- <a href="/api/v1/modules" target="_blank" class="btn btn-sm btn-outline-primary">
<i class="bi bi-box-arrow-up-right me-1"></i>API <i class="bi bi-box-arrow-up-right me-1"></i>API
</a> </a> -->
<span class="badge bg-secondary">API ikke implementeret endnu</span>
</div> </div>
<!-- Quick Start --> <!-- Quick Start -->

View File

@ -236,7 +236,7 @@
<li><a class="dropdown-item py-2" href="/ticket/worklog/review"><i class="bi bi-clock-history me-2"></i>Godkend Worklog</a></li> <li><a class="dropdown-item py-2" href="/ticket/worklog/review"><i class="bi bi-clock-history me-2"></i>Godkend Worklog</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="#">Ny Ticket</a></li> <li><a class="dropdown-item py-2" href="#">Ny Ticket</a></li>
<li><a class="dropdown-item py-2" href="#">Klippekort</a></li> <li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li> <li><a class="dropdown-item py-2" href="#">Knowledge Base</a></li>
</ul> </ul>
@ -1054,8 +1054,16 @@
function checkMaintenanceMode() { function checkMaintenanceMode() {
fetch('/api/v1/backups/maintenance') fetch('/api/v1/backups/maintenance')
.then(response => response.json()) .then(response => {
if (!response.ok) {
// Silently ignore 404 - maintenance endpoint not implemented yet
return null;
}
return response.json();
})
.then(data => { .then(data => {
if (!data) return; // Skip if endpoint doesn't exist
const overlay = document.getElementById('maintenance-overlay'); const overlay = document.getElementById('maintenance-overlay');
const messageEl = document.getElementById('maintenance-message'); const messageEl = document.getElementById('maintenance-message');
const etaEl = document.getElementById('maintenance-eta'); const etaEl = document.getElementById('maintenance-eta');
@ -1092,11 +1100,11 @@
} }
}) })
.catch(error => { .catch(error => {
console.error('Maintenance check error:', error); // Silently ignore errors - maintenance check is not critical
}); });
} }
// Check on page load // Check on page load (optional feature, don't block if not available)
checkMaintenanceMode(); checkMaintenanceMode();
// Check periodically (every 30 seconds when not in maintenance) // Check periodically (every 30 seconds when not in maintenance)

View File

@ -19,7 +19,7 @@ from typing import Dict, List, Optional
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from app.core.database import execute_query, execute_update from app.core.database import execute_query, execute_update, execute_query_single
from app.core.config import settings from app.core.config import settings
from app.services.economic_service import EconomicService from app.services.economic_service import EconomicService
from psycopg2.extras import Json from psycopg2.extras import Json
@ -164,7 +164,7 @@ class TicketEconomicExportService:
WHERE id = %s WHERE id = %s
""" """
customer = execute_query(query, (customer_id,), fetchone=True) customer = execute_query_single(query, (customer_id,))
if not customer: if not customer:
logger.error(f"❌ Customer {customer_id} not found") logger.error(f"❌ Customer {customer_id} not found")

View File

@ -14,7 +14,7 @@ import re
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
from datetime import datetime from datetime import datetime
from app.core.database import execute_query, execute_insert from app.core.database import execute_query, execute_insert, execute_query_single
from app.ticket.backend.ticket_service import TicketService from app.ticket.backend.ticket_service import TicketService
from app.ticket.backend.models import TTicketCreate, TicketPriority from app.ticket.backend.models import TTicketCreate, TicketPriority
from psycopg2.extras import Json from psycopg2.extras import Json
@ -122,7 +122,7 @@ class EmailTicketIntegration:
# Find ticket by ticket_number # Find ticket by ticket_number
query = "SELECT id FROM tticket_tickets WHERE ticket_number = %s" query = "SELECT id FROM tticket_tickets WHERE ticket_number = %s"
result = execute_query(query, (ticket_number,), fetchone=True) result = execute_query_single(query, (ticket_number,))
if not result: if not result:
logger.warning(f"⚠️ Ticket {ticket_number} not found - creating new ticket instead") logger.warning(f"⚠️ Ticket {ticket_number} not found - creating new ticket instead")

View File

@ -12,7 +12,7 @@ from datetime import datetime
from decimal import Decimal from decimal import Decimal
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
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.ticket.backend.models import ( from app.ticket.backend.models import (
TPrepaidCard, TPrepaidCard,
TPrepaidCardCreate, TPrepaidCardCreate,
@ -54,14 +54,12 @@ class KlippekortService:
from psycopg2.extras import Json from psycopg2.extras import Json
# Check if customer already has an active card # Check if customer already has an active card
existing = execute_query( existing = execute_query_single(
""" """
SELECT id, card_number FROM tticket_prepaid_cards SELECT id, card_number FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active' WHERE customer_id = %s AND status = 'active'
""", """,
(card_data.customer_id,), (card_data.customer_id,))
fetchone=True
)
if existing: if existing:
raise ValueError( raise ValueError(
@ -113,11 +111,9 @@ class KlippekortService:
) )
# Fetch created card # Fetch created card
card = execute_query( card = execute_query_single(
"SELECT * FROM tticket_prepaid_cards WHERE id = %s", "SELECT * FROM tticket_prepaid_cards WHERE id = %s",
(card_id,), (card_id,))
fetchone=True
)
logger.info(f"✅ Created prepaid card {card['card_number']} (ID: {card_id})") logger.info(f"✅ Created prepaid card {card['card_number']} (ID: {card_id})")
return card return card
@ -125,20 +121,16 @@ class KlippekortService:
@staticmethod @staticmethod
def get_card(card_id: int) -> Optional[Dict[str, Any]]: def get_card(card_id: int) -> Optional[Dict[str, Any]]:
"""Get prepaid card by ID""" """Get prepaid card by ID"""
return execute_query( return execute_query_single(
"SELECT * FROM tticket_prepaid_cards WHERE id = %s", "SELECT * FROM tticket_prepaid_cards WHERE id = %s",
(card_id,), (card_id,))
fetchone=True
)
@staticmethod @staticmethod
def get_card_with_stats(card_id: int) -> Optional[Dict[str, Any]]: def get_card_with_stats(card_id: int) -> Optional[Dict[str, Any]]:
"""Get prepaid card with usage statistics""" """Get prepaid card with usage statistics"""
return execute_query( return execute_query_single(
"SELECT * FROM tticket_prepaid_balances WHERE id = %s", "SELECT * FROM tticket_prepaid_balances WHERE id = %s",
(card_id,), (card_id,))
fetchone=True
)
@staticmethod @staticmethod
def get_active_card_for_customer(customer_id: int) -> Optional[Dict[str, Any]]: def get_active_card_for_customer(customer_id: int) -> Optional[Dict[str, Any]]:
@ -147,14 +139,12 @@ class KlippekortService:
Returns None if no active card exists. Returns None if no active card exists.
""" """
return execute_query( return execute_query_single(
""" """
SELECT * FROM tticket_prepaid_cards SELECT * FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active' WHERE customer_id = %s AND status = 'active'
""", """,
(customer_id,), (customer_id,))
fetchone=True
)
@staticmethod @staticmethod
def check_balance(customer_id: int) -> Dict[str, Any]: def check_balance(customer_id: int) -> Dict[str, Any]:
@ -299,11 +289,9 @@ class KlippekortService:
logger.warning(f"💳 Card {card['card_number']} is now depleted") logger.warning(f"💳 Card {card['card_number']} is now depleted")
# Fetch transaction # Fetch transaction
transaction = execute_query( transaction = execute_query_single(
"SELECT * FROM tticket_prepaid_transactions WHERE id = %s", "SELECT * FROM tticket_prepaid_transactions WHERE id = %s",
(transaction_id,), (transaction_id,))
fetchone=True
)
logger.info(f"✅ Deducted {hours}h from card {card['card_number']}, new balance: {new_balance}h") logger.info(f"✅ Deducted {hours}h from card {card['card_number']}, new balance: {new_balance}h")
return transaction return transaction
@ -368,11 +356,9 @@ class KlippekortService:
) )
) )
transaction = execute_query( transaction = execute_query_single(
"SELECT * FROM tticket_prepaid_transactions WHERE id = %s", "SELECT * FROM tticket_prepaid_transactions WHERE id = %s",
(transaction_id,), (transaction_id,))
fetchone=True
)
logger.info(f"✅ Topped up card {card['card_number']} with {hours}h, new balance: {new_balance}h") logger.info(f"✅ Topped up card {card['card_number']} with {hours}h, new balance: {new_balance}h")
return transaction return transaction
@ -392,7 +378,7 @@ class KlippekortService:
Returns: Returns:
List of transaction dicts List of transaction dicts
""" """
transactions = execute_query( transactions = execute_query_single(
""" """
SELECT * FROM tticket_prepaid_transactions SELECT * FROM tticket_prepaid_transactions
WHERE card_id = %s WHERE card_id = %s
@ -496,9 +482,7 @@ class KlippekortService:
# Fetch updated card # Fetch updated card
updated = execute_query( updated = execute_query(
"SELECT * FROM tticket_prepaid_cards WHERE id = %s", "SELECT * FROM tticket_prepaid_cards WHERE id = %s",
(card_id,), (card_id,))
fetchone=True
)
logger.info(f"✅ Cancelled card {card['card_number']}") logger.info(f"✅ Cancelled card {card['card_number']}")
return updated return updated

View File

@ -495,3 +495,289 @@ class PrepaidCardDeductRequest(BaseModel):
"""Request model for deducting hours from prepaid card""" """Request model for deducting hours from prepaid card"""
worklog_id: int = Field(..., gt=0, description="Worklog ID der skal trækkes fra kort") worklog_id: int = Field(..., gt=0, description="Worklog ID der skal trækkes fra kort")
hours: Decimal = Field(..., gt=0, description="Timer at trække") hours: Decimal = Field(..., gt=0, description="Timer at trække")
# ============================================================================
# TICKET RELATIONS MODELS (Migration 026)
# ============================================================================
class TicketRelationType(str, Enum):
"""Ticket relation types"""
MERGED_INTO = "merged_into"
SPLIT_FROM = "split_from"
PARENT_OF = "parent_of"
CHILD_OF = "child_of"
RELATED_TO = "related_to"
class TTicketRelationBase(BaseModel):
"""Base model for ticket relation"""
ticket_id: int
related_ticket_id: int
relation_type: TicketRelationType
reason: Optional[str] = None
class TTicketRelationCreate(TTicketRelationBase):
"""Create ticket relation"""
pass
class TTicketRelation(TTicketRelationBase):
"""Full ticket relation model"""
id: int
created_by_user_id: Optional[int] = None
created_at: datetime
class Config:
from_attributes = True
# ============================================================================
# CALENDAR EVENTS MODELS
# ============================================================================
class CalendarEventType(str, Enum):
"""Calendar event types"""
APPOINTMENT = "appointment"
DEADLINE = "deadline"
MILESTONE = "milestone"
REMINDER = "reminder"
FOLLOW_UP = "follow_up"
class CalendarEventStatus(str, Enum):
"""Calendar event status"""
PENDING = "pending"
CONFIRMED = "confirmed"
COMPLETED = "completed"
CANCELLED = "cancelled"
class TTicketCalendarEventBase(BaseModel):
"""Base model for calendar event"""
ticket_id: int
title: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = None
event_type: CalendarEventType = Field(default=CalendarEventType.APPOINTMENT)
event_date: date
event_time: Optional[str] = None
duration_minutes: Optional[int] = None
all_day: bool = False
status: CalendarEventStatus = Field(default=CalendarEventStatus.PENDING)
class TTicketCalendarEventCreate(TTicketCalendarEventBase):
"""Create calendar event"""
suggested_by_ai: bool = False
ai_confidence: Optional[Decimal] = None
ai_source_text: Optional[str] = None
class TTicketCalendarEvent(TTicketCalendarEventBase):
"""Full calendar event model"""
id: int
suggested_by_ai: bool = False
ai_confidence: Optional[Decimal] = None
ai_source_text: Optional[str] = None
created_by_user_id: Optional[int] = None
created_at: datetime
updated_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
class Config:
from_attributes = True
# ============================================================================
# TEMPLATES MODELS
# ============================================================================
class TTicketTemplateBase(BaseModel):
"""Base model for template"""
name: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = None
category: Optional[str] = None
subject_template: Optional[str] = Field(None, max_length=500)
body_template: str = Field(..., min_length=1)
available_placeholders: Optional[List[str]] = None
default_attachments: Optional[dict] = None
is_active: bool = True
requires_approval: bool = False
class TTicketTemplateCreate(TTicketTemplateBase):
"""Create template"""
pass
class TTicketTemplate(TTicketTemplateBase):
"""Full template model"""
id: int
created_by_user_id: Optional[int] = None
created_at: datetime
updated_at: Optional[datetime] = None
last_used_at: Optional[datetime] = None
usage_count: int = 0
class Config:
from_attributes = True
class TemplateRenderRequest(BaseModel):
"""Request to render template with data"""
template_id: int
ticket_id: int
custom_data: Optional[dict] = None
class TemplateRenderResponse(BaseModel):
"""Rendered template"""
subject: Optional[str] = None
body: str
placeholders_used: List[str]
# ============================================================================
# AI SUGGESTIONS MODELS
# ============================================================================
class AISuggestionType(str, Enum):
"""AI suggestion types"""
CONTACT_UPDATE = "contact_update"
NEW_CONTACT = "new_contact"
CATEGORY = "category"
TAG = "tag"
PRIORITY = "priority"
DEADLINE = "deadline"
CALENDAR_EVENT = "calendar_event"
TEMPLATE = "template"
MERGE = "merge"
RELATED_TICKET = "related_ticket"
class AISuggestionStatus(str, Enum):
"""AI suggestion status"""
PENDING = "pending"
ACCEPTED = "accepted"
REJECTED = "rejected"
AUTO_EXPIRED = "auto_expired"
class TTicketAISuggestionBase(BaseModel):
"""Base model for AI suggestion"""
ticket_id: int
suggestion_type: AISuggestionType
suggestion_data: dict # Struktureret data om forslaget
confidence: Optional[Decimal] = None
reasoning: Optional[str] = None
source_text: Optional[str] = None
source_comment_id: Optional[int] = None
class TTicketAISuggestionCreate(TTicketAISuggestionBase):
"""Create AI suggestion"""
expires_at: Optional[datetime] = None
class TTicketAISuggestion(TTicketAISuggestionBase):
"""Full AI suggestion model"""
id: int
status: AISuggestionStatus = Field(default=AISuggestionStatus.PENDING)
reviewed_by_user_id: Optional[int] = None
reviewed_at: Optional[datetime] = None
created_at: datetime
expires_at: Optional[datetime] = None
class Config:
from_attributes = True
class AISuggestionReviewRequest(BaseModel):
"""Request to accept/reject AI suggestion"""
action: str = Field(..., pattern="^(accept|reject)$")
note: Optional[str] = None
# ============================================================================
# EMAIL METADATA MODELS
# ============================================================================
class TTicketEmailMetadataBase(BaseModel):
"""Base model for email metadata"""
ticket_id: int
message_id: Optional[str] = None
in_reply_to: Optional[str] = None
references: Optional[str] = None
from_email: str
from_name: Optional[str] = None
from_signature: Optional[str] = None
class TTicketEmailMetadataCreate(TTicketEmailMetadataBase):
"""Create email metadata"""
matched_contact_id: Optional[int] = None
match_confidence: Optional[Decimal] = None
match_method: Optional[str] = None
suggested_contacts: Optional[dict] = None
extracted_phone: Optional[str] = None
extracted_address: Optional[str] = None
extracted_company: Optional[str] = None
extracted_title: Optional[str] = None
class TTicketEmailMetadata(TTicketEmailMetadataCreate):
"""Full email metadata model"""
id: int
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# ============================================================================
# AUDIT LOG MODELS
# ============================================================================
class TTicketAuditLog(BaseModel):
"""Audit log entry"""
id: int
ticket_id: int
action: str
field_name: Optional[str] = None
old_value: Optional[str] = None
new_value: Optional[str] = None
user_id: Optional[int] = None
performed_at: datetime
reason: Optional[str] = None
metadata: Optional[dict] = None
class Config:
from_attributes = True
# ============================================================================
# EXTENDED REQUEST MODELS
# ============================================================================
class TicketMergeRequest(BaseModel):
"""Request to merge tickets"""
source_ticket_ids: List[int] = Field(..., min_length=1, description="Tickets at lægge sammen")
target_ticket_id: int = Field(..., description="Primær ticket der skal beholdes")
reason: Optional[str] = Field(None, description="Hvorfor lægges de sammen")
class TicketSplitRequest(BaseModel):
"""Request to split ticket"""
source_ticket_id: int = Field(..., description="Ticket at splitte")
comment_ids: List[int] = Field(..., min_length=1, description="Kommentarer til ny ticket")
new_subject: str = Field(..., min_length=1, description="Emne på ny ticket")
new_description: Optional[str] = Field(None, description="Beskrivelse på ny ticket")
reason: Optional[str] = Field(None, description="Hvorfor splittes ticketen")
class TicketDeadlineUpdateRequest(BaseModel):
"""Request to update ticket deadline"""
deadline: Optional[datetime] = None
reason: Optional[str] = None

View File

@ -26,9 +26,28 @@ from app.ticket.backend.models import (
TicketListResponse, TicketListResponse,
TicketStatusUpdateRequest, TicketStatusUpdateRequest,
WorklogReviewResponse, WorklogReviewResponse,
WorklogBillingRequest WorklogBillingRequest,
# Migration 026 models
TTicketRelation,
TTicketRelationCreate,
TTicketCalendarEvent,
TTicketCalendarEventCreate,
CalendarEventStatus,
TTicketTemplate,
TTicketTemplateCreate,
TemplateRenderRequest,
TemplateRenderResponse,
TTicketAISuggestion,
TTicketAISuggestionCreate,
AISuggestionStatus,
AISuggestionType,
AISuggestionReviewRequest,
TTicketAuditLog,
TicketMergeRequest,
TicketSplitRequest,
TicketDeadlineUpdateRequest
) )
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 datetime import date from datetime import date
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -81,7 +100,7 @@ async def list_tickets(
total_query += " AND customer_id = %s" total_query += " AND customer_id = %s"
params.append(customer_id) params.append(customer_id)
total_result = execute_query(total_query, tuple(params), fetchone=True) total_result = execute_query_single(total_query, tuple(params))
total = total_result['count'] if total_result else 0 total = total_result['count'] if total_result else 0
return TicketListResponse( return TicketListResponse(
@ -217,7 +236,7 @@ async def list_comments(ticket_id: int):
List all comments for a ticket List all comments for a ticket
""" """
try: try:
comments = execute_query( comments = execute_query_single(
"SELECT * FROM tticket_comments WHERE ticket_id = %s ORDER BY created_at ASC", "SELECT * FROM tticket_comments WHERE ticket_id = %s ORDER BY created_at ASC",
(ticket_id,) (ticket_id,)
) )
@ -322,9 +341,7 @@ async def create_worklog(
worklog = execute_query( worklog = execute_query(
"SELECT * FROM tticket_worklog WHERE id = %s", "SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,), (worklog_id,))
fetchone=True
)
logger.info(f"✅ Created worklog entry {worklog_id} for ticket {ticket_id}") logger.info(f"✅ Created worklog entry {worklog_id} for ticket {ticket_id}")
return worklog return worklog
@ -347,11 +364,9 @@ async def update_worklog(
""" """
try: try:
# Get current worklog # Get current worklog
current = execute_query( current = execute_query_single(
"SELECT * FROM tticket_worklog WHERE id = %s", "SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,), (worklog_id,))
fetchone=True
)
if not current: if not current:
raise HTTPException(status_code=404, detail=f"Worklog {worklog_id} not found") raise HTTPException(status_code=404, detail=f"Worklog {worklog_id} not found")
@ -384,11 +399,9 @@ async def update_worklog(
) )
# Fetch updated # Fetch updated
worklog = execute_query( worklog = execute_query_single(
"SELECT * FROM tticket_worklog WHERE id = %s", "SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,), (worklog_id,))
fetchone=True
)
return worklog return worklog
@ -427,7 +440,7 @@ async def review_worklog(
query += " ORDER BY w.work_date DESC, t.customer_id" query += " ORDER BY w.work_date DESC, t.customer_id"
worklogs = execute_query(query, tuple(params)) worklogs = execute_query_single(query, tuple(params))
# Calculate totals # Calculate totals
total_hours = Decimal('0') total_hours = Decimal('0')
@ -467,9 +480,7 @@ async def mark_worklog_billable(
# Get worklog # Get worklog
worklog = execute_query( worklog = execute_query(
"SELECT * FROM tticket_worklog WHERE id = %s", "SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,), (worklog_id,))
fetchone=True
)
if not worklog: if not worklog:
logger.warning(f"⚠️ Worklog {worklog_id} not found, skipping") logger.warning(f"⚠️ Worklog {worklog_id} not found, skipping")
@ -700,7 +711,7 @@ async def get_stats_by_status():
Get ticket statistics grouped by status Get ticket statistics grouped by status
""" """
try: try:
stats = execute_query( stats = execute_query_single(
"SELECT * FROM tticket_stats_by_status ORDER BY status" "SELECT * FROM tticket_stats_by_status ORDER BY status"
) )
return stats or [] return stats or []
@ -725,9 +736,7 @@ async def get_open_tickets_stats():
COUNT(*) FILTER (WHERE priority = 'urgent') as urgent_count, COUNT(*) FILTER (WHERE priority = 'urgent') as urgent_count,
AVG(age_hours) as avg_age_hours AVG(age_hours) as avg_age_hours
FROM tticket_open_tickets FROM tticket_open_tickets
""", """)
fetchone=True
)
return stats or {} return stats or {}
@ -816,3 +825,586 @@ async def execute_economic_export(
except Exception as e: except Exception as e:
logger.error(f"❌ Error executing export: {e}") logger.error(f"❌ Error executing export: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# TICKET RELATIONS ENDPOINTS (Migration 026)
# ============================================================================
@router.post("/tickets/{ticket_id}/merge", tags=["Ticket Relations"])
async def merge_tickets(ticket_id: int, request: TicketMergeRequest):
"""
Flet flere tickets sammen til én primær ticket
**Process**:
1. Validerer at alle source tickets eksisterer
2. Kopierer kommentarer og worklogs til target ticket
3. Opretter relation records
4. Markerer source tickets som merged
5. Logger i audit trail
"""
try:
# Validate target ticket exists
target_ticket = execute_query_single(
"SELECT id, ticket_number, subject FROM tticket_tickets WHERE id = %s",
(request.target_ticket_id,)
)
if not target_ticket:
raise HTTPException(status_code=404, detail=f"Target ticket {request.target_ticket_id} not found")
merged_count = 0
for source_id in request.source_ticket_ids:
# Validate source ticket
source_ticket = execute_query_single(
"SELECT id, ticket_number FROM tticket_tickets WHERE id = %s",
(source_id,)
)
if not source_ticket:
logger.warning(f"⚠️ Source ticket {source_id} not found, skipping")
continue
# Create relation
execute_query(
"""INSERT INTO tticket_relations (ticket_id, related_ticket_id, relation_type, reason, created_by_user_id)
VALUES (%s, %s, 'merged_into', %s, 1)
ON CONFLICT (ticket_id, related_ticket_id, relation_type) DO NOTHING""",
(source_id, request.target_ticket_id, request.reason)
)
# Mark source as merged
execute_query(
"""UPDATE tticket_tickets
SET is_merged = true, merged_into_ticket_id = %s, status = 'closed'
WHERE id = %s""",
(request.target_ticket_id, source_id),
fetch=False
)
# Log audit
execute_query(
"""INSERT INTO tticket_audit_log (ticket_id, action, new_value, reason)
VALUES (%s, 'merged_into', %s, %s)""",
(source_id, str(request.target_ticket_id), request.reason)
)
merged_count += 1
logger.info(f"✅ Merged ticket {source_id} into {request.target_ticket_id}")
return {
"status": "success",
"merged_count": merged_count,
"target_ticket": target_ticket,
"message": f"Successfully merged {merged_count} ticket(s)"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error merging tickets: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/tickets/{ticket_id}/split", tags=["Ticket Relations"])
async def split_ticket(ticket_id: int, request: TicketSplitRequest):
"""
Opdel en ticket i to - flyt kommentarer til ny ticket
**Process**:
1. Opretter ny ticket med nyt subject
2. Flytter valgte kommentarer til ny ticket
3. Opretter relation
4. Logger i audit trail
"""
try:
# Validate source ticket
source_ticket = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s",
(request.source_ticket_id,)
)
if not source_ticket:
raise HTTPException(status_code=404, detail=f"Source ticket {request.source_ticket_id} not found")
# Create new ticket (inherit customer, contact, priority)
new_ticket_id = execute_insert(
"""INSERT INTO tticket_tickets
(subject, description, status, priority, customer_id, contact_id, source, created_by_user_id)
VALUES (%s, %s, 'open', %s, %s, %s, 'manual', 1)
RETURNING id""",
(request.new_subject, request.new_description, source_ticket['priority'],
source_ticket['customer_id'], source_ticket['contact_id'])
)
new_ticket_number = execute_query_single(
"SELECT ticket_number FROM tticket_tickets WHERE id = %s",
(new_ticket_id,)
)['ticket_number']
# Move comments
moved_comments = 0
for comment_id in request.comment_ids:
result = execute_query(
"UPDATE tticket_comments SET ticket_id = %s WHERE id = %s AND ticket_id = %s",
(new_ticket_id, comment_id, request.source_ticket_id),
fetch=False
)
if result:
moved_comments += 1
# Create relation
execute_query(
"""INSERT INTO tticket_relations (ticket_id, related_ticket_id, relation_type, reason, created_by_user_id)
VALUES (%s, %s, 'split_from', %s, 1)""",
(new_ticket_id, request.source_ticket_id, request.reason)
)
# Log audit
execute_query(
"""INSERT INTO tticket_audit_log (ticket_id, action, new_value, reason)
VALUES (%s, 'split_into', %s, %s)""",
(request.source_ticket_id, str(new_ticket_id), request.reason)
)
logger.info(f"✅ Split ticket {request.source_ticket_id} into {new_ticket_id}, moved {moved_comments} comments")
return {
"status": "success",
"new_ticket_id": new_ticket_id,
"new_ticket_number": new_ticket_number,
"moved_comments": moved_comments,
"message": f"Successfully split ticket into {new_ticket_number}"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error splitting ticket: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/tickets/{ticket_id}/relations", tags=["Ticket Relations"])
async def get_ticket_relations(ticket_id: int):
"""Hent alle relationer for en ticket (begge retninger)"""
try:
relations = execute_query(
"""SELECT r.*,
t.ticket_number as related_ticket_number,
t.subject as related_subject,
t.status as related_status
FROM tticket_all_relations r
LEFT JOIN tticket_tickets t ON r.related_ticket_id = t.id
WHERE r.ticket_id = %s
ORDER BY r.created_at DESC""",
(ticket_id,)
)
return {"relations": relations, "total": len(relations)}
except Exception as e:
logger.error(f"❌ Error fetching relations: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/tickets/{ticket_id}/relations", tags=["Ticket Relations"])
async def create_ticket_relation(ticket_id: int, relation: TTicketRelationCreate):
"""Opret en relation mellem to tickets"""
try:
# Validate both tickets exist
for tid in [relation.ticket_id, relation.related_ticket_id]:
ticket = execute_query_single("SELECT id FROM tticket_tickets WHERE id = %s", (tid,))
if not ticket:
raise HTTPException(status_code=404, detail=f"Ticket {tid} not found")
execute_query(
"""INSERT INTO tticket_relations (ticket_id, related_ticket_id, relation_type, reason, created_by_user_id)
VALUES (%s, %s, %s, %s, 1)""",
(relation.ticket_id, relation.related_ticket_id, relation.relation_type, relation.reason)
)
return {"status": "success", "message": "Relation created"}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error creating relation: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# CALENDAR EVENTS ENDPOINTS
# ============================================================================
@router.get("/tickets/{ticket_id}/calendar-events", tags=["Calendar"])
async def get_calendar_events(ticket_id: int):
"""Hent alle kalender events for en ticket"""
try:
events = execute_query(
"""SELECT * FROM tticket_calendar_events
WHERE ticket_id = %s
ORDER BY event_date DESC, event_time DESC NULLS LAST""",
(ticket_id,)
)
return {"events": events, "total": len(events)}
except Exception as e:
logger.error(f"❌ Error fetching calendar events: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/tickets/{ticket_id}/calendar-events", tags=["Calendar"])
async def create_calendar_event(ticket_id: int, event: TTicketCalendarEventCreate):
"""Opret kalender event (manual eller AI-foreslået)"""
try:
event_id = execute_insert(
"""INSERT INTO tticket_calendar_events
(ticket_id, title, description, event_type, event_date, event_time,
duration_minutes, all_day, status, suggested_by_ai, ai_confidence,
ai_source_text, created_by_user_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 1)
RETURNING id""",
(ticket_id, event.title, event.description, event.event_type,
event.event_date, event.event_time, event.duration_minutes,
event.all_day, event.status, event.suggested_by_ai,
event.ai_confidence, event.ai_source_text)
)
logger.info(f"✅ Created calendar event {event_id} for ticket {ticket_id}")
return {"status": "success", "event_id": event_id, "message": "Calendar event created"}
except Exception as e:
logger.error(f"❌ Error creating calendar event: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/tickets/{ticket_id}/calendar-events/{event_id}", tags=["Calendar"])
async def update_calendar_event(ticket_id: int, event_id: int, status: CalendarEventStatus):
"""Opdater calendar event status"""
try:
execute_query(
"""UPDATE tticket_calendar_events
SET status = %s, updated_at = CURRENT_TIMESTAMP,
completed_at = CASE WHEN %s = 'completed' THEN CURRENT_TIMESTAMP ELSE completed_at END
WHERE id = %s AND ticket_id = %s""",
(status, status, event_id, ticket_id),
fetch=False
)
return {"status": "success", "message": "Event updated"}
except Exception as e:
logger.error(f"❌ Error updating calendar event: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/tickets/{ticket_id}/calendar-events/{event_id}", tags=["Calendar"])
async def delete_calendar_event(ticket_id: int, event_id: int):
"""Slet calendar event"""
try:
execute_query(
"DELETE FROM tticket_calendar_events WHERE id = %s AND ticket_id = %s",
(event_id, ticket_id),
fetch=False
)
return {"status": "success", "message": "Event deleted"}
except Exception as e:
logger.error(f"❌ Error deleting calendar event: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# TEMPLATES ENDPOINTS
# ============================================================================
@router.get("/templates", response_model=List[TTicketTemplate], tags=["Templates"])
async def list_templates(
category: Optional[str] = Query(None, description="Filter by category"),
active_only: bool = Query(True, description="Only show active templates")
):
"""List alle tilgængelige templates"""
try:
query = "SELECT * FROM tticket_templates WHERE 1=1"
params = []
if category:
query += " AND category = %s"
params.append(category)
if active_only:
query += " AND is_active = true"
query += " ORDER BY category, name"
templates = execute_query(query, tuple(params) if params else None)
return templates
except Exception as e:
logger.error(f"❌ Error listing templates: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/templates", tags=["Templates"])
async def create_template(template: TTicketTemplateCreate):
"""Opret ny template"""
try:
template_id = execute_insert(
"""INSERT INTO tticket_templates
(name, description, category, subject_template, body_template,
available_placeholders, default_attachments, is_active,
requires_approval, created_by_user_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 1)
RETURNING id""",
(template.name, template.description, template.category,
template.subject_template, template.body_template,
template.available_placeholders, template.default_attachments,
template.is_active, template.requires_approval)
)
logger.info(f"✅ Created template {template_id}: {template.name}")
return {"status": "success", "template_id": template_id, "message": "Template created"}
except Exception as e:
logger.error(f"❌ Error creating template: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/tickets/{ticket_id}/render-template", response_model=TemplateRenderResponse, tags=["Templates"])
async def render_template(ticket_id: int, request: TemplateRenderRequest):
"""
Render template med ticket data
Erstatter placeholders med faktiske værdier:
- {{ticket_number}}
- {{ticket_subject}}
- {{customer_name}}
- {{contact_name}}
- etc.
"""
try:
# Get template
template = execute_query_single(
"SELECT * FROM tticket_templates WHERE id = %s",
(request.template_id,)
)
if not template:
raise HTTPException(status_code=404, detail="Template not found")
# Get ticket with customer and contact data
ticket_data = execute_query_single(
"""SELECT t.*,
c.name as customer_name,
con.name as contact_name,
con.email as contact_email
FROM tticket_tickets t
LEFT JOIN customers c ON t.customer_id = c.id
LEFT JOIN contacts con ON t.contact_id = con.id
WHERE t.id = %s""",
(ticket_id,)
)
if not ticket_data:
raise HTTPException(status_code=404, detail="Ticket not found")
# Build replacement dict
replacements = {
'{{ticket_number}}': ticket_data.get('ticket_number', ''),
'{{ticket_subject}}': ticket_data.get('subject', ''),
'{{customer_name}}': ticket_data.get('customer_name', ''),
'{{contact_name}}': ticket_data.get('contact_name', ''),
'{{contact_email}}': ticket_data.get('contact_email', ''),
}
# Add custom data
if request.custom_data:
for key, value in request.custom_data.items():
replacements[f'{{{{{key}}}}}'] = str(value)
# Render subject and body
rendered_subject = template['subject_template']
rendered_body = template['body_template']
placeholders_used = []
for placeholder, value in replacements.items():
if placeholder in rendered_body or (rendered_subject and placeholder in rendered_subject):
placeholders_used.append(placeholder)
if rendered_subject:
rendered_subject = rendered_subject.replace(placeholder, value)
rendered_body = rendered_body.replace(placeholder, value)
return TemplateRenderResponse(
subject=rendered_subject,
body=rendered_body,
placeholders_used=placeholders_used
)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error rendering template: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# AI SUGGESTIONS ENDPOINTS
# ============================================================================
@router.get("/tickets/{ticket_id}/suggestions", response_model=List[TTicketAISuggestion], tags=["AI Suggestions"])
async def get_ai_suggestions(
ticket_id: int,
status: Optional[AISuggestionStatus] = Query(None, description="Filter by status"),
suggestion_type: Optional[AISuggestionType] = Query(None, description="Filter by type")
):
"""Hent AI forslag for ticket"""
try:
query = "SELECT * FROM tticket_ai_suggestions WHERE ticket_id = %s"
params = [ticket_id]
if status:
query += " AND status = %s"
params.append(status)
if suggestion_type:
query += " AND suggestion_type = %s"
params.append(suggestion_type)
query += " ORDER BY created_at DESC"
suggestions = execute_query(query, tuple(params))
return suggestions
except Exception as e:
logger.error(f"❌ Error fetching AI suggestions: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/tickets/{ticket_id}/suggestions/{suggestion_id}/review", tags=["AI Suggestions"])
async def review_ai_suggestion(ticket_id: int, suggestion_id: int, review: AISuggestionReviewRequest):
"""
Accepter eller afvis AI forslag
**VIGTIGT**: Denne endpoint ændrer KUN suggestion status.
Den udfører IKKE automatisk den foreslåede handling.
Brugeren skal selv implementere ændringen efter accept.
"""
try:
# Get suggestion
suggestion = execute_query_single(
"SELECT * FROM tticket_ai_suggestions WHERE id = %s AND ticket_id = %s",
(suggestion_id, ticket_id)
)
if not suggestion:
raise HTTPException(status_code=404, detail="Suggestion not found")
if suggestion['status'] != 'pending':
raise HTTPException(status_code=400, detail=f"Suggestion already {suggestion['status']}")
# Update status
new_status = 'accepted' if review.action == 'accept' else 'rejected'
execute_query(
"""UPDATE tticket_ai_suggestions
SET status = %s, reviewed_by_user_id = 1, reviewed_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(new_status, suggestion_id),
fetch=False
)
# Log audit
execute_query(
"""INSERT INTO tticket_audit_log (ticket_id, action, new_value, reason)
VALUES (%s, %s, %s, %s)""",
(ticket_id, f'ai_suggestion_{review.action}ed',
f"{suggestion['suggestion_type']}: {suggestion_id}", review.note)
)
logger.info(f"✅ AI suggestion {suggestion_id} {review.action}ed for ticket {ticket_id}")
return {
"status": "success",
"action": review.action,
"suggestion_type": suggestion['suggestion_type'],
"message": f"Suggestion {review.action}ed. Manual implementation required if accepted."
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error reviewing AI suggestion: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# DEADLINE ENDPOINT
# ============================================================================
@router.put("/tickets/{ticket_id}/deadline", tags=["Tickets"])
async def update_ticket_deadline(ticket_id: int, request: TicketDeadlineUpdateRequest):
"""Opdater ticket deadline"""
try:
# Get current deadline
current = execute_query_single(
"SELECT deadline FROM tticket_tickets WHERE id = %s",
(ticket_id,)
)
if not current:
raise HTTPException(status_code=404, detail="Ticket not found")
# Update deadline
execute_query(
"UPDATE tticket_tickets SET deadline = %s WHERE id = %s",
(request.deadline, ticket_id),
fetch=False
)
# Log audit (handled by trigger automatically)
if request.reason:
execute_query(
"""INSERT INTO tticket_audit_log (ticket_id, action, field_name, old_value, new_value, reason)
VALUES (%s, 'deadline_change', 'deadline', %s, %s, %s)""",
(ticket_id, str(current.get('deadline')), str(request.deadline), request.reason)
)
logger.info(f"✅ Updated deadline for ticket {ticket_id}: {request.deadline}")
return {
"status": "success",
"old_deadline": current.get('deadline'),
"new_deadline": request.deadline,
"message": "Deadline updated"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating deadline: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# AUDIT LOG ENDPOINT
# ============================================================================
@router.get("/tickets/{ticket_id}/audit-log", response_model=List[TTicketAuditLog], tags=["Audit"])
async def get_audit_log(
ticket_id: int,
limit: int = Query(50, ge=1, le=200, description="Number of entries"),
offset: int = Query(0, ge=0, description="Offset for pagination")
):
"""Hent audit log for ticket (sporbarhed)"""
try:
logs = execute_query(
"""SELECT * FROM tticket_audit_log
WHERE ticket_id = %s
ORDER BY performed_at DESC
LIMIT %s OFFSET %s""",
(ticket_id, limit, offset)
)
return logs
except Exception as e:
logger.error(f"❌ Error fetching audit log: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@ -10,7 +10,7 @@ from datetime import datetime
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from decimal import Decimal from decimal import Decimal
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.ticket.backend.models import ( from app.ticket.backend.models import (
TicketStatus, TicketStatus,
TicketPriority, TicketPriority,
@ -84,13 +84,14 @@ class TicketService:
from psycopg2.extras import Json from psycopg2.extras import Json
# Insert ticket (trigger will auto-generate ticket_number if NULL) # Insert ticket (trigger will auto-generate ticket_number if NULL)
ticket_id = execute_insert( result = execute_query_single(
""" """
INSERT INTO tticket_tickets ( INSERT INTO tticket_tickets (
ticket_number, subject, description, status, priority, category, ticket_number, subject, description, status, priority, category,
customer_id, contact_id, assigned_to_user_id, created_by_user_id, customer_id, contact_id, assigned_to_user_id, created_by_user_id,
source, tags, custom_fields source, tags, custom_fields
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", """,
( (
ticket_data.ticket_number, ticket_data.ticket_number,
@ -109,6 +110,11 @@ class TicketService:
) )
) )
if not result:
raise Exception("Failed to create ticket - no ID returned")
ticket_id = result['id']
# Log creation # Log creation
TicketService.log_audit( TicketService.log_audit(
ticket_id=ticket_id, ticket_id=ticket_id,
@ -120,11 +126,9 @@ class TicketService:
) )
# Fetch created ticket # Fetch created ticket
ticket = execute_query( ticket = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s", "SELECT * FROM tticket_tickets WHERE id = %s",
(ticket_id,), (ticket_id,))
fetchone=True
)
logger.info(f"✅ Created ticket {ticket['ticket_number']} (ID: {ticket_id})") logger.info(f"✅ Created ticket {ticket['ticket_number']} (ID: {ticket_id})")
return ticket return ticket
@ -147,11 +151,9 @@ class TicketService:
Updated ticket dict Updated ticket dict
""" """
# Get current ticket # Get current ticket
current = execute_query( current = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s", "SELECT * FROM tticket_tickets WHERE id = %s",
(ticket_id,), (ticket_id,))
fetchone=True
)
if not current: if not current:
raise ValueError(f"Ticket {ticket_id} not found") raise ValueError(f"Ticket {ticket_id} not found")
@ -198,11 +200,9 @@ class TicketService:
) )
# Fetch updated ticket # Fetch updated ticket
updated = execute_query( updated = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s", "SELECT * FROM tticket_tickets WHERE id = %s",
(ticket_id,), (ticket_id,))
fetchone=True
)
logger.info(f"✅ Updated ticket {updated['ticket_number']}") logger.info(f"✅ Updated ticket {updated['ticket_number']}")
return updated return updated
@ -230,11 +230,9 @@ class TicketService:
ValueError: If transition is not allowed ValueError: If transition is not allowed
""" """
# Get current ticket # Get current ticket
current = execute_query( current = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s", "SELECT * FROM tticket_tickets WHERE id = %s",
(ticket_id,), (ticket_id,))
fetchone=True
)
if not current: if not current:
raise ValueError(f"Ticket {ticket_id} not found") raise ValueError(f"Ticket {ticket_id} not found")
@ -280,11 +278,9 @@ class TicketService:
) )
# Fetch updated ticket # Fetch updated ticket
updated = execute_query( updated = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s", "SELECT * FROM tticket_tickets WHERE id = %s",
(ticket_id,), (ticket_id,))
fetchone=True
)
logger.info(f"✅ Updated ticket {updated['ticket_number']} status: {current_status}{new_status}") logger.info(f"✅ Updated ticket {updated['ticket_number']} status: {current_status}{new_status}")
return updated return updated
@ -307,11 +303,9 @@ class TicketService:
Updated ticket dict Updated ticket dict
""" """
# Get current assignment # Get current assignment
current = execute_query( current = execute_query_single(
"SELECT assigned_to_user_id FROM tticket_tickets WHERE id = %s", "SELECT assigned_to_user_id FROM tticket_tickets WHERE id = %s",
(ticket_id,), (ticket_id,))
fetchone=True
)
if not current: if not current:
raise ValueError(f"Ticket {ticket_id} not found") raise ValueError(f"Ticket {ticket_id} not found")
@ -334,11 +328,9 @@ class TicketService:
) )
# Fetch updated ticket # Fetch updated ticket
updated = execute_query( updated = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s", "SELECT * FROM tticket_tickets WHERE id = %s",
(ticket_id,), (ticket_id,))
fetchone=True
)
logger.info(f"✅ Assigned ticket {updated['ticket_number']} to user {assigned_to_user_id}") logger.info(f"✅ Assigned ticket {updated['ticket_number']} to user {assigned_to_user_id}")
return updated return updated
@ -363,11 +355,9 @@ class TicketService:
Created comment dict Created comment dict
""" """
# Verify ticket exists # Verify ticket exists
ticket = execute_query( ticket = execute_query_single(
"SELECT id FROM tticket_tickets WHERE id = %s", "SELECT id FROM tticket_tickets WHERE id = %s",
(ticket_id,), (ticket_id,))
fetchone=True
)
if not ticket: if not ticket:
raise ValueError(f"Ticket {ticket_id} not found") raise ValueError(f"Ticket {ticket_id} not found")
@ -389,11 +379,9 @@ class TicketService:
# Update first_response_at if this is the first non-internal comment # Update first_response_at if this is the first non-internal comment
if not is_internal: if not is_internal:
ticket = execute_query( ticket = execute_query_single(
"SELECT first_response_at FROM tticket_tickets WHERE id = %s", "SELECT first_response_at FROM tticket_tickets WHERE id = %s",
(ticket_id,), (ticket_id,))
fetchone=True
)
if not ticket['first_response_at']: if not ticket['first_response_at']:
execute_update( execute_update(
"UPDATE tticket_tickets SET first_response_at = CURRENT_TIMESTAMP WHERE id = %s", "UPDATE tticket_tickets SET first_response_at = CURRENT_TIMESTAMP WHERE id = %s",
@ -411,11 +399,9 @@ class TicketService:
) )
# Fetch created comment # Fetch created comment
comment = execute_query( comment = execute_query_single(
"SELECT * FROM tticket_comments WHERE id = %s", "SELECT * FROM tticket_comments WHERE id = %s",
(comment_id,), (comment_id,))
fetchone=True
)
logger.info(f"💬 Added comment to ticket {ticket_id} (internal: {is_internal})") logger.info(f"💬 Added comment to ticket {ticket_id} (internal: {is_internal})")
return comment return comment
@ -471,19 +457,15 @@ class TicketService:
Returns: Returns:
Ticket dict with stats or None if not found Ticket dict with stats or None if not found
""" """
ticket = execute_query( ticket = execute_query_single(
"SELECT * FROM tticket_open_tickets WHERE id = %s", "SELECT * FROM tticket_open_tickets WHERE id = %s",
(ticket_id,), (ticket_id,))
fetchone=True
)
# If not in open_tickets view, fetch from main table # If not in open_tickets view, fetch from main table
if not ticket: if not ticket:
ticket = execute_query( ticket = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s", "SELECT * FROM tticket_tickets WHERE id = %s",
(ticket_id,), (ticket_id,))
fetchone=True
)
return ticket return ticket

View File

@ -2,360 +2,282 @@
{% block title %}Ticket Dashboard - BMC Hub{% endblock %} {% block title %}Ticket Dashboard - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.stat-card {
text-align: center;
padding: 2rem 1.5rem;
cursor: pointer;
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: var(--accent);
transform: scaleX(0);
transition: transform 0.3s;
}
.stat-card:hover::before {
transform: scaleX(1);
}
.stat-card h3 {
font-size: 3rem;
font-weight: 700;
color: var(--accent);
margin-bottom: 0.5rem;
line-height: 1;
}
.stat-card p {
color: var(--text-secondary);
font-size: 0.9rem;
margin: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-card .icon {
font-size: 2rem;
opacity: 0.3;
margin-bottom: 1rem;
}
.stat-card.status-open h3 { color: #17a2b8; }
.stat-card.status-in-progress h3 { color: #ffc107; }
.stat-card.status-resolved h3 { color: #28a745; }
.stat-card.status-closed h3 { color: #6c757d; }
.ticket-list {
background: var(--bg-card);
}
.ticket-list th {
font-weight: 600;
color: var(--text-secondary);
border-bottom: 2px solid var(--accent-light);
padding: 1rem 0.75rem;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ticket-list td {
padding: 1rem 0.75rem;
vertical-align: middle;
border-bottom: 1px solid var(--accent-light);
}
.ticket-row {
transition: background-color 0.2s;
cursor: pointer;
}
.ticket-row:hover {
background-color: var(--accent-light);
}
.badge {
padding: 0.4rem 0.8rem;
font-weight: 500;
border-radius: 6px;
font-size: 0.75rem;
}
.badge-status-open {
background-color: #d1ecf1;
color: #0c5460;
}
.badge-status-in_progress {
background-color: #fff3cd;
color: #856404;
}
.badge-status-pending_customer {
background-color: #e2e3e5;
color: #383d41;
}
.badge-status-resolved {
background-color: #d4edda;
color: #155724;
}
.badge-status-closed {
background-color: #f8d7da;
color: #721c24;
}
.badge-priority-low {
background-color: var(--accent-light);
color: var(--accent);
}
.badge-priority-normal {
background-color: #e2e3e5;
color: #383d41;
}
.badge-priority-high {
background-color: #fff3cd;
color: #856404;
}
.badge-priority-urgent, .badge-priority-critical {
background-color: #f8d7da;
color: #721c24;
}
.ticket-number {
font-family: 'Monaco', 'Courier New', monospace;
background: var(--accent-light);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
color: var(--accent);
font-weight: 600;
}
.worklog-stats {
display: flex;
justify-content: space-around;
padding: 1.5rem;
background: linear-gradient(135deg, var(--accent-light) 0%, var(--bg-card) 100%);
border-radius: var(--border-radius);
margin-bottom: 2rem;
}
.worklog-stat {
text-align: center;
}
.worklog-stat h4 {
font-size: 2rem;
font-weight: 700;
color: var(--accent);
margin: 0;
}
.worklog-stat p {
color: var(--text-secondary);
margin: 0;
font-size: 0.85rem;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: var(--text-secondary);
}
.empty-state i {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.3;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.section-header h2 {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.quick-actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid px-4"> <div class="container-fluid py-4">
<!-- Page Header --> <!-- Header -->
<div class="section-header"> <div class="row mb-4">
<div> <div class="col">
<h1 class="mb-2"> <h1 class="h3 mb-0">🎫 Support Dashboard</h1>
<i class="bi bi-speedometer2"></i> Ticket Dashboard <p class="text-muted">Oversigt over alle support tickets og aktivitet</p>
</h1>
<p class="text-muted">Oversigt over alle tickets og worklog aktivitet</p>
</div>
<div class="quick-actions">
<a href="/ticket/tickets/new" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Ny Ticket
</a>
</div>
</div> </div>
<div class="col-auto">
<button class="btn btn-primary" onclick="window.location.href='/ticket/tickets/new'">
<i class="bi bi-plus-circle"></i> Ny Ticket
</button>
</div>
</div>
<!-- Ticket Statistics --> <!-- Status Overview -->
<div class="row mb-4"> <div class="row g-3 mb-4">
<div class="col-md-3"> <div class="col-md-2">
<div class="card stat-card status-open" onclick="filterTickets('open')"> <div class="card border-0 shadow-sm h-100" style="cursor: pointer;" onclick="filterByStatus('open')">
<div class="icon"><i class="bi bi-inbox"></i></div> <div class="card-body text-center">
<h3>{{ stats.open_count or 0 }}</h3> <div class="rounded-circle bg-info bg-opacity-10 p-3 d-inline-flex mb-3">
<p>Nye Tickets</p> <i class="bi bi-inbox-fill text-info fs-4"></i>
</div> </div>
</div> <h2 class="mb-1 text-info">{{ stats.open_count or 0 }}</h2>
<div class="col-md-3"> <p class="text-muted small mb-0">Åbne</p>
<div class="card stat-card status-in-progress" onclick="filterTickets('in_progress')">
<div class="icon"><i class="bi bi-arrow-repeat"></i></div>
<h3>{{ stats.in_progress_count or 0 }}</h3>
<p>I Gang</p>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card status-resolved" onclick="filterTickets('resolved')">
<div class="icon"><i class="bi bi-check-circle"></i></div>
<h3>{{ stats.resolved_count or 0 }}</h3>
<p>Løst</p>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card status-closed" onclick="filterTickets('closed')">
<div class="icon"><i class="bi bi-archive"></i></div>
<h3>{{ stats.closed_count or 0 }}</h3>
<p>Lukket</p>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-2">
<!-- Worklog Statistics --> <div class="card border-0 shadow-sm h-100" style="cursor: pointer;" onclick="filterByStatus('in_progress')">
<div class="worklog-stats"> <div class="card-body text-center">
<div class="worklog-stat"> <div class="rounded-circle bg-warning bg-opacity-10 p-3 d-inline-flex mb-3">
<h4>{{ worklog_stats.draft_count or 0 }}</h4> <i class="bi bi-hourglass-split text-warning fs-4"></i>
<p>Draft Worklog</p> </div>
</div> <h2 class="mb-1 text-warning">{{ stats.in_progress_count or 0 }}</h2>
<div class="worklog-stat"> <p class="text-muted small mb-0">I Gang</p>
<h4>{{ "%.1f"|format(worklog_stats.draft_hours or 0) }}t</h4> </div>
<p>Udraft Timer</p>
</div>
<div class="worklog-stat">
<h4>{{ worklog_stats.billable_count or 0 }}</h4>
<p>Billable Entries</p>
</div>
<div class="worklog-stat">
<h4>{{ "%.1f"|format(worklog_stats.billable_hours or 0) }}t</h4>
<p>Billable Timer</p>
</div> </div>
</div> </div>
<div class="col-md-2">
<div class="card border-0 shadow-sm h-100" style="cursor: pointer;" onclick="filterByStatus('pending_customer')">
<div class="card-body text-center">
<div class="rounded-circle bg-secondary bg-opacity-10 p-3 d-inline-flex mb-3">
<i class="bi bi-clock-fill text-secondary fs-4"></i>
</div>
<h2 class="mb-1 text-secondary">{{ stats.pending_count or 0 }}</h2>
<p class="text-muted small mb-0">Afventer</p>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card border-0 shadow-sm h-100" style="cursor: pointer;" onclick="filterByStatus('resolved')">
<div class="card-body text-center">
<div class="rounded-circle bg-success bg-opacity-10 p-3 d-inline-flex mb-3">
<i class="bi bi-check-circle-fill text-success fs-4"></i>
</div>
<h2 class="mb-1 text-success">{{ stats.resolved_count or 0 }}</h2>
<p class="text-muted small mb-0">Løst</p>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card border-0 shadow-sm h-100" style="cursor: pointer;" onclick="filterByStatus('closed')">
<div class="card-body text-center">
<div class="rounded-circle bg-dark bg-opacity-10 p-3 d-inline-flex mb-3">
<i class="bi bi-archive-fill text-dark fs-4"></i>
</div>
<h2 class="mb-1 text-dark">{{ stats.closed_count or 0 }}</h2>
<p class="text-muted small mb-0">Lukket</p>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card border-0 shadow-sm h-100 bg-primary text-white">
<div class="card-body text-center">
<div class="rounded-circle bg-white bg-opacity-25 p-3 d-inline-flex mb-3">
<i class="bi bi-ticket-detailed-fill fs-4"></i>
</div>
<h2 class="mb-1">{{ stats.total_count or 0 }}</h2>
<p class="small mb-0 opacity-75">I Alt</p>
</div>
</div>
</div>
</div>
<!-- Recent Tickets --> <!-- Worklog & Prepaid Overview -->
<div class="section-header"> <div class="row g-4 mb-4">
<h2> <div class="col-md-6">
<i class="bi bi-clock-history"></i> Seneste Tickets <div class="card border-0 shadow-sm">
</h2> <div class="card-header bg-white border-0 py-3">
<a href="/ticket/tickets" class="btn btn-outline-secondary"> <h5 class="mb-0">⏱️ Worklog Status</h5>
<i class="bi bi-list-ul"></i> Se Alle </div>
<div class="card-body">
<div class="row">
<div class="col-6 border-end">
<div class="text-center p-3">
<h3 class="text-warning mb-2">{{ worklog_stats.draft_count or 0 }}</h3>
<p class="text-muted small mb-1">Kladder</p>
<p class="mb-0"><strong>{{ "%.1f"|format(worklog_stats.draft_hours|float if worklog_stats.draft_hours else 0) }} timer</strong></p>
</div>
</div>
<div class="col-6">
<div class="text-center p-3">
<h3 class="text-success mb-2">{{ worklog_stats.billable_count or 0 }}</h3>
<p class="text-muted small mb-1">Fakturerbare</p>
<p class="mb-0"><strong>{{ "%.1f"|format(worklog_stats.billable_hours|float if worklog_stats.billable_hours else 0) }} timer</strong></p>
</div>
</div>
</div>
<div class="text-center pt-3 border-top">
<a href="/ticket/worklog/review" class="btn btn-outline-primary btn-sm">
<i class="bi bi-check-square"></i> Godkend Worklog
</a>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0">💳 Prepaid Cards</h5>
</div>
<div class="card-body" id="prepaidStats">
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Tickets -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0">📋 Seneste Tickets</h5>
<a href="/ticket/tickets" class="btn btn-sm btn-outline-secondary">
Se Alle <i class="bi bi-arrow-right"></i>
</a> </a>
</div> </div>
<div class="card-body p-0">
{% if recent_tickets %}
<div class="card">
<div class="table-responsive"> <div class="table-responsive">
<table class="table ticket-list mb-0"> <table class="table table-hover mb-0">
<thead> <thead class="table-light">
<tr> <tr>
<th>Ticket</th> <th>Ticket #</th>
<th>Emne</th>
<th>Kunde</th> <th>Kunde</th>
<th>Status</th> <th>Status</th>
<th>Prioritet</th> <th>Prioritet</th>
<th>Oprettet</th> <th>Oprettet</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for ticket in recent_tickets %} {% if recent_tickets %}
<tr class="ticket-row" onclick="window.location='/ticket/tickets/{{ ticket.id }}'"> {% for ticket in recent_tickets %}
<td> <tr onclick="window.location.href='/ticket/tickets/{{ ticket.id }}'" style="cursor: pointer;">
<span class="ticket-number">{{ ticket.ticket_number }}</span> <td><strong>{{ ticket.ticket_number }}</strong></td>
<br> <td>{{ ticket.subject }}</td>
<strong>{{ ticket.subject }}</strong> <td>{{ ticket.customer_name or '-' }}</td>
</td> <td>
<td> {% if ticket.status == 'open' %}
{% if ticket.customer_name %} <span class="badge bg-info">Åben</span>
{{ ticket.customer_name }} {% elif ticket.status == 'in_progress' %}
{% else %} <span class="badge bg-warning">I Gang</span>
<span class="text-muted">-</span> {% elif ticket.status == 'pending_customer' %}
{% endif %} <span class="badge bg-secondary">Afventer</span>
</td> {% elif ticket.status == 'resolved' %}
<td> <span class="badge bg-success">Løst</span>
<span class="badge badge-status-{{ ticket.status }}"> {% elif ticket.status == 'closed' %}
{{ ticket.status.replace('_', ' ').title() }} <span class="badge bg-dark">Lukket</span>
</span> {% else %}
</td> <span class="badge bg-secondary">{{ ticket.status }}</span>
<td> {% endif %}
<span class="badge badge-priority-{{ ticket.priority }}"> </td>
{{ ticket.priority.title() }} <td>
</span> {% if ticket.priority == 'urgent' %}
</td> <span class="badge bg-danger">Akut</span>
<td> {% elif ticket.priority == 'high' %}
{{ ticket.created_at.strftime('%d-%m-%Y %H:%M') if ticket.created_at else '-' }} <span class="badge bg-warning">Høj</span>
</td> {% elif ticket.priority == 'normal' %}
</tr> <span class="badge bg-info">Normal</span>
{% endfor %} {% else %}
<span class="badge bg-secondary">Lav</span>
{% endif %}
</td>
<td>{{ ticket.created_at.strftime('%d/%m/%Y %H:%M') if ticket.created_at else '-' }}</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation(); window.location.href='/ticket/tickets/{{ ticket.id }}'">
<i class="bi bi-arrow-right"></i>
</button>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="7" class="text-center text-muted py-5">
Ingen tickets endnu
</td>
</tr>
{% endif %}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
{% else %} </div>
<div class="card"> </div>
<div class="empty-state">
<i class="bi bi-inbox"></i> <script>
<h3>Ingen tickets endnu</h3> // Load prepaid stats
<p>Opret din første ticket for at komme i gang</p> document.addEventListener('DOMContentLoaded', () => {
<a href="/ticket/tickets/new" class="btn btn-primary mt-3"> loadPrepaidStats();
<i class="bi bi-plus-circle"></i> Opret Ticket });
async function loadPrepaidStats() {
try {
const response = await fetch('/api/v1/prepaid-cards/stats/summary');
const stats = await response.json();
document.getElementById('prepaidStats').innerHTML = `
<div class="row text-center">
<div class="col-6 border-end">
<h4 class="text-success mb-1">${stats.active_count || 0}</h4>
<p class="text-muted small mb-0">Aktive Kort</p>
</div>
<div class="col-6">
<h4 class="text-primary mb-1">${parseFloat(stats.total_remaining_hours || 0).toFixed(1)} t</h4>
<p class="text-muted small mb-0">Timer Tilbage</p>
</div>
</div>
<div class="text-center pt-3 border-top">
<a href="/prepaid-cards" class="btn btn-outline-primary btn-sm">
<i class="bi bi-credit-card-2-front"></i> Se Alle Kort
</a> </a>
</div> </div>
</div> `;
{% endif %} } catch (error) {
</div> console.error('Error loading prepaid stats:', error);
{% endblock %} document.getElementById('prepaidStats').innerHTML = `
<p class="text-center text-muted mb-0">Kunne ikke indlæse data</p>
`;
}
}
{% block extra_js %} function filterByStatus(status) {
<script> window.location.href = `/ticket/tickets?status=${status}`;
// Filter tickets by status }
function filterTickets(status) { </script>
window.location.href = `/ticket/tickets?status=${status}`;
}
// Auto-refresh every 5 minutes <style>
setTimeout(() => { .card {
location.reload(); transition: transform 0.2s, box-shadow 0.2s;
}, 300000); }
</script>
.card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
.table th {
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--bs-secondary);
}
.table tbody tr {
transition: background-color 0.2s;
}
.table tbody tr:hover {
background-color: rgba(15, 76, 117, 0.05);
}
</style>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,361 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Ticket Dashboard - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.stat-card {
text-align: center;
padding: 2rem 1.5rem;
cursor: pointer;
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: var(--accent);
transform: scaleX(0);
transition: transform 0.3s;
}
.stat-card:hover::before {
transform: scaleX(1);
}
.stat-card h3 {
font-size: 3rem;
font-weight: 700;
color: var(--accent);
margin-bottom: 0.5rem;
line-height: 1;
}
.stat-card p {
color: var(--text-secondary);
font-size: 0.9rem;
margin: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-card .icon {
font-size: 2rem;
opacity: 0.3;
margin-bottom: 1rem;
}
.stat-card.status-open h3 { color: #17a2b8; }
.stat-card.status-in-progress h3 { color: #ffc107; }
.stat-card.status-resolved h3 { color: #28a745; }
.stat-card.status-closed h3 { color: #6c757d; }
.ticket-list {
background: var(--bg-card);
}
.ticket-list th {
font-weight: 600;
color: var(--text-secondary);
border-bottom: 2px solid var(--accent-light);
padding: 1rem 0.75rem;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ticket-list td {
padding: 1rem 0.75rem;
vertical-align: middle;
border-bottom: 1px solid var(--accent-light);
}
.ticket-row {
transition: background-color 0.2s;
cursor: pointer;
}
.ticket-row:hover {
background-color: var(--accent-light);
}
.badge {
padding: 0.4rem 0.8rem;
font-weight: 500;
border-radius: 6px;
font-size: 0.75rem;
}
.badge-status-open {
background-color: #d1ecf1;
color: #0c5460;
}
.badge-status-in_progress {
background-color: #fff3cd;
color: #856404;
}
.badge-status-pending_customer {
background-color: #e2e3e5;
color: #383d41;
}
.badge-status-resolved {
background-color: #d4edda;
color: #155724;
}
.badge-status-closed {
background-color: #f8d7da;
color: #721c24;
}
.badge-priority-low {
background-color: var(--accent-light);
color: var(--accent);
}
.badge-priority-normal {
background-color: #e2e3e5;
color: #383d41;
}
.badge-priority-high {
background-color: #fff3cd;
color: #856404;
}
.badge-priority-urgent, .badge-priority-critical {
background-color: #f8d7da;
color: #721c24;
}
.ticket-number {
font-family: 'Monaco', 'Courier New', monospace;
background: var(--accent-light);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
color: var(--accent);
font-weight: 600;
}
.worklog-stats {
display: flex;
justify-content: space-around;
padding: 1.5rem;
background: linear-gradient(135deg, var(--accent-light) 0%, var(--bg-card) 100%);
border-radius: var(--border-radius);
margin-bottom: 2rem;
}
.worklog-stat {
text-align: center;
}
.worklog-stat h4 {
font-size: 2rem;
font-weight: 700;
color: var(--accent);
margin: 0;
}
.worklog-stat p {
color: var(--text-secondary);
margin: 0;
font-size: 0.85rem;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: var(--text-secondary);
}
.empty-state i {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.3;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.section-header h2 {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.quick-actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<!-- Page Header -->
<div class="section-header">
<div>
<h1 class="mb-2">
<i class="bi bi-speedometer2"></i> Ticket Dashboard
</h1>
<p class="text-muted">Oversigt over alle tickets og worklog aktivitet</p>
</div>
<div class="quick-actions">
<a href="/ticket/tickets/new" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Ny Ticket
</a>
</div>
</div>
<!-- Ticket Statistics -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card stat-card status-open" onclick="filterTickets('open')">
<div class="icon"><i class="bi bi-inbox"></i></div>
<h3>{{ stats.open_count or 0 }}</h3>
<p>Nye Tickets</p>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card status-in-progress" onclick="filterTickets('in_progress')">
<div class="icon"><i class="bi bi-arrow-repeat"></i></div>
<h3>{{ stats.in_progress_count or 0 }}</h3>
<p>I Gang</p>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card status-resolved" onclick="filterTickets('resolved')">
<div class="icon"><i class="bi bi-check-circle"></i></div>
<h3>{{ stats.resolved_count or 0 }}</h3>
<p>Løst</p>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card status-closed" onclick="filterTickets('closed')">
<div class="icon"><i class="bi bi-archive"></i></div>
<h3>{{ stats.closed_count or 0 }}</h3>
<p>Lukket</p>
</div>
</div>
</div>
<!-- Worklog Statistics -->
<div class="worklog-stats">
<div class="worklog-stat">
<h4>{{ worklog_stats.draft_count or 0 }}</h4>
<p>Draft Worklog</p>
</div>
<div class="worklog-stat">
<h4>{{ "%.1f"|format(worklog_stats.draft_hours or 0) }}t</h4>
<p>Udraft Timer</p>
</div>
<div class="worklog-stat">
<h4>{{ worklog_stats.billable_count or 0 }}</h4>
<p>Billable Entries</p>
</div>
<div class="worklog-stat">
<h4>{{ "%.1f"|format(worklog_stats.billable_hours or 0) }}t</h4>
<p>Billable Timer</p>
</div>
</div>
<!-- Recent Tickets -->
<div class="section-header">
<h2>
<i class="bi bi-clock-history"></i> Seneste Tickets
</h2>
<a href="/ticket/tickets" class="btn btn-outline-secondary">
<i class="bi bi-list-ul"></i> Se Alle
</a>
</div>
{% if recent_tickets %}
<div class="card">
<div class="table-responsive">
<table class="table ticket-list mb-0">
<thead>
<tr>
<th>Ticket</th>
<th>Kunde</th>
<th>Status</th>
<th>Prioritet</th>
<th>Oprettet</th>
</tr>
</thead>
<tbody>
{% for ticket in recent_tickets %}
<tr class="ticket-row" onclick="window.location='/ticket/tickets/{{ ticket.id }}'">
<td>
<span class="ticket-number">{{ ticket.ticket_number }}</span>
<br>
<strong>{{ ticket.subject }}</strong>
</td>
<td>
{% if ticket.customer_name %}
{{ ticket.customer_name }}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<span class="badge badge-status-{{ ticket.status }}">
{{ ticket.status.replace('_', ' ').title() }}
</span>
</td>
<td>
<span class="badge badge-priority-{{ ticket.priority }}">
{{ ticket.priority.title() }}
</span>
</td>
<td>
{{ ticket.created_at.strftime('%d-%m-%Y %H:%M') if ticket.created_at else '-' }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="card">
<div class="empty-state">
<i class="bi bi-inbox"></i>
<h3>Ingen tickets endnu</h3>
<p>Opret din første ticket for at komme i gang</p>
<a href="/ticket/tickets/new" class="btn btn-primary mt-3">
<i class="bi bi-plus-circle"></i> Opret Ticket
</a>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script>
// Filter tickets by status
function filterTickets(status) {
window.location.href = `/ticket/tickets?status=${status}`;
}
// Auto-refresh every 5 minutes
setTimeout(() => {
location.reload();
}, 300000);
</script>
{% endblock %}

View File

@ -190,7 +190,7 @@
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="action-buttons mb-4"> <div class="action-buttons mb-4">
<a href="/api/v1/tickets/{{ ticket.id }}" class="btn btn-outline-primary"> <a href="/api/v1/ticket/tickets/{{ ticket.id }}" class="btn btn-outline-primary">
<i class="bi bi-pencil"></i> Rediger <i class="bi bi-pencil"></i> Rediger
</a> </a>
<button class="btn btn-outline-secondary" onclick="addComment()"> <button class="btn btn-outline-secondary" onclick="addComment()">
@ -398,12 +398,12 @@
<script> <script>
// Add comment (placeholder - integrate with API) // Add comment (placeholder - integrate with API)
function addComment() { function addComment() {
alert('Add comment functionality - integrate with POST /api/v1/tickets/{{ ticket.id }}/comments'); alert('Add comment functionality - integrate with POST /api/v1/ticket/tickets/{{ ticket.id }}/comments');
} }
// Add worklog (placeholder - integrate with API) // Add worklog (placeholder - integrate with API)
function addWorklog() { function addWorklog() {
alert('Add worklog functionality - integrate with POST /api/v1/tickets/{{ ticket.id }}/worklog'); alert('Add worklog functionality - integrate with POST /api/v1/ticket/tickets/{{ ticket.id }}/worklog');
} }
</script> </script>
{% endblock %} {% endblock %}

View File

@ -117,7 +117,7 @@
</h1> </h1>
<p class="text-muted">Oversigt over alle tickets i systemet</p> <p class="text-muted">Oversigt over alle tickets i systemet</p>
</div> </div>
<a href="/api/v1/tickets" class="btn btn-primary"> <a href="/api/v1/ticket/tickets" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Ny Ticket <i class="bi bi-plus-circle"></i> Ny Ticket
</a> </a>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@ -148,7 +148,7 @@ async def approve_worklog_entry(
FROM tticket_worklog FROM tticket_worklog
WHERE id = %s WHERE id = %s
""" """
entry = execute_query(check_query, (worklog_id,), fetchone=True) entry = execute_query_single(check_query, (worklog_id,))
if not entry: if not entry:
raise HTTPException(status_code=404, detail="Worklog entry not found") raise HTTPException(status_code=404, detail="Worklog entry not found")
@ -199,7 +199,7 @@ async def reject_worklog_entry(
FROM tticket_worklog FROM tticket_worklog
WHERE id = %s WHERE id = %s
""" """
entry = execute_query(check_query, (worklog_id,), fetchone=True) entry = execute_query_single(check_query, (worklog_id,))
if not entry: if not entry:
raise HTTPException(status_code=404, detail="Worklog entry not found") raise HTTPException(status_code=404, detail="Worklog entry not found")
@ -235,6 +235,14 @@ async def reject_worklog_entry(
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/tickets/new", response_class=HTMLResponse)
async def new_ticket_page(request: Request):
"""
New ticket creation page with multi-step wizard
"""
return templates.TemplateResponse("ticket/frontend/ticket_new.html", {"request": request})
@router.get("/dashboard", response_class=HTMLResponse) @router.get("/dashboard", response_class=HTMLResponse)
async def ticket_dashboard(request: Request): async def ticket_dashboard(request: Request):
""" """
@ -252,7 +260,8 @@ async def ticket_dashboard(request: Request):
COUNT(*) AS total_count COUNT(*) AS total_count
FROM tticket_tickets FROM tticket_tickets
""" """
stats = execute_query(stats_query, fetchone=True) stats_result = execute_query(stats_query)
stats = stats_result[0] if stats_result else {}
# Get recent tickets # Get recent tickets
recent_query = """ recent_query = """
@ -280,20 +289,21 @@ async def ticket_dashboard(request: Request):
COALESCE(SUM(hours) FILTER (WHERE status = 'billable'), 0) AS billable_hours COALESCE(SUM(hours) FILTER (WHERE status = 'billable'), 0) AS billable_hours
FROM tticket_worklog FROM tticket_worklog
""" """
worklog_stats = execute_query(worklog_stats_query, fetchone=True) worklog_stats_result = execute_query(worklog_stats_query)
worklog_stats = worklog_stats_result[0] if worklog_stats_result else {}
return templates.TemplateResponse( return templates.TemplateResponse(
"ticket/frontend/dashboard.html", "ticket/frontend/dashboard.html",
{ {
"request": request, "request": request,
"stats": stats, "stats": stats,
"recent_tickets": recent_tickets, "recent_tickets": recent_tickets or [],
"worklog_stats": worklog_stats "worklog_stats": worklog_stats
} }
) )
except Exception as e: except Exception as e:
logger.error(f"❌ Failed to load dashboard: {e}") logger.error(f"❌ Failed to load dashboard: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@ -396,7 +406,7 @@ async def ticket_detail_page(request: Request, ticket_id: int):
LEFT JOIN users u ON u.user_id = t.assigned_to_user_id LEFT JOIN users u ON u.user_id = t.assigned_to_user_id
WHERE t.id = %s WHERE t.id = %s
""" """
ticket = execute_query(ticket_query, (ticket_id,), fetchone=True) ticket = execute_query_single(ticket_query, (ticket_id,))
if not ticket: if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found") raise HTTPException(status_code=404, detail="Ticket not found")

View File

@ -272,7 +272,7 @@
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/api/v1/tickets"> <a class="nav-link" href="/api/v1/ticket/tickets">
<i class="bi bi-ticket-detailed"></i> Tickets <i class="bi bi-ticket-detailed"></i> Tickets
</a> </a>
</li> </li>

View File

@ -22,7 +22,7 @@ from app.core.database import execute_query, execute_update
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__)
@ -162,7 +162,7 @@ class EconomicExportService:
JOIN tmodule_customers c ON o.customer_id = c.id JOIN tmodule_customers c ON o.customer_id = c.id
WHERE o.id = %s WHERE o.id = %s
""" """
order = execute_query(order_query, (request.order_id,), fetchone=True) order = execute_query_single(order_query, (request.order_id,))
if not order: if not order:
raise HTTPException(status_code=404, detail="Order not found") raise HTTPException(status_code=404, detail="Order not found")
@ -187,7 +187,7 @@ class EconomicExportService:
WHERE order_id = %s WHERE order_id = %s
ORDER BY line_number ORDER BY line_number
""" """
lines = execute_query(lines_query, (request.order_id,)) lines = execute_query_single(lines_query, (request.order_id,))
if not lines: if not lines:
raise HTTPException( raise HTTPException(
@ -244,7 +244,7 @@ class EconomicExportService:
LEFT JOIN customers c ON tc.hub_customer_id = c.id LEFT JOIN customers c ON tc.hub_customer_id = c.id
WHERE tc.id = %s WHERE tc.id = %s
""" """
customer_data = execute_query(customer_number_query, (order['customer_id'],), fetchone=True) customer_data = execute_query(customer_number_query, (order['customer_id'],))
if not customer_data or not customer_data.get('economic_customer_number'): if not customer_data or not customer_data.get('economic_customer_number'):
raise HTTPException( raise HTTPException(

View File

@ -20,7 +20,7 @@ from app.timetracking.backend.models import (
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__)
@ -42,7 +42,7 @@ class OrderService:
try: try:
# Check module customer # Check module customer
query = "SELECT hourly_rate FROM tmodule_customers WHERE id = %s" query = "SELECT hourly_rate FROM tmodule_customers WHERE id = %s"
result = execute_query(query, (customer_id,), fetchone=True) result = execute_query_single(query, (customer_id,))
if result and result.get('hourly_rate'): if result and result.get('hourly_rate'):
rate = result['hourly_rate'] rate = result['hourly_rate']
@ -52,7 +52,7 @@ class OrderService:
# Check Hub customer if linked # Check Hub customer if linked
if hub_customer_id: if hub_customer_id:
query = "SELECT hourly_rate FROM customers WHERE id = %s" query = "SELECT hourly_rate FROM customers WHERE id = %s"
result = execute_query(query, (hub_customer_id,), fetchone=True) result = execute_query_single(query, (hub_customer_id,))
if result and result.get('hourly_rate'): if result and result.get('hourly_rate'):
rate = result['hourly_rate'] rate = result['hourly_rate']
@ -86,11 +86,9 @@ class OrderService:
""" """
try: try:
# Hent customer info # Hent customer info
customer = execute_query( customer = execute_query_single(
"SELECT * FROM tmodule_customers WHERE id = %s", "SELECT * FROM tmodule_customers WHERE id = %s",
(customer_id,), (customer_id,))
fetchone=True
)
if not customer: if not customer:
raise HTTPException(status_code=404, detail="Customer not found") raise HTTPException(status_code=404, detail="Customer not found")
@ -110,7 +108,7 @@ class OrderService:
AND t.billable = true AND t.billable = true
ORDER BY c.id, t.worked_date ORDER BY c.id, t.worked_date
""" """
approved_times = execute_query(query, (customer_id,)) approved_times = execute_query_single(query, (customer_id,))
if not approved_times: if not approved_times:
raise HTTPException( raise HTTPException(
@ -316,7 +314,7 @@ class OrderService:
LEFT JOIN tmodule_customers c ON o.customer_id = c.id LEFT JOIN tmodule_customers c ON o.customer_id = c.id
WHERE o.id = %s WHERE o.id = %s
""" """
order = execute_query(order_query, (order_id,), fetchone=True) order = execute_query(order_query, (order_id,))
if not order: if not order:
raise HTTPException(status_code=404, detail="Order not found") raise HTTPException(status_code=404, detail="Order not found")
@ -336,7 +334,7 @@ class OrderService:
ol.product_number, ol.account_number, ol.created_at ol.product_number, ol.account_number, ol.created_at
ORDER BY ol.line_number ORDER BY ol.line_number
""" """
lines = execute_query(lines_query, (order_id,)) lines = execute_query_single(lines_query, (order_id,))
return TModuleOrderWithLines( return TModuleOrderWithLines(
**order, **order,
@ -401,9 +399,7 @@ class OrderService:
# Check order exists and is not exported # Check order exists and is not exported
order = execute_query( order = execute_query(
"SELECT * FROM tmodule_orders WHERE id = %s", "SELECT * FROM tmodule_orders WHERE id = %s",
(order_id,), (order_id,))
fetchone=True
)
if not order: if not order:
raise HTTPException(status_code=404, detail="Order not found") raise HTTPException(status_code=404, detail="Order not found")
@ -424,7 +420,7 @@ class OrderService:
) )
# Reset time entries back to approved # Reset time entries back to approved
lines = execute_query( lines = execute_query_single(
"SELECT time_entry_ids FROM tmodule_order_lines WHERE order_id = %s", "SELECT time_entry_ids FROM tmodule_order_lines WHERE order_id = %s",
(order_id,) (order_id,)
) )
@ -453,9 +449,7 @@ class OrderService:
# Return updated order # Return updated order
updated = execute_query( updated = execute_query(
"SELECT * FROM tmodule_orders WHERE id = %s", "SELECT * FROM tmodule_orders WHERE id = %s",
(order_id,), (order_id,))
fetchone=True
)
return TModuleOrder(**updated) return TModuleOrder(**updated)

View File

@ -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
@ -80,11 +80,9 @@ async def sync_case_comments(case_id: int):
""" """
try: try:
# Hent case fra database # Hent case fra database
case = execute_query( case = execute_query_single(
"SELECT vtiger_id FROM tmodule_cases WHERE id = %s", "SELECT vtiger_id FROM tmodule_cases WHERE id = %s",
(case_id,), (case_id,))
fetchone=True
)
if not case: if not case:
raise HTTPException(status_code=404, detail="Case not found") raise HTTPException(status_code=404, detail="Case not found")
@ -185,7 +183,7 @@ async def approve_time_entry(
JOIN tmodule_customers cust ON t.customer_id = cust.id JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE t.id = %s WHERE t.id = %s
""" """
entry = execute_query(query, (time_id,), fetchone=True) entry = execute_query_single(query, (time_id,))
if not entry: if not entry:
raise HTTPException(status_code=404, detail="Time entry not found") raise HTTPException(status_code=404, detail="Time entry not found")
@ -470,7 +468,7 @@ async def unlock_order(
RETURNING * RETURNING *
""" """
result = execute_query(update_query, (order_id,), fetchone=True) result = execute_query_single(update_query, (order_id,))
# Log unlock # Log unlock
audit.log_event( audit.log_event(
@ -551,10 +549,8 @@ async def test_economic_connection():
async def get_module_metadata(): async def get_module_metadata():
"""Hent modul metadata""" """Hent modul metadata"""
try: try:
result = execute_query( result = execute_query_single(
"SELECT * FROM tmodule_metadata ORDER BY id DESC LIMIT 1", "SELECT * FROM tmodule_metadata ORDER BY id DESC LIMIT 1")
fetchone=True
)
if not result: if not result:
raise HTTPException(status_code=404, detail="Module metadata not found") raise HTTPException(status_code=404, detail="Module metadata not found")
@ -575,7 +571,7 @@ async def module_health():
SELECT COUNT(*) as count FROM information_schema.tables SELECT COUNT(*) as count FROM information_schema.tables
WHERE table_name LIKE 'tmodule_%' WHERE table_name LIKE 'tmodule_%'
""" """
result = execute_query(tables_query, fetchone=True) result = execute_query_single(tables_query)
table_count = result['count'] if result else 0 table_count = result['count'] if result else 0
# Get stats - count each table separately # Get stats - count each table separately
@ -588,10 +584,8 @@ async def module_health():
} }
for table_name in ["customers", "cases", "times", "orders"]: for table_name in ["customers", "cases", "times", "orders"]:
count_result = execute_query( count_result = execute_query_single(
f"SELECT COUNT(*) as count FROM tmodule_{table_name}", f"SELECT COUNT(*) as count FROM tmodule_{table_name}")
fetchone=True
)
stats[table_name] = count_result['count'] if count_result else 0 stats[table_name] = count_result['count'] if count_result else 0
except Exception as e: except Exception as e:
@ -673,11 +667,9 @@ async def update_customer_hourly_rate(customer_id: int, hourly_rate: float, user
) )
# Return updated customer # Return updated customer
customer = execute_query( customer = execute_query_single(
"SELECT id, name, hourly_rate FROM tmodule_customers WHERE id = %s", "SELECT id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
(customer_id,), (customer_id,))
fetchone=True
)
if not customer: if not customer:
raise HTTPException(status_code=404, detail="Customer not found") raise HTTPException(status_code=404, detail="Customer not found")
@ -720,11 +712,9 @@ async def toggle_customer_time_card(customer_id: int, enabled: bool, user_id: Op
) )
# Return updated customer # Return updated customer
customer = execute_query( customer = execute_query_single(
"SELECT * FROM tmodule_customers WHERE id = %s", "SELECT * FROM tmodule_customers WHERE id = %s",
(customer_id,), (customer_id,))
fetchone=True
)
if not customer: if not customer:
raise HTTPException(status_code=404, detail="Customer not found") raise HTTPException(status_code=404, detail="Customer not found")
@ -770,7 +760,7 @@ async def list_customers(
query += " ORDER BY customer_name" query += " ORDER BY customer_name"
customers = execute_query(query) customers = execute_query_single(query)
else: else:
# Simple customer list # Simple customer list
query = "SELECT * FROM tmodule_customers" query = "SELECT * FROM tmodule_customers"
@ -893,7 +883,7 @@ async def uninstall_module(
(SELECT COUNT(*) FROM tmodule_order_lines) + (SELECT COUNT(*) FROM tmodule_order_lines) +
(SELECT COUNT(*) FROM tmodule_sync_log) as total (SELECT COUNT(*) FROM tmodule_sync_log) as total
""" """
count_result = execute_query(count_query, fetchone=True) count_result = execute_query(count_query)
total_rows = count_result['total'] if count_result else 0 total_rows = count_result['total'] if count_result else 0
except: except:
total_rows = 0 total_rows = 0
@ -902,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() conn = get_db_connection(, execute_query_single)
cursor = conn.cursor() cursor = conn.cursor()
dropped_items = { dropped_items = {

View File

@ -302,11 +302,9 @@ class TimeTrackingVTigerService:
data_hash = self._calculate_hash(account) data_hash = self._calculate_hash(account)
# Check if exists # Check if exists
existing = execute_query( existing = execute_query_single(
"SELECT id, sync_hash FROM tmodule_customers WHERE vtiger_id = %s", "SELECT id, sync_hash FROM tmodule_customers WHERE vtiger_id = %s",
(vtiger_id,), (vtiger_id,))
fetchone=True
)
if existing: if existing:
# Check if data changed # Check if data changed
@ -424,11 +422,9 @@ class TimeTrackingVTigerService:
continue continue
# Find customer in our DB # Find customer in our DB
customer = execute_query( customer = execute_query_single(
"SELECT id FROM tmodule_customers WHERE vtiger_id = %s", "SELECT id FROM tmodule_customers WHERE vtiger_id = %s",
(account_id,), (account_id,))
fetchone=True
)
if not customer: if not customer:
logger.warning(f"⚠️ Customer {account_id} not found - sync customers first") logger.warning(f"⚠️ Customer {account_id} not found - sync customers first")
@ -453,11 +449,9 @@ class TimeTrackingVTigerService:
data_hash = self._calculate_hash(ticket_with_comments) data_hash = self._calculate_hash(ticket_with_comments)
# Check if exists # Check if exists
existing = execute_query( existing = execute_query_single(
"SELECT id, sync_hash FROM tmodule_cases WHERE vtiger_id = %s", "SELECT id, sync_hash FROM tmodule_cases WHERE vtiger_id = %s",
(vtiger_id,), (vtiger_id,))
fetchone=True
)
if existing: if existing:
if existing['sync_hash'] == data_hash: if existing['sync_hash'] == data_hash:
@ -685,22 +679,18 @@ class TimeTrackingVTigerService:
if related_to: if related_to:
# Try to find case first, then account # Try to find case first, then account
case = execute_query( case = execute_query_single(
"SELECT id, customer_id FROM tmodule_cases WHERE vtiger_id = %s", "SELECT id, customer_id FROM tmodule_cases WHERE vtiger_id = %s",
(related_to,), (related_to,))
fetchone=True
)
if case: if case:
case_id = case['id'] case_id = case['id']
customer_id = case['customer_id'] customer_id = case['customer_id']
else: else:
# Try to find customer directly # Try to find customer directly
customer = execute_query( customer = execute_query_single(
"SELECT id FROM tmodule_customers WHERE vtiger_id = %s", "SELECT id FROM tmodule_customers WHERE vtiger_id = %s",
(related_to,), (related_to,))
fetchone=True
)
if customer: if customer:
customer_id = customer['id'] customer_id = customer['id']
@ -725,11 +715,9 @@ class TimeTrackingVTigerService:
data_hash = self._calculate_hash(timelog) data_hash = self._calculate_hash(timelog)
# Check if exists # Check if exists
existing = execute_query( existing = execute_query_single(
"SELECT id, sync_hash FROM tmodule_times WHERE vtiger_id = %s", "SELECT id, sync_hash FROM tmodule_times WHERE vtiger_id = %s",
(vtiger_id,), (vtiger_id,))
fetchone=True
)
if existing: if existing:
if existing['sync_hash'] == data_hash: if existing['sync_hash'] == data_hash:

View File

@ -19,7 +19,7 @@ from app.timetracking.backend.models import (
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__)
@ -36,7 +36,7 @@ class WizardService:
SELECT * FROM tmodule_approval_stats SELECT * FROM tmodule_approval_stats
WHERE customer_id = %s WHERE customer_id = %s
""" """
result = execute_query(query, (customer_id,), fetchone=True) result = execute_query_single(query, (customer_id,))
if not result: if not result:
return None return None
@ -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(query) results = execute_query_single(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,), fetchone=True) result = execute_query(query, (customer_id,))
else: else:
# Hent næste generelt # Hent næste generelt
if exclude_time_card: if exclude_time_card:
@ -96,7 +96,7 @@ class WizardService:
else: else:
query = "SELECT * FROM tmodule_next_pending LIMIT 1" query = "SELECT * FROM tmodule_next_pending LIMIT 1"
result = execute_query(query, fetchone=True) result = execute_query_single(query)
if not result: if not result:
# Ingen flere entries # Ingen flere entries
@ -161,7 +161,7 @@ class WizardService:
JOIN tmodule_customers cust ON t.customer_id = cust.id JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE t.id = %s WHERE t.id = %s
""" """
entry = execute_query(query, (approval.time_id,), fetchone=True) entry = execute_query_single(query, (approval.time_id,))
if not entry: if not entry:
raise HTTPException(status_code=404, detail="Time entry not found") raise HTTPException(status_code=404, detail="Time entry not found")
@ -215,7 +215,7 @@ class WizardService:
) )
# Return updated entry # Return updated entry
updated = execute_query(query, (approval.time_id,), fetchone=True) updated = execute_query_single(query, (approval.time_id,))
return TModuleTimeWithContext(**updated) return TModuleTimeWithContext(**updated)
except HTTPException: except HTTPException:
@ -251,7 +251,7 @@ class WizardService:
JOIN tmodule_customers cust ON t.customer_id = cust.id JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE t.id = %s WHERE t.id = %s
""" """
entry = execute_query(query, (time_id,), fetchone=True) entry = execute_query_single(query, (time_id,))
if not entry: if not entry:
raise HTTPException(status_code=404, detail="Time entry not found") raise HTTPException(status_code=404, detail="Time entry not found")
@ -285,7 +285,7 @@ class WizardService:
logger.info(f"❌ Rejected time entry {time_id}: {reason}") logger.info(f"❌ Rejected time entry {time_id}: {reason}")
# Return updated # Return updated
updated = execute_query(query, (time_id,), fetchone=True) updated = execute_query_single(query, (time_id,))
return TModuleTimeWithContext(**updated) return TModuleTimeWithContext(**updated)
except HTTPException: except HTTPException:
@ -321,7 +321,7 @@ class WizardService:
JOIN tmodule_customers cust ON t.customer_id = cust.id JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE t.id = %s WHERE t.id = %s
""" """
entry = execute_query(query, (time_id,), fetchone=True) entry = execute_query_single(query, (time_id,))
if not entry: if not entry:
raise HTTPException(status_code=404, detail="Time entry not found") raise HTTPException(status_code=404, detail="Time entry not found")
@ -368,7 +368,7 @@ class WizardService:
logger.info(f"🔄 Reset time entry {time_id} to pending: {reason}") logger.info(f"🔄 Reset time entry {time_id} to pending: {reason}")
# Return updated # Return updated
updated = execute_query(query, (time_id,), fetchone=True) updated = execute_query_single(query, (time_id,))
return TModuleTimeWithContext(**updated) return TModuleTimeWithContext(**updated)
except HTTPException: except HTTPException:
@ -491,7 +491,7 @@ class WizardService:
ORDER BY t.worked_date ORDER BY t.worked_date
LIMIT 1 LIMIT 1
""" """
case = execute_query(query, (customer_id,), fetchone=True) case = execute_query_single(query, (customer_id,))
if case: if case:
current_case_id = case['id'] current_case_id = case['id']
current_case_title = case['title'] current_case_title = case['title']
@ -585,7 +585,7 @@ class WizardService:
ORDER BY t.worked_date, t.id ORDER BY t.worked_date, t.id
""" """
results = execute_query(query, (case_id,)) results = execute_query_single(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:
@ -608,7 +608,7 @@ class WizardService:
FROM tmodule_cases FROM tmodule_cases
WHERE id = %s WHERE id = %s
""" """
case = execute_query(case_query, (case_id,), fetchone=True) case = execute_query(case_query, (case_id,))
if not case: if not case:
raise HTTPException(status_code=404, detail="Case not found") raise HTTPException(status_code=404, detail="Case not found")

View File

@ -0,0 +1,11 @@
{
"folders": [
{
"path": "."
},
{
"path": "../../pakkemodtagelse"
}
],
"settings": {}
}

View File

@ -42,17 +42,12 @@ services:
# Mount for local development - live code reload # Mount for local development - live code reload
- ./app:/app/app:ro - ./app:/app/app:ro
- ./main.py:/app/main.py:ro - ./main.py:/app/main.py:ro
- ./scripts:/app/scripts:ro
# Mount OmniSync database for import (read-only)
- /Users/christianthomas/pakkemodtagelse/data:/omnisync_data:ro
env_file: env_file:
- .env - .env
environment: environment:
# Override database URL to point to postgres service # Override database URL to point to postgres service
- DATABASE_URL=postgresql://${POSTGRES_USER:-bmc_hub}:${POSTGRES_PASSWORD:-bmc_hub}@postgres:5432/${POSTGRES_DB:-bmc_hub} - DATABASE_URL=postgresql://${POSTGRES_USER:-bmc_hub}:${POSTGRES_PASSWORD:-bmc_hub}@postgres:5432/${POSTGRES_DB:-bmc_hub}
- ENABLE_RELOAD=false - ENABLE_RELOAD=false
- OLLAMA_MODEL=qwen3:4b # Bruger Chat API format
- OLLAMA_MODEL_FALLBACK=qwen2.5:3b # Backup model
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"] test: ["CMD", "curl", "-f", "http://localhost:8000/health"]

128
docs/SIMPLY_CRM_SETUP.md Normal file
View File

@ -0,0 +1,128 @@
# Simply-CRM Integration Setup
## Status
⚠️ **Simply-CRM credentials ikke konfigureret** - Salgsordre fra det gamle system vises ikke
## Hvad er Simply-CRM?
Simply-CRM er et **separat CRM system** (VTiger fork) der bruges til at hente **historiske salgsordre** med `recurring_frequency`.
⚠️ **Vigtigt:** Simply-CRM er IKKE det samme som:
- vTiger Cloud (https://bmcnetworks.od2.vtiger.com)
- Det gamle on-premise vTiger (http://crm.bmcnetworks.dk)
Simply-CRM har sin egen URL, credentials og API endpoint (`/webservice.php`).
## Hvorfor vises ingen Simply-CRM data?
3 grunde:
1. ⚠️ **OLD_VTIGER_URL, OLD_VTIGER_USERNAME, OLD_VTIGER_API_KEY er tomme** i `.env` filen
2. Koden leder efter `OLD_VTIGER_API_KEY` men kan ikke finde credentials
3. Serveren er tilgængelig (301 response), men authentication mangler
## Sådan finder du credentials
### Option 1: Hvis I stadig bruger det gamle system
1. **Log ind på Simply-CRM:**
- URL: http://crm.bmcnetworks.dk
- Brug din normale bruger
2. **Find Access Key:**
- Gå til **Settings** (tandhjul øverst til højre)
- Klik på **My Preferences**
- Under **Webservices** vil du se din **Access Key**
- Kopier access key'en
3. **Tilføj til .env:**
```bash
# Simply-CRM (separat system)
SIMPLYCRM_URL=http://your-simplycrm-server.com
SIMPLYCRM_USERNAME=din_email@domain.dk
SIMPLYCRM_API_KEY=din_access_key_herfra
# ELLER hvis det er samme som gamle vTiger (fallback):
OLD_VTIGER_URL=http://crm.bmcnetworks.dk
OLD_VTIGER_USERNAME=din_email@bmcnetworks.dk
OLD_VTIGER_API_KEY=din_access_key_herfra
```
4. **Genstart API:**
```bash
docker restart bmc-hub-api
```
### Option 2: Hvis I ikke længere bruger det gamle system
Hvis alle kunder er migreret til vTiger Cloud og Simply-CRM ikke længere bruges:
1. **Kommenter linjerne ud i .env:**
```bash
# OLD_VTIGER_URL=
# OLD_VTIGER_USERNAME=
# OLD_VTIGER_API_KEY=
```
2. Simply-CRM vil automatisk blive sprunget over og der vises kun:
- vTiger Cloud subscriptions ✅
- BMC Office subscriptions ✅
## Test After Setup
```bash
# Test med en kunde
curl http://localhost:8001/api/v1/customers/327/subscriptions | jq '.sales_orders | length'
# Check logs
docker logs bmc-hub-api --tail=30 | grep -i simply
```
## Hvad henter Simply-CRM?
Koden henter **kun salgsordre med `recurring_frequency`** - altså abonnementer:
```sql
SELECT * FROM SalesOrder
WHERE account_id='<kunde_id>'
AND recurring_frequency IS NOT NULL
AND sostatus NOT IN ('closed', 'cancelled')
```
For hver ordre:
- Henter line items (produkter)
- Grupperer efter ordre ID
- Viser i "Salgsordre" sektionen på kunde-siden
## Hvorfor er det vigtigt?
Uden Simply-CRM credentials kan I ikke se:
- Gamle abonnementer oprettet før cloud migrationen
- Historiske recurring orders
- Kunder der stadig har aktive ordrer i det gamle system
**Men** I kan stadig se:
- ✅ vTiger Cloud subscriptions
- ✅ BMC Office subscriptions
- ✅ Nye vTiger Cloud sales orders
## Current Status
```
✅ vTiger Cloud - Virker (2 subscriptions for Maskinsikkerhed)
✅ BMC Office - Virker (16 subscriptions for Maskinsikkerhed)
⚠️ Simply-CRM - Mangler credentials
```
## Troubleshooting
### "Simply-CRM credentials not configured"
→ Tilføj OLD_VTIGER_* settings til `.env` og genstart
### "Not logged in to Simply-CRM"
→ Access key er forkert eller expired
### "No Simply-CRM account found for 'Kunde Navn'"
→ Kundens navn matcher ikke præcist mellem systemer (vTiger Cloud vs Simply-CRM)
### Server timeout
→ Check at `http://crm.bmcnetworks.dk` er tilgængelig fra Docker containeren

View File

@ -0,0 +1 @@
w

107
docs/VTIGER_SETUP.md Normal file
View File

@ -0,0 +1,107 @@
# vTiger & Simply-CRM Integration Setup
## Status
**BMC Office Abonnementer** - Virker nu! (fix applied: changed `execute_query_single` to `execute_query`)
⚠️ **vTiger Cloud Abonnementer** - Kræver credentials
⚠️ **Simply-CRM Salgsordre** - Kræver credentials
## Problem
Abonnementer & Salgsordre fanen viste ingen data fra vTiger og Simply-CRM fordi:
1. **BMC Office query brugte `execute_query_single()`** - returnerede kun 1 række i stedet for alle
2. **vTiger Cloud credentials mangler** - VTIGER_URL, VTIGER_USERNAME, VTIGER_ACCESS_KEY
3. **Simply-CRM credentials mangler** - OLD_VTIGER_URL, OLD_VTIGER_USERNAME, OLD_VTIGER_ACCESS_KEY
## Løsning
### 1. BMC Office Subscriptions (✅ Fixed)
Changed from `execute_query_single()` to `execute_query()` in `app/customers/backend/router.py` line 554.
Nu vises alle BMC Office abonnementer korrekt:
- Kunde 327 (Maskinsikkerhed): 16 abonnementer
- Kunde 372 (Norva24 Danmark A/S): 12 abonnementer
### 2. vTiger Cloud Integration (⚠️ Requires Credentials)
Tilføj følgende til `.env` filen:
```bash
# vTiger Cloud Integration
VTIGER_URL=https://bmcnetworks.od2.vtiger.com
VTIGER_USERNAME=din_vtiger_bruger
VTIGER_ACCESS_KEY=din_vtiger_access_key
```
**Sådan finder du credentials:**
1. Log ind på vTiger Cloud (https://bmcnetworks.od2.vtiger.com)
2. Gå til **Settings** (tandhjul øverst til højre)
3. Vælg **Integration** → **Webservices**
4. Kopier **Access Key** for din bruger
5. Username er din vTiger login email
### 3. Simply-CRM / Old vTiger Integration (⚠️ Requires Credentials)
Hvis I stadig bruger den gamle on-premise vTiger installation:
```bash
# Simply-CRM (Old vTiger On-Premise)
OLD_VTIGER_URL=http://crm.bmcnetworks.dk
OLD_VTIGER_USERNAME=din_gamle_vtiger_bruger
OLD_VTIGER_ACCESS_KEY=din_gamle_access_key
```
**Note:** Simply-CRM bruges til at hente salgsordre med `recurring_frequency` fra det gamle system.
## Test Efter Setup
1. Genstart API containeren:
```bash
docker restart bmc-hub-api
```
2. Test en kunde med vTiger ID:
```bash
curl http://localhost:8001/api/v1/customers/39/subscriptions | jq
```
3. Check logs for fejl:
```bash
docker logs bmc-hub-api --tail=50 | grep -i "vtiger\|simply"
```
## Forventet Output
Med credentials konfigureret skulle du se:
```json
{
"status": "success",
"recurring_orders": [...], // vTiger recurring sales orders
"sales_orders": [...], // Simply-CRM orders med recurring_frequency
"subscriptions": [...], // vTiger Subscriptions module
"expired_subscriptions": [...], // Expired/cancelled subscriptions
"bmc_office_subscriptions": [...] // Local BMC Office subscriptions (✅ works now)
}
```
## Frontend Display
Abonnementer & Salgsordre fanen viser nu 3 sektioner:
1. **vTiger Abonnementer** - Subscriptions module data med lock/unlock funktion
2. **BMC Office Abonnementer** - Lokale abonnementer (✅ virker)
3. **Samlet overblik** - Stats kortene øverst
## Troubleshooting
### "VTIGER_URL not configured"
→ Tilføj credentials til `.env` og genstart containeren
### "No Simply-CRM account found"
→ Kunden findes ikke i det gamle system, eller navnet matcher ikke præcist
### "Not logged in to Simply-CRM"
→ OLD_VTIGER_ACCESS_KEY er forkert eller mangler
### BMC Office subscriptions viser stadig ikke data
→ Tjek at containeren er restartet efter query fix

117
main.py
View File

@ -7,43 +7,25 @@ import logging
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse, FileResponse from fastapi.responses import RedirectResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from app.core.config import settings from app.core.config import settings
from app.core.database import init_db from app.core.database import init_db
from app.core.module_loader import module_loader
from app.services.email_scheduler import email_scheduler
# Import CORE Feature Routers (disse forbliver hardcoded) # Import Feature Routers
from app.auth.backend import router as auth_api
from app.auth.backend import views as auth_views
from app.customers.backend import router as customers_api 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.contacts.backend import router as contacts_api
from app.contacts.backend import views as contacts_views
from app.vendors.backend import router as vendors_api
from app.vendors.backend import views as vendors_views
from app.settings.backend import router as settings_api
from app.settings.backend import views as settings_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.dashboard.backend import router as dashboard_api from app.prepaid.backend import router as prepaid_api
from app.devportal.backend import router as devportal_api from app.prepaid.backend import views as prepaid_views
from app.devportal.backend import views as devportal_views
from app.timetracking.backend import router as timetracking_api
from app.timetracking.frontend import views as timetracking_views
from app.emails.backend import router as emails_api
from app.emails.frontend import views as emails_views
from app.backups.backend import router as backups_api
from app.backups.frontend import views as backups_views
from app.backups.backend.scheduler import backup_scheduler
from app.ticket.backend import router as ticket_api 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 views as vendors_views
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@ -66,25 +48,10 @@ async def lifespan(app: FastAPI):
init_db() init_db()
# Start email scheduler (background job)
email_scheduler.start()
# Start backup scheduler (background job)
backup_scheduler.start()
# Load dynamic modules (hvis enabled)
if settings.MODULES_ENABLED:
logger.info("📦 Loading dynamic modules...")
module_loader.register_modules(app)
module_status = module_loader.get_module_status()
logger.info(f"✅ Loaded {len(module_status)} modules: {list(module_status.keys())}")
logger.info("✅ System initialized successfully") logger.info("✅ System initialized successfully")
yield yield
# Shutdown # Shutdown
logger.info("👋 Shutting down...") logger.info("👋 Shutting down...")
email_scheduler.stop()
backup_scheduler.stop()
# Create FastAPI app # Create FastAPI app
app = FastAPI( app = FastAPI(
@ -105,11 +72,6 @@ app = FastAPI(
openapi_url="/api/openapi.json" openapi_url="/api/openapi.json"
) )
@app.get("/")
async def root():
"""Redirect root to dashboard"""
return RedirectResponse(url="/dashboard")
# CORS middleware # CORS middleware
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@ -120,34 +82,20 @@ app.add_middleware(
) )
# Include routers # Include routers
app.include_router(auth_api.router, prefix="/api/v1/auth", tags=["Authentication"])
app.include_router(customers_api.router, prefix="/api/v1", tags=["Customers"]) app.include_router(customers_api.router, prefix="/api/v1", tags=["Customers"])
app.include_router(contacts_api.router, prefix="/api/v1", tags=["Contacts"])
app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
app.include_router(settings_api.router, prefix="/api/v1", tags=["Settings"])
app.include_router(hardware_api.router, prefix="/api/v1", tags=["Hardware"]) app.include_router(hardware_api.router, prefix="/api/v1", tags=["Hardware"])
app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"]) app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"])
app.include_router(system_api.router, prefix="/api/v1", tags=["System"]) app.include_router(system_api.router, prefix="/api/v1", tags=["System"])
app.include_router(dashboard_api.router, prefix="/api/v1/dashboard", tags=["Dashboard"]) app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"])
app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["DEV Portal"]) app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"])
app.include_router(timetracking_api, prefix="/api/v1/timetracking", tags=["Time Tracking"]) app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
app.include_router(backups_api.router, prefix="/api/v1", tags=["Backup System"])
app.include_router(emails_api.router, prefix="/api/v1", tags=["Email System"])
app.include_router(ticket_api.router, prefix="/api/v1", tags=["Ticket System"])
# Frontend Routers # Frontend Routers
app.include_router(auth_views.router, tags=["Frontend"])
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(contacts_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(billing_views.router, tags=["Frontend"]) app.include_router(ticket_views.router, prefix="/ticket", tags=["Frontend"])
app.include_router(settings_views.router, tags=["Frontend"])
app.include_router(devportal_views.router, tags=["Frontend"])
app.include_router(backups_views.router, tags=["Frontend"])
app.include_router(timetracking_views.router, tags=["Frontend"])
app.include_router(emails_views.router, tags=["Frontend"])
app.include_router(ticket_views.router, prefix="/ticket", tags=["Frontend - Tickets"])
# 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")
@ -161,49 +109,6 @@ async def health_check():
"version": "1.0.0" "version": "1.0.0"
} }
@app.get("/api/v1/modules")
async def list_modules():
"""List alle dynamic modules og deres status"""
return {
"modules_enabled": settings.MODULES_ENABLED,
"modules": module_loader.get_module_status()
}
@app.post("/api/v1/modules/{module_name}/enable")
async def enable_module_endpoint(module_name: str):
"""Enable et modul (kræver restart)"""
success = module_loader.enable_module(module_name)
return {
"success": success,
"message": f"Modul {module_name} enabled. Restart app for at loade.",
"restart_required": True
}
@app.post("/api/v1/modules/{module_name}/disable")
async def disable_module_endpoint(module_name: str):
"""Disable et modul (kræver restart)"""
success = module_loader.disable_module(module_name)
return {
"success": success,
"message": f"Modul {module_name} disabled. Restart app for at unload.",
"restart_required": True
}
@app.get("/docs/{doc_name}")
async def serve_documentation(doc_name: str):
"""Serve markdown documentation files"""
docs_dir = Path(__file__).parent / "docs"
doc_path = docs_dir / doc_name
# Security: Ensure path is within docs directory
if not doc_path.resolve().is_relative_to(docs_dir.resolve()):
return {"error": "Invalid path"}
if doc_path.exists() and doc_path.suffix == ".md":
return FileResponse(doc_path, media_type="text/markdown")
return {"error": "Documentation not found"}
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
import os import os

View File

@ -0,0 +1,446 @@
-- ============================================================================
-- Migration 026: Ticket System Enhancements - Kravspecifikation Implementation
-- ============================================================================
-- Implementerer:
-- 1. Ticket relations (merge, split, parent/child hierarchy)
-- 2. Calendar events og deadlines
-- 3. Templates system
-- 4. AI suggestions (metadata only - ingen automatik)
-- 5. Enhanced contact identification
-- ============================================================================
-- ============================================================================
-- TICKET RELATIONS (flette, splitte, hierarki)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tticket_relations (
id SERIAL PRIMARY KEY,
ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
related_ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
relation_type VARCHAR(20) NOT NULL CHECK (relation_type IN ('merged_into', 'split_from', 'parent_of', 'child_of', 'related_to')),
-- Metadata om relationen
created_by_user_id INTEGER, -- Reference til users.user_id (read-only)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
reason TEXT, -- Hvorfor blev relationen oprettet
CONSTRAINT unique_relation UNIQUE (ticket_id, related_ticket_id, relation_type),
CONSTRAINT no_self_reference CHECK (ticket_id != related_ticket_id)
);
CREATE INDEX idx_tticket_relations_ticket ON tticket_relations(ticket_id);
CREATE INDEX idx_tticket_relations_related ON tticket_relations(related_ticket_id);
CREATE INDEX idx_tticket_relations_type ON tticket_relations(relation_type);
-- View for at finde alle relationer for en ticket (begge retninger)
CREATE OR REPLACE VIEW tticket_all_relations AS
SELECT
ticket_id,
related_ticket_id,
relation_type,
created_by_user_id,
created_at,
reason
FROM tticket_relations
UNION ALL
SELECT
related_ticket_id as ticket_id,
ticket_id as related_ticket_id,
CASE
WHEN relation_type = 'parent_of' THEN 'child_of'
WHEN relation_type = 'child_of' THEN 'parent_of'
WHEN relation_type = 'merged_into' THEN 'merged_from'
WHEN relation_type = 'split_from' THEN 'split_into'
ELSE relation_type
END as relation_type,
created_by_user_id,
created_at,
reason
FROM tticket_relations;
-- ============================================================================
-- CALENDAR EVENTS (aftaler, deadlines, milepæle)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tticket_calendar_events (
id SERIAL PRIMARY KEY,
ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
-- Event data
title VARCHAR(200) NOT NULL,
description TEXT,
event_type VARCHAR(20) DEFAULT 'appointment' CHECK (event_type IN ('appointment', 'deadline', 'milestone', 'reminder', 'follow_up')),
-- Tidspunkt
event_date DATE NOT NULL,
event_time TIME,
duration_minutes INTEGER, -- Varighed i minutter
all_day BOOLEAN DEFAULT false,
-- AI forslag
suggested_by_ai BOOLEAN DEFAULT false, -- Blev denne foreslået af AI?
ai_confidence DECIMAL(3,2), -- AI confidence score 0-1
ai_source_text TEXT, -- Tekst som AI fandt datoen i
-- Status
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'completed', 'cancelled')),
-- Metadata
created_by_user_id INTEGER, -- Reference til users.user_id
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
completed_at TIMESTAMP
);
CREATE INDEX idx_tticket_calendar_ticket ON tticket_calendar_events(ticket_id);
CREATE INDEX idx_tticket_calendar_date ON tticket_calendar_events(event_date);
CREATE INDEX idx_tticket_calendar_type ON tticket_calendar_events(event_type);
CREATE INDEX idx_tticket_calendar_status ON tticket_calendar_events(status);
-- ============================================================================
-- TEMPLATES (svarskabeloner, guides, standardbreve)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tticket_templates (
id SERIAL PRIMARY KEY,
-- Template metadata
name VARCHAR(200) NOT NULL,
description TEXT,
category VARCHAR(100), -- guide, standard_letter, technical, billing, etc.
-- Template indhold
subject_template VARCHAR(500), -- Emne med placeholders
body_template TEXT NOT NULL, -- Indhold med placeholders
-- Placeholders dokumentation
available_placeholders TEXT[], -- fx ['{{customer_name}}', '{{ticket_number}}']
-- Attachments (optional)
default_attachments JSONB, -- Array af fil-paths/URLs
-- Settings
is_active BOOLEAN DEFAULT true,
requires_approval BOOLEAN DEFAULT false, -- Kræver godkendelse før afsendelse
-- Metadata
created_by_user_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP,
last_used_at TIMESTAMP,
usage_count INTEGER DEFAULT 0
);
CREATE INDEX idx_tticket_templates_category ON tticket_templates(category);
CREATE INDEX idx_tticket_templates_active ON tticket_templates(is_active);
-- ============================================================================
-- TEMPLATE USAGE LOG (hvornår blev skabeloner brugt)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tticket_template_usage (
id SERIAL PRIMARY KEY,
template_id INTEGER NOT NULL REFERENCES tticket_templates(id) ON DELETE CASCADE,
ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
user_id INTEGER, -- Reference til users.user_id
used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
was_modified BOOLEAN DEFAULT false -- Blev template redigeret før afsendelse?
);
CREATE INDEX idx_tticket_template_usage_template ON tticket_template_usage(template_id);
CREATE INDEX idx_tticket_template_usage_ticket ON tticket_template_usage(ticket_id);
-- ============================================================================
-- AI SUGGESTIONS (forslag til actions - aldrig automatisk)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tticket_ai_suggestions (
id SERIAL PRIMARY KEY,
ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
-- Suggestion data
suggestion_type VARCHAR(50) NOT NULL CHECK (suggestion_type IN (
'contact_update', -- Opdater kontakt oplysninger
'new_contact', -- Ny kontakt opdaget
'category', -- Foreslå kategori
'tag', -- Foreslå tag
'priority', -- Foreslå prioritet
'deadline', -- Foreslå deadline
'calendar_event', -- Foreslå kalender event
'template', -- Foreslå skabelon
'merge', -- Foreslå flet med anden ticket
'related_ticket' -- Foreslå relation til anden ticket
)),
-- Suggestion content
suggestion_data JSONB NOT NULL, -- Struktureret data om forslaget
confidence DECIMAL(3,2), -- AI confidence 0-1
reasoning TEXT, -- Hvorfor blev dette foreslået
-- Source
source_text TEXT, -- Tekst som AI analyserede
source_comment_id INTEGER REFERENCES tticket_comments(id) ON DELETE CASCADE,
-- Status
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'auto_expired')),
reviewed_by_user_id INTEGER, -- Hvem behandlede forslaget
reviewed_at TIMESTAMP,
-- Metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP -- Forslag udløber efter X dage
);
CREATE INDEX idx_tticket_ai_suggestions_ticket ON tticket_ai_suggestions(ticket_id);
CREATE INDEX idx_tticket_ai_suggestions_type ON tticket_ai_suggestions(suggestion_type);
CREATE INDEX idx_tticket_ai_suggestions_status ON tticket_ai_suggestions(status);
CREATE INDEX idx_tticket_ai_suggestions_created ON tticket_ai_suggestions(created_at);
-- ============================================================================
-- EMAIL METADATA (udvidet til contact identification)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tticket_email_metadata (
id SERIAL PRIMARY KEY,
ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
-- Email headers
message_id VARCHAR(500) UNIQUE, -- Email Message-ID for threading
in_reply_to VARCHAR(500), -- In-Reply-To header
email_references TEXT, -- References header (renamed to avoid SQL keyword conflict)
-- Sender info (fra email)
from_email VARCHAR(255) NOT NULL,
from_name VARCHAR(255),
from_signature TEXT, -- Udtræk af signatur
-- Matched contact (hvis fundet)
matched_contact_id INTEGER, -- Reference til contacts.id
match_confidence DECIMAL(3,2), -- Hvor sikker er vi på match
match_method VARCHAR(50), -- email_exact, email_domain, name_similarity, etc.
-- Suggested contacts (hvis tvivl)
suggested_contacts JSONB, -- Array af {contact_id, confidence, reason}
-- Extracted data (AI analysis)
extracted_phone VARCHAR(50),
extracted_address TEXT,
extracted_company VARCHAR(255),
extracted_title VARCHAR(100),
-- Metadata
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
);
CREATE INDEX idx_tticket_email_ticket ON tticket_email_metadata(ticket_id);
CREATE INDEX idx_tticket_email_message_id ON tticket_email_metadata(message_id);
CREATE INDEX idx_tticket_email_from ON tticket_email_metadata(from_email);
-- ============================================================================
-- Tilføj manglende kolonner til existing tticket_tickets
-- ============================================================================
ALTER TABLE tticket_tickets
ADD COLUMN IF NOT EXISTS deadline TIMESTAMP,
ADD COLUMN IF NOT EXISTS parent_ticket_id INTEGER REFERENCES tticket_tickets(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS is_merged BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS merged_into_ticket_id INTEGER REFERENCES tticket_tickets(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_tticket_tickets_deadline ON tticket_tickets(deadline);
CREATE INDEX IF NOT EXISTS idx_tticket_tickets_parent ON tticket_tickets(parent_ticket_id);
-- ============================================================================
-- AUDIT LOG for ticket changes (sporbarhed)
-- ============================================================================
CREATE TABLE IF NOT EXISTS tticket_audit_log (
id SERIAL PRIMARY KEY,
ticket_id INTEGER NOT NULL REFERENCES tticket_tickets(id) ON DELETE CASCADE,
-- What changed
action VARCHAR(50) NOT NULL, -- created, updated, merged, split, status_change, etc.
field_name VARCHAR(100), -- Hvilket felt blev ændret
old_value TEXT,
new_value TEXT,
-- Who and when
user_id INTEGER, -- Reference til users.user_id
performed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- Context
reason TEXT,
metadata JSONB -- Additional context
);
CREATE INDEX idx_tticket_audit_ticket ON tticket_audit_log(ticket_id);
CREATE INDEX idx_tticket_audit_action ON tticket_audit_log(action);
CREATE INDEX idx_tticket_audit_performed ON tticket_audit_log(performed_at DESC);
-- ============================================================================
-- TRIGGERS for audit logging
-- ============================================================================
CREATE OR REPLACE FUNCTION tticket_log_ticket_changes()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'UPDATE' THEN
-- Log status changes
IF OLD.status != NEW.status THEN
INSERT INTO tticket_audit_log (ticket_id, action, field_name, old_value, new_value)
VALUES (NEW.id, 'status_change', 'status', OLD.status, NEW.status);
END IF;
-- Log priority changes
IF OLD.priority != NEW.priority THEN
INSERT INTO tticket_audit_log (ticket_id, action, field_name, old_value, new_value)
VALUES (NEW.id, 'priority_change', 'priority', OLD.priority, NEW.priority);
END IF;
-- Log assignment changes
IF OLD.assigned_to_user_id IS DISTINCT FROM NEW.assigned_to_user_id THEN
INSERT INTO tticket_audit_log (ticket_id, action, field_name, old_value, new_value)
VALUES (NEW.id, 'assignment_change', 'assigned_to_user_id',
OLD.assigned_to_user_id::TEXT, NEW.assigned_to_user_id::TEXT);
END IF;
-- Log deadline changes
IF OLD.deadline IS DISTINCT FROM NEW.deadline THEN
INSERT INTO tticket_audit_log (ticket_id, action, field_name, old_value, new_value)
VALUES (NEW.id, 'deadline_change', 'deadline',
OLD.deadline::TEXT, NEW.deadline::TEXT);
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tticket_audit_changes
AFTER UPDATE ON tticket_tickets
FOR EACH ROW
EXECUTE FUNCTION tticket_log_ticket_changes();
-- ============================================================================
-- VIEWS for enhanced queries
-- ============================================================================
-- View for tickets with hierarchy info
CREATE OR REPLACE VIEW tticket_tickets_with_hierarchy AS
SELECT
t.*,
parent.ticket_number as parent_ticket_number,
parent.subject as parent_subject,
(SELECT COUNT(*) FROM tticket_tickets WHERE parent_ticket_id = t.id) as child_count,
(SELECT COUNT(*) FROM tticket_relations WHERE ticket_id = t.id) as relation_count
FROM tticket_tickets t
LEFT JOIN tticket_tickets parent ON t.parent_ticket_id = parent.id;
-- View for tickets with pending AI suggestions
CREATE OR REPLACE VIEW tticket_tickets_with_suggestions AS
SELECT
t.id,
t.ticket_number,
t.subject,
t.status,
COUNT(DISTINCT s.id) FILTER (WHERE s.status = 'pending') as pending_suggestions,
COUNT(DISTINCT ce.id) FILTER (WHERE ce.suggested_by_ai = true AND ce.status = 'pending') as pending_calendar_suggestions
FROM tticket_tickets t
LEFT JOIN tticket_ai_suggestions s ON t.id = s.ticket_id
LEFT JOIN tticket_calendar_events ce ON t.id = ce.ticket_id
GROUP BY t.id, t.ticket_number, t.subject, t.status;
-- View for overdue tickets
CREATE OR REPLACE VIEW tticket_overdue_tickets AS
SELECT
t.id,
t.ticket_number,
t.subject,
t.status,
t.priority,
t.deadline,
t.assigned_to_user_id,
(CURRENT_TIMESTAMP - t.deadline) as overdue_duration,
c.name as customer_name
FROM tticket_tickets t
LEFT JOIN customers c ON t.customer_id = c.id
WHERE t.deadline < CURRENT_TIMESTAMP
AND t.status NOT IN ('resolved', 'closed')
ORDER BY t.deadline ASC;
-- ============================================================================
-- Seed data: Default templates
-- ============================================================================
INSERT INTO tticket_templates (name, description, category, subject_template, body_template, available_placeholders, is_active)
VALUES
(
'Tak for henvendelse',
'Standard svar ved modtagelse af ticket',
'standard_letter',
'Re: {{ticket_subject}}',
'Hej {{contact_name}},
Tak for din henvendelse. Vi har modtaget din sag og den er nu registreret som sag nr. {{ticket_number}}.
Vi vender tilbage hurtigst muligt med svar.
Med venlig hilsen,
BMC Networks',
ARRAY['{{contact_name}}', '{{customer_name}}', '{{ticket_number}}', '{{ticket_subject}}'],
true
),
(
'Løsning: Genstart router',
'Guide til genstart af router',
'guide',
'Re: {{ticket_subject}} - Løsning: Genstart router',
'Hej {{contact_name}},
Her er en guide til at genstarte din router:
1. Træk strømkablet ud af routeren
2. Vent 30 sekunder
3. Sæt strømkablet i igen
4. Vent 2-3 minutter mens routeren starter op
5. Test forbindelsen
Hvis problemet fortsætter, er du velkommen til at svare denne mail.
Med venlig hilsen,
BMC Networks
Sag: {{ticket_number}}',
ARRAY['{{contact_name}}', '{{customer_name}}', '{{ticket_number}}', '{{ticket_subject}}'],
true
),
(
'Afslutning af sag',
'Besked ved lukning af sag',
'standard_letter',
'Re: {{ticket_subject}} - Sag lukket',
'Hej {{contact_name}},
Vi betragter nu denne sag som løst og lukker den.
Hvis du har yderligere spørgsmål, er du velkommen til at kontakte os.
Med venlig hilsen,
BMC Networks
Sag: {{ticket_number}}',
ARRAY['{{contact_name}}', '{{customer_name}}', '{{ticket_number}}', '{{ticket_subject}}'],
true
);
-- ============================================================================
-- Comments
-- ============================================================================
COMMENT ON TABLE tticket_relations IS 'Ticket relationer: merge, split, parent/child hierarki';
COMMENT ON TABLE tticket_calendar_events IS 'Kalender events, deadlines og milepæle på tickets';
COMMENT ON TABLE tticket_templates IS 'Svarskabeloner med placeholders';
COMMENT ON TABLE tticket_ai_suggestions IS 'AI forslag der kræver manuel godkendelse';
COMMENT ON TABLE tticket_email_metadata IS 'Email metadata og contact identification data';
COMMENT ON TABLE tticket_audit_log IS 'Audit trail for alle ticket ændringer';
-- ============================================================================
-- Migration complete
-- ============================================================================
-- Dette modul tilføjer:
-- ✅ Ticket relations (merge, split, hierarchy)
-- ✅ Calendar events med AI forslag
-- ✅ Templates system
-- ✅ AI suggestions (kun forslag)
-- ✅ Enhanced email/contact matching
-- ✅ Full audit trail
-- ============================================================================

View File

@ -6,21 +6,3 @@ pydantic-settings==2.6.1
python-dotenv==1.0.1 python-dotenv==1.0.1
python-multipart==0.0.17 python-multipart==0.0.17
jinja2==3.1.4 jinja2==3.1.4
pyjwt==2.9.0
aiohttp==3.10.10
# Email & Scheduling
APScheduler==3.10.4
msal==1.31.1
# Backup & SSH
paramiko==3.4.0
# AI & Document Processing
httpx==0.27.2
PyPDF2==3.0.1
pdfplumber==0.11.4
pytesseract==0.3.13
Pillow==11.0.0
invoice2data==0.4.4
pyyaml==6.0.2

View File

@ -1,4 +1,4 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-bs-theme="dark"> <html lang="en" data-bs-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">