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
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
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_USERNAME=your_username@yourdomain.com
VTIGER_API_KEY=your_api_key_or_access_key
VTIGER_PASSWORD=your_password_if_using_basic_auth
VTIGER_USERNAME=your_vtiger_username
VTIGER_API_KEY=your_vtiger_api_key
# =====================================================
# TIME TRACKING MODULE - Isolated Settings
# Simply-CRM / Old vTiger On-Premise (Legacy)
# =====================================================
# vTiger Integration Safety Flags
TIMETRACKING_VTIGER_READ_ONLY=true # 🚨 Bloker ALLE skrivninger til vTiger
TIMETRACKING_VTIGER_DRY_RUN=true # 🚨 Log uden at synkronisere
# 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
# Old vTiger installation (if different from cloud)
OLD_VTIGER_URL=http://your-old-vtiger-server.com
OLD_VTIGER_USERNAME=your_old_username
OLD_VTIGER_API_KEY=your_old_api_key

View File

@ -8,10 +8,6 @@ RUN apt-get update && apt-get install -y \
git \
libpq-dev \
gcc \
postgresql-client \
tesseract-ocr \
tesseract-ocr-dan \
tesseract-ocr-eng \
&& rm -rf /var/lib/apt/lists/*
# 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
- **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
- **Service Management**: Håndtering af services og abonnementer
- **Billing Integration**: Automatisk fakturering via e-conomic
@ -129,43 +123,12 @@ bmc_hub/
## 🔌 API Endpoints
### Main API
- `GET /api/v1/customers` - List customers
- `GET /api/v1/hardware` - List hardware
- `GET /api/v1/billing/invoices` - List invoices
- `GET /health` - Health check
### Time Tracking Module
- `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).
Se fuld dokumentation: http://localhost:8000/api/docs
## 🧪 Testing

View File

@ -161,7 +161,7 @@ async def list_backups(
query += " ORDER BY created_at DESC LIMIT %s OFFSET %s"
params.extend([limit, offset])
backups = execute_query(query, tuple(params))
backups = execute_query_single(query, tuple(params))
return backups if backups else []
@ -171,9 +171,7 @@ async def get_backup(job_id: int):
"""Get details of a specific backup job"""
backup = execute_query(
"SELECT * FROM backup_jobs WHERE id = %s",
(job_id,),
fetchone=True
)
(job_id,))
if not backup:
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
backup = execute_query(
backup = execute_query_single(
"SELECT * FROM backup_jobs WHERE id = %s",
(job_id,),
fetchone=True
)
(job_id,))
if not backup:
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
"""
# Get backup job
backup = execute_query(
backup = execute_query_single(
"SELECT * FROM backup_jobs WHERE id = %s",
(job_id,),
fetchone=True
)
(job_id,))
if not backup:
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
"""
status = execute_query(
"SELECT * FROM system_status WHERE id = 1",
fetchone=True
)
status = execute_query_single(
"SELECT * FROM system_status WHERE id = 1")
if not status:
# Return default status if not found

View File

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

View File

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

View File

@ -380,6 +380,12 @@
// Load backups list
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 {
const response = await fetch('/api/v1/backups/jobs?limit=50');
const backups = await response.json();
@ -433,6 +439,10 @@
// Load storage stats
async function loadStorageStats() {
// TODO: Implement /api/v1/backups/storage endpoint
return;
/* Disabled until API implemented:
try {
const response = await fetch('/api/v1/backups/storage');
const stats = await response.json();
@ -464,6 +474,10 @@
// Load notifications
async function loadNotifications() {
// TODO: Implement /api/v1/backups/notifications endpoint
return;
/* Disabled until API implemented:
try {
const response = await fetch('/api/v1/backups/notifications?limit=10');
const notifications = await response.json();
@ -493,6 +507,10 @@
// Load scheduler status
async function loadSchedulerStatus() {
// TODO: Implement /api/v1/backups/scheduler/status endpoint
return;
/* Disabled until API implemented:
try {
const response = await fetch('/api/v1/backups/scheduler/status');
const status = await response.json();
@ -528,9 +546,13 @@
async function createBackup(event) {
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 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>';
@ -558,10 +580,14 @@
async function uploadBackup(event) {
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 type = document.getElementById('upload-type').value;
const isMonthly = document.getElementById('upload-monthly').checked;
const resultDiv = document.getElementById('upload-result');
if (!fileInput.files || fileInput.files.length === 0) {
resultDiv.innerHTML = '<div class="alert alert-danger">Please select a file</div>';
@ -613,6 +639,10 @@
// Confirm restore
async function confirmRestore() {
alert('⚠️ Restore API er ikke implementeret endnu');
return;
/* Disabled until API implemented:
if (!selectedJobId) return;
try {
@ -639,6 +669,10 @@
// Upload to offsite
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;
try {
@ -658,6 +692,10 @@
// Delete backup
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;
try {
@ -676,6 +714,10 @@
// Acknowledge notification
async function acknowledgeNotification(notificationId) {
console.warn('⚠️ Notification API ikke implementeret');
return;
/* Disabled until API implemented:
try {
await fetch(`/api/v1/backups/notifications/${notificationId}/acknowledge`, {method: 'POST'});
loadNotifications();

View File

@ -203,7 +203,7 @@ async def list_supplier_invoices(
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
for invoice in invoices:
@ -324,9 +324,7 @@ async def get_file_pdf_text(file_id: int):
# Get file info
file_info = execute_query(
"SELECT file_path, filename FROM incoming_files WHERE file_id = %s",
(file_id,),
fetchone=True
)
(file_id,))
if not file_info:
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"""
try:
# Get file info
file_info = execute_query(
file_info = execute_query_single(
"SELECT * FROM incoming_files WHERE file_id = %s",
(file_id,),
fetchone=True
)
(file_id,))
if not file_info:
raise HTTPException(status_code=404, detail="Fil ikke fundet")
# 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",
(file_id,),
fetchone=True
)
(file_id,))
# Parse llm_response_json if it exists (from AI or template extraction)
llm_json_data = None
@ -386,7 +380,7 @@ async def get_file_extracted_data(file_id: int):
# Get extraction lines if exist
extraction_lines = []
if extraction:
extraction_lines = execute_query(
extraction_lines = execute_query_single(
"""SELECT * FROM extraction_lines
WHERE extraction_id = %s
ORDER BY line_number""",
@ -493,9 +487,7 @@ async def download_pending_file(file_id: int):
# Get file info
file_info = execute_query(
"SELECT * FROM incoming_files WHERE file_id = %s",
(file_id,),
fetchone=True
)
(file_id,))
if not file_info:
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")
# Verify vendor exists
vendor = execute_query(
vendor = execute_query_single(
"SELECT id, name FROM vendors WHERE id = %s",
(vendor_id,),
fetchone=True
)
(vendor_id,))
if not vendor:
raise HTTPException(status_code=404, detail="Leverandør ikke fundet")
# 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",
(file_id,),
fetchone=True
)
(file_id,))
if not extraction:
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:
# Get file info
file_info = execute_query(
file_info = execute_query_single(
"SELECT * FROM incoming_files WHERE file_id = %s",
(file_id,),
fetchone=True
)
(file_id,))
if not file_info:
raise HTTPException(status_code=404, detail="Fil ikke fundet")
# Check if already converted to invoice
invoice_exists = execute_query(
invoice_exists = execute_query_single(
"""SELECT si.id FROM supplier_invoices si
JOIN extractions e ON si.extraction_id = e.extraction_id
WHERE e.file_id = %s""",
(file_id,),
fetchone=True
)
(file_id,))
if invoice_exists:
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")
# Verify vendor exists
vendor = execute_query(
vendor = execute_query_single(
"SELECT id, name FROM vendors WHERE id = %s",
(vendor_id,),
fetchone=True
)
(vendor_id,))
if not vendor:
raise HTTPException(status_code=404, detail=f"Leverandør {vendor_id} ikke fundet")
# 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",
(file_id,),
fetchone=True
)
(file_id,))
if not extraction:
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"""
try:
# Get latest extraction for this file
extraction = execute_query(
extraction = execute_query_single(
"""SELECT e.*, v.name as vendor_name
FROM extractions e
LEFT JOIN vendors v ON v.id = e.vendor_matched_id
WHERE e.file_id = %s
ORDER BY e.created_at DESC
LIMIT 1""",
(file_id,),
fetchone=True
)
(file_id,))
if not extraction:
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
existing = execute_query(
existing = execute_query_single(
"SELECT id FROM supplier_invoices WHERE extraction_id = %s",
(extraction['extraction_id'],),
fetchone=True
)
(extraction['extraction_id'],))
if existing:
raise HTTPException(status_code=400, detail="Faktura er allerede oprettet fra denne extraction")
# Get extraction lines
lines = execute_query(
lines = execute_query_single(
"""SELECT * FROM extraction_lines
WHERE extraction_id = %s
ORDER BY line_number""",
@ -892,9 +868,7 @@ async def list_templates():
if vendor_cvr:
vendor = execute_query(
"SELECT id, name FROM vendors WHERE cvr_number = %s",
(vendor_cvr,),
fetchone=True
)
(vendor_cvr,))
if vendor:
vendor_id = vendor['id']
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
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:
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)
if vendor_cvr:
vendor = execute_query(
vendor = execute_query_single(
"SELECT id, name, cvr_number FROM vendors WHERE cvr_number = %s",
(vendor_cvr,),
fetchone=True
)
(vendor_cvr,))
if vendor:
return {
"found": True,
@ -984,7 +956,7 @@ async def search_vendor_by_info(request: Dict):
# Search by name (fuzzy)
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",
(f"%{vendor_name}%",)
)
@ -1178,15 +1150,13 @@ async def get_supplier_invoice(invoice_id: int):
FROM supplier_invoices si
LEFT JOIN vendors v ON si.vendor_id = v.id
WHERE si.id = %s""",
(invoice_id,),
fetchone=True
)
(invoice_id,))
if not invoice:
raise HTTPException(status_code=404, detail=f"Invoice {invoice_id} not found")
# Get lines
lines = execute_query(
lines = execute_query_single(
"SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id = %s ORDER BY line_number",
(invoice_id,)
)
@ -1313,9 +1283,7 @@ async def update_supplier_invoice(invoice_id: int, data: Dict):
# Check if invoice exists
existing = execute_query(
"SELECT id, status FROM supplier_invoices WHERE id = %s",
(invoice_id,),
fetchone=True
)
(invoice_id,))
if not existing:
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):
"""Delete supplier invoice (soft delete if integrated with e-conomic)"""
try:
invoice = execute_query(
invoice = execute_query_single(
"SELECT id, invoice_number, economic_voucher_number FROM supplier_invoices WHERE id = %s",
(invoice_id,),
fetchone=True
)
(invoice_id,))
if not invoice:
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):
"""Approve supplier invoice for payment"""
try:
invoice = execute_query(
invoice = execute_query_single(
"SELECT id, invoice_number, status FROM supplier_invoices WHERE id = %s",
(invoice_id,),
fetchone=True
)
(invoice_id,))
if not invoice:
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:
# 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
FROM supplier_invoices si
LEFT JOIN vendors v ON si.vendor_id = v.id
WHERE si.id = %s""",
(invoice_id,),
fetchone=True
)
(invoice_id,))
if not invoice:
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")
# Get lines
lines = execute_query(
lines = execute_query_single(
"SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id = %s ORDER BY line_number",
(invoice_id,)
)
@ -1505,9 +1467,7 @@ async def send_to_economic(invoice_id: int):
# Get default journal number from settings
journal_setting = execute_query(
"SELECT setting_value FROM supplier_invoice_settings WHERE setting_key = 'economic_default_journal'",
fetchone=True
)
"SELECT setting_value FROM supplier_invoice_settings WHERE setting_key = 'economic_default_journal'")
journal_number = int(journal_setting['setting_value']) if journal_setting else 1
# Build VAT breakdown from lines
@ -1634,7 +1594,7 @@ async def get_payment_overview():
try:
today = date.today().isoformat()
stats = execute_query("""
stats = execute_query_single("""
SELECT
COUNT(*) as total_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
FROM supplier_invoices
WHERE status != 'cancelled'
""", (today, today, today, today, today), fetchone=True)
""", (today, today, today, today, today))
return {
"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():
"""Get supplier invoice statistics grouped by vendor"""
try:
stats = execute_query("""
stats = execute_query_single("""
SELECT
v.id as vendor_id,
v.name as vendor_name,
@ -1762,22 +1722,18 @@ async def upload_supplier_invoice(file: UploadFile = File(...)):
# Check for duplicate file
existing_file = execute_query(
"SELECT file_id, status FROM incoming_files WHERE checksum = %s",
(checksum,),
fetchone=True
)
(checksum,))
if existing_file:
temp_path.unlink(missing_ok=True)
logger.warning(f"⚠️ Duplicate file detected: {checksum[:16]}...")
# Get existing invoice if linked
existing_invoice = execute_query(
existing_invoice = execute_query_single(
"""SELECT si.* FROM supplier_invoices si
JOIN extractions e ON si.extraction_id = e.extraction_id
WHERE e.file_id = %s""",
(existing_file['file_id'],),
fetchone=True
)
(existing_file['file_id'],))
return {
"status": "duplicate",
@ -1797,14 +1753,12 @@ async def upload_supplier_invoice(file: UploadFile = File(...)):
logger.info(f"💾 Saved file as: {final_path.name}")
# Insert file record
file_record = execute_query(
file_record = execute_query_single(
"""INSERT INTO incoming_files
(filename, original_filename, file_path, file_size, mime_type, checksum, status)
VALUES (%s, %s, %s, %s, %s, %s, 'processing') RETURNING file_id""",
(final_path.name, file.filename, str(final_path), total_size,
ollama_service._get_mime_type(final_path), checksum),
fetchone=True
)
ollama_service._get_mime_type(final_path), checksum))
file_id = file_record['file_id']
# 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}")
# 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
FROM supplier_invoices si
LEFT JOIN vendors v ON v.id = si.vendor_id
WHERE si.invoice_number = %s
ORDER BY si.created_at DESC
LIMIT 1""",
(document_number,),
fetchone=True
)
(document_number,))
if existing_invoice:
# DUPLICATE FOUND IN DATABASE
@ -2055,11 +2007,9 @@ async def reprocess_uploaded_file(file_id: int):
try:
# Get file record
file_record = execute_query(
file_record = execute_query_single(
"SELECT * FROM incoming_files WHERE file_id = %s",
(file_id,),
fetchone=True
)
(file_id,))
if not file_record:
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")
# Try to find vendor from extracted CVR
if extracted_fields.get('vendor_vat'):
vendor = execute_query(
vendor = execute_query_single(
"SELECT id FROM vendors WHERE cvr_number = %s",
(extracted_fields['vendor_vat'],),
fetchone=True
)
(extracted_fields['vendor_vat'],))
if vendor:
vendor_id = vendor['id']
@ -2134,11 +2082,9 @@ async def reprocess_uploaded_file(file_id: int):
# Fallback: match by issuer name
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",
(extracted_fields['issuer'],),
fetchone=True
)
(extracted_fields['issuer'],))
if vendor:
vendor_id = vendor['id']
@ -2301,11 +2247,9 @@ async def reprocess_uploaded_file(file_id: int):
# Add warning if no template exists
if not template_id and vendor_id:
vendor = execute_query(
vendor = execute_query_single(
"SELECT name FROM vendors WHERE id = %s",
(vendor_id,),
fetchone=True
)
(vendor_id,))
if vendor:
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
{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
# Get contacts with company count
@ -71,7 +71,7 @@ async def get_contacts(
"""
params.extend([limit, offset])
contacts = execute_query(query, tuple(params)) # Default is fetchall
contacts = execute_query_single(query, tuple(params)) # Default is fetchall
return {
"contacts": contacts or [],
@ -99,7 +99,7 @@ async def get_contact(contact_id: int):
FROM contacts
WHERE id = %s
"""
contact = execute_query(contact_query, (contact_id,), fetchone=True)
contact = execute_query(contact_query, (contact_id,))
if not contact:
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
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 []
return contact
@ -171,7 +171,7 @@ async def update_contact(contact_id: int, contact: ContactUpdate):
"""
try:
# 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:
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:
# 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:
raise HTTPException(status_code=404, detail="Contact not found")
# 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:
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
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",
(user_id,),
fetchone=True
)
(user_id,))
return {
"id": user_id,

View File

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

View File

@ -33,169 +33,28 @@ class Settings(BaseSettings):
ECONOMIC_READ_ONLY: 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_USERNAME: str = ""
VTIGER_API_KEY: str = ""
VTIGER_PASSWORD: str = "" # Fallback hvis API key ikke virker
# Simply-CRM Integration (Legacy System med CVR data)
OLD_VTIGER_URL: str = "https://bmcnetworks.simply-crm.dk"
OLD_VTIGER_USERNAME: str = "ct"
OLD_VTIGER_ACCESS_KEY: str = "b00ff2b7c08d591"
# Simply-CRM (Old vTiger On-Premise)
OLD_VTIGER_URL: str = ""
OLD_VTIGER_USERNAME: str = ""
OLD_VTIGER_API_KEY: str = ""
# Time Tracking Module - vTiger Integration (Isoleret)
TIMETRACKING_VTIGER_READ_ONLY: bool = True # 🚨 SAFETY: Bloker ALLE skrivninger til vTiger
TIMETRACKING_VTIGER_DRY_RUN: bool = True # 🚨 SAFETY: Log uden at synkronisere
# 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
# Simply-CRM (Separate System)
SIMPLYCRM_URL: str = ""
SIMPLYCRM_USERNAME: str = ""
SIMPLYCRM_API_KEY: str = ""
class Config:
env_file = ".env"
case_sensitive = True
extra = "ignore" # Ignore extra fields from .env
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)
def execute_query(query: str, params: Optional[tuple] = None, fetchone: bool = False):
"""
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
"""
def execute_query(query: str, params: tuple = None, fetch: bool = True):
"""Execute a SQL query and return results"""
conn = get_db_connection()
try:
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()
is_write = any(query_upper.startswith(cmd) for cmd in ['INSERT', 'UPDATE', 'DELETE'])
if query_upper.startswith(('INSERT', 'UPDATE', 'DELETE')):
conn.commit()
if fetchone:
row = cursor.fetchone()
if is_write:
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]
if fetch:
return cursor.fetchall()
return cursor.rowcount
except Exception as e:
conn.rollback()
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)
def execute_insert(query: str, params: tuple = ()) -> Optional[int]:
"""
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
"""
def execute_insert(query: str, params: tuple = None):
"""Execute INSERT query and return new ID"""
conn = get_db_connection()
try:
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)
result = cursor.fetchone()
conn.commit()
# If result exists, return the first column value (typically ID)
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
result = cursor.fetchone()
return result['id'] if result and 'id' in result else None
except Exception as e:
conn.rollback()
logger.error(f"Insert error: {e}")
@ -131,24 +95,14 @@ def execute_insert(query: str, params: tuple = ()) -> Optional[int]:
release_db_connection(conn)
def execute_update(query: str, params: tuple = ()) -> int:
"""
Execute an UPDATE/DELETE query and return affected rows
Args:
query: SQL UPDATE/DELETE query
params: Query parameters tuple
Returns:
Number of affected rows
"""
def execute_update(query: str, params: tuple = None):
"""Execute UPDATE/DELETE query and return affected rows"""
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(query, params)
rowcount = cursor.rowcount
conn.commit()
return rowcount
return cursor.rowcount
except Exception as e:
conn.rollback()
logger.error(f"Update error: {e}")
@ -157,66 +111,7 @@ def execute_update(query: str, params: tuple = ()) -> int:
release_db_connection(conn)
def execute_module_migration(module_name: str, migration_sql: str) -> bool:
"""
Kør en migration for et specifikt modul
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
def execute_query_single(query: str, params: tuple = None):
"""Execute query and return single row (backwards compatibility for fetchone=True)"""
result = execute_query(query, params)
return result[0] if result and len(result) > 0 else None

View File

@ -9,7 +9,7 @@ from typing import List, Optional, Dict
from pydantic import BaseModel
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
logger = logging.getLogger(__name__)
@ -82,13 +82,24 @@ async def list_customers(
source: Filter by source ('vtiger' or 'local')
is_active: Filter by active status
"""
# Build query
# Build query with primary contact info
query = """
SELECT
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
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
"""
params = []
@ -117,7 +128,7 @@ async def list_customers(
params.append(is_active)
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
LIMIT %s OFFSET %s
"""
@ -148,7 +159,7 @@ async def list_customers(
count_query += " AND is_active = %s"
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
return {
@ -163,21 +174,17 @@ async def list_customers(
async def get_customer(customer_id: int):
"""Get single customer by ID with contact count and vTiger BMC Låst status"""
# Get customer
customer = execute_query(
customer = execute_query_single(
"SELECT * FROM customers WHERE id = %s",
(customer_id,),
fetchone=True
)
(customer_id,))
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
# 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",
(customer_id,),
fetchone=True
)
(customer_id,))
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}")
# Fetch and return created customer
created = execute_query(
created = execute_query_single(
"SELECT * FROM customers WHERE id = %s",
(customer_id,),
fetchone=True
)
(customer_id,))
return created
except Exception as e:
@ -246,11 +251,9 @@ async def create_customer(customer: CustomerCreate):
async def update_customer(customer_id: int, update: CustomerUpdate):
"""Update customer information"""
# Verify customer exists
existing = execute_query(
existing = execute_query_single(
"SELECT id FROM customers WHERE id = %s",
(customer_id,),
fetchone=True
)
(customer_id,))
if not existing:
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}")
# Fetch and return updated customer
updated = execute_query(
updated = execute_query_single(
"SELECT * FROM customers WHERE id = %s",
(customer_id,),
fetchone=True
)
(customer_id,))
return updated
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)
# Get customer
customer = execute_query(
customer = execute_query_single(
"SELECT id, name FROM customers WHERE id = %s",
(customer_id,),
fetchone=True
)
(customer_id,))
if not customer:
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")
async def get_customer_contacts(customer_id: int):
"""Get all contacts for a specific customer"""
rows = execute_query("""
rows = execute_query_single("""
SELECT
c.*,
cc.is_primary,
@ -348,9 +347,7 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
# Verify customer exists
customer = execute_query(
"SELECT id FROM customers WHERE id = %s",
(customer_id,),
fetchone=True
)
(customer_id,))
if not customer:
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}")
# Fetch and return created contact
created = execute_query(
created = execute_query_single(
"SELECT * FROM contacts WHERE id = %s",
(contact_id,),
fetchone=True
)
(contact_id,))
return created
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
# Get customer with vTiger ID
customer = execute_query(
customer = execute_query_single(
"SELECT id, name, vtiger_id FROM customers WHERE id = %s",
(customer_id,),
fetchone=True
)
(customer_id,))
if not customer:
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
query = f"SELECT * FROM SalesOrder WHERE account_id='{simplycrm_account_id}';"
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
# Filter: Only include orders with recurring_frequency (otherwise not subscription)
orders_dict = {}
filtered_closed = 0
filtered_no_freq = 0
for row in (all_simplycrm_orders or []):
status = row.get('sostatus', '').lower()
if status in ['closed', 'cancelled']:
filtered_closed += 1
logger.debug(f" ⏭️ Skipping closed order: {row.get('subject', 'N/A')} ({status})")
continue
# MUST have recurring_frequency to be a subscription
recurring_frequency = row.get('recurring_frequency', '').strip()
if not recurring_frequency:
filtered_no_freq += 1
logger.debug(f" ⏭️ Skipping order without frequency: {row.get('subject', 'N/A')}")
continue
logger.info(f" ✅ Including order: {row.get('subject', 'N/A')} - {recurring_frequency} ({status})")
order_id = row.get('id')
if order_id not in orders_dict:
# First occurrence - create order object
@ -548,7 +550,7 @@ async def get_customer_subscriptions(customer_id: int):
})
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:
logger.info(f" No Simply-CRM account found for '{customer_name}'")
except Exception as e:
@ -608,9 +610,7 @@ async def create_subscription(customer_id: int, subscription: SubscriptionCreate
# Get customer's vTiger ID
customer = execute_query(
"SELECT vtiger_id FROM customers WHERE id = %s",
(customer_id,),
fetchone=True
)
(customer_id,))
if not customer or not customer.get('vtiger_id'):
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:
# Check if subscriptions are locked for this customer (if customer_id provided)
if customer_id:
customer = execute_query(
customer = execute_query_single(
"SELECT subscriptions_locked FROM customers WHERE id = %s",
(customer_id,),
fetchone=True
)
(customer_id,))
if customer and customer.get('subscriptions_locked'):
raise HTTPException(
status_code=403,

View File

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

View File

@ -215,7 +215,7 @@
</li>
<li class="nav-item">
<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>
</li>
<li class="nav-item">
@ -353,7 +353,7 @@
<!-- Subscriptions Tab -->
<div class="tab-pane fade" id="subscriptions">
<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">
<button class="btn btn-success btn-sm" onclick="showCreateSubscriptionModal()">
<i class="bi bi-plus-circle me-2"></i>Opret Abonnement
@ -860,11 +860,13 @@ function renderSalesOrdersList(orders) {
const lineItems = order.lineItems || [];
const hasLineItems = Array.isArray(lineItems) && lineItems.length > 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 `
<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="flex-grow-1">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="flex-grow-1" style="cursor: pointer;" onclick="toggleLineItems('${itemId}')">
<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>
${escapeHtml(order.subject || order.salesorder_no || 'Unnamed')}
@ -874,14 +876,23 @@ function renderSalesOrdersList(orders) {
</div>
</div>
<div class="text-end ms-3">
<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 class="btn-group btn-group-sm mb-2" role="group">
<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 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.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>
${hasLineItems ? `

View File

@ -12,7 +12,6 @@
border-radius: 20px;
font-size: 0.9rem;
transition: all 0.2s;
cursor: pointer;
}
.filter-btn:hover, .filter-btn.active {
@ -20,38 +19,6 @@
color: white;
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>
{% endblock %}
@ -62,29 +29,16 @@
<p class="text-muted mb-0">Administrer dine kunder</p>
</div>
<div class="d-flex gap-3">
<input type="text" id="searchInput" class="header-search" placeholder="Søg kunde, CVR, email...">
<button class="btn btn-primary" onclick="showCreateCustomerModal()">
<i class="bi bi-plus-lg me-2"></i>Opret Kunde
</button>
<input type="text" id="searchInput" class="header-search" placeholder="Søg kunde...">
<button class="btn btn-primary"><i class="bi bi-plus-lg me-2"></i>Opret Kunde</button>
</div>
</div>
<div class="mb-4 d-flex gap-2 flex-wrap">
<button class="filter-btn active" data-filter="all" onclick="setFilter('all')">
Alle Kunder <span id="countAll" class="ms-1"></span>
</button>
<button class="filter-btn" data-filter="active" onclick="setFilter('active')">
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 class="mb-4 d-flex gap-2">
<button class="filter-btn active">Alle Kunder</button>
<button class="filter-btn">Aktive</button>
<button class="filter-btn">Inaktive</button>
<button class="filter-btn">VIP</button>
</div>
<div class="card p-4">
@ -93,17 +47,16 @@
<thead>
<tr>
<th>Virksomhed</th>
<th>Kontakt Info</th>
<th>Kontakt</th>
<th>CVR</th>
<th>Kilde</th>
<th>Status</th>
<th>Kontakter</th>
<th>E-mail</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="customersTableBody">
<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">
<span class="visually-hidden">Loading...</span>
</div>
@ -112,407 +65,181 @@
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="d-flex justify-content-between align-items-center mt-4">
<div class="text-muted small">
Viser <span id="showingStart">0</span>-<span id="showingEnd">0</span> af <span id="totalCount">0</span> kunder
</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 class="d-flex justify-content-between align-items-center mt-3">
<div class="text-muted" id="customerCount">Loading...</div>
<nav>
<ul class="pagination mb-0" id="pagination"></ul>
</nav>
</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>
let currentPage = 0;
let pageSize = 20;
let currentFilter = 'all';
let searchQuery = '';
let currentPage = 1;
const pageSize = 50;
let totalCustomers = 0;
let searchTerm = '';
let searchTimeout = null;
// Load customers on page load
document.addEventListener('DOMContentLoaded', () => {
loadCustomers();
// Setup search with debounce
const searchInput = document.getElementById('searchInput');
// Search with debounce
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchQuery = e.target.value;
currentPage = 0;
console.log('🔍 Searching for:', searchQuery);
loadCustomers();
searchTerm = e.target.value;
loadCustomers(1);
}, 300);
});
});
// Cmd+K / Ctrl+K keyboard shortcut (outside DOMContentLoaded so it works everywhere)
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
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>';
async function loadCustomers(page = 1) {
currentPage = page;
const offset = (page - 1) * pageSize;
try {
// Build query parameters
let params = new URLSearchParams({
limit: pageSize,
offset: currentPage * pageSize
});
if (searchQuery) {
params.append('search', searchQuery);
console.log('📤 Sending search query:', searchQuery);
let url = `/api/v1/customers?limit=${pageSize}&offset=${offset}`;
if (searchTerm) {
url += `&search=${encodeURIComponent(searchTerm)}`;
}
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 response = await fetch(url);
const data = await response.json();
totalCustomers = data.total;
displayCustomers(data.customers);
updatePagination(data.total);
renderCustomers(data.customers);
renderPagination();
updateCount();
} catch (error) {
console.error('Failed to load customers:', error);
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-5 text-danger">Kunne ikke indlæse kunder</td></tr>';
console.error('Error loading customers:', error);
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');
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;
}
tbody.innerHTML = customers.map(customer => {
const initials = getInitials(customer.name);
const statusBadge = customer.is_active
? '<span class="badge bg-success bg-opacity-10 text-success">Aktiv</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;
const initials = customer.name ? customer.name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase() : '??';
const statusBadge = customer.is_active ?
'<span class="badge bg-success bg-opacity-10 text-success">Aktiv</span>' :
'<span class="badge bg-secondary bg-opacity-10 text-secondary">Inaktiv</span>';
return `
<tr style="cursor: pointer;" onclick="viewCustomer(${customer.id})">
<tr onclick="window.location.href='/customers/${customer.id}'" style="cursor: pointer;">
<td>
<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 class="fw-bold">${escapeHtml(customer.name)}</div>
<div class="small text-muted">${customer.city || customer.address || '-'}</div>
<div class="fw-bold">${customer.name || '-'}</div>
<div class="small text-muted">${customer.address || '-'}</div>
</div>
</div>
</td>
<td>
<div class="fw-medium">${customer.email || '-'}</div>
<div class="small text-muted">${customer.phone || '-'}</div>
<div class="fw-medium">${customer.contact_name || '-'}</div>
<div class="small text-muted">${customer.contact_phone || '-'}</div>
</td>
<td class="text-muted">${customer.cvr_number || '-'}</td>
<td>${sourceBadge}</td>
<td>${statusBadge}</td>
<td>
<span class="badge bg-light text-dark border">
<i class="bi bi-person me-1"></i>${contactCount}
</span>
</td>
<td class="text-muted">${customer.email || '-'}</td>
<td class="text-end">
<div class="btn-group">
<button class="btn btn-sm btn-light" onclick="event.stopPropagation(); viewCustomer(${customer.id})">
<i class="bi bi-eye"></i>
</button>
<button class="btn btn-sm btn-light" onclick="event.stopPropagation(); editCustomer(${customer.id})">
<i class="bi bi-pencil"></i>
</button>
</div>
<button class="btn btn-sm btn-outline-primary"
onclick="event.stopPropagation(); window.location.href='/customers/${customer.id}'"
title="Se detaljer">
<i class="bi bi-arrow-right"></i>
</button>
</td>
</tr>
`;
}).join('');
}
function updatePagination(total) {
const start = currentPage * pageSize + 1;
const end = Math.min((currentPage + 1) * pageSize, total);
function renderPagination() {
const totalPages = Math.ceil(totalCustomers / pageSize);
const pagination = document.getElementById('pagination');
document.getElementById('showingStart').textContent = total > 0 ? start : 0;
document.getElementById('showingEnd').textContent = end;
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>';
if (totalPages <= 1) {
pagination.innerHTML = '';
return;
}
// Show loading state
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>';
let pages = [];
try {
const response = await fetch(`/api/v1/cvr/${cvr}`);
if (!response.ok) {
throw new Error('CVR ikke fundet');
// Previous button
pages.push(`
<li class="page-item ${currentPage === 1 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="loadCustomers(${currentPage - 1}); return false;">
<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 = {
name: name,
cvr_number: document.getElementById('cvrInput').value.trim() || null,
address: document.getElementById('addressInput').value.trim() || null,
postal_code: document.getElementById('postalCodeInput').value.trim() || null,
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
};
for (let i = startPage; i <= endPage; i++) {
pages.push(`
<li class="page-item ${i === currentPage ? 'active' : ''}">
<a class="page-link" href="#" onclick="loadCustomers(${i}); return false;">${i}</a>
</li>
`);
}
try {
const response = await fetch('/api/v1/customers', {
method: 'POST',
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');
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
pages.push(`<li class="page-item disabled"><span class="page-link">...</span></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);
pages.push(`<li class="page-item"><a class="page-link" href="#" onclick="loadCustomers(${totalPages}); return false;">${totalPages}</a></li>`);
}
// 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) {
if (!name) return '?';
const words = name.trim().split(' ');
if (words.length === 1) return words[0].substring(0, 2).toUpperCase();
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
function updateCount() {
const start = (currentPage - 1) * pageSize + 1;
const end = Math.min(currentPage * pageSize, totalCustomers);
document.getElementById('customerCount').textContent =
`Viser ${start}-${end} af ${totalCustomers} kunder`;
}
</script>
{% endblock %}
{% endblock %}

View File

@ -17,22 +17,22 @@ async def get_dashboard_stats():
# 1. Customer Counts
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
# 2. Contact Counts
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
# 3. Vendor Counts
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
# 4. Recent Customers (Real "Activity")
logger.info("Fetching recent customers...")
recent_customers = execute_query("""
recent_customers = execute_query_single("""
SELECT id, name, created_at, 'customer' as type
FROM customers
WHERE deleted_at IS NULL
@ -154,7 +154,7 @@ async def get_live_stats():
# Try to get real customer count as a demo
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
except:
pass

View File

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

View File

@ -9,217 +9,123 @@
<p class="text-muted mb-0">Velkommen tilbage, Christian</p>
</div>
<div class="d-flex gap-3">
<div class="input-group">
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search"></i></span>
<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>
<input type="text" class="header-search" placeholder="Søg...">
<button class="btn btn-primary"><i class="bi bi-plus-lg me-2"></i>Ny Opgave</button>
</div>
</div>
<!-- 1. Live Metrics Cards -->
<div class="row g-4 mb-5">
<div class="col-md-3">
<div class="card stat-card p-4 h-100">
<div class="d-flex justify-content-between mb-2">
<p>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>
<p>Aktive Kunder</p>
<i class="bi bi-people text-primary" style="color: var(--accent) !important;"></i>
</div>
<h3 id="contactCount">-</h3>
<small class="text-muted">Tilknyttede personer</small>
<h3>124</h3>
<small class="text-success"><i class="bi bi-arrow-up-short"></i> 12% denne måned</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>Leverandører</p>
<i class="bi bi-shop text-primary" style="color: var(--accent) !important;"></i>
<p>Hardware</p>
<i class="bi bi-hdd text-primary" style="color: var(--accent) !important;"></i>
</div>
<h3 id="vendorCount">-</h3>
<small class="text-muted">Aktive leverandøraftaler</small>
<h3>856</h3>
<small class="text-muted">Enheder online</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>System Status</p>
<i class="bi bi-cpu text-primary" style="color: var(--accent) !important;"></i>
<p>Support</p>
<i class="bi bi-ticket text-primary" style="color: var(--accent) !important;"></i>
</div>
<h3 id="systemStatus" class="text-success">Online</h3>
<small class="text-muted" id="systemVersion">v1.0.0</small>
<h3>12</h3>
<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 class="row g-4">
<!-- 2. Recent Activity (New Customers) -->
<div class="col-lg-8">
<div class="card p-4 h-100">
<div class="d-flex justify-content-between align-items-center mb-4">
<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="card p-4">
<h5 class="fw-bold mb-4">Seneste Aktiviteter</h5>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Navn</th>
<th>Type</th>
<th>Oprettet</th>
<th class="text-end">Handling</th>
<th>Kunde</th>
<th>Handling</th>
<th>Status</th>
<th class="text-end">Tid</th>
</tr>
</thead>
<tbody id="recentActivityTable">
<tbody>
<tr>
<td colspan="4" class="text-center py-4">
<div class="spinner-border text-primary" role="status"></div>
</td>
<td class="fw-bold">Advokatgruppen A/S</td>
<td>Firewall konfiguration</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>
</tbody>
</table>
</div>
</div>
</div>
<!-- 3. Vendor Distribution & Quick Links -->
<div class="col-lg-4">
<div class="card p-4 mb-4">
<h5 class="fw-bold mb-4">Leverandør Fordeling</h5>
<div id="vendorDistribution">
<div class="text-center py-3">
<div class="spinner-border text-primary" role="status"></div>
<div class="card p-4 h-100">
<h5 class="fw-bold mb-4">System Status</h5>
<div class="mb-4">
<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>
<!-- 4. Quick Actions / Shortcuts -->
<div class="card p-4">
<h5 class="fw-bold mb-3">Genveje</h5>
<div class="d-grid gap-2">
<a href="/settings" class="btn btn-light text-start p-3 d-flex align-items-center">
<div class="bg-white p-2 rounded me-3 shadow-sm">
<i class="bi bi-gear text-primary"></i>
</div>
<div>
<div class="fw-bold">Indstillinger</div>
<small class="text-muted">Konfigurer systemet</small>
</div>
</a>
<a href="/vendors" class="btn btn-light text-start p-3 d-flex align-items-center">
<div class="bg-white p-2 rounded me-3 shadow-sm">
<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 class="mb-4">
<div class="d-flex justify-content-between mb-2">
<span class="small fw-bold text-muted">MEMORY</span>
<span class="small fw-bold">56%</span>
</div>
<div class="progress" style="height: 8px; background-color: var(--accent-light);">
<div class="progress-bar" style="width: 56%; background-color: var(--accent);"></div>
</div>
</div>
<div class="mt-auto p-3 rounded" style="background-color: var(--accent-light);">
<div class="d-flex">
<i class="bi bi-check-circle-fill text-success me-2"></i>
<small class="fw-bold" style="color: var(--accent)">Alle systemer kører optimalt.</small>
</div>
</div>
</div>
</div>
</div>
{% 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 %}
{% endblock %}

View File

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

View File

@ -183,7 +183,7 @@ async def list_emails(
"""
params.extend([limit, offset])
result = execute_query(query, tuple(params))
result = execute_query_single(query, tuple(params))
return result
@ -241,7 +241,7 @@ async def mark_email_processed(email_id: int):
WHERE id = %s AND deleted_at IS NULL
RETURNING id, folder, status
"""
result = execute_query(update_query, (email_id,), fetchone=True)
result = execute_query(update_query, (email_id,))
if not result:
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
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:
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"""
try:
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:
raise HTTPException(status_code=404, detail="Workflow not found")
@ -745,7 +745,7 @@ async def create_workflow(workflow: EmailWorkflow):
RETURNING *
"""
result = execute_query(query, (
result = execute_query_single(query, (
workflow.name,
workflow.description,
workflow.classification_trigger,
@ -756,7 +756,7 @@ async def create_workflow(workflow: EmailWorkflow):
workflow.priority,
workflow.enabled,
workflow.stop_on_match
), fetchone=True)
))
if result:
logger.info(f"✅ Created workflow: {workflow.name}")
@ -791,7 +791,7 @@ async def update_workflow(workflow_id: int, workflow: EmailWorkflow):
RETURNING *
"""
result = execute_query(query, (
result = execute_query_single(query, (
workflow.name,
workflow.description,
workflow.classification_trigger,
@ -803,7 +803,7 @@ async def update_workflow(workflow_id: int, workflow: EmailWorkflow):
workflow.enabled,
workflow.stop_on_match,
workflow_id
), fetchone=True)
))
if result:
logger.info(f"✅ Updated workflow {workflow_id}")
@ -841,7 +841,7 @@ async def toggle_workflow(workflow_id: int):
WHERE id = %s
RETURNING enabled
"""
result = execute_query(query, (workflow_id,), fetchone=True)
result = execute_query_single(query, (workflow_id,))
if not result:
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
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:
raise HTTPException(status_code=404, detail="Email not found")

View File

@ -3,11 +3,10 @@ Pydantic Models and Schemas
"""
from pydantic import BaseModel
from typing import Optional, List
from typing import Optional
from datetime import datetime
# Customer Schemas
class CustomerBase(BaseModel):
"""Base customer schema"""
name: str
@ -16,31 +15,9 @@ class CustomerBase(BaseModel):
address: Optional[str] = None
class CustomerCreate(BaseModel):
class CustomerCreate(CustomerBase):
"""Schema for creating a customer"""
name: str
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
pass
class Customer(CustomerBase):
@ -53,70 +30,6 @@ class Customer(CustomerBase):
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):
"""Base hardware schema"""
serial_number: str
@ -138,46 +51,32 @@ class Hardware(HardwareBase):
from_attributes = True
# Vendor Schemas
class VendorBase(BaseModel):
"""Base vendor schema"""
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
domain: 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
domain: Optional[str] = None
email_pattern: Optional[str] = None
category: str = 'general'
priority: int = 50
contact_person: Optional[str] = None
category: Optional[str] = None
notes: Optional[str] = None
is_active: bool = True
class VendorCreate(VendorBase):
"""Schema for creating a vendor"""
pass
class VendorUpdate(BaseModel):
"""Schema for updating a vendor"""
name: Optional[str] = None
cvr_number: Optional[str] = None
domain: 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
domain: Optional[str] = None
email_pattern: Optional[str] = None
contact_person: Optional[str] = None
category: Optional[str] = None
priority: Optional[int] = None
notes: Optional[str] = None
is_active: Optional[bool] = None
@ -185,20 +84,9 @@ class VendorUpdate(BaseModel):
class Vendor(VendorBase):
"""Full vendor schema"""
id: int
cvr_number: Optional[str] = None
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
is_active: bool = True
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True

View File

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

View File

@ -29,7 +29,7 @@ async def get_items():
read_only = get_module_config("test_module", "READ_ONLY", "true") == "true"
# Hent items (bemærk table_prefix)
items = execute_query(
items = execute_query_single(
"SELECT * FROM test_module_items ORDER BY created_at DESC"
)
@ -58,9 +58,7 @@ async def get_item(item_id: int):
try:
item = execute_query(
"SELECT * FROM test_module_items WHERE id = %s",
(item_id,),
fetchone=True
)
(item_id,))
if not item:
raise HTTPException(status_code=404, detail="Item not found")
@ -245,7 +243,7 @@ async def health_check():
"""
try:
# Test database connectivity
result = execute_query("SELECT 1 as test", fetchone=True)
result = execute_query_single("SELECT 1 as test")
return {
"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
if self.rules_enabled and not workflow_processed:
# 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",
(email_id,), fetchone=True
)
(email_id,))
if existing_execution:
logger.info(f"⏭️ Email {email_id} already processed by workflow, skipping rules")

View File

@ -104,7 +104,7 @@ class EmailWorkflowService:
ORDER BY priority ASC
"""
workflows = execute_query(query, (classification, confidence))
workflows = execute_query_single(query, (classification, confidence))
# Filter by additional patterns
matching = []
@ -400,16 +400,15 @@ class EmailWorkflowService:
# Find vendor by email
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:
vendor_id = result['id']
# 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",
(email_data['id'],), fetchone=True
)
(email_data['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")
@ -458,7 +457,7 @@ class EmailWorkflowService:
vendor_id = email_data.get('supplier_id')
# Get PDF attachments from email
attachments = execute_query(
attachments = execute_query_single(
"""SELECT filename, file_path, size_bytes, content_type
FROM email_attachments
WHERE email_id = %s AND content_type = 'application/pdf'""",
@ -515,9 +514,7 @@ class EmailWorkflowService:
# Check if file already exists
existing = execute_query(
"SELECT file_id FROM incoming_files WHERE checksum = %s",
(checksum,),
fetchone=True
)
(checksum,))
if existing:
logger.info(f"⚠️ File already exists: {attachment['filename']}")

View File

@ -12,7 +12,7 @@ from datetime import datetime
import re
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__)
@ -582,11 +582,9 @@ Output: {
return None
# Search vendors table
vendor = execute_query(
vendor = execute_query_single(
"SELECT * FROM vendors WHERE cvr_number = %s",
(cvr_clean,),
fetchone=True
)
(cvr_clean,))
if vendor:
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)"""
def __init__(self):
# Simply-CRM bruger OLD_VTIGER settings
self.base_url = getattr(settings, 'OLD_VTIGER_URL', None)
self.username = getattr(settings, 'OLD_VTIGER_USERNAME', None)
self.access_key = getattr(settings, 'OLD_VTIGER_ACCESS_KEY', None)
# Try SIMPLYCRM_* first, fallback to OLD_VTIGER_* for backward compatibility
self.base_url = getattr(settings, 'SIMPLYCRM_URL', None) or getattr(settings, 'OLD_VTIGER_URL', None)
self.username = getattr(settings, 'SIMPLYCRM_USERNAME', None) or getattr(settings, 'OLD_VTIGER_USERNAME', 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: Optional[aiohttp.ClientSession] = None
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):
"""Context manager entry - create session and login"""

View File

@ -208,9 +208,10 @@
<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>
</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
</a>
</a> -->
<span class="badge bg-secondary">API ikke implementeret endnu</span>
</div>
<!-- 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><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="#">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><a class="dropdown-item py-2" href="#">Knowledge Base</a></li>
</ul>
@ -1054,8 +1054,16 @@
function checkMaintenanceMode() {
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 => {
if (!data) return; // Skip if endpoint doesn't exist
const overlay = document.getElementById('maintenance-overlay');
const messageEl = document.getElementById('maintenance-message');
const etaEl = document.getElementById('maintenance-eta');
@ -1092,11 +1100,11 @@
}
})
.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();
// 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 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.services.economic_service import EconomicService
from psycopg2.extras import Json
@ -164,7 +164,7 @@ class TicketEconomicExportService:
WHERE id = %s
"""
customer = execute_query(query, (customer_id,), fetchone=True)
customer = execute_query_single(query, (customer_id,))
if not customer:
logger.error(f"❌ Customer {customer_id} not found")

View File

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

View File

@ -495,3 +495,289 @@ class PrepaidCardDeductRequest(BaseModel):
"""Request model for deducting hours from prepaid card"""
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")
# ============================================================================
# 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,
TicketStatusUpdateRequest,
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
logger = logging.getLogger(__name__)
@ -81,7 +100,7 @@ async def list_tickets(
total_query += " AND customer_id = %s"
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
return TicketListResponse(
@ -217,7 +236,7 @@ async def list_comments(ticket_id: int):
List all comments for a ticket
"""
try:
comments = execute_query(
comments = execute_query_single(
"SELECT * FROM tticket_comments WHERE ticket_id = %s ORDER BY created_at ASC",
(ticket_id,)
)
@ -322,9 +341,7 @@ async def create_worklog(
worklog = execute_query(
"SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,),
fetchone=True
)
(worklog_id,))
logger.info(f"✅ Created worklog entry {worklog_id} for ticket {ticket_id}")
return worklog
@ -347,11 +364,9 @@ async def update_worklog(
"""
try:
# Get current worklog
current = execute_query(
current = execute_query_single(
"SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,),
fetchone=True
)
(worklog_id,))
if not current:
raise HTTPException(status_code=404, detail=f"Worklog {worklog_id} not found")
@ -384,11 +399,9 @@ async def update_worklog(
)
# Fetch updated
worklog = execute_query(
worklog = execute_query_single(
"SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,),
fetchone=True
)
(worklog_id,))
return worklog
@ -427,7 +440,7 @@ async def review_worklog(
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
total_hours = Decimal('0')
@ -467,9 +480,7 @@ async def mark_worklog_billable(
# Get worklog
worklog = execute_query(
"SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,),
fetchone=True
)
(worklog_id,))
if not worklog:
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
"""
try:
stats = execute_query(
stats = execute_query_single(
"SELECT * FROM tticket_stats_by_status ORDER BY status"
)
return stats or []
@ -725,9 +736,7 @@ async def get_open_tickets_stats():
COUNT(*) FILTER (WHERE priority = 'urgent') as urgent_count,
AVG(age_hours) as avg_age_hours
FROM tticket_open_tickets
""",
fetchone=True
)
""")
return stats or {}
@ -816,3 +825,586 @@ async def execute_economic_export(
except Exception as e:
logger.error(f"❌ Error executing export: {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 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 (
TicketStatus,
TicketPriority,
@ -84,13 +84,14 @@ class TicketService:
from psycopg2.extras import Json
# Insert ticket (trigger will auto-generate ticket_number if NULL)
ticket_id = execute_insert(
result = execute_query_single(
"""
INSERT INTO tticket_tickets (
ticket_number, subject, description, status, priority, category,
customer_id, contact_id, assigned_to_user_id, created_by_user_id,
source, tags, custom_fields
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
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
TicketService.log_audit(
ticket_id=ticket_id,
@ -120,11 +126,9 @@ class TicketService:
)
# Fetch created ticket
ticket = execute_query(
ticket = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
(ticket_id,))
logger.info(f"✅ Created ticket {ticket['ticket_number']} (ID: {ticket_id})")
return ticket
@ -147,11 +151,9 @@ class TicketService:
Updated ticket dict
"""
# Get current ticket
current = execute_query(
current = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
(ticket_id,))
if not current:
raise ValueError(f"Ticket {ticket_id} not found")
@ -198,11 +200,9 @@ class TicketService:
)
# Fetch updated ticket
updated = execute_query(
updated = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
(ticket_id,))
logger.info(f"✅ Updated ticket {updated['ticket_number']}")
return updated
@ -230,11 +230,9 @@ class TicketService:
ValueError: If transition is not allowed
"""
# Get current ticket
current = execute_query(
current = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
(ticket_id,))
if not current:
raise ValueError(f"Ticket {ticket_id} not found")
@ -280,11 +278,9 @@ class TicketService:
)
# Fetch updated ticket
updated = execute_query(
updated = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
(ticket_id,))
logger.info(f"✅ Updated ticket {updated['ticket_number']} status: {current_status}{new_status}")
return updated
@ -307,11 +303,9 @@ class TicketService:
Updated ticket dict
"""
# Get current assignment
current = execute_query(
current = execute_query_single(
"SELECT assigned_to_user_id FROM tticket_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
(ticket_id,))
if not current:
raise ValueError(f"Ticket {ticket_id} not found")
@ -334,11 +328,9 @@ class TicketService:
)
# Fetch updated ticket
updated = execute_query(
updated = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
(ticket_id,))
logger.info(f"✅ Assigned ticket {updated['ticket_number']} to user {assigned_to_user_id}")
return updated
@ -363,11 +355,9 @@ class TicketService:
Created comment dict
"""
# Verify ticket exists
ticket = execute_query(
ticket = execute_query_single(
"SELECT id FROM tticket_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
(ticket_id,))
if not ticket:
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
if not is_internal:
ticket = execute_query(
ticket = execute_query_single(
"SELECT first_response_at FROM tticket_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
(ticket_id,))
if not ticket['first_response_at']:
execute_update(
"UPDATE tticket_tickets SET first_response_at = CURRENT_TIMESTAMP WHERE id = %s",
@ -411,11 +399,9 @@ class TicketService:
)
# Fetch created comment
comment = execute_query(
comment = execute_query_single(
"SELECT * FROM tticket_comments WHERE id = %s",
(comment_id,),
fetchone=True
)
(comment_id,))
logger.info(f"💬 Added comment to ticket {ticket_id} (internal: {is_internal})")
return comment
@ -471,19 +457,15 @@ class TicketService:
Returns:
Ticket dict with stats or None if not found
"""
ticket = execute_query(
ticket = execute_query_single(
"SELECT * FROM tticket_open_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
(ticket_id,))
# If not in open_tickets view, fetch from main table
if not ticket:
ticket = execute_query(
ticket = execute_query_single(
"SELECT * FROM tticket_tickets WHERE id = %s",
(ticket_id,),
fetchone=True
)
(ticket_id,))
return ticket

View File

@ -2,360 +2,282 @@
{% 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 class="container-fluid py-4">
<!-- Header -->
<div class="row mb-4">
<div class="col">
<h1 class="h3 mb-0">🎫 Support Dashboard</h1>
<p class="text-muted">Oversigt over alle support tickets og aktivitet</p>
</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 -->
<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>
<!-- Status Overview -->
<div class="row g-3 mb-4">
<div class="col-md-2">
<div class="card border-0 shadow-sm h-100" style="cursor: pointer;" onclick="filterByStatus('open')">
<div class="card-body text-center">
<div class="rounded-circle bg-info bg-opacity-10 p-3 d-inline-flex mb-3">
<i class="bi bi-inbox-fill text-info fs-4"></i>
</div>
<h2 class="mb-1 text-info">{{ stats.open_count or 0 }}</h2>
<p class="text-muted small mb-0">Åbne</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 class="col-md-2">
<div class="card border-0 shadow-sm h-100" style="cursor: pointer;" onclick="filterByStatus('in_progress')">
<div class="card-body text-center">
<div class="rounded-circle bg-warning bg-opacity-10 p-3 d-inline-flex mb-3">
<i class="bi bi-hourglass-split text-warning fs-4"></i>
</div>
<h2 class="mb-1 text-warning">{{ stats.in_progress_count or 0 }}</h2>
<p class="text-muted small mb-0">I Gang</p>
</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 -->
<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
<!-- Worklog & Prepaid Overview -->
<div class="row g-4 mb-4">
<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">⏱️ Worklog Status</h5>
</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>
</div>
{% if recent_tickets %}
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table ticket-list mb-0">
<thead>
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Ticket</th>
<th>Ticket #</th>
<th>Emne</th>
<th>Kunde</th>
<th>Status</th>
<th>Prioritet</th>
<th>Oprettet</th>
<th></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 %}
{% if recent_tickets %}
{% for ticket in recent_tickets %}
<tr onclick="window.location.href='/ticket/tickets/{{ ticket.id }}'" style="cursor: pointer;">
<td><strong>{{ ticket.ticket_number }}</strong></td>
<td>{{ ticket.subject }}</td>
<td>{{ ticket.customer_name or '-' }}</td>
<td>
{% if ticket.status == 'open' %}
<span class="badge bg-info">Åben</span>
{% elif ticket.status == 'in_progress' %}
<span class="badge bg-warning">I Gang</span>
{% elif ticket.status == 'pending_customer' %}
<span class="badge bg-secondary">Afventer</span>
{% elif ticket.status == 'resolved' %}
<span class="badge bg-success">Løst</span>
{% elif ticket.status == 'closed' %}
<span class="badge bg-dark">Lukket</span>
{% else %}
<span class="badge bg-secondary">{{ ticket.status }}</span>
{% endif %}
</td>
<td>
{% if ticket.priority == 'urgent' %}
<span class="badge bg-danger">Akut</span>
{% elif ticket.priority == 'high' %}
<span class="badge bg-warning">Høj</span>
{% elif ticket.priority == 'normal' %}
<span class="badge bg-info">Normal</span>
{% 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>
</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
</div>
</div>
<script>
// Load prepaid stats
document.addEventListener('DOMContentLoaded', () => {
loadPrepaidStats();
});
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>
</div>
</div>
{% endif %}
</div>
{% endblock %}
`;
} catch (error) {
console.error('Error loading prepaid stats:', error);
document.getElementById('prepaidStats').innerHTML = `
<p class="text-center text-muted mb-0">Kunne ikke indlæse data</p>
`;
}
}
{% block extra_js %}
<script>
// Filter tickets by status
function filterTickets(status) {
window.location.href = `/ticket/tickets?status=${status}`;
}
function filterByStatus(status) {
window.location.href = `/ticket/tickets?status=${status}`;
}
</script>
// Auto-refresh every 5 minutes
setTimeout(() => {
location.reload();
}, 300000);
</script>
<style>
.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;
}
.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 %}

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 -->
<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
</a>
<button class="btn btn-outline-secondary" onclick="addComment()">
@ -398,12 +398,12 @@
<script>
// Add comment (placeholder - integrate with API)
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)
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>
{% endblock %}

View File

@ -117,7 +117,7 @@
</h1>
<p class="text-muted">Oversigt over alle tickets i systemet</p>
</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
</a>
</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
WHERE id = %s
"""
entry = execute_query(check_query, (worklog_id,), fetchone=True)
entry = execute_query_single(check_query, (worklog_id,))
if not entry:
raise HTTPException(status_code=404, detail="Worklog entry not found")
@ -199,7 +199,7 @@ async def reject_worklog_entry(
FROM tticket_worklog
WHERE id = %s
"""
entry = execute_query(check_query, (worklog_id,), fetchone=True)
entry = execute_query_single(check_query, (worklog_id,))
if not entry:
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))
@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)
async def ticket_dashboard(request: Request):
"""
@ -252,7 +260,8 @@ async def ticket_dashboard(request: Request):
COUNT(*) AS total_count
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
recent_query = """
@ -280,20 +289,21 @@ async def ticket_dashboard(request: Request):
COALESCE(SUM(hours) FILTER (WHERE status = 'billable'), 0) AS billable_hours
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(
"ticket/frontend/dashboard.html",
{
"request": request,
"stats": stats,
"recent_tickets": recent_tickets,
"recent_tickets": recent_tickets or [],
"worklog_stats": worklog_stats
}
)
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))
@ -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
WHERE t.id = %s
"""
ticket = execute_query(ticket_query, (ticket_id,), fetchone=True)
ticket = execute_query_single(ticket_query, (ticket_id,))
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")

View File

@ -272,7 +272,7 @@
</a>
</li>
<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
</a>
</li>

View File

@ -22,7 +22,7 @@ from app.core.database import execute_query, execute_update
from app.timetracking.backend.models import (
TModuleEconomicExportRequest,
TModuleEconomicExportResult
)
, execute_query_single)
from app.timetracking.backend.audit import audit
logger = logging.getLogger(__name__)
@ -162,7 +162,7 @@ class EconomicExportService:
JOIN tmodule_customers c ON o.customer_id = c.id
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:
raise HTTPException(status_code=404, detail="Order not found")
@ -187,7 +187,7 @@ class EconomicExportService:
WHERE order_id = %s
ORDER BY line_number
"""
lines = execute_query(lines_query, (request.order_id,))
lines = execute_query_single(lines_query, (request.order_id,))
if not lines:
raise HTTPException(
@ -244,7 +244,7 @@ class EconomicExportService:
LEFT JOIN customers c ON tc.hub_customer_id = c.id
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'):
raise HTTPException(

View File

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

View File

@ -27,7 +27,7 @@ from app.timetracking.backend.models import (
TModuleMetadata,
TModuleUninstallRequest,
TModuleUninstallResult
)
, execute_query_single)
from app.timetracking.backend.vtiger_sync import vtiger_service
from app.timetracking.backend.wizard import wizard
from app.timetracking.backend.order_service import order_service
@ -80,11 +80,9 @@ async def sync_case_comments(case_id: int):
"""
try:
# Hent case fra database
case = execute_query(
case = execute_query_single(
"SELECT vtiger_id FROM tmodule_cases WHERE id = %s",
(case_id,),
fetchone=True
)
(case_id,))
if not case:
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
WHERE t.id = %s
"""
entry = execute_query(query, (time_id,), fetchone=True)
entry = execute_query_single(query, (time_id,))
if not entry:
raise HTTPException(status_code=404, detail="Time entry not found")
@ -470,7 +468,7 @@ async def unlock_order(
RETURNING *
"""
result = execute_query(update_query, (order_id,), fetchone=True)
result = execute_query_single(update_query, (order_id,))
# Log unlock
audit.log_event(
@ -551,10 +549,8 @@ async def test_economic_connection():
async def get_module_metadata():
"""Hent modul metadata"""
try:
result = execute_query(
"SELECT * FROM tmodule_metadata ORDER BY id DESC LIMIT 1",
fetchone=True
)
result = execute_query_single(
"SELECT * FROM tmodule_metadata ORDER BY id DESC LIMIT 1")
if not result:
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
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
# Get stats - count each table separately
@ -588,10 +584,8 @@ async def module_health():
}
for table_name in ["customers", "cases", "times", "orders"]:
count_result = execute_query(
f"SELECT COUNT(*) as count FROM tmodule_{table_name}",
fetchone=True
)
count_result = execute_query_single(
f"SELECT COUNT(*) as count FROM tmodule_{table_name}")
stats[table_name] = count_result['count'] if count_result else 0
except Exception as e:
@ -673,11 +667,9 @@ async def update_customer_hourly_rate(customer_id: int, hourly_rate: float, user
)
# Return updated customer
customer = execute_query(
customer = execute_query_single(
"SELECT id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
(customer_id,),
fetchone=True
)
(customer_id,))
if not customer:
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
customer = execute_query(
customer = execute_query_single(
"SELECT * FROM tmodule_customers WHERE id = %s",
(customer_id,),
fetchone=True
)
(customer_id,))
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
@ -770,7 +760,7 @@ async def list_customers(
query += " ORDER BY customer_name"
customers = execute_query(query)
customers = execute_query_single(query)
else:
# Simple customer list
query = "SELECT * FROM tmodule_customers"
@ -893,7 +883,7 @@ async def uninstall_module(
(SELECT COUNT(*) FROM tmodule_order_lines) +
(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
except:
total_rows = 0
@ -902,7 +892,7 @@ async def uninstall_module(
from app.core.database import get_db_connection
import psycopg2
conn = get_db_connection()
conn = get_db_connection(, execute_query_single)
cursor = conn.cursor()
dropped_items = {

View File

@ -302,11 +302,9 @@ class TimeTrackingVTigerService:
data_hash = self._calculate_hash(account)
# Check if exists
existing = execute_query(
existing = execute_query_single(
"SELECT id, sync_hash FROM tmodule_customers WHERE vtiger_id = %s",
(vtiger_id,),
fetchone=True
)
(vtiger_id,))
if existing:
# Check if data changed
@ -424,11 +422,9 @@ class TimeTrackingVTigerService:
continue
# Find customer in our DB
customer = execute_query(
customer = execute_query_single(
"SELECT id FROM tmodule_customers WHERE vtiger_id = %s",
(account_id,),
fetchone=True
)
(account_id,))
if not customer:
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)
# Check if exists
existing = execute_query(
existing = execute_query_single(
"SELECT id, sync_hash FROM tmodule_cases WHERE vtiger_id = %s",
(vtiger_id,),
fetchone=True
)
(vtiger_id,))
if existing:
if existing['sync_hash'] == data_hash:
@ -685,22 +679,18 @@ class TimeTrackingVTigerService:
if related_to:
# 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",
(related_to,),
fetchone=True
)
(related_to,))
if case:
case_id = case['id']
customer_id = case['customer_id']
else:
# Try to find customer directly
customer = execute_query(
customer = execute_query_single(
"SELECT id FROM tmodule_customers WHERE vtiger_id = %s",
(related_to,),
fetchone=True
)
(related_to,))
if customer:
customer_id = customer['id']
@ -725,11 +715,9 @@ class TimeTrackingVTigerService:
data_hash = self._calculate_hash(timelog)
# Check if exists
existing = execute_query(
existing = execute_query_single(
"SELECT id, sync_hash FROM tmodule_times WHERE vtiger_id = %s",
(vtiger_id,),
fetchone=True
)
(vtiger_id,))
if existing:
if existing['sync_hash'] == data_hash:

View File

@ -19,7 +19,7 @@ from app.timetracking.backend.models import (
TModuleWizardProgress,
TModuleWizardNextEntry,
TModuleApprovalStats
)
, execute_query_single)
from app.timetracking.backend.audit import audit
logger = logging.getLogger(__name__)
@ -36,7 +36,7 @@ class WizardService:
SELECT * FROM tmodule_approval_stats
WHERE customer_id = %s
"""
result = execute_query(query, (customer_id,), fetchone=True)
result = execute_query_single(query, (customer_id,))
if not result:
return None
@ -52,7 +52,7 @@ class WizardService:
"""Hent approval statistics for alle kunder"""
try:
query = "SELECT * FROM tmodule_approval_stats ORDER BY customer_name"
results = execute_query(query)
results = execute_query_single(query)
return [TModuleApprovalStats(**row) for row in results]
@ -83,7 +83,7 @@ class WizardService:
WHERE customer_id = %s
LIMIT 1
"""
result = execute_query(query, (customer_id,), fetchone=True)
result = execute_query(query, (customer_id,))
else:
# Hent næste generelt
if exclude_time_card:
@ -96,7 +96,7 @@ class WizardService:
else:
query = "SELECT * FROM tmodule_next_pending LIMIT 1"
result = execute_query(query, fetchone=True)
result = execute_query_single(query)
if not result:
# Ingen flere entries
@ -161,7 +161,7 @@ class WizardService:
JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE t.id = %s
"""
entry = execute_query(query, (approval.time_id,), fetchone=True)
entry = execute_query_single(query, (approval.time_id,))
if not entry:
raise HTTPException(status_code=404, detail="Time entry not found")
@ -215,7 +215,7 @@ class WizardService:
)
# Return updated entry
updated = execute_query(query, (approval.time_id,), fetchone=True)
updated = execute_query_single(query, (approval.time_id,))
return TModuleTimeWithContext(**updated)
except HTTPException:
@ -251,7 +251,7 @@ class WizardService:
JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE t.id = %s
"""
entry = execute_query(query, (time_id,), fetchone=True)
entry = execute_query_single(query, (time_id,))
if not entry:
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}")
# Return updated
updated = execute_query(query, (time_id,), fetchone=True)
updated = execute_query_single(query, (time_id,))
return TModuleTimeWithContext(**updated)
except HTTPException:
@ -321,7 +321,7 @@ class WizardService:
JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE t.id = %s
"""
entry = execute_query(query, (time_id,), fetchone=True)
entry = execute_query_single(query, (time_id,))
if not entry:
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}")
# Return updated
updated = execute_query(query, (time_id,), fetchone=True)
updated = execute_query_single(query, (time_id,))
return TModuleTimeWithContext(**updated)
except HTTPException:
@ -491,7 +491,7 @@ class WizardService:
ORDER BY t.worked_date
LIMIT 1
"""
case = execute_query(query, (customer_id,), fetchone=True)
case = execute_query_single(query, (customer_id,))
if case:
current_case_id = case['id']
current_case_title = case['title']
@ -585,7 +585,7 @@ class WizardService:
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]
except Exception as e:
@ -608,7 +608,7 @@ class WizardService:
FROM tmodule_cases
WHERE id = %s
"""
case = execute_query(case_query, (case_id,), fetchone=True)
case = execute_query(case_query, (case_id,))
if not case:
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
- ./app:/app/app: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
environment:
# 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}
- ENABLE_RELOAD=false
- OLLAMA_MODEL=qwen3:4b # Bruger Chat API format
- OLLAMA_MODEL_FALLBACK=qwen2.5:3b # Backup model
restart: unless-stopped
healthcheck:
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.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse, FileResponse
from fastapi.responses import RedirectResponse
from contextlib import asynccontextmanager
from pathlib import Path
from app.core.config import settings
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)
from app.auth.backend import router as auth_api
from app.auth.backend import views as auth_views
# Import Feature Routers
from app.customers.backend import router as customers_api
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.billing.backend import router as billing_api
from app.billing.frontend import views as billing_views
from app.system.backend import router as system_api
from app.dashboard.backend import views as dashboard_views
from app.dashboard.backend import router as dashboard_api
from app.devportal.backend import router as devportal_api
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.prepaid.backend import router as prepaid_api
from app.prepaid.backend import views as prepaid_views
from app.ticket.backend import router as ticket_api
from app.ticket.frontend import views as ticket_views
from app.vendors.backend import router as vendors_api
from app.vendors.backend import views as vendors_views
# Configure logging
logging.basicConfig(
@ -66,25 +48,10 @@ async def lifespan(app: FastAPI):
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")
yield
# Shutdown
logger.info("👋 Shutting down...")
email_scheduler.stop()
backup_scheduler.stop()
# Create FastAPI app
app = FastAPI(
@ -105,11 +72,6 @@ app = FastAPI(
openapi_url="/api/openapi.json"
)
@app.get("/")
async def root():
"""Redirect root to dashboard"""
return RedirectResponse(url="/dashboard")
# CORS middleware
app.add_middleware(
CORSMiddleware,
@ -120,34 +82,20 @@ app.add_middleware(
)
# 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(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(billing_api.router, prefix="/api/v1", tags=["Billing"])
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(devportal_api.router, prefix="/api/v1/devportal", tags=["DEV Portal"])
app.include_router(timetracking_api, prefix="/api/v1/timetracking", tags=["Time Tracking"])
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"])
app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"])
app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"])
app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
# Frontend Routers
app.include_router(auth_views.router, tags=["Frontend"])
app.include_router(dashboard_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(billing_views.router, 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"])
app.include_router(ticket_views.router, prefix="/ticket", tags=["Frontend"])
# Serve static files (UI)
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
@ -161,49 +109,6 @@ async def health_check():
"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__":
import uvicorn
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-multipart==0.0.17
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">
<head>
<meta charset="UTF-8">