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
|
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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
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>
|
</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">
|
||||||
|
|||||||
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,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 %}
|
||||||
|
|||||||
@ -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 [])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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