bmc_hub/script_1.js
Christian bc504b9257 feat: Add subscription management functionality and AnyDesk API integration
- Implemented subscription creation, updating, and rendering in script_9.js.
- Added functions for handling subscription line items, product selection, and total calculations.
- Integrated AnyDesk API for session management in test_anydesk.py.
- Created REST client test requests for API endpoints in api.http.
- Developed a script to check ESET machine status and save details in tmp_check_eset_machine.py.
2026-03-30 07:50:15 +02:00

1433 lines
63 KiB
JavaScript

const caseId = {{ case.id }};
const wikiCustomerId = {{ customer.id if customer else 'null' }};
const wikiDefaultTag = "guide";
let contactSearchTimeout;
let customerSearchTimeout;
let relationSearchTimeout;
let wikiSearchTimeout;
let selectedRelationCaseId = null;
const caseTypeKey = "{{ (case.template_key or case.type or 'ticket')|lower }}";
function forceCaseTabActivation(tabId) {
if (!tabId) return;
const tabContent = document.getElementById('caseTabsContent');
const targetPane = document.getElementById(tabId);
if (!tabContent || !targetPane) return;
tabContent.querySelectorAll(':scope > .tab-pane').forEach((pane) => {
pane.classList.remove('show', 'active');
pane.style.display = 'none';
});
targetPane.classList.add('show', 'active');
targetPane.style.display = 'block';
const tabButtons = document.querySelectorAll('#caseTabs [data-bs-target]');
tabButtons.forEach((btn) => {
btn.classList.toggle('active', btn.getAttribute('data-bs-target') === `#${tabId}`);
});
}
window.moduleDisplayNames = {
'relations': 'Relationer',
'call-history': 'Opkaldshistorik',
'files': 'Filer',
'emails': 'E-mails',
'pipeline': 'Salgspipeline',
'hardware': 'Hardware',
'locations': 'Lokationer',
'contacts': 'Kontakter',
'customers': 'Kunder',
'tags': 'Tags',
'wiki': 'Wiki',
'todo-steps': 'Todo-opgaver',
'time': 'Tid',
'timetracking': 'Tidsforbrug',
'solution': 'Løsning',
'sales': 'Varekøb & salg',
'subscription': 'Abonnement',
'reminders': 'Påmindelser',
'calendar': 'Kalender'
};
let caseTypeModuleDefaults = {};
// Modal instances
let contactSearchModal, customerSearchModal, relationModal, contactInfoModal, createRelatedCaseModalInstance;
let currentContactInfo = null;
// Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
hydrateTopbarStatusOptions();
// Initialize modals
contactSearchModal = new bootstrap.Modal(document.getElementById('contactSearchModal'));
customerSearchModal = new bootstrap.Modal(document.getElementById('customerSearchModal'));
relationModal = new bootstrap.Modal(document.getElementById('relationModal'));
contactInfoModal = new bootstrap.Modal(document.getElementById('contactInfoModal'));
createRelatedCaseModalInstance = new bootstrap.Modal(document.getElementById('createRelatedCaseModal'));
// Setup search handlers
setupContactSearch();
setupCustomerSearch();
setupRelationSearch();
updateRelationTypeHint();
updateNewCaseRelationTypeHint();
// Initialize all tooltips on the page
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
bootstrap.Tooltip.getOrCreateInstance(el, { html: true, container: 'body' });
});
Promise.all([loadModulePrefs(), loadCaseTypeModuleDefaultsSetting()]).then(() => applyViewFromTags());
// Set default context for keyboard shortcuts (Option+Shift+T)
if (window.setTagPickerContext) {
window.setTagPickerContext('case', {{ case.id }}, () => syncCaseTagsUi());
}
// Load Hardware & Locations
loadCaseHardware();
loadCaseLocations();
loadCaseWiki();
loadTodoSteps();
loadCaseTagsModule();
loadCaseTagSuggestions();
// Keep suggestions fresh while user works on the case.
setInterval(loadCaseTagSuggestions, 30000);
const wikiSearchInput = document.getElementById('wikiSearchInput');
if (wikiSearchInput) {
wikiSearchInput.addEventListener('input', () => {
clearTimeout(wikiSearchTimeout);
wikiSearchTimeout = setTimeout(() => {
loadCaseWiki(wikiSearchInput.value || '');
}, 300);
});
}
const todoForm = document.getElementById('todoStepForm');
if (todoForm) {
todoForm.addEventListener('submit', createTodoStep);
}
const caseTabs = document.getElementById('caseTabs');
if (caseTabs) {
caseTabs.addEventListener('shown.bs.tab', async (event) => {
const targetSelector = event?.target?.getAttribute('data-bs-target') || '';
const tabId = targetSelector.startsWith('#') ? targetSelector.slice(1) : targetSelector;
forceCaseTabActivation(tabId);
try {
if (tabId === 'sales' && typeof loadVarekobSalg === 'function') {
await loadVarekobSalg();
} else if (tabId === 'timetracking' && typeof loadTimeTrackingTab === 'function') {
await loadTimeTrackingTab();
} else if (tabId === 'subscription' && typeof loadSubscriptionForCase === 'function') {
await loadSubscriptionForCase();
} else if (tabId === 'reminders') {
if (typeof loadReminders === 'function') await loadReminders();
if (typeof loadCaseCalendar === 'function') await loadCaseCalendar();
}
} catch (tabLoadError) {
console.error('Tab data reload failed:', tabLoadError);
}
});
caseTabs.addEventListener('click', (event) => {
const btn = event.target.closest('[data-bs-target]');
if (!btn) return;
const targetSelector = btn.getAttribute('data-bs-target') || '';
const tabId = targetSelector.startsWith('#') ? targetSelector.slice(1) : targetSelector;
if (tabId) {
setTimeout(() => forceCaseTabActivation(tabId), 0);
}
});
}
forceCaseTabActivation('details');
// Focus on title when create modal opens
const createModalEl = document.getElementById('createRelatedCaseModal');
if (createModalEl) {
createModalEl.addEventListener('shown.bs.modal', function () {
document.getElementById('newCaseTitle').focus();
});
}
});
// Show modal functions
function showContactSearch() {
contactSearchModal.show();
setTimeout(() => document.getElementById('contactSearch').focus(), 300);
}
function showCustomerSearch() {
customerSearchModal.show();
setTimeout(() => document.getElementById('customerSearch').focus(), 300);
}
function showRelationModal() {
relationModal.show();
updateRelationTypeHint();
setTimeout(() => document.getElementById('relationCaseSearch').focus(), 300);
}
function showContactInfoModal(el) {
currentContactInfo = {
id: el.dataset.contactId,
name: el.dataset.name || '-',
title: el.dataset.title || '-',
company: el.dataset.company || '-',
email: el.dataset.email || '-',
phone: el.dataset.phone || '-',
mobile: el.dataset.mobile || '-',
role: el.dataset.role || '-',
isPrimary: el.dataset.isPrimary === 'true'
};
document.getElementById('contactInfoName').textContent = currentContactInfo.name;
document.getElementById('contactInfoTitle').textContent = currentContactInfo.title || '-';
document.getElementById('contactInfoCompany').textContent = currentContactInfo.company || '-';
document.getElementById('contactInfoEmail').textContent = currentContactInfo.email || '-';
document.getElementById('contactInfoPhone').innerHTML = renderCasePhone(currentContactInfo.phone);
document.getElementById('contactInfoMobile').innerHTML = renderCaseMobile(currentContactInfo.mobile, currentContactInfo.name);
document.getElementById('contactInfoRole').textContent = currentContactInfo.role || '-';
const primaryBadge = document.getElementById('contactInfoPrimary');
if (currentContactInfo.isPrimary) {
primaryBadge.classList.remove('d-none');
} else {
primaryBadge.classList.add('d-none');
}
contactInfoModal.show();
}
function renderCasePhone(number) {
const clean = String(number || '').trim();
if (!clean || clean === '-') return '-';
return `<a href="tel:${escapeHtml(clean)}" style="color: var(--accent);">${escapeHtml(clean)}</a>`;
}
function renderCaseMobile(number, name) {
const clean = String(number || '').trim();
if (!clean || clean === '-') return '-';
return `
<div class="d-flex align-items-center gap-2 flex-wrap">
<a href="tel:${escapeHtml(clean)}" style="color: var(--accent);">${escapeHtml(clean)}</a>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt('${escapeHtml(clean)}', '${escapeHtml(name || '')}', ${currentContactInfo?.id || 'null'})">SMS</button>
</div>
`;
}
function openContactRoleFromInfo() {
if (!currentContactInfo) return;
contactInfoModal.hide();
openContactRoleModal(
currentContactInfo.id,
currentContactInfo.name,
currentContactInfo.role || 'Kontakt',
currentContactInfo.isPrimary
);
}
function showCreateRelatedModal() {
createRelatedCaseModalInstance.show();
updateNewCaseRelationTypeHint();
}
function relationTypeMeaning(type) {
const map = {
'Relateret til': {
icon: '🔗',
text: 'Sagerne hænger fagligt sammen, men ingen af dem er direkte afhængig af den anden.'
},
'Afledt af': {
icon: '↪',
text: 'Denne sag er opstået på baggrund af den anden sag (den anden er ophav/forløber).'
},
'Årsag til': {
icon: '➡',
text: 'Denne sag er årsag til den anden sag (du peger frem mod en konsekvens/opfølgning).'
},
'Blokkerer': {
icon: '⛔',
text: 'Arbejdet i denne sag stopper fremdrift i den anden sag, indtil blokeringen er løst.'
}
};
return map[type] || null;
}
function updateRelationTypeHint() {
const select = document.getElementById('relationTypeSelect');
const hint = document.getElementById('relationTypeHint');
if (!select || !hint) return;
const meaning = relationTypeMeaning(select.value);
if (!meaning) {
hint.style.display = 'none';
hint.innerHTML = '';
return;
}
hint.style.display = 'block';
hint.innerHTML = `<strong>${meaning.icon} Betydning:</strong> ${meaning.text}`;
}
function updateNewCaseRelationTypeHint() {
const select = document.getElementById('newCaseRelationType');
const hint = document.getElementById('newCaseRelationTypeHint');
if (!select || !hint) return;
const selected = select.value;
if (selected === 'Afledt af') {
hint.innerHTML = '<strong>↪ Effekt:</strong> Nuværende sag markeres som afledt af den nye sag.';
return;
}
if (selected === 'Årsag til') {
hint.innerHTML = '<strong>➡ Effekt:</strong> Nuværende sag markeres som årsag til den nye sag.';
return;
}
if (selected === 'Blokkerer') {
hint.innerHTML = '<strong>⛔ Effekt:</strong> Nuværende sag markeres som blokering for den nye sag.';
return;
}
hint.innerHTML = '<strong>🔗 Effekt:</strong> Sagerne kobles fagligt uden direkte afhængighed.';
}
async function createRelatedCase() {
const title = document.getElementById('newCaseTitle').value;
const relationType = document.getElementById('newCaseRelationType').value;
const description = document.getElementById('newCaseDescription').value;
if (!title) {
alert('Titel er påkrævet');
return;
}
// 1. Create the new case
try {
const caseResponse = await fetch('/api/v1/sag', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
titel: title,
beskrivelse: description,
customer_id: {{ case.customer_id }},
status: 'åben'
})
});
if (!caseResponse.ok) throw new Error('Kunne ikke oprette sag');
const newCase = await caseResponse.json();
// 2. Create the relation
const relationResponse = await fetch(`/api/v1/sag/${caseId}/relationer`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
målsag_id: newCase.id,
relationstype: relationType
})
});
if (!relationResponse.ok) throw new Error('Kunne ikke oprette relation');
// 3. Reload to show new relation
window.location.reload();
} catch (err) {
console.error('Error creating related case:', err);
alert('Der opstod en fejl: ' + err.message);
}
}
function confirmDeleteCase() {
if(confirm('Slet denne sag?')) {
fetch('/api/v1/sag/{{ case.id }}', {method: 'DELETE'})
.then(() => window.location='/sag');
}
}
// Contact Search
function setupContactSearch() {
const contactSearchInput = document.getElementById('contactSearch');
contactSearchInput.addEventListener('input', function(e) {
clearTimeout(contactSearchTimeout);
const query = e.target.value.trim();
if (query.length < 2) {
document.getElementById('contactSearchResults').innerHTML = '';
return;
}
contactSearchTimeout = setTimeout(async () => {
try {
const response = await fetch(`/api/v1/search/contacts?q=${encodeURIComponent(query)}`);
const contacts = await response.json();
const resultsDiv = document.getElementById('contactSearchResults');
if (contacts.length === 0) {
resultsDiv.innerHTML = '<div class="p-3 text-muted">Ingen kontakter fundet</div>';
} else {
resultsDiv.innerHTML = contacts.map(c => `
<div class="list-group-item list-group-item-action" style="cursor: pointer;"
onclick="addContact(${caseId}, ${c.id}, '${(c.first_name + ' ' + c.last_name).replace(/'/g, "\\'")}')">
<strong>${c.first_name} ${c.last_name}</strong>
<div class="small text-muted">${c.email || ''} ${c.user_company ? '(' + c.user_company + ')' : ''}</div>
</div>
`).join('');
}
} catch (err) {
console.error('Error searching contacts:', err);
}
}, 300);
});
}
async function addContact(caseId, contactId, contactName) {
try {
const response = await fetch(`/api/v1/sag/${caseId}/contacts`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({contact_id: contactId, role: 'Kontakt'})
});
if (response.ok) {
contactSearchModal.hide();
window.location.reload();
} else {
const error = await response.json();
alert(`Fejl: ${error.detail}`);
}
} catch (err) {
alert('Fejl ved tilføjelse af kontakt: ' + err.message);
}
}
async function removeContact(caseId, contactId) {
if (confirm('Fjern denne kontakt fra sagen?')) {
const response = await fetch(`/api/v1/sag/${caseId}/contacts/${contactId}`, {method: 'DELETE'});
if (response.ok) {
window.location.reload();
} else {
alert('Fejl ved fjernelse af kontakt');
}
}
}
// Customer Search
function setupCustomerSearch() {
const customerSearchInput = document.getElementById('customerSearch');
customerSearchInput.addEventListener('input', function(e) {
clearTimeout(customerSearchTimeout);
const query = e.target.value.trim();
if (query.length < 2) {
document.getElementById('customerSearchResults').innerHTML = '';
return;
}
customerSearchTimeout = setTimeout(async () => {
try {
const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(query)}`);
const customers = await response.json();
const resultsDiv = document.getElementById('customerSearchResults');
if (customers.length === 0) {
resultsDiv.innerHTML = '<div class="p-3 text-muted">Ingen kunder fundet</div>';
} else {
resultsDiv.innerHTML = customers.map(c => `
<div class="list-group-item list-group-item-action" style="cursor: pointer;"
onclick="addCustomer(${caseId}, ${c.id}, '${c.name.replace(/'/g, "\\'")}')">
<strong>${c.name}</strong>
<div class="small text-muted">${c.email || ''} ${c.cvr_number ? '(CVR: ' + c.cvr_number + ')' : ''}</div>
</div>
`).join('');
}
} catch (err) {
console.error('Error searching customers:', err);
}
}, 300);
});
}
async function addCustomer(caseId, customerId, customerName) {
try {
const response = await fetch(`/api/v1/sag/${caseId}/customers`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({customer_id: customerId, role: 'Kunde'})
});
if (response.ok) {
customerSearchModal.hide();
window.location.reload();
} else {
const error = await response.json();
alert(`Fejl: ${error.detail}`);
}
} catch (err) {
alert('Fejl ved tilføjelse af kunde: ' + err.message);
}
}
async function removeCustomer(caseId, customerId) {
if (confirm('Fjern denne kunde fra sagen?')) {
const response = await fetch(`/api/v1/sag/${caseId}/customers/${customerId}`, {method: 'DELETE'});
if (response.ok) {
window.location.reload();
} else {
alert('Fejl ved fjernelse af kunde');
}
}
}
// Relation Search - Enhanced version
let currentFocusIndex = -1;
let searchResults = [];
function setupRelationSearch() {
const relationSearchInput = document.getElementById('relationCaseSearch');
// Input handler
relationSearchInput.addEventListener('input', function(e) {
clearTimeout(relationSearchTimeout);
const query = e.target.value.trim();
currentFocusIndex = -1;
if (query.length < 2) {
document.getElementById('relationSearchResults').innerHTML = '';
document.getElementById('relationSearchResults').style.display = 'none';
return;
}
relationSearchTimeout = setTimeout(async () => {
try {
const response = await fetch(`/api/v1/search/sag?q=${encodeURIComponent(query)}`);
const cases = await response.json();
searchResults = cases.filter(c => c.id !== caseId);
renderRelationSearchResults(searchResults);
} catch (err) {
console.error('Error searching cases:', err);
}
}, 200);
});
// Keyboard navigation
relationSearchInput.addEventListener('keydown', function(e) {
const resultsDiv = document.getElementById('relationSearchResults');
const items = resultsDiv.querySelectorAll('.relation-search-item');
if (e.key === 'ArrowDown') {
e.preventDefault();
currentFocusIndex = (currentFocusIndex + 1) % items.length;
updateFocusedItem(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
currentFocusIndex = currentFocusIndex <= 0 ? items.length - 1 : currentFocusIndex - 1;
updateFocusedItem(items);
} else if (e.key === 'Enter') {
e.preventDefault();
if (currentFocusIndex >= 0 && currentFocusIndex < items.length) {
items[currentFocusIndex].click();
}
}
});
}
function updateFocusedItem(items) {
items.forEach((item, index) => {
if (index === currentFocusIndex) {
item.classList.add('active');
item.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
} else {
item.classList.remove('active');
}
});
}
function renderRelationSearchResults(cases) {
const resultsDiv = document.getElementById('relationSearchResults');
if (cases.length === 0) {
resultsDiv.innerHTML = '<div class="p-3 text-muted text-center"><i class="bi bi-search me-2"></i>Ingen sager fundet</div>';
resultsDiv.style.display = 'block';
return;
}
// Group by status
const grouped = {};
cases.forEach(c => {
const status = c.status || 'ukendt';
if (!grouped[status]) grouped[status] = [];
grouped[status].push(c);
});
let html = '<div class="list-group list-group-flush">';
// Sort status groups: åben first, then others
const statusOrder = ['åben', 'under behandling', 'afventer', 'løst', 'lukket'];
const sortedStatuses = Object.keys(grouped).sort((a, b) => {
const aIndex = statusOrder.indexOf(a);
const bIndex = statusOrder.indexOf(b);
if (aIndex === -1 && bIndex === -1) return a.localeCompare(b);
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
});
sortedStatuses.forEach(status => {
const statusCases = grouped[status];
// Status group header
html += `
<div class="list-group-item bg-light" style="padding: 0.5rem 1rem; font-weight: 600; font-size: 0.85rem; color: var(--text-secondary);">
<span class="status-badge status-${status}">${status}</span>
<span class="badge bg-secondary float-end">${statusCases.length}</span>
</div>
`;
statusCases.forEach(c => {
const createdDate = c.created_at ? new Date(c.created_at).toLocaleDateString('da-DK') : 'N/A';
const beskrivelse = c.beskrivelse ? c.beskrivelse.substring(0, 80) + '...' : '';
const customerName = c.customer_name || '';
const safeTitle = (c.titel || '').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
const safeCustomer = customerName.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
html += `
<div class="list-group-item list-group-item-action relation-search-item"
style="cursor: pointer; padding: 0.75rem 1rem;"
onclick="selectRelationCase(${c.id}, '${safeTitle}', '${safeCustomer}', '${status}');"
data-case-id="${c.id}">
<div class="d-flex justify-content-between align-items-start">
<div style="flex: 1;">
<div class="d-flex align-items-center gap-2 mb-1">
<span class="badge bg-primary" style="font-size: 0.75rem;">#${c.id}</span>
<strong style="font-size: 0.95rem;">${escapeHtml(c.titel)}</strong>
</div>
${c.customer_name ? `
<div class="small text-muted mb-1">
<i class="bi bi-building me-1"></i>${escapeHtml(c.customer_name)}
</div>
` : ''}
${beskrivelse ? `
<div class="small text-muted" style="font-size: 0.8rem;">${escapeHtml(beskrivelse)}</div>
` : ''}
</div>
<div class="text-end" style="min-width: 100px;">
<div class="small text-muted">${createdDate}</div>
</div>
</div>
</div>
`;
});
});
html += '</div>';
resultsDiv.innerHTML = html;
resultsDiv.style.display = 'block';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function selectRelationCase(caseIdValue, caseTitel, customerName, status) {
selectedRelationCaseId = caseIdValue;
// Update preview
const previewDiv = document.getElementById('selectedCasePreview');
const titleDiv = document.getElementById('selectedCaseTitle');
titleDiv.innerHTML = `
<div class="d-flex align-items-center gap-2 mb-1">
<span class="badge bg-primary">#${caseIdValue}</span>
<strong>${escapeHtml(caseTitel)}</strong>
<span class="status-badge status-${status}">${status}</span>
</div>
${customerName ? `<div class="small"><i class="bi bi-building me-1"></i>${escapeHtml(customerName)}</div>` : ''}
`;
previewDiv.style.display = 'block';
document.getElementById('relationSearchResults').innerHTML = '';
document.getElementById('relationSearchResults').style.display = 'none';
document.getElementById('relationCaseSearch').value = '';
// Enable add button
updateAddRelationButton();
}
function clearSelectedRelationCase() {
selectedRelationCaseId = null;
document.getElementById('selectedCasePreview').style.display = 'none';
document.getElementById('relationCaseSearch').value = '';
document.getElementById('relationCaseSearch').focus();
updateAddRelationButton();
}
function updateAddRelationButton() {
const btn = document.getElementById('addRelationBtn');
const relationType = document.getElementById('relationTypeSelect').value;
btn.disabled = !selectedRelationCaseId || !relationType;
}
async function addRelation() {
const relationType = document.getElementById('relationTypeSelect').value;
const btn = document.getElementById('addRelationBtn');
if (!selectedRelationCaseId) {
alert('Vælg en sag først');
return;
}
if (!relationType) {
alert('Vælg en relationstype');
return;
}
// Disable button during request
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Tilføjer...';
try {
const response = await fetch(`/api/v1/sag/${caseId}/relationer`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
målsag_id: selectedRelationCaseId,
relationstype: relationType
})
});
if (response.ok) {
selectedRelationCaseId = null;
relationModal.hide();
window.location.reload();
} else {
const error = await response.json();
alert(`Fejl: ${error.detail}`);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-plus-circle me-1"></i> Tilføj relation';
}
} catch (err) {
alert('Fejl ved tilføjelse af relation: ' + err.message);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-plus-circle me-1"></i> Tilføj relation';
}
}
async function deleteRelation(relationId) {
if (confirm('Fjern denne relation?')) {
const response = await fetch(`/api/v1/sag/${caseId}/relationer/${relationId}`, {method: 'DELETE'});
if (response.ok) {
window.location.reload();
} else {
alert('Fejl ved fjernelse af relation');
}
}
}
// ============ Hardware Handling ============
async function loadCaseHardware() {
try {
const res = await fetch(`/api/v1/sag/{{ case.id }}/hardware`);
if (!res.ok) {
let message = 'Kunne ikke hente hardware.';
try {
const err = await res.json();
if (err?.detail) {
message = err.detail;
}
} catch (_) {
}
throw new Error(message);
}
const hardware = await res.json();
if (!Array.isArray(hardware)) {
throw new Error('Uventet svar fra serveren ved hardware-hentning.');
}
const container = document.getElementById('hardware-list');
if (hardware.length === 0) {
container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen hardware tilknyttet</div>';
setModuleContentState('hardware', false);
return;
}
container.innerHTML = `
<div class="hardware-list-header">
<span>Enhed</span>
<span>SN</span>
<span>Slet</span>
</div>
${hardware.map(h => `
<div class="hardware-row">
<div>
<a href="/hardware/${h.id}" class="text-decoration-none fw-semibold">
${h.brand} ${h.model}
</a>
</div>
<small>${h.serial_number || '-'}</small>
<button class="btn btn-sm btn-delete" onclick="unlinkHardware(${h.id})" title="Slet">
</button>
</div>
`).join('')}
`;
setModuleContentState('hardware', true);
} catch (e) {
console.error("Error loading hardware:", e);
const message = (e?.message || '').trim() || 'Fejl ved hentning';
document.getElementById('hardware-list').innerHTML = `<div class="p-3 text-danger text-center">${escapeHtml(message)}</div>`;
setModuleContentState('hardware', true);
}
}
async function promptLinkHardware() {
const id = prompt("Indtast Hardware ID:");
if (!id) return;
try {
const res = await fetch(`/api/v1/sag/{{ case.id }}/hardware`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ hardware_id: parseInt(id) })
});
if (!res.ok) throw await res.json();
loadCaseHardware();
} catch (e) {
alert("Fejl: " + (e.detail || e.message));
}
}
async function unlinkHardware(hwId) {
if(!confirm("Fjern link til dette hardware?")) return;
try {
await fetch(`/api/v1/sag/{{ case.id }}/hardware/${hwId}`, { method: 'DELETE' });
loadCaseHardware();
} catch (e) {
alert("Fejl ved sletning");
}
}
// ============ Location Handling ============
async function loadCaseLocations() {
try {
const res = await fetch(`/api/v1/sag/{{ case.id }}/locations`);
if (!res.ok) {
let message = 'Kunne ikke hente lokationer.';
try {
const err = await res.json();
if (err?.detail) {
message = err.detail;
}
} catch (_) {
}
throw new Error(message);
}
const locations = await res.json();
if (!Array.isArray(locations)) {
throw new Error('Uventet svar fra serveren ved lokations-hentning.');
}
const container = document.getElementById('locations-list');
if (locations.length === 0) {
container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen lokationer tilknyttet</div>';
setModuleContentState('locations', false);
return;
}
container.innerHTML = `
<div class="location-list-header">
<span>Navn</span>
<span>Type</span>
<span>Slet</span>
</div>
${locations.map(l => `
<div class="location-row">
<div class="fw-semibold">
<i class="bi bi-geo-alt me-1 text-secondary"></i>
${l.name}
</div>
<small>${l.location_type || '-'}</small>
<button class="btn btn-sm btn-delete" onclick="unlinkLocation(${l.relation_id || l.id})" title="Slet">
</button>
</div>
`).join('')}
`;
setModuleContentState('locations', true);
} catch (e) {
console.error("Error loading locations:", e);
const message = (e?.message || '').trim() || 'Fejl ved hentning';
document.getElementById('locations-list').innerHTML = `<div class="p-3 text-danger text-center">${escapeHtml(message)}</div>`;
setModuleContentState('locations', true);
}
}
// ============ Wiki Handling ============
async function loadCaseWiki(searchValue = '') {
const container = document.getElementById('wiki-list');
if (!container) return;
if (!wikiCustomerId) {
container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen kunde tilknyttet</div>';
setModuleContentState('wiki', false);
return;
}
container.innerHTML = '<div class="p-3 text-center text-muted small">Henter wiki...</div>';
const params = new URLSearchParams();
const trimmed = (searchValue || '').trim();
if (trimmed) {
params.set('query', trimmed);
} else {
params.set('tag', wikiDefaultTag);
}
try {
const res = await fetch(`/api/v1/wiki/customers/${wikiCustomerId}/pages?${params.toString()}`);
if (!res.ok) {
throw new Error('Kunne ikke hente Wiki');
}
const payload = await res.json();
if (payload.errors && payload.errors.length) {
container.innerHTML = '<div class="p-3 text-center text-danger small">Wiki API fejlede</div>';
setModuleContentState('wiki', true);
return;
}
const pages = Array.isArray(payload.pages) ? payload.pages : [];
if (!pages.length) {
container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen sider fundet</div>';
setModuleContentState('wiki', false);
return;
}
container.innerHTML = pages.map(page => {
const title = page.title || page.path || 'Wiki side';
const url = page.url || page.path || '#';
const safeUrl = url ? encodeURI(url) : '#';
return `
<a class="list-group-item list-group-item-action" href="${safeUrl}" target="_blank" rel="noopener">
<div class="fw-semibold">${escapeHtml(title)}</div>
<small class="text-muted">${escapeHtml(page.path || '')}</small>
</a>
`;
}).join('');
setModuleContentState('wiki', true);
} catch (e) {
console.error('Error loading Wiki:', e);
container.innerHTML = '<div class="p-3 text-center text-danger small">Fejl ved hentning</div>';
setModuleContentState('wiki', true);
}
}
async function loadCaseTagsModule() {
const moduleContainer = document.getElementById('case-tags-module');
if (!moduleContainer) return;
try {
const response = await fetch(`/api/v1/tags/entity/case/${caseId}`);
if (!response.ok) throw new Error('Kunne ikke hente tags');
const tags = await response.json();
if (!Array.isArray(tags) || tags.length === 0) {
moduleContainer.innerHTML = '<div class="p-3 text-center text-muted small">Ingen tags paaa sagen endnu</div>';
setModuleContentState('tags', false);
return;
}
moduleContainer.innerHTML = tags.map((tag) => `
<span class="badge me-1 mb-1" style="background-color: ${tag.color};">
${tag.icon ? `<i class="bi ${tag.icon}"></i> ` : ''}${escapeHtml(tag.name)}
<button type="button" class="btn-close btn-close-white btn-sm ms-1"
onclick="removeCaseTagAndSync(${tag.id})"
style="font-size: 0.6rem; vertical-align: middle;"></button>
</span>
`).join('');
setModuleContentState('tags', true);
} catch (error) {
console.error('Error loading case tags module:', error);
moduleContainer.innerHTML = '<div class="p-3 text-center text-danger small">Fejl ved hentning af tags</div>';
setModuleContentState('tags', true);
}
}
async function loadCaseTagSuggestions() {
const suggestionsContainer = document.getElementById('case-tag-suggestions');
if (!suggestionsContainer) return;
try {
const response = await fetch(`/api/v1/tags/entity/case/${caseId}/suggestions`);
if (!response.ok) throw new Error('Kunne ikke hente forslag');
const suggestions = await response.json();
if (!Array.isArray(suggestions) || suggestions.length === 0) {
suggestionsContainer.innerHTML = '<div class="text-muted small">Ingen nye forslag lige nu</div>';
return;
}
suggestionsContainer.innerHTML = suggestions.slice(0, 8).map((item) => {
const tag = item.tag || {};
const matched = Array.isArray(item.matched_words) ? item.matched_words.join(', ') : '';
return `
<div class="d-flex justify-content-between align-items-start gap-2 mb-2 border rounded p-2">
<div class="small">
<span class="badge" style="background-color: ${tag.color || '#0f4c75'};">
${tag.icon ? `<i class="bi ${tag.icon}"></i> ` : ''}${escapeHtml(tag.name || 'Tag')}
</span>
${matched ? `<div class="text-muted mt-1">Match: ${escapeHtml(matched)}</div>` : ''}
</div>
<button class="btn btn-sm btn-outline-primary" type="button" onclick="applySuggestedCaseTag(${Number(tag.id)})">Tilfoej</button>
</div>
`;
}).join('');
} catch (error) {
console.error('Error loading tag suggestions:', error);
suggestionsContainer.innerHTML = '<div class="text-danger small">Fejl ved forslag</div>';
}
}
async function applySuggestedCaseTag(tagId) {
if (!tagId) return;
try {
const response = await fetch('/api/v1/tags/entity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_type: 'case', entity_id: caseId, tag_id: tagId })
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || 'Kunne ikke tilfoeje tag');
}
await syncCaseTagsUi();
if (typeof showNotification === 'function') {
showNotification('Tag tilfoejet', 'success');
}
} catch (error) {
alert('Fejl: ' + error.message);
}
}
async function removeCaseTagAndSync(tagId) {
await window.removeEntityTag('case', caseId, tagId, 'case-tags-module');
await syncCaseTagsUi();
}
async function syncCaseTagsUi() {
if (window.renderEntityTags) {
await window.renderEntityTags('case', caseId, 'case-tags');
}
await loadCaseTagsModule();
await loadCaseTagSuggestions();
}
let todoUserId = null;
function getTodoUserId() {
if (todoUserId) return todoUserId;
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
todoUserId = payload.sub || payload.user_id;
return todoUserId;
} catch (e) {
console.warn('Could not decode token for todo user_id');
}
}
const metaTag = document.querySelector('meta[name="user-id"]');
if (metaTag) {
todoUserId = metaTag.getAttribute('content');
}
return todoUserId;
}
function formatTodoDate(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleDateString('da-DK');
}
function formatTodoDateTime(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleString('da-DK', { hour: '2-digit', minute: '2-digit', hour12: false });
}
function getNextTodoOverrideStorageKey() {
return `case:${caseId}:nextTodoStepId`;
}
function getNextTodoOverrideId() {
const raw = localStorage.getItem(getNextTodoOverrideStorageKey());
const parsed = Number(raw);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function setNextTodoOverrideId(stepIdOrNull) {
const key = getNextTodoOverrideStorageKey();
if (stepIdOrNull === null || stepIdOrNull === undefined) {
localStorage.removeItem(key);
return;
}
localStorage.setItem(key, String(stepIdOrNull));
}
function renderTodoSteps(steps) {
const list = document.getElementById('todo-steps-list');
if (!list) return;
updateTopbarNextTodo(steps || []);
const escapeAttr = (value) => String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
if (!steps || steps.length === 0) {
list.innerHTML = '<div class="p-3 text-center text-muted">Ingen opgaver endnu</div>';
setModuleContentState('todo-steps', false);
return;
}
const openSteps = steps.filter(step => !step.is_done);
const doneSteps = steps.filter(step => step.is_done);
const nextOverrideId = getNextTodoOverrideId();
const renderStep = (step) => {
const createdBy = step.created_by_name || 'Ukendt';
const completedBy = step.completed_by_name || 'Ukendt';
const dueLabel = step.due_date ? formatTodoDate(step.due_date) : '-';
const createdLabel = formatTodoDateTime(step.created_at);
const completedLabel = step.completed_at ? formatTodoDateTime(step.completed_at) : null;
const isNextEffective = !step.is_done && (!!step.is_next || (nextOverrideId !== null && step.id === nextOverrideId));
const statusBadge = step.is_done
? '<span class="badge bg-success">Færdig</span>'
: `<span class="badge ${isNextEffective ? 'bg-primary' : 'bg-warning text-dark'}">${isNextEffective ? 'Næste' : 'Åben'}</span>`;
const toggleLabel = step.is_done ? 'Genåbn' : 'Færdig';
const toggleClass = step.is_done ? 'btn-outline-secondary' : 'btn-outline-success';
const nextLabel = isNextEffective ? 'Fjern som næste' : 'Sæt som næste';
const nextClass = isNextEffective ? 'btn-primary' : 'btn-outline-primary';
const tooltipText = [
`Oprettet af: ${createdBy}`,
`Oprettet: ${createdLabel}`,
`Forfald: ${dueLabel}`,
isNextEffective ? 'Markeret som næste opgave' : null,
step.is_done && completedLabel ? `Færdiggjort af: ${completedBy}` : null,
step.is_done && completedLabel ? `Færdiggjort: ${completedLabel}` : null
].filter(Boolean).join('<br>');
return `
<div class="list-group-item todo-step-item">
<div class="todo-step-title todo-step-header">
<div class="todo-step-left">
<span>${step.title}</span>
<button type="button" class="btn btn-outline-secondary todo-info-btn" data-bs-toggle="tooltip" data-bs-html="true" title="${escapeAttr(tooltipText)}" aria-label="Vis detaljer">
<i class="bi bi-info"></i>
</button>
</div>
<div class="todo-step-right">
${statusBadge}
<div class="todo-step-actions">
${!step.is_done ? `
<button class="btn btn-sm ${nextClass}" onclick="setNextTodoStep(${step.id}, ${isNextEffective ? 'false' : 'true'})" title="${nextLabel}" aria-label="${nextLabel}">
<i class="bi bi-arrow-up"></i>
</button>
` : ''}
<button class="btn btn-sm ${toggleClass}" onclick="toggleTodoStep(${step.id}, ${step.is_done ? 'false' : 'true'})" title="${toggleLabel}" aria-label="${toggleLabel}">
<i class="bi ${step.is_done ? 'bi-arrow-counterclockwise' : 'bi-check2'}"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteTodoStep(${step.id})" title="Slet" aria-label="Slet">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>
${step.description ? `<div class="small text-muted">${step.description}</div>` : ''}
<div class="todo-step-meta">
<span class="meta-pill">Forfald: ${dueLabel}</span>
</div>
</div>
`;
};
const sections = [];
if (openSteps.length) {
sections.push(`
<div class="todo-section-header">Åbne (${openSteps.length})</div>
${openSteps.map(renderStep).join('')}
`);
}
if (doneSteps.length) {
sections.push(`
<div class="todo-section-header">Færdige (${doneSteps.length})</div>
${doneSteps.map(renderStep).join('')}
`);
}
list.innerHTML = sections.join('');
if (window.bootstrap) {
list.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((el) => {
bootstrap.Tooltip.getOrCreateInstance(el, {
trigger: 'hover focus',
placement: 'left',
container: 'body',
html: true
});
});
}
setModuleContentState('todo-steps', true);
}
function updateTopbarNextTodo(steps) {
const valueEl = document.getElementById('topbarNextTodoValue');
const metaEl = document.getElementById('topbarNextTodoMeta');
if (!valueEl || !metaEl) return;
const openSteps = Array.isArray(steps) ? steps.filter((step) => !step.is_done) : [];
if (!openSteps.length) {
valueEl.textContent = 'Ingen åbne todo-opgaver';
metaEl.textContent = 'Alt er færdigt';
setNextTodoOverrideId(null);
return;
}
const nextOverrideId = getNextTodoOverrideId();
const overrideStep = nextOverrideId ? openSteps.find((step) => step.id === nextOverrideId) : null;
const nextStep = overrideStep || openSteps.find((step) => !!step.is_next) || openSteps[0];
if (!overrideStep && nextOverrideId) {
setNextTodoOverrideId(null);
}
valueEl.textContent = nextStep.title || 'Untitled todo';
metaEl.textContent = nextStep.due_date
? `Forfald: ${formatTodoDate(nextStep.due_date)}`
: 'Ingen forfaldsdato';
}
async function setNextTodoStep(stepId, isNext) {
try {
const res = await fetch(`/api/v1/sag/todo-steps/${stepId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_next: isNext, is_done: false })
});
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new Error(error.detail || 'Kunne ikke opdatere næste-opgave');
}
setNextTodoOverrideId(isNext ? stepId : null);
await loadTodoSteps();
} catch (e) {
alert('Fejl: ' + e.message);
}
}
async function loadTodoSteps() {
const list = document.getElementById('todo-steps-list');
if (!list) return;
list.innerHTML = '<div class="p-3 text-center text-muted">Henter opgaver...</div>';
try {
const res = await fetch(`/api/v1/sag/${caseId}/todo-steps`);
if (!res.ok) throw new Error('Kunne ikke hente steps');
const steps = await res.json();
renderTodoSteps(steps || []);
} catch (e) {
console.error('Error loading todo steps:', e);
list.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning</div>';
setModuleContentState('todo-steps', true);
}
}
function toggleTodoStepForm(forceOpen = null) {
const form = document.getElementById('todoStepForm');
const moduleCard = document.querySelector('[data-module="todo-steps"]');
if (!form) return;
const shouldOpen = forceOpen === null ? form.classList.contains('d-none') : Boolean(forceOpen);
if (shouldOpen) {
form.classList.remove('d-none');
if (moduleCard) {
moduleCard.classList.remove('module-empty-compact');
}
const titleInput = document.getElementById('todoStepTitle');
if (titleInput) {
titleInput.focus();
}
} else {
form.classList.add('d-none');
applyViewLayout(currentCaseView);
}
}
async function createTodoStep(event) {
event.preventDefault();
const titleInput = document.getElementById('todoStepTitle');
const descInput = document.getElementById('todoStepDescription');
const dueInput = document.getElementById('todoStepDueDate');
if (!titleInput) return;
const title = titleInput.value.trim();
if (!title) {
alert('Titel er paakraevet');
return;
}
const userId = getTodoUserId();
if (!userId) {
alert('Mangler bruger-id. Log ind igen.');
return;
}
try {
const res = await fetch(`/api/v1/sag/${caseId}/todo-steps?user_id=${userId}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description: descInput.value.trim() || null,
due_date: dueInput.value || null
})
}
);
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Kunne ikke oprette step');
}
titleInput.value = '';
descInput.value = '';
dueInput.value = '';
await loadTodoSteps();
toggleTodoStepForm(false);
} catch (e) {
alert('Fejl: ' + e.message);
}
}
async function toggleTodoStep(stepId, isDone) {
const userId = getTodoUserId();
if (!userId) {
alert('Mangler bruger-id. Log ind igen.');
return;
}
try {
const res = await fetch(`/api/v1/sag/todo-steps/${stepId}?user_id=${userId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_done: isDone })
}
);
if (!res.ok) throw new Error('Kunne ikke opdatere step');
await loadTodoSteps();
} catch (e) {
alert('Fejl: ' + e.message);
}
}
async function deleteTodoStep(stepId) {
if (!confirm('Slet dette step?')) return;
try {
const res = await fetch(`/api/v1/sag/todo-steps/${stepId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Kunne ikke slette step');
await loadTodoSteps();
} catch (e) {
alert('Fejl: ' + e.message);
}
}
async function promptLinkLocation() {
const id = prompt("Indtast Lokations ID:");
if (!id) return;
try {
const res = await fetch(`/api/v1/sag/{{ case.id }}/locations`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ location_id: parseInt(id) })
});
if (!res.ok) throw await res.json();
loadCaseLocations();
} catch (e) {
alert("Fejl: " + (e.detail || e.message));
}
}
async function unlinkLocation(locId) {
if(!confirm("Fjern link til denne lokation?")) return;
try {
const res = await fetch(`/api/v1/sag/{{ case.id }}/locations/${locId}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Kunne ikke fjerne lokation');
}
loadCaseLocations();
} catch (e) {
alert("Fejl ved sletning: " + (e.message || 'Ukendt fejl'));
}
}
// Initialize relation search when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupRelationSearch);
} else {
setupRelationSearch();
}
// Kontakt Modal functions
function showKontaktModal() {
const modal = new bootstrap.Modal(document.getElementById('kontaktModal'));
modal.show();
}
// Afdeling Modal functions
function showAfdelingModal() {
const modal = new bootstrap.Modal(document.getElementById('afdelingModal'));
modal.show();
}
async function updateAfdeling() {
const newAfdeling = document.getElementById('afdelingInput').value.trim();
try {
const response = await fetch('/api/v1/customers/{{ customer.id if customer else 0 }}', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ department: newAfdeling })
});
if (!response.ok) throw await response.json();
// Reload page to show updated data
window.location.reload();
} catch (e) {
alert("Fejl ved opdatering: " + (e.detail || e.message));
}
}