- 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.
547 lines
26 KiB
HTML
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 %}
|