1433 lines
63 KiB
JavaScript
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, '"').replace(/'/g, ''');
|
||
|
|
const safeCustomer = customerName.replace(/"/g, '"').replace(/'/g, ''');
|
||
|
|
|
||
|
|
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, '&')
|
||
|
|
.replace(/"/g, '"')
|
||
|
|
.replace(/</g, '<')
|
||
|
|
.replace(/>/g, '>');
|
||
|
|
|
||
|
|
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));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|