/* Progressive case creation. Existing searches and submit retain their API contracts. */ (() => { 'use strict'; const $ = id => document.getElementById(id); const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const state = {tags: [], hardware: [], tagCatalog: [], panels: {}, ready: false, restoring: false, key: null, pending: false, completed: false}; let saveTimer, duplicateTimer, duplicateToken = 0, workloadToken = 0, contactCasesToken = 0, tagSuggestionTimer, templates = []; async function api(url, options) { const response = await fetch(url, {credentials: 'include', ...options}); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(typeof body.detail === 'string' ? body.detail : 'Kunne ikke hente oplysninger'); return body; } function panel(id, title, elements, open = false) { const detail = document.createElement('details'); detail.className = 'cc-panel'; detail.id = `cc-${id}`; detail.open = open; const icon = {relations:'bi-people',tags:'bi-tags',hardware:'bi-pc-display',pipeline:'bi-graph-up-arrow',order:'bi-bag',metadata:'bi-sliders'}[id]; detail.innerHTML = `${esc(title)}
`; elements[0].before(detail); elements.forEach(el => detail.lastElementChild.append(el)); state.panels[id] = detail; detail.addEventListener('toggle', scheduleSave); return detail; } function summaries() { const values = { relations: `${selectedCustomer?.name || 'Vælg firma'} · ${Object.keys(selectedContacts).length} kontakter`, tags: state.tags.map(t => t.name).join(', ') || 'Ingen tags', hardware: `${state.hardware.length} enheder valgt`, pipeline: [$('pipeline_stage_id').selectedOptions[0]?.text, $('pipeline_amount').value ? `${$('pipeline_amount').value} kr.` : ''].filter(Boolean).join(' · '), order: `${document.querySelectorAll('#orderLines .order-line').length} linjer`, metadata: [$('status').selectedOptions[0]?.text, $('ansvarlig_bruger_id').selectedOptions[0]?.text, $('deadline').value.replace('T', ' ')].filter(Boolean).join(' · ') }; Object.entries(values).forEach(([key, value]) => { if (state.panels[key]) state.panels[key].querySelector('.cc-summary').textContent = value; }); } function fieldSnapshot() { return Object.fromEntries(Array.from($('createForm').querySelectorAll('input[id],select[id],textarea[id]')).filter(el => !['customerSearch','contactSearch'].includes(el.id)).map(el => [el.id, el.type === 'checkbox' ? el.checked : el.value])); } function snapshot() { return {version: 1, savedAt: Date.now(), fields: fieldSnapshot(), customer: selectedCustomer, contacts: selectedContacts, contactCompanies: selectedContactsCompanies, tags: state.tags, hardware: state.hardware, orders: collectOrderItems(), prefill: telefoniPrefill, customerSearch: $('customerSearch').value, contactSearch: $('contactSearch').value, panels: Object.fromEntries(Object.entries(state.panels).map(([k, el]) => [k, el.open]))}; } function save() { if (!state.ready || state.pending || state.restoring || state.completed || !state.key) return; try { localStorage.setItem(state.key, JSON.stringify(snapshot())); $('cc-save').textContent = 'Kladde gemt lokalt'; } catch (error) { console.error('Kunne ikke gemme kladde', error); $('cc-save').textContent = 'Kladde kunne ikke gemmes i browseren'; } } function scheduleSave() { summaries(); if (!state.ready || state.pending || state.restoring || state.completed) return; $('cc-save').textContent = 'Gemmer kladde…'; clearTimeout(saveTimer); saveTimer = setTimeout(save, 500); } function renderTags() { $('cc-tag-chips').innerHTML = state.tags.map(t => `${/^bi-[a-z0-9-]+$/.test(t.icon || '') ? ` ` : ''}${esc(t.name)} `).join(''); state.tags.forEach(t => { const chip = $('cc-tag-chips').querySelector(`[data-tag-id="${Number(t.id)}"]`); if (/^#[0-9a-f]{6}$/i.test(t.color)) chip.style.backgroundColor = t.color; }); summaries(); renderTagSuggestions(); } function renderTagSuggestions() { const box = $('cc-tag-suggestions'); if (!box) return; const haystack = `${$('titel')?.value || ''} ${$('beskrivelse')?.value || ''}`.toLocaleLowerCase('da-DK'); if (haystack.trim().length < 3 || !state.tagCatalog.length) { box.innerHTML = ''; return; } const selected = new Set(state.tags.map(tag => Number(tag.id))); const suggestions = state.tagCatalog.filter(tag => { if (selected.has(Number(tag.id)) || !['brand', 'type'].includes(tag.type)) return false; const words = Array.isArray(tag.catch_words) ? tag.catch_words : []; return words.some(word => String(word).trim() && haystack.includes(String(word).trim().toLocaleLowerCase('da-DK'))); }).slice(0, 8); box.innerHTML = suggestions.length ? `
Foreslået ud fra teksten
${suggestions.map(tag => ``).join('')}
` : ''; } function scheduleTagSuggestions() { clearTimeout(tagSuggestionTimer); tagSuggestionTimer = setTimeout(renderTagSuggestions, 350); } async function addTag(tag) { if (!state.tags.some(t => t.id === tag.id)) { const groups = await api('/api/v1/tags/groups').catch(() => []); if (groups.some(g => g.id === tag.tag_group_id && ['single','toggle'].includes(g.behavior))) state.tags = state.tags.filter(t => t.tag_group_id !== tag.tag_group_id); state.tags.push(tag); renderTags(); scheduleSave(); } } function renderSelectedHardware() { $('cc-hardware-chips').innerHTML = state.hardware.map(h => `${esc(h.name)} `).join(''); document.querySelectorAll('[data-hardware-choice]').forEach(el => { el.checked = state.hardware.some(h => h.id === Number(el.dataset.hardwareChoice)); }); summaries(); } async function duplicates() { const token = ++duplicateToken, customerId = selectedCustomer?.id, title = $('titel').value.trim(); $('cc-duplicates').innerHTML = ''; if (!customerId || title.length < 5) return; try { const rows = await api(`/api/v1/case-create/duplicates?customer_id=${customerId}&title=${encodeURIComponent(title)}`); if (token !== duplicateToken) return; $('cc-duplicates').innerHTML = rows.length ? `
Mulige eksisterende sager${rows.map(r => `
SAG-${r.id} · ${esc(r.titel)} · ${esc(r.status)} · ${esc(r.ansvarlig_navn || 'Ingen ansvarlig')}
`).join('')}
` : ''; } catch { if (token === duplicateToken) $('cc-duplicates').textContent = 'Duplikatkontrol er midlertidigt utilgængelig. Du kan stadig oprette sagen.'; } } function scheduleDuplicates() { ++duplicateToken; clearTimeout(duplicateTimer); duplicateTimer = setTimeout(duplicates, 500); } async function contactOpenCases() { const box = $('cc-contact-cases'); if (!box) return; const contacts = Object.values(selectedContacts); const token = ++contactCasesToken; if (!contacts.length) { box.hidden = true; box.innerHTML = ''; return; } box.hidden = false; box.innerHTML = 'Tjekker kontaktens åbne sager…'; const query = contacts.map(contact => `contact_ids=${encodeURIComponent(contact.id)}`).join('&'); try { const data = await api(`/api/v1/case-create/contacts-open-cases?${query}`); if (token !== contactCasesToken) return; const groups = contacts.map(contact => ({contact, data: data[contact.id] || {total:0, items:[]}})); const total = groups.reduce((sum, group) => sum + Number(group.data.total || 0), 0); const title = total ? `${total} åben${total === 1 ? '' : 'ne'} sag${total === 1 ? '' : 'er'} på valgt kontakt` : 'Ingen åbne sager på valgt kontakt'; box.innerHTML = `
Dublettjek${title}
${total ? `
${groups.map(({contact,data}) => data.total ? `
${esc(contact.name)}${data.items.map(item => `
SAG-${Number(item.id)} · ${esc(item.titel)}${esc(item.status || '')} · ${esc(item.ansvarlig_navn || 'Ingen ansvarlig')}${item.deadline ? ` · ${esc(new Date(item.deadline).toLocaleDateString('da-DK'))}` : ''}
`).join('')}${data.total > data.items.length ? `Viser ${data.items.length} af ${data.total} åbne sager` : ''}
` : '').join('')}
` : '
Du kan oprette sagen uden risiko for en kendt, åben sag på kontakten.
'}`; } catch { if (token === contactCasesToken) box.innerHTML = 'Kunne ikke tjekke kontaktens åbne sager. Du kan stadig oprette sagen.'; } } async function workload() { const token = ++workloadToken, id = $('ansvarlig_bruger_id').value; $('cc-workload').textContent = id ? 'Henter åbne sager…' : 'Vælg en ansvarlig for at se åbne sager.'; if (!id) return; try { const data = await api(`/api/v1/case-create/workload?user_id=${id}`); if (token !== workloadToken) return; $('cc-workload').innerHTML = `
${data.total} åbne sager · ${esc($('ansvarlig_bruger_id').selectedOptions[0].text)}${data.items.map(r => `
SAG-${r.id} · ${esc(r.titel)}${esc(r.customer_name || '')} · ${esc(r.status)} ${r.deadline ? esc(new Date(r.deadline).toLocaleString('da-DK')) : ''}
`).join('') || '

Ingen åbne sager.

'}${data.total > 10 ? `Se alle ${data.total} sager` : ''}
`; } catch { if (token === workloadToken) $('cc-workload').textContent = 'Kunne ikke hente åbne sager.'; } } function typeChanged(type) { document.querySelectorAll('[data-case-type]').forEach(b => { const active = b.dataset.caseType === type; b.classList.toggle('active', active); b.setAttribute('aria-pressed', String(active)); }); const key = {ticket:'hardware', pipeline:'pipeline', ordre:'order'}[type]; if (key && state.panels[key] && !state.restoring) state.panels[key].open = true; if ($('cc-more')) $('cc-more').open = Boolean($('cc-more').querySelector(`[data-case-type="${CSS.escape(type)}"]`)); scheduleSave(); } function buildTypes() { const options = Array.from($('type').options), current = $('type').value; const keys = [...new Set([current, 'ticket','pipeline','ordre','opgave', ...options.map(o => o.value)])].filter(k => options.some(o => o.value === k)); const hints = {ticket:'Support og hjælp',pipeline:'Muligheder og tilbud',ordre:'Indkøb og salg',opgave:'Noget der skal løses',projekt:'Større forløb',service:'Service og vedligehold',abonnement:'Løbende aftaler'}; const button = key => ``; const messageButton = $('caseMessageTypeButton'); $('type').classList.add('d-none'); $('type').removeAttribute('required'); const div = document.createElement('div'); div.id = 'cc-types'; div.className = 'cc-types'; div.setAttribute('role', 'group'); div.setAttribute('aria-label', 'Sagstype'); div.innerHTML = keys.slice(0,4).map(button).join('') + (keys.length > 4 ? `
Flere
${keys.slice(4).map(button).join('')}
` : ''); if (messageButton) { messageButton.classList.remove('cc-message-fallback'); messageButton.dataset.caseMessage = 'true'; div.insertBefore(messageButton, div.querySelector('#cc-more')); } $('type').after(div); div.addEventListener('click', e => { const message = e.target.closest('[data-case-message]'); if (message) { window.caseCreateUI?.openMessage(); return; } const b = e.target.closest('[data-case-type]'); if (b) { $('type').value = b.dataset.caseType; $('type').dispatchEvent(new Event('change', {bubbles:true})); } }); } async function applyTemplate(id) { const template = templates.find(t => t.id === Number(id)); if (!template) return; const values = template.values, fields = {type: values.type, titel: values.titel, beskrivelse: values.beskrivelse, status: values.status, assigned_group_id: values.assigned_group_id ?? ''}; Object.entries(values.pipeline || {}).forEach(([k,v]) => { fields[`pipeline_${k === 'stage_id' ? 'stage_id' : k}`] = v ?? ''; }); if (Object.entries(fields).some(([k,v]) => $(k)?.value && String(v) !== $(k).value) && !confirm('Skabelonen ændrer udfyldte felter. Vil du bruge den?')) return; Object.entries(fields).forEach(([k,v]) => { const el = $(k); if (el && (el.tagName !== 'SELECT' || Array.from(el.options).some(o => o.value === String(v)))) el.value = v; }); const tags = await api('/api/v1/tags?is_active=true'); for (const id of values.tag_ids || []) { const tag = tags.find(t => t.id === id); if (tag) await addTag(tag); } updateCaseTypeSections(); $('charCount').textContent = `${$('beskrivelse').value.length} tegn`; scheduleSave(); scheduleDuplicates(); } async function restore(draft) { state.restoring = true; try { selectedCustomer = draft.customer; selectedContacts = draft.contacts || {}; selectedContactsCompanies = draft.contactCompanies || {}; telefoniPrefill = draft.prefill || telefoniPrefill; Object.entries(draft.fields || {}).forEach(([id,value]) => { const el = $(id); if (el && !id.startsWith('cc-')) { if (el.type === 'checkbox') el.checked = value; else el.value = value; } }); state.tags = draft.tags || []; state.hardware = draft.hardware || []; $('orderLines').innerHTML = ''; (draft.orders || []).forEach(item => { addOrderLine(item.type); const row = $('orderLines').lastElementChild; Object.entries({description:'description',quantity:'quantity',unit:'unit',unit_price:'unit-price',amount:'amount',currency:'currency',status:'status',external_ref:'reference',purchase_purpose:'purpose'}).forEach(([k,cls]) => { const el = row.querySelector(`.order-${cls}`); if (el) el.value = item[k] ?? ''; }); }); renderSelections(); await loadHardwareForContacts(); renderTags(); renderSelectedHardware(); renderOrderLinesEmptyState(); await loadCreateTopAlertsForCustomer(selectedCustomer?.id || null); if (selectedCustomer) await loadSelectedCustomerContacts(selectedCustomer.id); $('customerSearch').value = draft.customerSearch || ''; $('contactSearch').value = draft.contactSearch || ''; updateCaseTypeSections(); Object.entries(draft.panels || {}).forEach(([k,v]) => { if (state.panels[k]) state.panels[k].open = v; }); $('charCount').textContent = `${$('beskrivelse').value.length} tegn`; } finally { state.restoring = false; } state.pending = false; $('cc-draft').remove(); scheduleSave(); scheduleDuplicates(); workload(); } function validate() { document.querySelectorAll('.cc-error').forEach(el => el.remove()); document.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid')); const errors = []; const add = (el, text) => { errors.push(text); el.classList.add('is-invalid'); el.insertAdjacentHTML('afterend', `
${esc(text)}
`); const detail = el.closest('details'); if (detail) { detail.open = true; if (!detail.querySelector('summary .cc-error')) detail.querySelector('summary').insertAdjacentHTML('beforeend',' · Kontrollér felter'); } }; if (!$('titel').value.trim()) add($('titel'), 'Titel er påkrævet'); if (!selectedCustomer && $('customerSearch').value.trim().length < 2) add($('customerSearch'), 'Vælg et firma'); $('createForm').querySelectorAll('input[type=number]').forEach(el => { if (!el.checkValidity()) add(el, 'Kontrollér tallets interval og format'); }); document.querySelectorAll('#orderLines .order-line').forEach(row => { for (const cls of ['description','amount']) { const el = row.querySelector(`.order-${cls}`); if (!el.value.trim()) add(el, cls === 'description' ? 'Ordrelinjen mangler beskrivelse' : 'Ordrelinjen mangler beløb'); } }); if (errors.length) { $('error').classList.remove('d-none'); $('error-text').textContent = [...new Set(errors)].join(' · '); document.querySelector('.is-invalid')?.focus(); return false; } return true; } async function init() { const form = $('createForm'); try { const setting = await api('/api/v1/settings/case_statuses'); const statuses = JSON.parse(setting.value || '[]'); if (Array.isArray(statuses)) statuses.forEach(s => { const value = typeof s === 'string' ? s : s.value; if (value && !Array.from($('status').options).some(o => o.value === value)) $('status').add(new Option(typeof s === 'string' ? s : (s.label || value), value)); }); } catch { /* Keep the built-in statuses available. */ } const headings = Array.from(form.querySelectorAll(':scope > h5')); panel('relations', 'Kunde og kontakter', [headings[0], headings[0].nextElementSibling], true); for (const [key,id,title] of [['hardware','hardwareSection','Hardware og AnyDesk'],['pipeline','pipelineSection','Pipeline'],['order','orderSection','Indkøb og salg']]) panel(key, title, [$(id)]); const metadata = headings[1]; panel('metadata', 'Status, ansvar og deadline', [metadata,metadata.nextElementSibling,metadata.nextElementSibling.nextElementSibling]); const tags = document.createElement('section'); tags.innerHTML = '
'; state.panels.hardware.before(tags); panel('tags', 'Tags', [tags]); $('cc-tag-add').onclick = () => window.tagPicker.showSelection(addTag); window.setTagPickerContext?.('case', null, addTag); $('cc-tag-chips').onclick = e => { const chip = e.target.closest('[data-tag-id]'); if (chip && e.target.closest('button')) { state.tags = state.tags.filter(t => t.id !== Number(chip.dataset.tagId)); renderTags(); scheduleSave(); } }; $('cc-tag-suggestions').onclick = e => { const button = e.target.closest('[data-suggested-tag]'); const tag = state.tagCatalog.find(item => item.id === Number(button?.dataset.suggestedTag)); if (tag) addTag(tag).catch(error => showCreateError(error.message)); }; const chips = document.createElement('div'); chips.id = 'cc-hardware-chips'; $('hardwareList').before(chips); chips.onclick = e => { const b = e.target.closest('[data-remove-hardware]'); if (b) { state.hardware = state.hardware.filter(h => h.id !== Number(b.dataset.removeHardware)); renderSelectedHardware(); scheduleSave(); } }; const originalRenderHardware = window.renderHardwareList; window.renderHardwareList = items => { originalRenderHardware(items); Array.from($('hardwareList').children).forEach((row,i) => { if (!items[i]) return; const item = items[i]; const input = document.createElement('input'); input.type = 'checkbox'; input.className = 'form-check-input me-2'; input.dataset.hardwareChoice = item.id; input.setAttribute('aria-label', `Vælg ${item.model || item.brand || item.id}`); input.onchange = () => { state.hardware = state.hardware.filter(h => h.id !== item.id); if (input.checked) state.hardware.push({id:item.id,name:item.model || item.brand || `Hardware #${item.id}`}); renderSelectedHardware(); scheduleSave(); }; row.prepend(input); }); renderSelectedHardware(); }; window.quickCreateHardware = async () => { const name = $('hardwareNameInput').value.trim(), customerId = selectedCustomer?.id || getSingleContactCompanyId(); if (!name || !customerId) { showCreateError('Udfyld hardwarenavn og vælg et firma'); return; } const anydeskId = $('hardwareAnyDeskIdInput').value.trim(); const button = $('hardwareNameInput').closest('.card-body').querySelector('button'); button.disabled = true; try { const item = await api('/api/v1/hardware/quick', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,customer_id:customerId,anydesk_id:anydeskId || null,anydesk_link:anydeskId ? `anydesk://${anydeskId}` : null})}); state.hardware.push({id:item.id,name}); $('hardwareNameInput').value = ''; $('hardwareAnyDeskIdInput').value = ''; renderSelectedHardware(); scheduleSave(); } catch(e) { showCreateError(e.message); } finally { button.disabled = false; } }; const originalRender = window.renderSelections; window.renderSelections = () => { originalRender(); if (state.ready) { scheduleSave(); scheduleDuplicates(); contactOpenCases(); } }; const contactCases = document.createElement('aside'); contactCases.id = 'cc-contact-cases'; contactCases.className = 'cc-contact-cases'; contactCases.hidden = true; $('selectedContacts').after(contactCases); const workloadEl = document.createElement('div'); workloadEl.id = 'cc-workload'; workloadEl.className = 'cc-workload'; state.panels.metadata.after(workloadEl); const duplicateEl = document.createElement('div'); duplicateEl.id = 'cc-duplicates'; $('titel').closest('.row').after(duplicateEl); const toolbar = document.createElement('div'); toolbar.className = 'mb-3'; toolbar.innerHTML = ''; form.prepend(toolbar); try { templates = await api('/api/v1/case-create/templates'); $('cc-template').insertAdjacentHTML('beforeend',templates.map(t => ``).join('')); } catch { $('cc-template-error').textContent = 'Skabeloner er ikke tilgængelige.'; } $('cc-template').onchange = e => applyTemplate(e.target.value).catch(err => showCreateError(err.message)); const actions = $('submitBtn').parentElement; actions.classList.add('cc-actions'); actions.insertAdjacentHTML('afterbegin','Klargør kladde…'); toolbar.classList.add('cc-template-toolbar'); buildTypes(); updateCaseTypeSections(); form.addEventListener('input', scheduleSave); form.addEventListener('change', scheduleSave); form.addEventListener('click', () => setTimeout(scheduleSave,0)); $('titel').addEventListener('input', () => { scheduleDuplicates(); scheduleTagSuggestions(); }); $('beskrivelse').addEventListener('input', scheduleTagSuggestions); $('ansvarlig_bruger_id').addEventListener('change', workload); document.addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !document.querySelector('.modal.show')) { e.preventDefault(); if (!$('submitBtn').disabled) form.requestSubmit(); } }); window.addEventListener('pagehide', save); try { const user = await api('/api/v1/auth/me'); if (!(user.user_id || user.id)) throw new Error('Mangler bruger'); const params = new URLSearchParams(location.search); params.sort(); state.key = `bmc:case-draft:v1:${user.user_id || user.id}:${params.toString() || 'new'}`; const raw = localStorage.getItem(state.key); let draft; try { draft = raw ? JSON.parse(raw) : null; } catch { localStorage.removeItem(state.key); } if (draft && (draft.version !== 1 || !Number.isFinite(draft.savedAt) || !draft.fields || !Array.isArray(draft.orders) || Date.now()-draft.savedAt > 7*86400000)) { localStorage.removeItem(state.key); draft=null; } if (draft) { state.pending = true; const prompt = document.createElement('div'); prompt.id = 'cc-draft'; prompt.className = 'alert alert-info'; prompt.innerHTML = 'Der findes en lokal kladde. '; form.prepend(prompt); $('cc-restore').onclick = () => restore(draft).catch(e => showCreateError(e.message)); $('cc-discard').onclick = () => { localStorage.removeItem(state.key); state.pending=false; prompt.remove(); scheduleSave(); }; } $('cc-save').textContent = draft ? 'Vælg om kladden skal gendannes' : 'Kladde gemmes i denne browser'; } catch { $('cc-save').textContent = 'Autosave er ikke tilgængelig'; } state.tagCatalog = await api('/api/v1/tags?is_active=true').catch(() => []); state.ready = true; summaries(); renderTagSuggestions(); workload(); scheduleDuplicates(); contactOpenCases(); await loadHardwareForContacts(); } window.caseCreateUI = {init,typeChanged,contactsChanged:contactOpenCases,validate,openMessage:() => { const contacts=Object.values(selectedContacts); window.openInternalMessage({contact:contacts.length===1?contacts[0]:null}); },relations:() => ({tag_ids:state.tags.map(t=>t.id),hardware_ids:state.hardware.map(h=>h.id)}),created: result => { state.completed = true; clearTimeout(saveTimer); try { if (state.key) localStorage.removeItem(state.key); if (result.tag_actions?.length) sessionStorage.setItem(`bmc:case-tag-actions:${result.id}`, JSON.stringify(result.tag_actions)); } catch { /* Case is already saved. */ } }}; })();