feat: Implement AI-powered Case Analysis Service and QuickCreate Modal

- Added CaseAnalysisService for analyzing case text using Ollama LLM.
- Integrated AI analysis into the QuickCreate modal for automatic case creation.
- Created HTML structure for QuickCreate modal with dynamic fields for title, description, customer, priority, technician, and tags.
- Implemented customer search functionality with debounce for efficient querying.
- Added priority field to sag_sager table with migration for consistency in case management.
- Introduced caching mechanism in CaseAnalysisService to optimize repeated analyses.
- Enhanced error handling and user feedback in the QuickCreate modal.
This commit is contained in:
Christian 2026-02-20 07:10:06 +01:00
parent e6b4d8fb47
commit bef5c20c83
13 changed files with 1923 additions and 112 deletions

View File

@ -78,8 +78,8 @@ class Settings(BaseSettings):
WIKI_READ_ONLY: bool = True WIKI_READ_ONLY: bool = True
# Ollama LLM # Ollama LLM
OLLAMA_ENDPOINT: str = "http://localhost:11434" OLLAMA_ENDPOINT: str = "http://172.16.31.195:11434"
OLLAMA_MODEL: str = "llama3.2:3b" OLLAMA_MODEL: str = "llama3.2"
# Email System Configuration # Email System Configuration
# IMAP Settings # IMAP Settings

View File

@ -2,6 +2,7 @@
Pydantic Models and Schemas Pydantic Models and Schemas
""" """
from enum import Enum
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from typing import Optional, List from typing import Optional, List
from datetime import datetime, date from datetime import datetime, date
@ -303,3 +304,75 @@ class AnyDeskSessionUpdate(BaseModel):
status: str status: str
ended_at: Optional[str] = None ended_at: Optional[str] = None
duration_minutes: Optional[int] = None duration_minutes: Optional[int] = None
# ============================================================================
# SAG MODULE (Cases) - QuickCreate and Priority Support
# ============================================================================
class SagPriority(str, Enum):
"""Case priority levels matching database enum"""
LOW = "low"
NORMAL = "normal"
HIGH = "high"
URGENT = "urgent"
class QuickCreateAnalysis(BaseModel):
"""AI analysis result for QuickCreate feature"""
suggested_title: str
suggested_description: str
suggested_priority: SagPriority = SagPriority.NORMAL
suggested_customer_id: Optional[int] = None
suggested_customer_name: Optional[str] = None
suggested_technician_id: Optional[int] = None
suggested_technician_name: Optional[str] = None
suggested_group_id: Optional[int] = None
suggested_group_name: Optional[str] = None
suggested_tags: List[str] = []
hardware_references: List[dict] = [] # [{id, brand, model, serial_number}]
confidence: float = 0.0
ai_reasoning: Optional[str] = None # Debug info for low confidence
model_config = ConfigDict(from_attributes=True)
class SagBase(BaseModel):
"""Base schema for SAG (cases)"""
titel: str
beskrivelse: Optional[str] = None
priority: SagPriority = SagPriority.NORMAL
customer_id: Optional[int] = None
ansvarlig_bruger_id: Optional[int] = None
assigned_group_id: Optional[int] = None
deadline: Optional[datetime] = None
class SagCreate(SagBase):
"""Schema for creating a case"""
template_key: Optional[str] = None
tags: Optional[List[str]] = None
class SagUpdate(BaseModel):
"""Schema for updating a case"""
titel: Optional[str] = None
beskrivelse: Optional[str] = None
priority: Optional[SagPriority] = None
status: Optional[str] = None
ansvarlig_bruger_id: Optional[int] = None
assigned_group_id: Optional[int] = None
deadline: Optional[datetime] = None
class Sag(SagBase):
"""Full case schema"""
id: int
status: str
template_key: Optional[str] = None
created_by_user_id: int
created_at: datetime
updated_at: datetime
deleted_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)

View File

@ -10,9 +10,10 @@ from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_query_single from app.core.database import execute_query, execute_query_single
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis
from app.core.config import settings from app.core.config import settings
from app.services.email_service import EmailService from app.services.email_service import EmailService
from app.services.case_analysis_service import CaseAnalysisService
try: try:
import extract_msg import extract_msg
@ -99,6 +100,35 @@ def _validate_group_id(group_id: Optional[int], field_name: str = "assigned_grou
if not exists: if not exists:
raise HTTPException(status_code=400, detail=f"Invalid {field_name}") raise HTTPException(status_code=400, detail=f"Invalid {field_name}")
# ============================================================================
# QUICKCREATE AI ANALYSIS
# ============================================================================
class QuickCreateRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=5000)
user_id: int
@router.post("/sag/analyze-quick-create", response_model=QuickCreateAnalysis)
async def analyze_quick_create(request: QuickCreateRequest):
"""
Analyze case description text using AI for QuickCreate feature.
Returns structured suggestions for customer, technician, priority, tags, etc.
"""
try:
logger.info(f"🔍 QuickCreate analysis requested by user {request.user_id}, text length: {len(request.text)}")
# Initialize service and analyze
service = CaseAnalysisService()
analysis = await service.analyze_case_text(request.text, request.user_id)
logger.info(f"✅ QuickCreate analysis complete: confidence={analysis.confidence}, priority={analysis.suggested_priority}")
return analysis
except Exception as e:
logger.error(f"❌ QuickCreate analysis failed: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
# ============================================================================ # ============================================================================
# SAGER - CRUD Operations # SAGER - CRUD Operations
# ============================================================================ # ============================================================================

View File

@ -309,8 +309,23 @@
let selectedContactsCompanies = {}; let selectedContactsCompanies = {};
let customerSearchTimeout; let customerSearchTimeout;
let contactSearchTimeout; let contactSearchTimeout;
let successAlertTimeout;
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null }; let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null };
// Helper function to show success alert
function showSuccessAlert(message, duration = 3000) {
if (successAlertTimeout) {
clearTimeout(successAlertTimeout);
}
const successDiv = document.getElementById('success');
successDiv.classList.remove('d-none');
document.getElementById('success-text').textContent = message;
successAlertTimeout = setTimeout(() => {
successDiv.classList.add('d-none');
successAlertTimeout = null;
}, duration);
}
// --- Character Counter --- // --- Character Counter ---
const beskrInput = document.getElementById('beskrivelse'); const beskrInput = document.getElementById('beskrivelse');
if (beskrInput) { if (beskrInput) {
@ -415,12 +430,17 @@
} }
// --- Selection Logic --- // --- Selection Logic ---
function selectCustomer(id, name) { function selectCustomer(id, name, skipAlert = false) {
selectedCustomer = { id, name }; selectedCustomer = { id, name };
document.getElementById('customer_id').value = id; document.getElementById('customer_id').value = id;
document.getElementById('customerSearch').value = ''; document.getElementById('customerSearch').value = '';
document.getElementById('customerResults').classList.add('d-none'); document.getElementById('customerResults').classList.add('d-none');
renderSelections(); renderSelections();
// Show notification
if (!skipAlert) {
showSuccessAlert(`Valgte firma: ${name}`);
}
} }
function removeCustomer() { function removeCustomer() {
@ -430,7 +450,8 @@
} }
async function selectContact(id, name) { async function selectContact(id, name) {
if (!selectedContacts[id]) { const isNewContact = !selectedContacts[id];
if (isNewContact) {
selectedContacts[id] = { id, name }; selectedContacts[id] = { id, name };
} }
document.getElementById('contactSearch').value = ''; document.getElementById('contactSearch').value = '';
@ -446,19 +467,24 @@
if (data.companies && data.companies.length === 1) { if (data.companies && data.companies.length === 1) {
const company = data.companies[0]; const company = data.companies[0];
if (!selectedCustomer) { if (!selectedCustomer && isNewContact) {
selectCustomer(company.id, company.name); // Auto-select company silently, then show combined alert
selectCustomer(company.id, company.name, true);
// Show brief notification showSuccessAlert(`Valgte kontakt: ${name} + firma: ${company.name}`, 4000);
const successDiv = document.getElementById('success'); } else if (isNewContact) {
successDiv.classList.remove('d-none'); // Just show contact alert
document.getElementById('success-text').textContent = `Valgte automatisk kunde: ${company.name}`; showSuccessAlert(`Valgte kontakt: ${name}`);
setTimeout(() => successDiv.classList.add('d-none'), 3000);
} }
} else if (isNewContact) {
// No auto-select, just show contact alert
showSuccessAlert(`Valgte kontakt: ${name}`);
} }
} }
} catch (e) { } catch (e) {
console.error("Auto-select company failed", e); console.error("Auto-select company failed", e);
if (isNewContact) {
showSuccessAlert(`Valgte kontakt: ${name}`);
}
} }
loadHardwareForContacts(); loadHardwareForContacts();

View File

@ -81,12 +81,6 @@
border: 1px solid rgba(0,0,0,0.1); border: 1px solid rgba(0,0,0,0.1);
} }
/* Wider tooltip for relation type explanations */
.tooltip-wide .tooltip-inner {
max-width: 400px;
text-align: left;
}
.tag-closed { .tag-closed {
background-color: #e0e0e0; background-color: #e0e0e0;
color: #666; color: #666;
@ -959,15 +953,15 @@
<div class="col-12 mb-3"> <div class="col-12 mb-3">
<div class="card h-100 d-flex flex-column" data-module="relations" data-has-content="{{ 'true' if relation_tree else 'false' }}"> <div class="card h-100 d-flex flex-column" data-module="relations" data-has-content="{{ 'true' if relation_tree else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);"> <div class="d-flex align-items-center gap-2">
🔗 Relationer <h6 class="mb-0" style="color: var(--accent);">🔗 Relationer</h6>
<i class="bi bi-info-circle-fill ms-2" <i class="bi bi-info-circle text-muted"
style="font-size: 0.85rem; cursor: help; opacity: 0.7;" style="font-size:0.9rem; cursor:help;"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-placement="right"
data-bs-html="true" data-bs-html="true"
title="<div class='text-start'><strong>Relateret til:</strong> Faglig kobling uden direkte afhængighed.<br><strong>Afledt af:</strong> Denne sag er opstået på baggrund af en anden sag.<br><strong>Årsag til:</strong> Denne sag er årsagen til en anden sag.<br><strong>Blokkerer:</strong> Arbejde i en sag stopper fremdrift i den anden.</div>"></i> data-bs-placement="right"
</h6> title="<strong>Hvad betyder relationstyper?</strong><br><br><strong>Relateret til</strong>: Faglig kobling uden direkte afhængighed.<br><strong>Afledt af</strong>: Denne sag er opstået på baggrund af en anden sag.<br><strong>Årsag til</strong>: Denne sag er årsagen til en anden sag.<br><strong>Blokkerer</strong>: Arbejde i en sag stopper fremdrift i den anden."></i>
</div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" onclick="showRelationModal()"> <button class="btn btn-sm btn-outline-primary" onclick="showRelationModal()">
<i class="bi bi-link-45deg"></i> <i class="bi bi-link-45deg"></i>
@ -1553,13 +1547,6 @@
// Initialize everything when DOM is ready // Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Initialize Bootstrap tooltips
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((el) => {
new bootstrap.Tooltip(el, {
customClass: 'tooltip-wide'
});
});
// Initialize modals // Initialize modals
contactSearchModal = new bootstrap.Modal(document.getElementById('contactSearchModal')); contactSearchModal = new bootstrap.Modal(document.getElementById('contactSearchModal'));
customerSearchModal = new bootstrap.Modal(document.getElementById('customerSearchModal')); customerSearchModal = new bootstrap.Modal(document.getElementById('customerSearchModal'));
@ -1574,6 +1561,11 @@
updateRelationTypeHint(); updateRelationTypeHint();
updateNewCaseRelationTypeHint(); updateNewCaseRelationTypeHint();
// Initialize all tooltips on the page
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
bootstrap.Tooltip.getOrCreateInstance(el, { html: true, container: 'body' });
});
// Render Global Tags // Render Global Tags
if (window.renderEntityTags) { if (window.renderEntityTags) {
window.renderEntityTags('case', {{ case.id }}, 'case-tags'); window.renderEntityTags('case', {{ case.id }}, 'case-tags');

View File

@ -1,6 +1,7 @@
import json import json
import logging import logging
import base64 import base64
import re
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from urllib.error import URLError, HTTPError from urllib.error import URLError, HTTPError
@ -527,10 +528,11 @@ async def click_to_call(payload: TelefoniClickToCallRequest):
if "{phone_password}" in template and not phone_password_value: if "{phone_password}" in template and not phone_password_value:
raise HTTPException(status_code=400, detail="Template requires {phone_password}, but selected user has no phone password") raise HTTPException(status_code=400, detail="Template requires {phone_password}, but selected user has no phone password")
raw_number_clean = re.sub(r"\s+", "", payload.number.strip())
resolved_url = ( resolved_url = (
template template
.replace("{number}", number_normalized) .replace("{number}", number_normalized)
.replace("{raw_number}", payload.number.strip()) .replace("{raw_number}", raw_number_clean)
.replace("{extension}", extension_value) .replace("{extension}", extension_value)
.replace("{phone_ip}", phone_ip_value) .replace("{phone_ip}", phone_ip_value)
.replace("{phone_username}", phone_username_value) .replace("{phone_username}", phone_username_value)

View File

@ -0,0 +1,498 @@
"""
Case Analysis Service
AI-powered case text analysis using Ollama LLM
Extracts entities for QuickCreate feature
"""
import logging
import json
import hashlib
from typing import Dict, Optional, List
from datetime import datetime, timedelta
import aiohttp
import re
from app.core.config import settings
from app.core.database import execute_query, execute_query_single
from app.models.schemas import QuickCreateAnalysis, SagPriority
logger = logging.getLogger(__name__)
class CaseAnalysisService:
"""AI-powered case text analysis for QuickCreate feature"""
def __init__(self):
self.ollama_endpoint = settings.OLLAMA_ENDPOINT
self.ollama_model = settings.OLLAMA_MODEL
self.confidence_threshold = getattr(settings, 'QUICK_CREATE_CONFIDENCE_THRESHOLD', 0.6)
self.ai_timeout = getattr(settings, 'QUICK_CREATE_AI_TIMEOUT', 15) # Increased for larger models
self._cache = {} # Simple in-memory cache {text_hash: (result, timestamp)}
self._cache_ttl = 300 # 5 minutes
async def analyze_case_text(self, text: str, user_id: int) -> QuickCreateAnalysis:
"""
Analyze case text and extract structured data
Returns QuickCreateAnalysis with suggestions
"""
if not text or len(text) < 5:
return self._empty_analysis(text)
# Check cache first
cached_result = self._get_cached_analysis(text)
if cached_result:
logger.info(f"✅ Using cached analysis for text hash")
return cached_result
# Build system prompt (Danish for accuracy)
system_prompt = self._build_analysis_prompt()
# Build user message
user_message = self._build_text_context(text)
try:
# Call Ollama with timeout
result = await self._call_ollama(system_prompt, user_message)
if result:
# Enrich with database matches
analysis = await self._enrich_with_db_matches(result, user_id)
# Apply business rules
analysis = await self._apply_business_rules(analysis, user_id)
# Cache the result
self._cache_analysis(text, analysis)
return analysis
else:
logger.warning("⚠️ Ollama returned no result, using empty analysis")
return self._empty_analysis(text)
except Exception as e:
logger.error(f"❌ Case analysis failed: {e}", exc_info=True)
return self._empty_analysis(text)
def _build_analysis_prompt(self) -> str:
"""Build Danish system prompt for case analysis"""
return """Du er en intelligent assistent der analyserer beskeder om IT-support sager og udtrækker struktureret information.
VIGTIGE REGLER:
1. Returner KUN gyldig JSON - ingen forklaring eller ekstra tekst
2. Hvis et felt ikke findes, sæt det til null eller tom liste
3. Beregn confidence baseret hvor sikker du er (0.0-1.0) - SÆT IKKE 0.0 hvis du faktisk kan udtrække information!
4. Analyser tone og urgency for at bestemme prioritet
5. OPFIND IKKE ORD - brug kun ord fra den originale tekst
6. Ved "Ring til X fra Y" mønster: Y er oftest firma, X er kontaktperson
FELTER AT UDTRÆKKE:
**suggested_title**: Kort beskrivende titel (max 80 tegn) - BASERET ORIGINAL TEKST
**suggested_description**: Fuld beskrivelse (kan være hele teksten hvis den er kort)
**priority**: "low", "normal", "high", eller "urgent"
- "urgent": Kritiske ord som "nede", "virker ikke", "kritisk", "ASAP", "sur kunde", "omgående"
- "high": "hurtigt", "snart", "vigtigt", "prioriter", "Haster"
- "low": "når I får tid", "ikke hastende", "lavprioriteret"
- "normal": Standard hvis intet tyder andet
**customer_hints**: Liste af hints om kunde (firmanavn, CVR, nummer, lokation) - f.eks. ["norva24", "Nets", "24"]
**technician_hints**: Liste af INTERNE BMC Hub medarbejdere der skal tildeles sagen (Christian, Peter, osv.)
- VIG: "Ring til Dennis fra norva24" betyder Dennis er KUNDE-kontaktperson, IKKE vores tekniker
- Kun sæt technician_hints hvis der står "Send til Christian" eller "Christian skal ordne det"
- Lad være tom liste hvis der ikke nævnes en INTERN medarbejder
**tags**: Relevante nøgleord (produkt, lokation, type problem) - brug kun fra original tekst
**hardware_keywords**: Hardware-relaterede termer (modeller, serial numbers)
**confidence**: 0.5-0.9 hvis du kan udtrække nogen information, 0.0-0.4 kun hvis teksten er uforståelig
EKSEMPLER:
Input: "Ring til Dennis fra norva24. Haster"
Output:
{
"suggested_title": "Ring til Dennis fra norva24",
"suggested_description": "Ring til Dennis fra norva24. Haster",
"priority": "high",
"customer_hints": ["norva24"],
"technician_hints": [],
"tags": ["telefonbesked", "opfølgning"],
"hardware_keywords": [],
"confidence": 0.75
}
Input: "Kunde 24 ringer. Printer i Hellerup virker ikke. Skal fixes hurtigt."
Output:
{
"suggested_title": "Printer i Hellerup virker ikke",
"suggested_description": "Kunde 24 ringer. Printer i Hellerup virker ikke. Skal fixes hurtigt.",
"priority": "high",
"customer_hints": ["24", "Hellerup"],
"technician_hints": [],
"tags": ["printer", "hellerup", "hardware"],
"hardware_keywords": ["printer"],
"confidence": 0.85
}
Input: "Ny medarbejder starter 1. marts hos Nets. Skal have laptop og adgang til systemer."
Output:
{
"suggested_title": "Ny medarbejder hos Nets starter 1. marts",
"suggested_description": "Ny medarbejder starter 1. marts hos Nets. Skal have laptop og adgang til systemer.",
"priority": "normal",
"customer_hints": ["Nets"],
"technician_hints": [],
"tags": ["onboarding", "ny medarbejder", "laptop"],
"hardware_keywords": ["laptop"],
"confidence": 0.90
}
Input: "Lenovo T14 serial ABC123 kører langsomt. Når I får tid."
Output:
{
"suggested_title": "Lenovo T14 kører langsomt",
"suggested_description": "Lenovo T14 serial ABC123 kører langsomt. Når I får tid.",
"priority": "low",
"customer_hints": [],
"technician_hints": [],
"tags": ["performance", "lenovo"],
"hardware_keywords": ["Lenovo T14", "ABC123"],
"confidence": 0.75
}
Input: "Norva24 ringer - server er nede! Send Christian ud ASAP."
Output:
{
"suggested_title": "Norva24 server er nede",
"suggested_description": "Norva24 ringer - server er nede! Send Christian ud ASAP.",
"priority": "urgent",
"customer_hints": ["Norva24"],
"technician_hints": ["Christian"],
"tags": ["server", "onsite", "kritisk"],
"hardware_keywords": ["server"],
"confidence": 0.90
}
Husk: Returner KUN JSON, ingen forklaring eller tekst udenfor JSON-objektet."""
def _build_text_context(self, text: str) -> str:
"""Build context for AI analysis"""
# Truncate if too long (avoid token limits)
if len(text) > 2000:
text = text[:2000] + "..."
return f"""Analyser denne sags-tekst og udtrække struktureret information:
{text}
Returner JSON med suggested_title, suggested_description, priority, customer_hints, technician_hints, tags, hardware_keywords, og confidence."""
async def _call_ollama(self, system_prompt: str, user_message: str) -> Optional[Dict]:
"""Call Ollama API for analysis"""
url = f"{self.ollama_endpoint}/api/chat"
payload = {
"model": self.ollama_model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message}
],
"stream": False,
"options": {
"temperature": 0.2, # Low for consistent extraction
"num_predict": 800 # Enough for detailed response
}
}
try:
start_time = datetime.now()
async with aiohttp.ClientSession() as session:
timeout = aiohttp.ClientTimeout(total=self.ai_timeout)
async with session.post(url, json=payload, timeout=timeout) as response:
if response.status != 200:
error_text = await response.text()
logger.error(f"❌ Ollama API error: {response.status} - {error_text}")
return None
data = await response.json()
message_data = data.get('message', {})
content = message_data.get('content', '') or message_data.get('thinking', '')
processing_time = (datetime.now() - start_time).total_seconds() * 1000
if not content:
logger.error(f"❌ Ollama returned empty response")
return None
# Parse JSON response
logger.info(f"🔍 Raw Ollama response: {content[:500]}")
result = self._parse_ollama_response(content)
if result:
result['processing_time_ms'] = int(processing_time)
logger.info(f"✅ AI analysis: priority={result.get('priority', 'unknown')} (confidence: {result.get('confidence', 0):.2f}, {processing_time:.0f}ms)")
logger.info(f"🔍 Parsed result: {json.dumps(result, ensure_ascii=False)[:500]}")
return result
else:
logger.warning(f"⚠️ Failed to parse Ollama response: {content[:200]}")
return None
except aiohttp.ClientError as e:
logger.warning(f"⚠️ Ollama connection error: {e}")
return None
except Exception as e:
logger.error(f"❌ Unexpected error calling Ollama: {e}", exc_info=True)
return None
def _parse_ollama_response(self, content: str) -> Optional[Dict]:
"""Parse Ollama JSON response"""
try:
# Try to extract JSON if wrapped in markdown
if '```json' in content:
json_match = re.search(r'```json\s*(\{.*?\})\s*```', content, re.DOTALL)
if json_match:
content = json_match.group(1)
elif '```' in content:
json_match = re.search(r'```\s*(\{.*?\})\s*```', content, re.DOTALL)
if json_match:
content = json_match.group(1)
# Try to find JSON object
json_match = re.search(r'\{.*\}', content, re.DOTALL)
if json_match:
content = json_match.group(0)
result = json.loads(content)
return result
except json.JSONDecodeError as e:
logger.error(f"❌ JSON parse error: {e} - Content: {content[:500]}")
return None
async def _enrich_with_db_matches(self, ai_result: Dict, user_id: int) -> QuickCreateAnalysis:
"""Enrich AI results with database matches"""
# Extract base fields
title = ai_result.get('suggested_title', '')
description = ai_result.get('suggested_description', '')
priority_str = ai_result.get('priority', 'normal')
confidence = float(ai_result.get('confidence', 0.0))
# Map priority to enum
try:
priority = SagPriority(priority_str.lower())
except ValueError:
priority = SagPriority.NORMAL
# Match customer
customer_hints = ai_result.get('customer_hints', [])
customer_id, customer_name = await self._match_customer(customer_hints)
# Match technician
technician_hints = ai_result.get('technician_hints', [])
technician_id, technician_name = await self._match_technician(technician_hints)
# Match group (can be enhanced with rules later)
group_id, group_name = None, None
# Extract tags
tags = ai_result.get('tags', [])
# Match hardware
hardware_keywords = ai_result.get('hardware_keywords', [])
hardware_refs = await self._match_hardware(hardware_keywords)
# Build AI reasoning for low confidence
ai_reasoning = None
if confidence < self.confidence_threshold:
ai_reasoning = f"Lav confidence: {confidence:.2f}. Tjek alle felter grundigt."
return QuickCreateAnalysis(
suggested_title=title,
suggested_description=description,
suggested_priority=priority,
suggested_customer_id=customer_id,
suggested_customer_name=customer_name,
suggested_technician_id=technician_id,
suggested_technician_name=technician_name,
suggested_group_id=group_id,
suggested_group_name=group_name,
suggested_tags=tags,
hardware_references=hardware_refs,
confidence=confidence,
ai_reasoning=ai_reasoning
)
async def _match_customer(self, hints: List[str]) -> tuple[Optional[int], Optional[str]]:
"""Match customer from hints"""
if not hints:
return None, None
for hint in hints:
hint_clean = str(hint).strip()
if not hint_clean:
continue
# Try exact ID match first
if hint_clean.isdigit():
row = execute_query_single(
"SELECT id, name FROM customers WHERE id = %s AND deleted_at IS NULL AND is_active = true",
(int(hint_clean),)
)
if row:
return row['id'], row['name']
# Try name or CVR match with intelligent sorting
# Priority: 1) Exact match, 2) Starts with hint, 3) Contains hint
row = execute_query_single(
"""SELECT id, name FROM customers
WHERE (name ILIKE %s OR cvr_number ILIKE %s)
AND deleted_at IS NULL AND is_active = true
ORDER BY
CASE WHEN LOWER(name) = LOWER(%s) THEN 1
WHEN LOWER(name) LIKE LOWER(%s) THEN 2
ELSE 3 END,
name ASC
LIMIT 1""",
(f"%{hint_clean}%", f"%{hint_clean}%", hint_clean, f"{hint_clean}%")
)
if row:
logger.info(f"✅ Matched customer: {row['name']} (id={row['id']}) from hint '{hint_clean}'")
return row['id'], row['name']
return None, None
async def _match_technician(self, hints: List[str]) -> tuple[Optional[int], Optional[str]]:
"""Match technician from hints (BMC Hub employees only)"""
if not hints:
return None, None
for hint in hints:
hint_clean = str(hint).strip()
if not hint_clean:
continue
# Match by full_name or username with intelligent sorting
# Priority: 1) Exact match, 2) Starts with hint, 3) Contains hint
row = execute_query_single(
"""SELECT user_id as id, full_name, username FROM users
WHERE (full_name ILIKE %s OR username ILIKE %s)
AND is_active = true
ORDER BY
CASE WHEN LOWER(full_name) = LOWER(%s) THEN 1
WHEN LOWER(full_name) LIKE LOWER(%s) THEN 2
WHEN LOWER(username) = LOWER(%s) THEN 1
WHEN LOWER(username) LIKE LOWER(%s) THEN 2
ELSE 3 END,
full_name ASC
LIMIT 1""",
(f"%{hint_clean}%", f"%{hint_clean}%", hint_clean, f"{hint_clean}%", hint_clean, f"{hint_clean}%")
)
if row:
logger.info(f"✅ Matched technician: {row['full_name']} (user_id={row['id']}) from hint '{hint_clean}'")
return row['id'], row['full_name'] or row['username']
else:
logger.warning(f"⚠️ No BMC Hub employee found matching '{hint_clean}' - may be external contact")
return None, None
async def _match_hardware(self, keywords: List[str]) -> List[Dict]:
"""Match hardware from keywords"""
hardware_refs = []
for keyword in keywords:
keyword_clean = str(keyword).strip()
if not keyword_clean or len(keyword_clean) < 3:
continue
# Search hardware assets
rows = execute_query(
"""SELECT id, brand, model, serial_number
FROM hardware_assets
WHERE (brand ILIKE %s OR model ILIKE %s OR serial_number ILIKE %s)
AND deleted_at IS NULL
LIMIT 3""",
(f"%{keyword_clean}%", f"%{keyword_clean}%", f"%{keyword_clean}%")
)
for row in rows:
hardware_refs.append({
'id': row['id'],
'brand': row['brand'],
'model': row['model'],
'serial_number': row['serial_number']
})
return hardware_refs
async def _apply_business_rules(self, analysis: QuickCreateAnalysis, user_id: int) -> QuickCreateAnalysis:
"""Apply business rules to enhance analysis (Phase 2 feature)"""
# Rule 1: Check customer history for technician preference
if analysis.suggested_customer_id and not analysis.suggested_technician_id:
# Find most frequent technician for this customer
row = execute_query_single(
"""SELECT ansvarlig_bruger_id, COUNT(*) as cnt
FROM sag_sager
WHERE customer_id = %s AND ansvarlig_bruger_id IS NOT NULL
GROUP BY ansvarlig_bruger_id
ORDER BY cnt DESC LIMIT 1""",
(analysis.suggested_customer_id,)
)
if row:
user_row = execute_query_single(
"SELECT user_id as id, full_name FROM users WHERE user_id = %s AND is_active = true",
(row['ansvarlig_bruger_id'],)
)
if user_row:
analysis.suggested_technician_id = user_row['id']
analysis.suggested_technician_name = user_row['full_name']
logger.info(f"✨ Business rule: Assigned technician {user_row['full_name']} based on history")
# Rule 2: Check for VIP customers (future: alert_notes integration)
# TODO: Add alert_notes check for popup rules
# Rule 3: Onboarding detection
onboarding_keywords = ['onboarding', 'ny medarbejder', 'nyt bruger', 'starter den']
if any(keyword in analysis.suggested_description.lower() for keyword in onboarding_keywords):
if 'onboarding' not in analysis.suggested_tags:
analysis.suggested_tags.append('onboarding')
logger.info(f"✨ Business rule: Detected onboarding case")
return analysis
def _empty_analysis(self, text: str) -> QuickCreateAnalysis:
"""Return empty analysis with original text"""
return QuickCreateAnalysis(
suggested_title="",
suggested_description=text,
suggested_priority=SagPriority.NORMAL,
confidence=0.0,
ai_reasoning="AI unavailable - fill fields manually"
)
def _get_cached_analysis(self, text: str) -> Optional[QuickCreateAnalysis]:
"""Get cached analysis if available and not expired"""
text_hash = hashlib.md5(text.encode()).hexdigest()
if text_hash in self._cache:
result, timestamp = self._cache[text_hash]
if (datetime.now() - timestamp).total_seconds() < self._cache_ttl:
return result
else:
# Expired
del self._cache[text_hash]
return None
def _cache_analysis(self, text: str, analysis: QuickCreateAnalysis):
"""Cache analysis result"""
text_hash = hashlib.md5(text.encode()).hexdigest()
self._cache[text_hash] = (analysis, datetime.now())
# Clean old cache entries (simple LRU)
if len(self._cache) > 100:
# Remove oldest 20 entries
sorted_cache = sorted(self._cache.items(), key=lambda x: x[1][1])
for key, _ in sorted_cache[:20]:
del self._cache[key]

View File

@ -305,6 +305,9 @@
</li> </li>
</ul> </ul>
<div class="d-flex align-items-center gap-3"> <div class="d-flex align-items-center gap-3">
<button class="btn btn-light rounded-circle border-0" id="quickCreateBtn" style="background: var(--accent-light); color: var(--accent);" title="Opret ny sag (+ eller Cmd+Shift+C)">
<i class="bi bi-plus-circle-fill fs-5"></i>
</button>
<button class="btn btn-light rounded-circle border-0" id="darkModeToggle" style="background: var(--accent-light); color: var(--accent);"> <button class="btn btn-light rounded-circle border-0" id="darkModeToggle" style="background: var(--accent-light); color: var(--accent);">
<i class="bi bi-moon-fill"></i> <i class="bi bi-moon-fill"></i>
</button> </button>
@ -585,6 +588,7 @@
// Keyboard shortcut: Cmd+K or Ctrl+K // Keyboard shortcut: Cmd+K or Ctrl+K
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
// Cmd+K / Ctrl+K for global search
if ((e.metaKey || e.ctrlKey) && e.key === 'k') { if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault(); e.preventDefault();
console.log('Cmd+K pressed - opening search modal'); // Debug console.log('Cmd+K pressed - opening search modal'); // Debug
@ -598,12 +602,43 @@
}, 300); }, 300);
} }
// '+' key for QuickCreate (not in input fields)
if (e.key === '+' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
e.preventDefault();
openQuickCreateModal();
}
// Cmd+Shift+C / Ctrl+Shift+C for QuickCreate
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'c') {
e.preventDefault();
openQuickCreateModal();
}
// ESC to close // ESC to close
if (e.key === 'Escape') { if (e.key === 'Escape') {
searchModal.hide(); searchModal.hide();
} }
}); });
// QuickCreate modal opener function
function openQuickCreateModal() {
const quickCreateModal = new bootstrap.Modal(document.getElementById('quickCreateModal'));
quickCreateModal.show();
setTimeout(() => {
const textInput = document.getElementById('quickCreateText');
if (textInput) {
textInput.focus();
}
}, 300);
}
// QuickCreate button click handler
document.getElementById('quickCreateBtn')?.addEventListener('click', (e) => {
e.preventDefault();
openQuickCreateModal();
});
// Reset search when modal is closed // Reset search when modal is closed
document.getElementById('globalSearchModal').addEventListener('hidden.bs.modal', () => { document.getElementById('globalSearchModal').addEventListener('hidden.bs.modal', () => {
if (globalSearchInput) { if (globalSearchInput) {
@ -1049,6 +1084,9 @@
}); });
</script> </script>
<!-- QuickCreate Modal (AI-Powered Case Creation) -->
{% include "shared/frontend/quick_create_modal.html" %}
<!-- Profile Modal --> <!-- Profile Modal -->
<div class="modal fade" id="profileModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="profileModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg"> <div class="modal-dialog modal-dialog-centered modal-lg">

View File

@ -0,0 +1,670 @@
<!-- QuickCreate Modal - AI-Powered Case Creation -->
<div class="modal fade" id="quickCreateModal" tabindex="-1" aria-labelledby="quickCreateModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="quickCreateModalLabel">
<i class="bi bi-plus-circle"></i> Opret Sag
<span id="quickCreateLoadingSpinner" class="spinner-border spinner-border-sm ms-2 d-none" role="status">
<span class="visually-hidden">Analyserer...</span>
</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<form id="quickCreateForm">
<!-- Main Text Input -->
<div class="mb-4">
<label for="quickCreateText" class="form-label fw-bold">
Beskriv sagen <span class="text-muted">(AI analyserer automatisk)</span>
</label>
<textarea
class="form-control"
id="quickCreateText"
rows="6"
placeholder="Beskriv kort hvad sagen handler om...&#10;&#10;Eksempel: 'Kunde 24 ringer. Printer i Hellerup virker ikke. Skal fixes hurtigt.'"
autofocus
></textarea>
<div class="form-text">
<i class="bi bi-lightbulb"></i> AI genkender automatisk kunde, prioritet, tekniker og tags mens du skriver.
</div>
</div>
<!-- AI Analysis Results (initially hidden) -->
<div id="quickCreateAnalysisSection" class="d-none">
<hr class="my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">
<i class="bi bi-robot"></i> AI-forslag
<span id="quickCreateConfidenceBadge" class="badge bg-secondary ms-2"></span>
</h6>
<small class="text-muted" id="quickCreateAiReasoning"></small>
</div>
<!-- Title -->
<div class="mb-3">
<label for="quickCreateTitle" class="form-label">Titel</label>
<input
type="text"
class="form-control"
id="quickCreateTitle"
maxlength="200"
required
>
</div>
<!-- Description -->
<div class="mb-3">
<label for="quickCreateDescription" class="form-label">Beskrivelse</label>
<textarea
class="form-control"
id="quickCreateDescription"
rows="4"
></textarea>
</div>
<!-- Customer -->
<div class="mb-3">
<label for="quickCreateCustomer" class="form-label">Kunde *</label>
<div class="input-group">
<input
type="text"
class="form-control"
id="quickCreateCustomerSearch"
placeholder="Søg kunde..."
autocomplete="off"
>
<button class="btn btn-outline-secondary" type="button" id="quickCreateCustomerClear">
<i class="bi bi-x"></i>
</button>
</div>
<input type="hidden" id="quickCreateCustomerId">
<div id="quickCreateCustomerResults" class="list-group mt-1 position-absolute" style="z-index: 1050; max-height: 200px; overflow-y: auto;"></div>
<div class="form-text">
<span id="quickCreateCustomerDisplay" class="text-success"></span>
</div>
</div>
<div class="row">
<!-- Priority -->
<div class="col-md-6 mb-3">
<label class="form-label">Prioritet</label>
<div class="btn-group w-100" role="group" id="quickCreatePriorityGroup">
<input type="radio" class="btn-check" name="priority" id="priorityLow" value="low">
<label class="btn btn-outline-secondary" for="priorityLow">
<i class="bi bi-arrow-down"></i> Lav
</label>
<input type="radio" class="btn-check" name="priority" id="priorityNormal" value="normal" checked>
<label class="btn btn-outline-primary" for="priorityNormal">
<i class="bi bi-dash"></i> Normal
</label>
<input type="radio" class="btn-check" name="priority" id="priorityHigh" value="high">
<label class="btn btn-outline-warning" for="priorityHigh">
<i class="bi bi-arrow-up"></i> Høj
</label>
<input type="radio" class="btn-check" name="priority" id="priorityUrgent" value="urgent">
<label class="btn btn-outline-danger" for="priorityUrgent">
<i class="bi bi-exclamation-triangle"></i> Kritisk
</label>
</div>
</div>
<!-- Technician -->
<div class="col-md-6 mb-3">
<label for="quickCreateTechnician" class="form-label">Tekniker</label>
<select class="form-select" id="quickCreateTechnician">
<option value="">Vælg tekniker...</option>
</select>
</div>
</div>
<!-- Group -->
<div class="mb-3">
<label for="quickCreateGroup" class="form-label">Gruppe</label>
<select class="form-select" id="quickCreateGroup">
<option value="">Vælg gruppe...</option>
</select>
</div>
<!-- Tags -->
<div class="mb-3">
<label class="form-label">Tags</label>
<div id="quickCreateTagsContainer" class="d-flex flex-wrap gap-2 mb-2">
<!-- Tags will be rendered here -->
</div>
<div class="input-group">
<input
type="text"
class="form-control"
id="quickCreateTagInput"
placeholder="Tilføj tag..."
>
<button class="btn btn-outline-secondary" type="button" id="quickCreateAddTag">
<i class="bi bi-plus"></i> Tilføj
</button>
</div>
</div>
<!-- Hardware References (if any) -->
<div id="quickCreateHardwareSection" class="mb-3 d-none">
<label class="form-label">
<i class="bi bi-laptop"></i> Hardware-referencer fundet
</label>
<div id="quickCreateHardwareList" class="list-group">
<!-- Hardware references will be rendered here -->
</div>
</div>
</div>
<!-- Manual Mode Message (shown when AI fails) -->
<div id="quickCreateManualMode" class="alert alert-info d-none" role="alert">
<i class="bi bi-info-circle"></i> AI-assistent utilgængelig. Udfyld felterne manuelt.
</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" id="quickCreateSubmit" disabled>
<i class="bi bi-check-circle"></i> Opret Sag
</button>
</div>
</div>
</div>
</div>
<script>
(function() {
const modal = document.getElementById('quickCreateModal');
const form = document.getElementById('quickCreateForm');
const textInput = document.getElementById('quickCreateText');
const analysisSection = document.getElementById('quickCreateAnalysisSection');
const manualMode = document.getElementById('quickCreateManualMode');
const loadingSpinner = document.getElementById('quickCreateLoadingSpinner');
const submitBtn = document.getElementById('quickCreateSubmit');
// Form fields
const titleInput = document.getElementById('quickCreateTitle');
const descriptionInput = document.getElementById('quickCreateDescription');
const customerSearchInput = document.getElementById('quickCreateCustomerSearch');
const customerIdInput = document.getElementById('quickCreateCustomerId');
const customerDisplay = document.getElementById('quickCreateCustomerDisplay');
const customerResults = document.getElementById('quickCreateCustomerResults');
const customerClearBtn = document.getElementById('quickCreateCustomerClear');
const technicianSelect = document.getElementById('quickCreateTechnician');
const groupSelect = document.getElementById('quickCreateGroup');
const tagsContainer = document.getElementById('quickCreateTagsContainer');
const tagInput = document.getElementById('quickCreateTagInput');
const addTagBtn = document.getElementById('quickCreateAddTag');
const confidenceBadge = document.getElementById('quickCreateConfidenceBadge');
const aiReasoning = document.getElementById('quickCreateAiReasoning');
const hardwareSection = document.getElementById('quickCreateHardwareSection');
const hardwareList = document.getElementById('quickCreateHardwareList');
let analysisDebounce;
let currentTags = [];
let currentHardware = [];
let customerSearchDebounce;
let cachedUserId = null;
// Get user ID from JWT token or meta tag
function getUserId() {
if (cachedUserId) return cachedUserId;
// Try JWT token first
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
cachedUserId = payload.sub || payload.user_id;
return cachedUserId;
} catch (e) {
console.warn('Could not decode token for user_id');
}
}
// Fallback to meta tag
const metaTag = document.querySelector('meta[name="user-id"]');
if (metaTag) {
cachedUserId = metaTag.getAttribute('content');
return cachedUserId;
}
// Last resort: hardcoded value (not ideal but prevents errors)
console.warn('Could not get user_id, using default value 1');
return '1';
}
// Initialize on modal show
modal.addEventListener('show.bs.modal', function() {
resetForm();
loadTechnicians();
loadGroups();
});
// Text input handler - triggers AI analysis
textInput.addEventListener('input', function(e) {
clearTimeout(analysisDebounce);
const text = e.target.value.trim();
if (text.length < 10) {
hideAnalysisSection();
return;
}
showLoadingState();
analysisDebounce = setTimeout(async () => {
await performAnalysis(text);
}, 800); // 800ms debounce
});
// Customer search with debounce
customerSearchInput.addEventListener('input', function(e) {
clearTimeout(customerSearchDebounce);
const query = e.target.value.trim();
if (query.length < 2) {
customerResults.innerHTML = '';
customerResults.classList.remove('show');
return;
}
customerSearchDebounce = setTimeout(async () => {
await searchCustomers(query);
}, 300);
});
// Customer clear button
customerClearBtn.addEventListener('click', function() {
customerSearchInput.value = '';
customerIdInput.value = '';
customerDisplay.textContent = '';
customerResults.innerHTML = '';
validateForm();
});
// Tag management
addTagBtn.addEventListener('click', addTag);
tagInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
});
// Form submission
submitBtn.addEventListener('click', async function() {
await submitCase();
});
async function performAnalysis(text) {
try {
const userId = getUserId();
const response = await fetch(`/api/v1/sag/analyze-quick-create?user_id=${userId}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'include',
body: JSON.stringify({text})
});
if (!response.ok) {
throw new Error('Analysis failed');
}
const analysis = await response.json();
populateFields(analysis);
showAnalysisSection();
hideLoadingState();
} catch (error) {
console.error('AI analysis error:', error);
showManualMode();
hideLoadingState();
}
}
function populateFields(analysis) {
// Title and description
titleInput.value = analysis.suggested_title || '';
descriptionInput.value = analysis.suggested_description || '';
// Customer
if (analysis.suggested_customer_id) {
customerIdInput.value = analysis.suggested_customer_id;
customerSearchInput.value = analysis.suggested_customer_name || '';
customerDisplay.textContent = `✓ ${analysis.suggested_customer_name}`;
}
// Priority
const priorityValue = analysis.suggested_priority || 'normal';
document.getElementById(`priority${capitalizeFirst(priorityValue)}`).checked = true;
// Technician
if (analysis.suggested_technician_id) {
technicianSelect.value = analysis.suggested_technician_id;
}
// Group
if (analysis.suggested_group_id) {
groupSelect.value = analysis.suggested_group_id;
}
// Tags
currentTags = analysis.suggested_tags || [];
renderTags();
// Hardware
currentHardware = analysis.hardware_references || [];
if (currentHardware.length > 0) {
renderHardware();
hardwareSection.classList.remove('d-none');
} else {
hardwareSection.classList.add('d-none');
}
// Confidence badge
const confidence = analysis.confidence || 0;
updateConfidenceBadge(confidence);
// AI reasoning
if (analysis.ai_reasoning) {
aiReasoning.textContent = analysis.ai_reasoning;
aiReasoning.classList.remove('d-none');
} else {
aiReasoning.classList.add('d-none');
}
validateForm();
}
function updateConfidenceBadge(confidence) {
let badgeClass = 'bg-secondary';
let text = 'Lav sikkerhed';
if (confidence >= 0.8) {
badgeClass = 'bg-success';
text = 'Høj sikkerhed';
} else if (confidence >= 0.6) {
badgeClass = 'bg-info';
text = 'Middel sikkerhed';
} else if (confidence >= 0.4) {
badgeClass = 'bg-warning';
text = 'Lav sikkerhed';
} else {
badgeClass = 'bg-danger';
text = 'Meget lav sikkerhed';
}
confidenceBadge.className = `badge ${badgeClass} ms-2`;
confidenceBadge.textContent = `${text} (${Math.round(confidence * 100)}%)`;
}
async function searchCustomers(query) {
try {
const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(query)}`, {
credentials: 'include'
});
const customers = await response.json();
if (customers.length === 0) {
customerResults.innerHTML = '<div class="list-group-item text-muted">Ingen kunder fundet</div>';
} else {
customerResults.innerHTML = customers.map(c => `
<button type="button" class="list-group-item list-group-item-action" data-id="${c.id}" data-name="${c.name}">
<strong>${c.name}</strong>
${c.cvr_nummer ? `<br><small class="text-muted">CVR: ${c.cvr_nummer}</small>` : ''}
</button>
`).join('');
// Add click handlers
customerResults.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', function() {
selectCustomer(this.dataset.id, this.dataset.name);
});
});
}
customerResults.classList.add('show');
} catch (error) {
console.error('Customer search error:', error);
}
}
function selectCustomer(id, name) {
customerIdInput.value = id;
customerSearchInput.value = name;
customerDisplay.textContent = `✓ ${name}`;
customerResults.innerHTML = '';
customerResults.classList.remove('show');
validateForm();
}
async function loadTechnicians() {
try {
const response = await fetch('/api/v1/users?is_active=true', {
credentials: 'include'
});
const users = await response.json();
technicianSelect.innerHTML = '<option value="">Vælg tekniker...</option>' +
users.map(u => `<option value="${u.id}">${u.full_name || u.username}</option>`).join('');
} catch (error) {
console.error('Error loading technicians:', error);
}
}
async function loadGroups() {
try {
const response = await fetch('/api/v1/groups', {
credentials: 'include'
});
const groups = await response.json();
groupSelect.innerHTML = '<option value="">Vælg gruppe...</option>' +
groups.map(g => `<option value="${g.id}">${g.name}</option>`).join('');
} catch (error) {
console.error('Error loading groups:', error);
}
}
function addTag() {
const tag = tagInput.value.trim();
if (tag && !currentTags.includes(tag)) {
currentTags.push(tag);
renderTags();
tagInput.value = '';
}
}
function removeTag(tag) {
currentTags = currentTags.filter(t => t !== tag);
renderTags();
}
function renderTags() {
tagsContainer.innerHTML = currentTags.map(tag => `
<span class="badge bg-secondary">
${tag}
<button type="button" class="btn-close btn-close-white ms-1" style="font-size: 0.6rem;" data-tag="${tag}"></button>
</span>
`).join('');
// Add remove handlers
tagsContainer.querySelectorAll('.btn-close').forEach(btn => {
btn.addEventListener('click', function() {
removeTag(this.dataset.tag);
});
});
}
function renderHardware() {
hardwareList.innerHTML = currentHardware.map(hw => `
<div class="list-group-item">
<strong>${hw.brand} ${hw.model}</strong>
${hw.serial_number ? `<br><small class="text-muted">Serial: ${hw.serial_number}</small>` : ''}
</div>
`).join('');
}
function validateForm() {
const hasTitle = titleInput.value.trim().length > 0;
const hasCustomer = customerIdInput.value.length > 0;
submitBtn.disabled = !(hasTitle && hasCustomer);
}
// Add validation listeners
titleInput.addEventListener('input', validateForm);
async function submitCase() {
const priority = document.querySelector('input[name="priority"]:checked').value;
const userId = getUserId();
const caseData = {
titel: titleInput.value.trim(),
beskrivelse: descriptionInput.value.trim(),
customer_id: parseInt(customerIdInput.value),
ansvarlig_bruger_id: technicianSelect.value ? parseInt(technicianSelect.value) : null,
assigned_group_id: groupSelect.value ? parseInt(groupSelect.value) : null,
priority: priority,
created_by_user_id: parseInt(userId),
template_key: 'quickcreate'
};
try {
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Opretter...';
const response = await fetch('/api/v1/sag', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'include',
body: JSON.stringify(caseData)
});
if (!response.ok) {
throw new Error('Failed to create case');
}
const newCase = await response.json();
// TODO: Add tags via separate endpoint if any exist
// Redirect to case detail
window.location.href = `/sag/${newCase.id}`;
} catch (error) {
console.error('Error creating case:', error);
alert('Fejl ved oprettelse af sag. Prøv igen.');
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="bi bi-check-circle"></i> Opret Sag';
}
}
function showLoadingState() {
loadingSpinner.classList.remove('d-none');
}
function hideLoadingState() {
loadingSpinner.classList.add('d-none');
}
function showAnalysisSection() {
analysisSection.classList.remove('d-none');
manualMode.classList.add('d-none');
}
function hideAnalysisSection() {
analysisSection.classList.add('d-none');
manualMode.classList.add('d-none');
}
function showManualMode() {
manualMode.classList.remove('d-none');
analysisSection.classList.remove('d-none');
// Pre-fill description with original text
descriptionInput.value = textInput.value;
}
function resetForm() {
form.reset();
textInput.value = '';
customerIdInput.value = '';
customerDisplay.textContent = '';
currentTags = [];
currentHardware = [];
hideAnalysisSection();
hideLoadingState();
submitBtn.disabled = true;
renderTags();
}
function capitalizeFirst(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
// Close customer results when clicking outside
document.addEventListener('click', function(e) {
if (!customerSearchInput.contains(e.target) && !customerResults.contains(e.target)) {
customerResults.innerHTML = '';
customerResults.classList.remove('show');
}
});
})();
</script>
<style>
#quickCreateModal .modal-body {
max-height: 70vh;
overflow-y: auto;
}
#quickCreateCustomerResults {
max-width: 100%;
display: none;
}
#quickCreateCustomerResults.show {
display: block;
}
#quickCreateTagsContainer .badge {
display: inline-flex;
align-items: center;
padding: 0.5rem 0.75rem;
}
#quickCreateHardwareList .list-group-item {
padding: 0.75rem 1rem;
}
/* Priority button group styling */
#quickCreatePriorityGroup label {
flex: 1;
font-size: 0.9rem;
}
#quickCreatePriorityGroup .btn-check:checked + label.btn-outline-secondary {
background-color: #6c757d;
color: white;
}
#quickCreatePriorityGroup .btn-check:checked + label.btn-outline-primary {
background-color: var(--bs-primary);
color: white;
}
#quickCreatePriorityGroup .btn-check:checked + label.btn-outline-warning {
background-color: #ffc107;
color: #000;
}
#quickCreatePriorityGroup .btn-check:checked + label.btn-outline-danger {
background-color: #dc3545;
color: white;
}
</style>

View File

@ -17,104 +17,530 @@
</div> </div>
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<div class="col-6 col-lg-2"><div class="card border-0 shadow-sm"><div class="card-body text-center"><div class="small text-muted">Nye sager</div><div class="h4 mb-0">{{ kpis.new_cases_count }}</div></div></div></div> <div class="col-6 col-lg-2">
<div class="col-6 col-lg-2"><div class="card border-0 shadow-sm"><div class="card-body text-center"><div class="small text-muted">Mine sager</div><div class="h4 mb-0">{{ kpis.my_cases_count }}</div></div></div></div> <div class="card border-0 shadow-sm kpi-toggle" onclick="toggleSection('newCases')" id="kpiNewCases" style="cursor: pointer; transition: all 0.2s;">
<div class="col-6 col-lg-2"><div class="card border-0 shadow-sm"><div class="card-body text-center"><div class="small text-muted">Dagens opgaver</div><div class="h4 mb-0">{{ kpis.today_tasks_count }}</div></div></div></div> <div class="card-body text-center">
<div class="col-6 col-lg-3"><div class="card border-0 shadow-sm"><div class="card-body text-center"><div class="small text-muted">Haste / over SLA</div><div class="h4 mb-0 text-danger">{{ kpis.urgent_overdue_count }}</div></div></div></div> <div class="small text-muted">Nye sager</div>
<div class="col-6 col-lg-3"><div class="card border-0 shadow-sm"><div class="card-body text-center"><div class="small text-muted">Mine opportunities</div><div class="h4 mb-0">{{ kpis.my_opportunities_count }}</div></div></div></div> <div class="h4 mb-0">{{ kpis.new_cases_count }}</div>
</div>
</div>
</div>
<div class="col-6 col-lg-2">
<div class="card border-0 shadow-sm kpi-toggle" onclick="toggleSection('myCases')" id="kpiMyCases" style="cursor: pointer; transition: all 0.2s;">
<div class="card-body text-center">
<div class="small text-muted">Mine sager</div>
<div class="h4 mb-0">{{ kpis.my_cases_count }}</div>
</div>
</div>
</div>
<div class="col-6 col-lg-2">
<div class="card border-0 shadow-sm kpi-toggle" onclick="toggleSection('todayTasks')" id="kpiTodayTasks" style="cursor: pointer; transition: all 0.2s;">
<div class="card-body text-center">
<div class="small text-muted">Dagens opgaver</div>
<div class="h4 mb-0">{{ kpis.today_tasks_count }}</div>
</div>
</div>
</div>
<div class="col-6 col-lg-2">
<div class="card border-0 shadow-sm kpi-toggle" onclick="toggleSection('groupCases')" id="kpiGroupCases" style="cursor: pointer; transition: all 0.2s;">
<div class="card-body text-center">
<div class="small text-muted">Gruppe-sager</div>
<div class="h4 mb-0">{{ kpis.group_cases_count }}</div>
</div>
</div>
</div>
<div class="col-6 col-lg-2"><div class="card border-0 shadow-sm"><div class="card-body text-center"><div class="small text-muted">Haste / over SLA</div><div class="h4 mb-0 text-danger">{{ kpis.urgent_overdue_count }}</div></div></div></div>
<div class="col-6 col-lg-2"><div class="card border-0 shadow-sm"><div class="card-body text-center"><div class="small text-muted">Mine opportunities</div><div class="h4 mb-0">{{ kpis.my_opportunities_count }}</div></div></div></div>
</div> </div>
<!-- Liste og detalje område -->
<div class="row g-4"> <div class="row g-4">
<div class="col-lg-6"> <div class="col-lg-8">
<div class="card border-0 shadow-sm h-100"> <div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0"><h5 class="mb-0">Nye sager</h5></div> <div class="card-header bg-white border-0">
<h5 class="mb-0" id="listTitle">Alle sager</h5>
</div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm table-hover mb-0"> <table class="table table-sm table-hover mb-0" id="caseTable">
<thead class="table-light"><tr><th>ID</th><th>Titel</th><th>Kunde</th><th>Oprettet</th></tr></thead> <thead class="table-light" id="tableHead">
<tbody> <tr>
<th>ID</th>
<th>Titel</th>
<th>Kunde</th>
<th>Status</th>
<th>Dato</th>
</tr>
</thead>
<tbody id="tableBody">
<tr><td colspan="5" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<!-- Placeholder -->
<div class="card border-0 shadow-sm" id="placeholderPanel">
<div class="card-body text-center py-5">
<i class="bi bi-arrow-left-circle text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-3 mb-0">Klik på en sag i listen for at se detaljer</p>
</div>
</div>
<!-- Detail panel -->
<div class="card border-0 shadow-sm" id="detailPanel" style="display: none; max-height: 85vh; overflow-y: auto;">
<!-- Header -->
<div class="card-header bg-white border-bottom sticky-top d-flex justify-content-between align-items-center py-2">
<div>
<span class="small text-muted" id="detailCaseBadge"></span>
<h6 class="mb-0 fw-bold" id="detailTitle">Sag</h6>
</div>
<div class="d-flex gap-1">
<a id="detailOpenBtn" href="#" class="btn btn-sm btn-outline-primary" target="_blank">
<i class="bi bi-box-arrow-up-right"></i>
</a>
<button class="btn btn-sm btn-outline-secondary" onclick="closeDetail()">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<!-- Module pills (mouseover viser indhold) -->
<div class="px-3 pt-2 pb-1 border-bottom" id="modulePills" style="display:none;"></div>
<!-- Contact row (ring / SMS) -->
<div class="px-3 py-2 border-bottom" id="contactRow" style="display:none;"></div>
<!-- Status + meta -->
<div class="px-3 py-2 border-bottom" id="detailMeta"></div>
<!-- Kommentar-feed -->
<div class="px-3 pt-2">
<div class="small text-muted fw-semibold mb-2">Kommentarer</div>
<div id="kommentarFeed" style="max-height: 220px; overflow-y: auto;"></div>
<!-- Skriv kommentar -->
<div class="mt-2">
<textarea class="form-control form-control-sm" id="newKommentar" rows="2" placeholder="Skriv en kommentar..."></textarea>
<button class="btn btn-sm btn-primary mt-1 w-100" onclick="postKommentar()">
<i class="bi bi-chat-left-text"></i> Send kommentar
</button>
</div>
</div>
<!-- Tid & Fakturering -->
<div class="px-3 py-2 mt-1 border-top">
<div class="small text-muted fw-semibold mb-2">Tid & Fakturering</div>
<div class="row g-2">
<div class="col-6">
<input type="number" class="form-control form-control-sm" id="tidMinutter" placeholder="Min." min="1" step="15">
</div>
<div class="col-6">
<select class="form-select form-select-sm" id="tidAfregning">
<option value="invoice">Faktura</option>
<option value="prepaid">Forudbetalt</option>
<option value="internal">Intern</option>
<option value="warranty">Garanti</option>
</select>
</div>
<div class="col-12">
<input type="text" class="form-control form-control-sm" id="tidBeskrivelse" placeholder="Beskrivelse (hvad lavede du?)">
</div>
</div>
<button class="btn btn-sm btn-success mt-1 w-100" onclick="registrerTid()">
<i class="bi bi-clock-history"></i> Registrer tid
</button>
</div>
</div>
</div>
</div>
</div>
<script>
let currentFilter = null;
const allData = {
newCases: [
{% for item in new_cases %} {% for item in new_cases %}
<tr onclick="window.location.href='/sag/{{ item.id }}'" style="cursor:pointer;"> {
<td>#{{ item.id }}</td> id: {{ item.id }},
<td>{{ item.titel }}</td> titel: {{ item.titel | tojson | safe }},
<td>{{ item.customer_name }}</td> customer_name: {{ item.customer_name | tojson | safe }},
<td>{{ item.created_at.strftime('%d/%m %H:%M') if item.created_at else '-' }}</td> created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }},
</tr> status: {{ item.status | tojson | safe if item.status else 'null' }},
{% else %} deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
<tr><td colspan="4" class="text-center text-muted py-3">Ingen nye sager</td></tr> }{% if not loop.last %},{% endif %}
{% endfor %} {% endfor %}
</tbody> ],
</table> myCases: [
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0"><h5 class="mb-0">Mine sager</h5></div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light"><tr><th>ID</th><th>Titel</th><th>Deadline</th><th>Status</th></tr></thead>
<tbody>
{% for item in my_cases %} {% for item in my_cases %}
<tr onclick="window.location.href='/sag/{{ item.id }}'" style="cursor:pointer;"> {
<td>#{{ item.id }}</td> id: {{ item.id }},
<td>{{ item.titel }}</td> titel: {{ item.titel | tojson | safe }},
<td>{{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}</td> customer_name: {{ item.customer_name | tojson | safe }},
<td>{{ item.status }}</td> status: {{ item.status | tojson | safe if item.status else 'null' }},
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
}{% if not loop.last %},{% endif %}
{% endfor %}
],
todayTasks: [
{% for item in today_tasks %}
{
item_type: {{ item.item_type | tojson | safe }},
item_id: {{ item.item_id }},
title: {{ item.title | tojson | safe }},
customer_name: {{ item.customer_name | tojson | safe }},
task_reason: {{ item.task_reason | tojson | safe if item.task_reason else 'null' }},
created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }},
priority: {{ item.priority | tojson | safe if item.priority else 'null' }},
status: {{ item.status | tojson | safe if item.status else 'null' }}
}{% if not loop.last %},{% endif %}
{% endfor %}
],
groupCases: [
{% for item in group_cases %}
{
id: {{ item.id }},
titel: {{ item.titel | tojson | safe }},
group_name: {{ item.group_name | tojson | safe }},
customer_name: {{ item.customer_name | tojson | safe }},
status: {{ item.status | tojson | safe if item.status else 'null' }},
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
}{% if not loop.last %},{% endif %}
{% endfor %}
]
};
function formatDate(dateStr) {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function formatShortDate(dateStr) {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function toggleSection(filterName) {
const kpiCard = document.getElementById('kpi' + filterName.charAt(0).toUpperCase() + filterName.slice(1));
const listTitle = document.getElementById('listTitle');
const tableBody = document.getElementById('tableBody');
// Reset all KPI cards
document.querySelectorAll('.kpi-toggle').forEach(card => {
card.style.background = '';
card.style.color = '';
const label = card.querySelector('.text-muted');
if (label) label.style.color = '';
});
// If clicking same filter, clear it
if (currentFilter === filterName) {
currentFilter = null;
listTitle.textContent = 'Alle sager';
tableBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>';
return;
}
// Highlight selected KPI
kpiCard.style.background = 'linear-gradient(135deg, var(--accent), var(--accent-dark, #084c75))';
kpiCard.style.color = 'white';
const label = kpiCard.querySelector('.text-muted');
if (label) label.style.color = 'rgba(255,255,255,0.85)';
// Apply filter and populate table
currentFilter = filterName;
filterAndPopulateTable(filterName);
}
function filterAndPopulateTable(filterName) {
const listTitle = document.getElementById('listTitle');
const tableBody = document.getElementById('tableBody');
let bodyHTML = '';
if (filterName === 'newCases') {
listTitle.innerHTML = '<i class="bi bi-inbox-fill text-primary"></i> Nye sager';
const data = allData.newCases || [];
if (data.length === 0) {
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen nye sager</td></tr>';
} else {
bodyHTML = data.map(item => `
<tr onclick="showCaseDetails(${item.id}, 'case')" style="cursor:pointer;">
<td>#${item.id}</td>
<td>${item.titel || '-'}</td>
<td>${item.customer_name || '-'}</td>
<td><span class="badge bg-secondary">${item.status || 'Ny'}</span></td>
<td>${formatDate(item.created_at)}</td>
</tr> </tr>
{% else %} `).join('');
<tr><td colspan="4" class="text-center text-muted py-3">Ingen sager tildelt</td></tr> }
{% endfor %} } else if (filterName === 'myCases') {
</tbody> listTitle.innerHTML = '<i class="bi bi-person-check-fill text-success"></i> Mine sager';
</table> const data = allData.myCases || [];
</div> if (data.length === 0) {
</div> bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen sager tildelt</td></tr>';
</div> } else {
</div> bodyHTML = data.map(item => `
<tr onclick="showCaseDetails(${item.id}, 'case')" style="cursor:pointer;">
<td>#${item.id}</td>
<td>${item.titel || '-'}</td>
<td>${item.customer_name || '-'}</td>
<td><span class="badge bg-info">${item.status || '-'}</span></td>
<td>${formatShortDate(item.deadline)}</td>
</tr>
`).join('');
}
} else if (filterName === 'todayTasks') {
listTitle.innerHTML = '<i class="bi bi-calendar-check text-primary"></i> Dagens opgaver';
const data = allData.todayTasks || [];
if (data.length === 0) {
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen opgaver i dag</td></tr>';
} else {
bodyHTML = data.map(item => {
const badge = item.item_type === 'case'
? '<span class="badge bg-primary">Sag</span>'
: '<span class="badge bg-info">Ticket</span>';
return `
<tr onclick="showCaseDetails(${item.item_id}, '${item.item_type}')" style="cursor:pointer;">
<td>#${item.item_id}</td>
<td>${item.title || '-'}<br><small class="text-muted">${item.task_reason || ''}</small></td>
<td>${item.customer_name || '-'}</td>
<td>${badge}</td>
<td>${formatDate(item.created_at)}</td>
</tr>
`;
}).join('');
}
} else if (filterName === 'groupCases') {
listTitle.innerHTML = '<i class="bi bi-people-fill text-info"></i> Gruppe-sager';
const data = allData.groupCases || [];
if (data.length === 0) {
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen gruppe-sager</td></tr>';
} else {
bodyHTML = data.map(item => `
<tr onclick="showCaseDetails(${item.id}, 'case')" style="cursor:pointer;">
<td>#${item.id}</td>
<td>${item.titel || '-'}<br><span class="badge bg-secondary">${item.group_name || '-'}</span></td>
<td>${item.customer_name || '-'}</td>
<td><span class="badge bg-info">${item.status || '-'}</span></td>
<td>${formatShortDate(item.deadline)}</td>
</tr>
`).join('');
}
}
<div class="col-lg-6"> tableBody.innerHTML = bodyHTML;
<div class="card border-0 shadow-sm h-100"> }
<div class="card-header bg-white border-0"><h5 class="mb-0 text-danger">Haste / over SLA</h5></div>
<div class="card-body">
{% for item in urgent_overdue %}
<div class="d-flex justify-content-between align-items-start border-bottom pb-2 mb-2">
<div>
<div class="fw-semibold">{{ item.title }}</div>
<div class="small text-muted">{{ item.customer_name }} · {{ item.attention_reason }}</div>
</div>
<a href="{{ '/sag/' ~ item.item_id if item.item_type == 'case' else '/ticket/tickets/' ~ item.item_id }}" class="btn btn-sm btn-outline-danger">Åbn</a>
</div>
{% else %}
<p class="text-muted mb-0">Ingen haste-emner lige nu.</p>
{% endfor %}
</div>
</div>
</div>
<div class="col-lg-6"> async function showCaseDetails(id, type) {
<div class="card border-0 shadow-sm h-100"> const detailPanel = document.getElementById('detailPanel');
<div class="card-header bg-white border-0"><h5 class="mb-0">Mine opportunities</h5></div> const placeholderPanel = document.getElementById('placeholderPanel');
<div class="card-body">
{% for item in my_opportunities %} placeholderPanel.style.display = 'none';
<div class="d-flex justify-content-between align-items-start border-bottom pb-2 mb-2"> detailPanel.style.display = 'block';
// Highlight selected row
document.querySelectorAll('#tableBody tr').forEach(tr => tr.classList.remove('table-active'));
event.currentTarget && event.currentTarget.classList.add('table-active');
// Reset panels
document.getElementById('detailTitle').textContent = 'Henter...';
document.getElementById('detailCaseBadge').textContent = '';
document.getElementById('detailMeta').innerHTML = '<div class="text-center py-2"><div class="spinner-border spinner-border-sm text-primary"></div></div>';
document.getElementById('modulePills').style.display = 'none';
document.getElementById('contactRow').style.display = 'none';
document.getElementById('kommentarFeed').innerHTML = '';
document.getElementById('detailOpenBtn').href = type === 'case' ? `/sag/${id}` : `/ticket/tickets/${id}`;
window._currentDetailId = id;
window._currentDetailType = type;
try {
const [caseRes, contactsRes, kommentarerRes, modulesRes] = await Promise.all([
fetch(`/api/v1/sag/${id}`),
fetch(`/api/v1/sag/${id}/contacts`),
fetch(`/api/v1/sag/${id}/kommentarer`),
fetch(`/api/v1/sag/${id}/modules`)
]);
const data = caseRes.ok ? await caseRes.json() : null;
const contacts = contactsRes.ok ? await contactsRes.json() : [];
const kommentarer = kommentarerRes.ok ? await kommentarerRes.json() : [];
const modules = modulesRes.ok ? await modulesRes.json() : [];
if (!data) {
document.getElementById('detailMeta').innerHTML = '<div class="alert alert-danger m-0">Kunne ikke hente sag</div>';
return;
}
// Header
document.getElementById('detailTitle').textContent = data.titel || 'Ingen titel';
document.getElementById('detailCaseBadge').textContent = `Sag #${id}`;
// Module pills
if (modules && modules.length > 0) {
const pillsEl = document.getElementById('modulePills');
pillsEl.style.display = 'block';
const moduleIcons = {
contacts: 'bi-person', hardware: 'bi-pc-display', files: 'bi-paperclip',
locations: 'bi-geo-alt', calendar: 'bi-calendar', kommentarer: 'bi-chat',
subscriptions: 'bi-arrow-repeat', 'sale-items': 'bi-cart'
};
pillsEl.innerHTML = '<div class="d-flex flex-wrap gap-1">' +
modules.filter(m => m.count > 0).map(m => `
<span class="badge bg-light text-dark border position-relative"
style="cursor:default;"
data-bs-toggle="tooltip"
data-bs-html="true"
title="${m.label || m.module}: ${m.count} ${m.count === 1 ? 'post' : 'poster'}">
<i class="bi ${moduleIcons[m.module] || 'bi-grid'}"></i>
${m.label || m.module}
<span class="badge bg-primary rounded-pill ms-1">${m.count}</span>
</span>
`).join('') +
'</div>';
// Init tooltips
pillsEl.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el));
}
// Contact row med ring/SMS
if (contacts && contacts.length > 0) {
const contactRow = document.getElementById('contactRow');
contactRow.style.display = 'block';
contactRow.innerHTML = contacts.slice(0, 3).map(c => {
const name = [c.first_name, c.last_name].filter(Boolean).join(' ');
const phone = c.mobile || c.phone;
const smsHtml = phone ? `<a href="sms:${phone.replace(/\s/g,'')}" class="btn btn-xs btn-outline-success py-0 px-1" style="font-size:11px;" title="SMS ${phone}"><i class="bi bi-chat-dots"></i></a>` : '';
const callHtml = phone ? `<a href="tel:${phone.replace(/\s/g,'')}" class="btn btn-xs btn-outline-primary py-0 px-1" style="font-size:11px;" title="Ring ${phone}"><i class="bi bi-telephone"></i></a>` : '';
return `
<div class="d-flex justify-content-between align-items-center mb-1">
<div> <div>
<div class="fw-semibold">{{ item.titel }}</div> <span class="small fw-semibold">${name}</span>
<div class="small text-muted">{{ item.customer_name }} · {{ item.pipeline_stage or 'Uden stage' }}</div> ${phone ? `<br><span class="small text-muted">${phone}</span>` : ''}
</div> </div>
<div class="text-end"> <div class="d-flex gap-1">${callHtml}${smsHtml}</div>
<div class="small">{{ "%.0f"|format(item.pipeline_probability or 0) }}%</div>
<a href="/sag/{{ item.id }}" class="btn btn-sm btn-outline-primary mt-1">Åbn</a>
</div> </div>
`;
}).join('');
}
// Meta
const statusColor = {'Aktiv':'primary','Afsluttet':'success','Annulleret':'secondary','Venter':'warning'}[data.status] || 'secondary';
document.getElementById('detailMeta').innerHTML = `
<div class="row g-1 small">
<div class="col-6">
<span class="text-muted">Status</span><br>
<span class="badge bg-${statusColor}">${data.status || '-'}</span>
</div> </div>
{% else %} <div class="col-6">
<p class="text-muted mb-0">Ingen opportunities fundet.</p> <span class="text-muted">Deadline</span><br>
{% endfor %} <strong>${formatShortDate(data.deadline)}</strong>
</div>
</div> </div>
${data.customer_name ? `<div class="col-12 mt-1"><i class="bi bi-building text-muted"></i> ${data.customer_name}</div>` : ''}
${data.description ? `<div class="col-12 mt-1 text-muted" style="font-size:11px;">${data.description.substring(0,120)}${data.description.length>120?'...':''}</div>` : ''}
</div> </div>
`;
// Kommentarer feed
renderKommentarFeed(kommentarer);
} catch (error) {
document.getElementById('detailMeta').innerHTML = '<div class="alert alert-danger m-0 small">Fejl ved hentning</div>';
console.error(error);
}
}
function renderKommentarFeed(kommentarer) {
const feed = document.getElementById('kommentarFeed');
if (!kommentarer || kommentarer.length === 0) {
feed.innerHTML = '<p class="text-muted small">Ingen kommentarer endnu.</p>';
return;
}
feed.innerHTML = kommentarer.map(k => `
<div class="mb-2 p-2 rounded ${k.er_system_besked ? 'bg-light border-start border-info border-3' : 'bg-light'}">
<div class="d-flex justify-content-between">
<span class="small fw-semibold">${k.forfatter || 'System'}</span>
<span class="small text-muted">${formatDate(k.created_at)}</span>
</div> </div>
<div class="small mt-1">${k.indhold || ''}</div>
</div> </div>
`).join('');
feed.scrollTop = feed.scrollHeight;
}
async function postKommentar() {
const id = window._currentDetailId;
if (!id) return;
const textarea = document.getElementById('newKommentar');
const indhold = textarea.value.trim();
if (!indhold) return;
try {
const res = await fetch(`/api/v1/sag/${id}/kommentarer`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ indhold, forfatter: '{{ current_user.username if current_user else "Bruger" }}' })
});
if (!res.ok) throw new Error('Fejl');
textarea.value = '';
// Reload comments
const k = await (await fetch(`/api/v1/sag/${id}/kommentarer`)).json();
renderKommentarFeed(k);
} catch (e) {
alert('Kunne ikke sende kommentar');
}
}
async function registrerTid() {
const id = window._currentDetailId;
if (!id) return;
const minutter = parseInt(document.getElementById('tidMinutter').value);
const beskrivelse = document.getElementById('tidBeskrivelse').value.trim();
const afregning = document.getElementById('tidAfregning').value;
if (!minutter || minutter < 1) { alert('Angiv antal minutter'); return; }
if (!beskrivelse) { alert('Angiv en beskrivelse'); return; }
const hours = +(minutter / 60).toFixed(4);
try {
const res = await fetch('/api/v1/timetracking/entries/internal', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
sag_id: id,
original_hours: hours,
description: beskrivelse,
billing_method: afregning,
user_name: '{{ current_user.username if current_user else "Hub" }}',
worked_date: new Date().toISOString().slice(0, 10)
})
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert('Fejl: ' + (err.detail || res.status));
return;
}
document.getElementById('tidMinutter').value = '';
document.getElementById('tidBeskrivelse').value = '';
// Visual feedback
const btn = document.querySelector('[onclick="registrerTid()"]');
btn.innerHTML = '<i class="bi bi-check-circle"></i> Registreret!';
btn.classList.replace('btn-success','btn-outline-success');
setTimeout(() => { btn.innerHTML = '<i class="bi bi-clock-history"></i> Registrer tid'; btn.classList.replace('btn-outline-success','btn-success'); }, 2500);
} catch (e) {
alert('Kunne ikke registrere tid: ' + e.message);
}
}
function closeDetail() {
document.getElementById('detailPanel').style.display = 'none';
document.getElementById('placeholderPanel').style.display = 'block';
document.querySelectorAll('#tableBody tr').forEach(tr => tr.classList.remove('table-active'));
window._currentDetailId = null;
}
</script>
{% endblock %} {% endblock %}

View File

@ -530,6 +530,39 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
""" """
my_opportunities = execute_query(opportunities_query, (technician_user_id,)) my_opportunities = execute_query(opportunities_query, (technician_user_id,))
# Get user's group IDs
user_groups_query = """
SELECT group_id
FROM user_groups
WHERE user_id = %s
"""
user_groups = execute_query(user_groups_query, (technician_user_id,)) or []
user_group_ids = [g["group_id"] for g in user_groups]
# Get group cases (cases assigned to user's groups)
group_cases = []
if user_group_ids:
group_cases_query = """
SELECT
s.id,
s.titel,
s.status,
s.created_at,
s.deadline,
s.assigned_group_id,
g.name AS group_name,
COALESCE(c.name, 'Ukendt kunde') AS customer_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
WHERE s.deleted_at IS NULL
AND s.assigned_group_id = ANY(%s)
AND s.status <> 'lukket'
ORDER BY s.created_at DESC
LIMIT 20
"""
group_cases = execute_query(group_cases_query, (user_group_ids,))
return { return {
"technician_user_id": technician_user_id, "technician_user_id": technician_user_id,
"technician_name": technician_name, "technician_name": technician_name,
@ -538,12 +571,14 @@ def _get_technician_dashboard_data(technician_user_id: int) -> Dict[str, Any]:
"today_tasks": today_tasks or [], "today_tasks": today_tasks or [],
"urgent_overdue": urgent_overdue or [], "urgent_overdue": urgent_overdue or [],
"my_opportunities": my_opportunities or [], "my_opportunities": my_opportunities or [],
"group_cases": group_cases or [],
"kpis": { "kpis": {
"new_cases_count": len(new_cases or []), "new_cases_count": len(new_cases or []),
"my_cases_count": len(my_cases or []), "my_cases_count": len(my_cases or []),
"today_tasks_count": len(today_tasks or []), "today_tasks_count": len(today_tasks or []),
"urgent_overdue_count": len(urgent_overdue or []), "urgent_overdue_count": len(urgent_overdue or []),
"my_opportunities_count": len(my_opportunities or []) "my_opportunities_count": len(my_opportunities or []),
"group_cases_count": len(group_cases or [])
} }
} }

View File

@ -55,6 +55,8 @@ services:
- APIGATEWAY_URL=${APIGATEWAY_URL} - APIGATEWAY_URL=${APIGATEWAY_URL}
- APIGW_TIMEOUT_SECONDS=${APIGW_TIMEOUT_SECONDS} - APIGW_TIMEOUT_SECONDS=${APIGW_TIMEOUT_SECONDS}
restart: unless-stopped restart: unless-stopped
extra_hosts:
- "ollama-host:172.16.31.195"
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"] test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s interval: 30s

View File

@ -0,0 +1,19 @@
-- Migration 137: Add priority field to SAG module
-- Matches TTicket priority system for consistency
-- Create priority enum type
DO $$ BEGIN
CREATE TYPE sag_priority AS ENUM ('low', 'normal', 'high', 'urgent');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Add priority column to sag_sager
ALTER TABLE sag_sager
ADD COLUMN IF NOT EXISTS priority sag_priority DEFAULT 'normal';
-- Add index for filtering by priority
CREATE INDEX IF NOT EXISTS idx_sag_priority ON sag_sager(priority);
-- Add comment for documentation
COMMENT ON COLUMN sag_sager.priority IS 'Case priority level: low, normal (default), high, urgent';