bmc_hub/app/ticket/frontend/mockups/tech_v1_overview.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

547 lines
26 KiB
HTML

{% extends "shared/frontend/base.html" %}
{% block title %}Tekniker Dashboard V1 - Overblik{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<h1 class="h3 mb-1">🛠️ Tekniker Dashboard V1</h1>
<p class="text-muted mb-0">Kort overblik for {{ technician_name }} (bruger #{{ technician_user_id }})</p>
</div>
<div class="d-flex gap-2">
<a href="/ticket/dashboard/technician?technician_user_id={{ technician_user_id }}" class="btn btn-outline-secondary btn-sm">Tilbage til valg</a>
<a href="/ticket/dashboard/technician/v2?technician_user_id={{ technician_user_id }}" class="btn btn-outline-primary btn-sm">Se V2</a>
<a href="/ticket/dashboard/technician/v3?technician_user_id={{ technician_user_id }}" class="btn btn-outline-primary btn-sm">Se V3</a>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-6 col-lg-2">
<div class="card border-0 shadow-sm kpi-toggle" onclick="toggleSection('newCases')" id="kpiNewCases" style="cursor: pointer; transition: all 0.2s;">
<div class="card-body text-center">
<div class="small text-muted">Nye sager</div>
<div class="h4 mb-0">{{ kpis.new_cases_count }}</div>
</div>
</div>
</div>
<div class="col-6 col-lg-2">
<div class="card border-0 shadow-sm kpi-toggle" onclick="toggleSection('myCases')" id="kpiMyCases" style="cursor: pointer; transition: all 0.2s;">
<div class="card-body text-center">
<div class="small text-muted">Mine sager</div>
<div class="h4 mb-0">{{ kpis.my_cases_count }}</div>
</div>
</div>
</div>
<div class="col-6 col-lg-2">
<div class="card border-0 shadow-sm kpi-toggle" onclick="toggleSection('todayTasks')" id="kpiTodayTasks" style="cursor: pointer; transition: all 0.2s;">
<div class="card-body text-center">
<div class="small text-muted">Dagens opgaver</div>
<div class="h4 mb-0">{{ kpis.today_tasks_count }}</div>
</div>
</div>
</div>
<div class="col-6 col-lg-2">
<div class="card border-0 shadow-sm kpi-toggle" onclick="toggleSection('groupCases')" id="kpiGroupCases" style="cursor: pointer; transition: all 0.2s;">
<div class="card-body text-center">
<div class="small text-muted">Gruppe-sager</div>
<div class="h4 mb-0">{{ kpis.group_cases_count }}</div>
</div>
</div>
</div>
<div class="col-6 col-lg-2"><div class="card border-0 shadow-sm"><div class="card-body text-center"><div class="small text-muted">Haste / over SLA</div><div class="h4 mb-0 text-danger">{{ kpis.urgent_overdue_count }}</div></div></div></div>
<div class="col-6 col-lg-2"><div class="card border-0 shadow-sm"><div class="card-body text-center"><div class="small text-muted">Mine opportunities</div><div class="h4 mb-0">{{ kpis.my_opportunities_count }}</div></div></div></div>
</div>
<!-- Liste og detalje område -->
<div class="row g-4">
<div class="col-lg-8">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0">
<h5 class="mb-0" id="listTitle">Alle sager</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0" id="caseTable">
<thead class="table-light" id="tableHead">
<tr>
<th>ID</th>
<th>Titel</th>
<th>Kunde</th>
<th>Status</th>
<th>Dato</th>
</tr>
</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 %}
{
id: {{ item.id }},
titel: {{ item.titel | tojson | safe }},
customer_name: {{ item.customer_name | tojson | safe }},
created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }},
status: {{ item.status | tojson | safe if item.status else 'null' }},
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
}{% if not loop.last %},{% endif %}
{% endfor %}
],
myCases: [
{% for item in my_cases %}
{
id: {{ item.id }},
titel: {{ item.titel | tojson | safe }},
customer_name: {{ item.customer_name | tojson | safe }},
status: {{ item.status | tojson | safe if item.status else 'null' }},
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
}{% if not loop.last %},{% endif %}
{% endfor %}
],
todayTasks: [
{% for item in today_tasks %}
{
item_type: {{ item.item_type | tojson | safe }},
item_id: {{ item.item_id }},
title: {{ item.title | tojson | safe }},
customer_name: {{ item.customer_name | tojson | safe }},
task_reason: {{ item.task_reason | tojson | safe if item.task_reason else 'null' }},
created_at: {{ item.created_at.isoformat() | tojson | safe if item.created_at else 'null' }},
priority: {{ item.priority | tojson | safe if item.priority else 'null' }},
status: {{ item.status | tojson | safe if item.status else 'null' }}
}{% if not loop.last %},{% endif %}
{% endfor %}
],
groupCases: [
{% for item in group_cases %}
{
id: {{ item.id }},
titel: {{ item.titel | tojson | safe }},
group_name: {{ item.group_name | tojson | safe }},
customer_name: {{ item.customer_name | tojson | safe }},
status: {{ item.status | tojson | safe if item.status else 'null' }},
deadline: {{ item.deadline.isoformat() | tojson | safe if item.deadline else 'null' }}
}{% if not loop.last %},{% endif %}
{% endfor %}
]
};
function formatDate(dateStr) {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function formatShortDate(dateStr) {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function toggleSection(filterName) {
const kpiCard = document.getElementById('kpi' + filterName.charAt(0).toUpperCase() + filterName.slice(1));
const listTitle = document.getElementById('listTitle');
const tableBody = document.getElementById('tableBody');
// Reset all KPI cards
document.querySelectorAll('.kpi-toggle').forEach(card => {
card.style.background = '';
card.style.color = '';
const label = card.querySelector('.text-muted');
if (label) label.style.color = '';
});
// If clicking same filter, clear it
if (currentFilter === filterName) {
currentFilter = null;
listTitle.textContent = 'Alle sager';
tableBody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Vælg et filter ovenfor</td></tr>';
return;
}
// Highlight selected KPI
kpiCard.style.background = 'linear-gradient(135deg, var(--accent), var(--accent-dark, #084c75))';
kpiCard.style.color = 'white';
const label = kpiCard.querySelector('.text-muted');
if (label) label.style.color = 'rgba(255,255,255,0.85)';
// Apply filter and populate table
currentFilter = filterName;
filterAndPopulateTable(filterName);
}
function filterAndPopulateTable(filterName) {
const listTitle = document.getElementById('listTitle');
const tableBody = document.getElementById('tableBody');
let bodyHTML = '';
if (filterName === 'newCases') {
listTitle.innerHTML = '<i class="bi bi-inbox-fill text-primary"></i> Nye sager';
const data = allData.newCases || [];
if (data.length === 0) {
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen nye sager</td></tr>';
} else {
bodyHTML = data.map(item => `
<tr onclick="showCaseDetails(${item.id}, 'case')" style="cursor:pointer;">
<td>#${item.id}</td>
<td>${item.titel || '-'}</td>
<td>${item.customer_name || '-'}</td>
<td><span class="badge bg-secondary">${item.status || 'Ny'}</span></td>
<td>${formatDate(item.created_at)}</td>
</tr>
`).join('');
}
} else if (filterName === 'myCases') {
listTitle.innerHTML = '<i class="bi bi-person-check-fill text-success"></i> Mine sager';
const data = allData.myCases || [];
if (data.length === 0) {
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen sager tildelt</td></tr>';
} else {
bodyHTML = data.map(item => `
<tr onclick="showCaseDetails(${item.id}, 'case')" style="cursor:pointer;">
<td>#${item.id}</td>
<td>${item.titel || '-'}</td>
<td>${item.customer_name || '-'}</td>
<td><span class="badge bg-info">${item.status || '-'}</span></td>
<td>${formatShortDate(item.deadline)}</td>
</tr>
`).join('');
}
} else if (filterName === 'todayTasks') {
listTitle.innerHTML = '<i class="bi bi-calendar-check text-primary"></i> Dagens opgaver';
const data = allData.todayTasks || [];
if (data.length === 0) {
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen opgaver i dag</td></tr>';
} else {
bodyHTML = data.map(item => {
const badge = item.item_type === 'case'
? '<span class="badge bg-primary">Sag</span>'
: '<span class="badge bg-info">Ticket</span>';
return `
<tr onclick="showCaseDetails(${item.item_id}, '${item.item_type}')" style="cursor:pointer;">
<td>#${item.item_id}</td>
<td>${item.title || '-'}<br><small class="text-muted">${item.task_reason || ''}</small></td>
<td>${item.customer_name || '-'}</td>
<td>${badge}</td>
<td>${formatDate(item.created_at)}</td>
</tr>
`;
}).join('');
}
} else if (filterName === 'groupCases') {
listTitle.innerHTML = '<i class="bi bi-people-fill text-info"></i> Gruppe-sager';
const data = allData.groupCases || [];
if (data.length === 0) {
bodyHTML = '<tr><td colspan="5" class="text-center text-muted py-3">Ingen gruppe-sager</td></tr>';
} else {
bodyHTML = data.map(item => `
<tr onclick="showCaseDetails(${item.id}, 'case')" style="cursor:pointer;">
<td>#${item.id}</td>
<td>${item.titel || '-'}<br><span class="badge bg-secondary">${item.group_name || '-'}</span></td>
<td>${item.customer_name || '-'}</td>
<td><span class="badge bg-info">${item.status || '-'}</span></td>
<td>${formatShortDate(item.deadline)}</td>
</tr>
`).join('');
}
}
tableBody.innerHTML = bodyHTML;
}
async function showCaseDetails(id, type) {
const detailPanel = document.getElementById('detailPanel');
const placeholderPanel = document.getElementById('placeholderPanel');
placeholderPanel.style.display = 'none';
detailPanel.style.display = 'block';
// Highlight selected row
document.querySelectorAll('#tableBody tr').forEach(tr => tr.classList.remove('table-active'));
event.currentTarget && event.currentTarget.classList.add('table-active');
// Reset panels
document.getElementById('detailTitle').textContent = 'Henter...';
document.getElementById('detailCaseBadge').textContent = '';
document.getElementById('detailMeta').innerHTML = '<div class="text-center py-2"><div class="spinner-border spinner-border-sm text-primary"></div></div>';
document.getElementById('modulePills').style.display = 'none';
document.getElementById('contactRow').style.display = 'none';
document.getElementById('kommentarFeed').innerHTML = '';
document.getElementById('detailOpenBtn').href = type === 'case' ? `/sag/${id}` : `/ticket/tickets/${id}`;
window._currentDetailId = id;
window._currentDetailType = type;
try {
const [caseRes, contactsRes, kommentarerRes, modulesRes] = await Promise.all([
fetch(`/api/v1/sag/${id}`),
fetch(`/api/v1/sag/${id}/contacts`),
fetch(`/api/v1/sag/${id}/kommentarer`),
fetch(`/api/v1/sag/${id}/modules`)
]);
const data = caseRes.ok ? await caseRes.json() : null;
const contacts = contactsRes.ok ? await contactsRes.json() : [];
const kommentarer = kommentarerRes.ok ? await kommentarerRes.json() : [];
const modules = modulesRes.ok ? await modulesRes.json() : [];
if (!data) {
document.getElementById('detailMeta').innerHTML = '<div class="alert alert-danger m-0">Kunne ikke hente sag</div>';
return;
}
// Header
document.getElementById('detailTitle').textContent = data.titel || 'Ingen titel';
document.getElementById('detailCaseBadge').textContent = `Sag #${id}`;
// Module pills
if (modules && modules.length > 0) {
const pillsEl = document.getElementById('modulePills');
pillsEl.style.display = 'block';
const moduleIcons = {
contacts: 'bi-person', hardware: 'bi-pc-display', files: 'bi-paperclip',
locations: 'bi-geo-alt', calendar: 'bi-calendar', kommentarer: 'bi-chat',
subscriptions: 'bi-arrow-repeat', 'sale-items': 'bi-cart'
};
pillsEl.innerHTML = '<div class="d-flex flex-wrap gap-1">' +
modules.filter(m => m.count > 0).map(m => `
<span class="badge bg-light text-dark border position-relative"
style="cursor:default;"
data-bs-toggle="tooltip"
data-bs-html="true"
title="${m.label || m.module}: ${m.count} ${m.count === 1 ? 'post' : 'poster'}">
<i class="bi ${moduleIcons[m.module] || 'bi-grid'}"></i>
${m.label || m.module}
<span class="badge bg-primary rounded-pill ms-1">${m.count}</span>
</span>
`).join('') +
'</div>';
// Init tooltips
pillsEl.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => new bootstrap.Tooltip(el));
}
// Contact row med ring/SMS
if (contacts && contacts.length > 0) {
const contactRow = document.getElementById('contactRow');
contactRow.style.display = 'block';
contactRow.innerHTML = contacts.slice(0, 3).map(c => {
const name = [c.first_name, c.last_name].filter(Boolean).join(' ');
const phone = c.mobile || c.phone;
const smsHtml = phone ? `<a href="sms:${phone.replace(/\s/g,'')}" class="btn btn-xs btn-outline-success py-0 px-1" style="font-size:11px;" title="SMS ${phone}"><i class="bi bi-chat-dots"></i></a>` : '';
const callHtml = phone ? `<a href="tel:${phone.replace(/\s/g,'')}" class="btn btn-xs btn-outline-primary py-0 px-1" style="font-size:11px;" title="Ring ${phone}"><i class="bi bi-telephone"></i></a>` : '';
return `
<div class="d-flex justify-content-between align-items-center mb-1">
<div>
<span class="small fw-semibold">${name}</span>
${phone ? `<br><span class="small text-muted">${phone}</span>` : ''}
</div>
<div class="d-flex gap-1">${callHtml}${smsHtml}</div>
</div>
`;
}).join('');
}
// Meta
const statusColor = {'Aktiv':'primary','Afsluttet':'success','Annulleret':'secondary','Venter':'warning'}[data.status] || 'secondary';
document.getElementById('detailMeta').innerHTML = `
<div class="row g-1 small">
<div class="col-6">
<span class="text-muted">Status</span><br>
<span class="badge bg-${statusColor}">${data.status || '-'}</span>
</div>
<div class="col-6">
<span class="text-muted">Deadline</span><br>
<strong>${formatShortDate(data.deadline)}</strong>
</div>
${data.customer_name ? `<div class="col-12 mt-1"><i class="bi bi-building text-muted"></i> ${data.customer_name}</div>` : ''}
${data.description ? `<div class="col-12 mt-1 text-muted" style="font-size:11px;">${data.description.substring(0,120)}${data.description.length>120?'...':''}</div>` : ''}
</div>
`;
// Kommentarer feed
renderKommentarFeed(kommentarer);
} catch (error) {
document.getElementById('detailMeta').innerHTML = '<div class="alert alert-danger m-0 small">Fejl ved hentning</div>';
console.error(error);
}
}
function renderKommentarFeed(kommentarer) {
const feed = document.getElementById('kommentarFeed');
if (!kommentarer || kommentarer.length === 0) {
feed.innerHTML = '<p class="text-muted small">Ingen kommentarer endnu.</p>';
return;
}
feed.innerHTML = kommentarer.map(k => `
<div class="mb-2 p-2 rounded ${k.er_system_besked ? 'bg-light border-start border-info border-3' : 'bg-light'}">
<div class="d-flex justify-content-between">
<span class="small fw-semibold">${k.forfatter || 'System'}</span>
<span class="small text-muted">${formatDate(k.created_at)}</span>
</div>
<div class="small mt-1">${k.indhold || ''}</div>
</div>
`).join('');
feed.scrollTop = feed.scrollHeight;
}
async function postKommentar() {
const id = window._currentDetailId;
if (!id) return;
const textarea = document.getElementById('newKommentar');
const indhold = textarea.value.trim();
if (!indhold) return;
try {
const res = await fetch(`/api/v1/sag/${id}/kommentarer`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ indhold, forfatter: '{{ current_user.username if current_user else "Bruger" }}' })
});
if (!res.ok) throw new Error('Fejl');
textarea.value = '';
// Reload comments
const k = await (await fetch(`/api/v1/sag/${id}/kommentarer`)).json();
renderKommentarFeed(k);
} catch (e) {
alert('Kunne ikke sende kommentar');
}
}
async function registrerTid() {
const id = window._currentDetailId;
if (!id) return;
const minutter = parseInt(document.getElementById('tidMinutter').value);
const beskrivelse = document.getElementById('tidBeskrivelse').value.trim();
const afregning = document.getElementById('tidAfregning').value;
if (!minutter || minutter < 1) { alert('Angiv antal minutter'); return; }
if (!beskrivelse) { alert('Angiv en beskrivelse'); return; }
const hours = +(minutter / 60).toFixed(4);
try {
const res = await fetch('/api/v1/timetracking/entries/internal', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
sag_id: id,
original_hours: hours,
description: beskrivelse,
billing_method: afregning,
user_name: '{{ current_user.username if current_user else "Hub" }}',
worked_date: new Date().toISOString().slice(0, 10)
})
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert('Fejl: ' + (err.detail || res.status));
return;
}
document.getElementById('tidMinutter').value = '';
document.getElementById('tidBeskrivelse').value = '';
// Visual feedback
const btn = document.querySelector('[onclick="registrerTid()"]');
btn.innerHTML = '<i class="bi bi-check-circle"></i> Registreret!';
btn.classList.replace('btn-success','btn-outline-success');
setTimeout(() => { btn.innerHTML = '<i class="bi bi-clock-history"></i> Registrer tid'; btn.classList.replace('btn-outline-success','btn-success'); }, 2500);
} catch (e) {
alert('Kunne ikke registrere tid: ' + e.message);
}
}
function closeDetail() {
document.getElementById('detailPanel').style.display = 'none';
document.getElementById('placeholderPanel').style.display = 'block';
document.querySelectorAll('#tableBody tr').forEach(tr => tr.classList.remove('table-active'));
window._currentDetailId = null;
}
</script>
{% endblock %}