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:
parent
e6b4d8fb47
commit
bef5c20c83
@ -78,8 +78,8 @@ class Settings(BaseSettings):
|
||||
WIKI_READ_ONLY: bool = True
|
||||
|
||||
# Ollama LLM
|
||||
OLLAMA_ENDPOINT: str = "http://localhost:11434"
|
||||
OLLAMA_MODEL: str = "llama3.2:3b"
|
||||
OLLAMA_ENDPOINT: str = "http://172.16.31.195:11434"
|
||||
OLLAMA_MODEL: str = "llama3.2"
|
||||
|
||||
# Email System Configuration
|
||||
# IMAP Settings
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
Pydantic Models and Schemas
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date
|
||||
@ -303,3 +304,75 @@ class AnyDeskSessionUpdate(BaseModel):
|
||||
status: str
|
||||
ended_at: Optional[str] = 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)
|
||||
|
||||
@ -10,9 +10,10 @@ from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
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.services.email_service import EmailService
|
||||
from app.services.case_analysis_service import CaseAnalysisService
|
||||
|
||||
try:
|
||||
import extract_msg
|
||||
@ -99,6 +100,35 @@ def _validate_group_id(group_id: Optional[int], field_name: str = "assigned_grou
|
||||
if not exists:
|
||||
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
|
||||
# ============================================================================
|
||||
|
||||
@ -309,8 +309,23 @@
|
||||
let selectedContactsCompanies = {};
|
||||
let customerSearchTimeout;
|
||||
let contactSearchTimeout;
|
||||
let successAlertTimeout;
|
||||
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 ---
|
||||
const beskrInput = document.getElementById('beskrivelse');
|
||||
if (beskrInput) {
|
||||
@ -415,12 +430,17 @@
|
||||
}
|
||||
|
||||
// --- Selection Logic ---
|
||||
function selectCustomer(id, name) {
|
||||
function selectCustomer(id, name, skipAlert = false) {
|
||||
selectedCustomer = { id, name };
|
||||
document.getElementById('customer_id').value = id;
|
||||
document.getElementById('customerSearch').value = '';
|
||||
document.getElementById('customerResults').classList.add('d-none');
|
||||
renderSelections();
|
||||
|
||||
// Show notification
|
||||
if (!skipAlert) {
|
||||
showSuccessAlert(`Valgte firma: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function removeCustomer() {
|
||||
@ -430,7 +450,8 @@
|
||||
}
|
||||
|
||||
async function selectContact(id, name) {
|
||||
if (!selectedContacts[id]) {
|
||||
const isNewContact = !selectedContacts[id];
|
||||
if (isNewContact) {
|
||||
selectedContacts[id] = { id, name };
|
||||
}
|
||||
document.getElementById('contactSearch').value = '';
|
||||
@ -446,19 +467,24 @@
|
||||
|
||||
if (data.companies && data.companies.length === 1) {
|
||||
const company = data.companies[0];
|
||||
if (!selectedCustomer) {
|
||||
selectCustomer(company.id, company.name);
|
||||
|
||||
// Show brief notification
|
||||
const successDiv = document.getElementById('success');
|
||||
successDiv.classList.remove('d-none');
|
||||
document.getElementById('success-text').textContent = `Valgte automatisk kunde: ${company.name}`;
|
||||
setTimeout(() => successDiv.classList.add('d-none'), 3000);
|
||||
if (!selectedCustomer && isNewContact) {
|
||||
// Auto-select company silently, then show combined alert
|
||||
selectCustomer(company.id, company.name, true);
|
||||
showSuccessAlert(`Valgte kontakt: ${name} + firma: ${company.name}`, 4000);
|
||||
} else if (isNewContact) {
|
||||
// Just show contact alert
|
||||
showSuccessAlert(`Valgte kontakt: ${name}`);
|
||||
}
|
||||
} else if (isNewContact) {
|
||||
// No auto-select, just show contact alert
|
||||
showSuccessAlert(`Valgte kontakt: ${name}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Auto-select company failed", e);
|
||||
if (isNewContact) {
|
||||
showSuccessAlert(`Valgte kontakt: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
loadHardwareForContacts();
|
||||
|
||||
@ -81,12 +81,6 @@
|
||||
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 {
|
||||
background-color: #e0e0e0;
|
||||
color: #666;
|
||||
@ -959,15 +953,15 @@
|
||||
<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-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0" style="color: var(--accent);">
|
||||
🔗 Relationer
|
||||
<i class="bi bi-info-circle-fill ms-2"
|
||||
style="font-size: 0.85rem; cursor: help; opacity: 0.7;"
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<h6 class="mb-0" style="color: var(--accent);">🔗 Relationer</h6>
|
||||
<i class="bi bi-info-circle text-muted"
|
||||
style="font-size:0.9rem; cursor:help;"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="right"
|
||||
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>
|
||||
</h6>
|
||||
data-bs-placement="right"
|
||||
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">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="showRelationModal()">
|
||||
<i class="bi bi-link-45deg"></i>
|
||||
@ -1553,13 +1547,6 @@
|
||||
|
||||
// Initialize everything when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize Bootstrap tooltips
|
||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((el) => {
|
||||
new bootstrap.Tooltip(el, {
|
||||
customClass: 'tooltip-wide'
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize modals
|
||||
contactSearchModal = new bootstrap.Modal(document.getElementById('contactSearchModal'));
|
||||
customerSearchModal = new bootstrap.Modal(document.getElementById('customerSearchModal'));
|
||||
@ -1574,6 +1561,11 @@
|
||||
updateRelationTypeHint();
|
||||
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
|
||||
if (window.renderEntityTags) {
|
||||
window.renderEntityTags('case', {{ case.id }}, 'case-tags');
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
import base64
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
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:
|
||||
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 = (
|
||||
template
|
||||
.replace("{number}", number_normalized)
|
||||
.replace("{raw_number}", payload.number.strip())
|
||||
.replace("{raw_number}", raw_number_clean)
|
||||
.replace("{extension}", extension_value)
|
||||
.replace("{phone_ip}", phone_ip_value)
|
||||
.replace("{phone_username}", phone_username_value)
|
||||
|
||||
498
app/services/case_analysis_service.py
Normal file
498
app/services/case_analysis_service.py
Normal 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 på 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 PÅ 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 på 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]
|
||||
@ -305,6 +305,9 @@
|
||||
</li>
|
||||
</ul>
|
||||
<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);">
|
||||
<i class="bi bi-moon-fill"></i>
|
||||
</button>
|
||||
@ -585,6 +588,7 @@
|
||||
|
||||
// Keyboard shortcut: Cmd+K or Ctrl+K
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Cmd+K / Ctrl+K for global search
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
console.log('Cmd+K pressed - opening search modal'); // Debug
|
||||
@ -598,12 +602,43 @@
|
||||
}, 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
|
||||
if (e.key === 'Escape') {
|
||||
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
|
||||
document.getElementById('globalSearchModal').addEventListener('hidden.bs.modal', () => {
|
||||
if (globalSearchInput) {
|
||||
@ -1049,6 +1084,9 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- QuickCreate Modal (AI-Powered Case Creation) -->
|
||||
{% include "shared/frontend/quick_create_modal.html" %}
|
||||
|
||||
<!-- Profile Modal -->
|
||||
<div class="modal fade" id="profileModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
|
||||
670
app/shared/frontend/quick_create_modal.html
Normal file
670
app/shared/frontend/quick_create_modal.html
Normal 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... 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>
|
||||
@ -17,32 +17,63 @@
|
||||
</div>
|
||||
|
||||
<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="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="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="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="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="col-6 col-lg-2">
|
||||
<div class="card border-0 shadow-sm kpi-toggle" onclick="toggleSection('newCases')" id="kpiNewCases" style="cursor: pointer; transition: all 0.2s;">
|
||||
<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="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>
|
||||
|
||||
<!-- Liste og detalje område -->
|
||||
<div class="row g-4">
|
||||
<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">Nye sager</h5></div>
|
||||
<div class="col-lg-8">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<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="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light"><tr><th>ID</th><th>Titel</th><th>Kunde</th><th>Oprettet</th></tr></thead>
|
||||
<tbody>
|
||||
{% for item in new_cases %}
|
||||
<tr onclick="window.location.href='/sag/{{ item.id }}'" style="cursor:pointer;">
|
||||
<td>#{{ item.id }}</td>
|
||||
<td>{{ item.titel }}</td>
|
||||
<td>{{ item.customer_name }}</td>
|
||||
<td>{{ item.created_at.strftime('%d/%m %H:%M') if item.created_at else '-' }}</td>
|
||||
<table class="table table-sm table-hover mb-0" id="caseTable">
|
||||
<thead class="table-light" id="tableHead">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Titel</th>
|
||||
<th>Kunde</th>
|
||||
<th>Status</th>
|
||||
<th>Dato</th>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="text-center text-muted py-3">Ingen nye sager</td></tr>
|
||||
{% endfor %}
|
||||
</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>
|
||||
@ -50,71 +81,466 @@
|
||||
</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 %}
|
||||
<tr onclick="window.location.href='/sag/{{ item.id }}'" style="cursor:pointer;">
|
||||
<td>#{{ item.id }}</td>
|
||||
<td>{{ item.titel }}</td>
|
||||
<td>{{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}</td>
|
||||
<td>{{ item.status }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="text-center text-muted py-3">Ingen sager tildelt</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
<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 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">
|
||||
<!-- 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>
|
||||
<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 %}
|
||||
<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>
|
||||
|
||||
<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 opportunities</h5></div>
|
||||
<div class="card-body">
|
||||
{% for item in my_opportunities %}
|
||||
<div class="d-flex justify-content-between align-items-start border-bottom pb-2 mb-2">
|
||||
<div>
|
||||
<div class="fw-semibold">{{ item.titel }}</div>
|
||||
<div class="small text-muted">{{ item.customer_name }} · {{ item.pipeline_stage or 'Uden stage' }}</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<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>
|
||||
<!-- 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>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Ingen opportunities fundet.</p>
|
||||
{% endfor %}
|
||||
|
||||
<!-- 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 %}
|
||||
{
|
||||
id: {{ item.id }},
|
||||
titel: {{ item.titel | tojson | safe }},
|
||||
customer_name: {{ item.customer_name | tojson | safe }},
|
||||
created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }},
|
||||
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 %}
|
||||
],
|
||||
myCases: [
|
||||
{% for item in my_cases %}
|
||||
{
|
||||
id: {{ item.id }},
|
||||
titel: {{ item.titel | 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 %}
|
||||
],
|
||||
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>
|
||||
`).join('');
|
||||
}
|
||||
} else if (filterName === 'myCases') {
|
||||
listTitle.innerHTML = '<i class="bi bi-person-check-fill text-success"></i> Mine sager';
|
||||
const data = allData.myCases || [];
|
||||
if (data.length === 0) {
|
||||
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen sager tildelt</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-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('');
|
||||
}
|
||||
}
|
||||
|
||||
tableBody.innerHTML = bodyHTML;
|
||||
}
|
||||
|
||||
async function showCaseDetails(id, type) {
|
||||
const detailPanel = document.getElementById('detailPanel');
|
||||
const placeholderPanel = document.getElementById('placeholderPanel');
|
||||
|
||||
placeholderPanel.style.display = 'none';
|
||||
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>
|
||||
<span class="small fw-semibold">${name}</span>
|
||||
${phone ? `<br><span class="small text-muted">${phone}</span>` : ''}
|
||||
</div>
|
||||
<div class="d-flex gap-1">${callHtml}${smsHtml}</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 class="col-6">
|
||||
<span class="text-muted">Deadline</span><br>
|
||||
<strong>${formatShortDate(data.deadline)}</strong>
|
||||
</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>
|
||||
`;
|
||||
|
||||
// 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 class="small mt-1">${k.indhold || ''}</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 %}
|
||||
|
||||
@ -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,))
|
||||
|
||||
# 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 {
|
||||
"technician_user_id": technician_user_id,
|
||||
"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 [],
|
||||
"urgent_overdue": urgent_overdue or [],
|
||||
"my_opportunities": my_opportunities or [],
|
||||
"group_cases": group_cases or [],
|
||||
"kpis": {
|
||||
"new_cases_count": len(new_cases or []),
|
||||
"my_cases_count": len(my_cases or []),
|
||||
"today_tasks_count": len(today_tasks 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 [])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -55,6 +55,8 @@ services:
|
||||
- APIGATEWAY_URL=${APIGATEWAY_URL}
|
||||
- APIGW_TIMEOUT_SECONDS=${APIGW_TIMEOUT_SECONDS}
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- "ollama-host:172.16.31.195"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
|
||||
19
migrations/137_add_priority_to_sag.sql
Normal file
19
migrations/137_add_priority_to_sag.sql
Normal 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';
|
||||
Loading…
Reference in New Issue
Block a user