bmc_hub/app/shared/frontend/quick_create_modal.html
Christian bef5c20c83 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.
2026-02-20 07:10:06 +01:00

671 lines
26 KiB
HTML

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