2026-09-08 01:55:47 +02:00
/* 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 ] ) ) ;
2026-09-20 03:33:10 +02:00
const state = { tags : [ ] , hardware : [ ] , typeDetails : { } , tagCatalog : [ ] , panels : { } , ready : false , restoring : false , key : null , pending : false , completed : false } ;
2026-09-08 01:55:47 +02:00
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 = ` <summary><span class="cc-panel-icon"><i class="bi ${ icon } " aria-hidden="true"></i></span><span class="cc-panel-label"> ${ esc ( title ) } <small class="cc-summary"></small></span></summary><div class="cc-panel-body"></div> ` ;
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 ,
2026-09-20 03:33:10 +02:00
tags : state . tags , hardware : state . hardware , typeDetails : state . typeDetails , orders : collectOrderItems ( ) , prefill : telefoniPrefill ,
2026-09-08 01:55:47 +02:00
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 => ` <span class="badge me-1 mb-1" style="background-color:#64748b" data-tag-id=" ${ Number ( t . id ) } "> ${ / ^ bi - [ a - z0 - 9 - ] + $ / . test ( t . icon || '' ) ? ` <i class="bi ${ esc ( t . icon ) } "></i> ` : '' } ${ esc ( t . name ) } <button type="button" class="btn-close btn-close-white" aria-label="Fjern ${ esc ( t . name ) } "></button></span> ` ) . 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 ? ` <div class="cc-tag-suggestions__label">Foreslået ud fra teksten</div><div class="d-flex flex-wrap gap-1"> ${ suggestions . map ( tag => ` <button type="button" class="btn btn-sm btn-outline-primary" data-suggested-tag=" ${ Number ( tag . id ) } " title="Tilføj ${ esc ( tag . name ) } "> ${ tag . icon ? ` <i class="bi ${ esc ( tag . icon ) } "></i> ` : '' } ${ esc ( tag . name ) } <i class="bi bi-plus-lg"></i></button> ` ) . join ( '' ) } </div> ` : '' ;
}
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 => ` <span class="badge bg-secondary me-1"> ${ esc ( h . name ) } <button type="button" class="btn-close btn-close-white" data-remove-hardware=" ${ Number ( h . id ) } " aria-label="Fjern hardware"></button></span> ` ) . 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 ? ` <div class="alert alert-warning"><strong>Mulige eksisterende sager</strong> ${ rows . map ( r => ` <div><a target="_blank" rel="noopener" href="/sag/ ${ r . id } /v3">SAG- ${ r . id } · ${ esc ( r . titel ) } </a><small> · ${ esc ( r . status ) } · ${ esc ( r . ansvarlig _navn || 'Ingen ansvarlig' ) } </small></div> ` ) . join ( '' ) } </div> ` : '' ;
} 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 = '<span class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Tjekker kontaktens åbne sager…</span>' ;
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 = ` <div class="cc-contact-cases__header"><span class="cc-contact-cases__header-icon"><i class="bi ${ total ? 'bi-clipboard2-check' : 'bi-check2-circle' } "></i></span><div><span class="cc-contact-cases__eyebrow">Dublettjek</span><span class="cc-contact-cases__title"> ${ title } </span></div></div> ${ total ? ` <div class="cc-contact-cases__body"> ${ groups . map ( ( { contact , data } ) => data.total ? ` < div class = "cc-contact-cases__group" > < span class = "cc-contact-cases__group-name" > $ { esc ( contact . name ) } </span> ${ data . items . map ( item => ` <div class="cc-contact-cases__item"><a href="/sag/ ${ Number ( item . id ) } /v3" target="_blank" rel="noopener">SAG- ${ Number ( item . id ) } · ${ esc ( item . titel ) } </a><small> ${ esc ( item . status || '' ) } · ${ esc ( item . ansvarlig _navn || 'Ingen ansvarlig' ) } ${ item . deadline ? ` · ${ esc ( new Date ( item . deadline ) . toLocaleDateString ( 'da-DK' ) ) } ` : '' } </small></div> ` ) . join ( '' ) } ${ data . total > data . items . length ? ` <span class="cc-contact-cases__more">Viser ${ data . items . length } af ${ data . total } åbne sager</span> ` : '' } </div> ` : '' ) . join ( '' ) } < / d i v > ` : ' < d i v c l a s s = " c c - c o n t a c t - c a s e s _ _ e m p t y " > D u k a n o p r e t t e s a g e n u d e n r i s i k o f o r e n k e n d t , å b e n s a g p å k o n t a k t e n . < / d i v > ' } ` ;
} catch {
if ( token === contactCasesToken ) box . innerHTML = '<span class="text-muted">Kunne ikke tjekke kontaktens åbne sager. Du kan stadig oprette sagen.</span>' ;
}
}
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 = ` <details><summary> ${ data . total } åbne sager · ${ esc ( $ ( 'ansvarlig_bruger_id' ) . selectedOptions [ 0 ] . text ) } </summary> ${ data . items . map ( r => ` <div class="cc-work-item"><a target="_blank" rel="noopener" href="/sag/ ${ r . id } /v3">SAG- ${ r . id } · ${ esc ( r . titel ) } </a><small> ${ esc ( r . customer _name || '' ) } · ${ esc ( r . status ) } <span class=" ${ r . deadline && new Date ( r . deadline ) < new Date ( ) ? 'text-danger' : '' } "> ${ r . deadline ? esc ( new Date ( r . deadline ) . toLocaleString ( 'da-DK' ) ) : '' } </span></small></div> ` ) . join ( '' ) || '<p class="text-success">Ingen åbne sager.</p>' } ${ data . total > 10 ? ` <a href="/sag?ansvarlig_bruger_id= ${ id } " target="_blank" rel="noopener">Se alle ${ data . total } sager</a> ` : '' } </details> ` ;
} catch { if ( token === workloadToken ) $ ( 'cc-workload' ) . textContent = 'Kunne ikke hente åbne sager.' ; }
}
2026-09-20 03:33:10 +02:00
function typeChanged ( type , showTypeFields = false ) {
if ( type === 'intern_ticket' ) {
selectedCustomer = { id : 8 , name : 'BMC Networks' } ;
$ ( 'customer_id' ) . value = '8' ;
$ ( 'customerSearch' ) . value = 'BMC Networks' ;
const creatorId = Number ( window . caseCreateCurrentUserId || 0 ) ;
if ( creatorId && Array . from ( $ ( 'ansvarlig_bruger_id' ) . options ) . some ( option => Number ( option . value ) === creatorId ) ) {
$ ( 'ansvarlig_bruger_id' ) . value = String ( creatorId ) ;
}
window . renderSelections ? . ( ) ;
}
captureTypeDetails ( ) ;
renderTypeDetails ( type ) ;
$ ( 'createForm' ) . closest ( '.card-custom' ) ? . classList . toggle ( 'cc-postit-mode' , type === 'postit' ) ;
state . panels [ 'type-details' ] ? . classList . toggle ( 'cc-postit-panel' , type === 'postit' ) ;
2026-09-08 01:55:47 +02:00
document . querySelectorAll ( '[data-case-type]' ) . forEach ( b => { const active = b . dataset . caseType === type ; b . classList . toggle ( 'active' , active ) ; b . setAttribute ( 'aria-pressed' , String ( active ) ) ; } ) ;
2026-09-20 03:33:10 +02:00
const visiblePanels = {
ticket : [ 'relations' , 'type-details' , 'hardware' ] ,
salg : [ 'relations' , 'type-details' , 'pipeline' , 'order' ] ,
bogholderi : [ 'relations' , 'type-details' , 'order' ] ,
admin : [ 'type-details' ] ,
projekt : [ 'relations' , 'type-details' ] ,
postit : [ 'type-details' ] ,
intern _ticket : [ 'type-details' ] ,
bug _report : [ 'relations' , 'type-details' , 'hardware' ] ,
service : [ 'relations' , 'hardware' ] ,
pipeline : [ 'relations' , 'pipeline' ] ,
ordre : [ 'relations' , 'order' ] ,
opgave : [ 'relations' ] ,
abonnement : [ 'relations' ] ,
} [ type ] || [ 'relations' ] ;
[ 'relations' , 'type-details' , 'hardware' , 'pipeline' , 'order' ] . forEach ( key => state . panels [ key ] ? . classList . toggle ( 'd-none' , ! showTypeFields || ! visiblePanels . includes ( key ) ) ) ;
const sectionIds = { hardware : 'hardwareSection' , pipeline : 'pipelineSection' , order : 'orderSection' } ;
Object . entries ( sectionIds ) . forEach ( ( [ key , id ] ) => $ ( id ) ? . classList . toggle ( 'd-none' , ! showTypeFields || ! visiblePanels . includes ( key ) ) ) ;
document . querySelectorAll ( '.case-type-extra' ) . forEach ( el => el . classList . toggle ( 'd-none' , ! showTypeFields ) ) ;
$ ( 'cc-template' ) ? . closest ( '.cc-template-toolbar' ) ? . classList . toggle ( 'd-none' , ! showTypeFields ) ;
if ( showTypeFields && ! state . restoring ) visiblePanels . forEach ( panelKey => { if ( state . panels [ panelKey ] ) state . panels [ panelKey ] . open = true ; } ) ;
2026-09-08 01:55:47 +02:00
if ( $ ( 'cc-more' ) ) $ ( 'cc-more' ) . open = Boolean ( $ ( 'cc-more' ) . querySelector ( ` [data-case-type=" ${ CSS . escape ( type ) } "] ` ) ) ;
scheduleSave ( ) ;
}
2026-09-20 03:33:10 +02:00
const typeFieldSets = {
ticket : [
[ 'anydesk_id' , 'AnyDesk-ID' , 'text' , '123-456-789' ] ,
[ 'hardware_information' , 'Hardwareinformation' , 'textarea' , 'Enhed, model, serienummer eller fejl' ] ,
[ 'solution' , 'Løsning' , 'textarea' , 'Den aftalte eller udførte løsning' ] ,
] ,
salg : [
[ 'customer_need' , 'Kundebehov' , 'textarea' , 'Hvad ønsker kunden?' ] ,
[ 'expected_close_date' , 'Forventet lukkedato' , 'date' , '' ] ,
] ,
bogholderi : [
[ 'voucher_reference' , 'Bilags- eller fakturanummer' , 'text' , 'F.eks. F-12345' ] ,
[ 'supplier' , 'Leverandør' , 'text' , 'Leverandørnavn' ] ,
[ 'due_date' , 'Forfaldsdato' , 'date' , '' ] ,
] ,
admin : [
[ 'area' , 'Område eller system' , 'text' , 'F.eks. brugeradministration' ] ,
[ 'requested_action' , 'Ønsket handling' , 'textarea' , 'Hvad skal ændres eller undersøges?' ] ,
] ,
projekt : [
[ 'project_goal' , 'Projektmål' , 'textarea' , 'Hvad skal projektet levere?' ] ,
[ 'start_date' , 'Startdato' , 'date' , '' ] ,
[ 'end_date' , 'Slutdato' , 'date' , '' ] ,
] ,
postit : [
[ 'note' , 'Notat' , 'textarea' , 'Kort huskeseddel' ] ,
[ 'remind_date' , 'Påmindelsesdato' , 'date' , '' ] ,
] ,
intern _ticket : [
[ 'system' , 'System' , 'text' , 'Berørt system eller modul' ] ,
[ 'purpose' , 'Formål' , 'textarea' , 'Hvad skal den interne opgave føre til?' ] ,
[ 'next_step' , 'Næste skridt' , 'textarea' , 'Hvad skal der ske nu?' ] ,
] ,
bug _report : [
[ 'affected_area' , 'Berørt område' , 'text' , 'System, side eller modul' ] ,
[ 'steps_to_reproduce' , 'Trin til at genskabe' , 'textarea' , 'Beskriv trin for trin hvordan fejlen opstår' ] ,
[ 'expected_result' , 'Forventet resultat' , 'textarea' , 'Hvad skulle der være sket?' ] ,
[ 'actual_result' , 'Faktisk resultat' , 'textarea' , 'Hvad skete der i stedet?' ] ,
] ,
} ;
function captureTypeDetails ( ) {
const currentType = $ ( 'type' ) ? . value ;
if ( ! currentType ) return ;
const values = { } ;
document . querySelectorAll ( '[data-case-detail]' ) . forEach ( field => { values [ field . dataset . caseDetail ] = field . value ; } ) ;
if ( Object . keys ( values ) . length ) state . typeDetails [ currentType ] = values ;
}
function renderTypeDetails ( type ) {
const section = $ ( 'typeDetailsSection' ) , box = $ ( 'typeDetailsFields' ) ;
if ( ! section || ! box ) return ;
const fields = typeFieldSets [ type ] || [ ] ;
const values = state . typeDetails [ type ] || { } ;
box . innerHTML = fields . map ( ( [ key , label , kind , placeholder ] ) => ` <div class="mb-3"><label class="form-label" for="case-detail- ${ esc ( key ) } "> ${ esc ( label ) } </label> ${ kind === 'textarea' ? ` <textarea class="form-control" rows="3" id="case-detail- ${ esc ( key ) } " data-case-detail=" ${ esc ( key ) } " placeholder=" ${ esc ( placeholder ) } "> ${ esc ( values [ key ] || '' ) } </textarea> ` : ` <input class="form-control" type=" ${ esc ( kind ) } " id="case-detail- ${ esc ( key ) } " data-case-detail=" ${ esc ( key ) } " value=" ${ esc ( values [ key ] || '' ) } " placeholder=" ${ esc ( placeholder ) } "> ` } </div> ` ) . join ( '' ) ;
section . classList . toggle ( 'd-none' , ! fields . length ) ;
}
2026-09-08 01:55:47 +02:00
function buildTypes ( ) {
const options = Array . from ( $ ( 'type' ) . options ) , current = $ ( 'type' ) . value ;
2026-09-20 03:33:10 +02:00
const keys = [ ... new Set ( [ current , ... options . map ( o => o . value ) ] ) ] . filter ( k => options . some ( o => o . value === k ) ) ;
const hints = { ticket : 'Support og hjælp' , salg : 'Tilbud og salg' , bogholderi : 'Bilag og økonomi' , admin : 'Intern administration' , projekt : 'Større forløb' , postit : 'Huskeseddel' , intern _ticket : 'Intern opgave' , bug _report : 'Rapportér en fejl' } ;
2026-09-08 01:55:47 +02:00
const button = key => ` <button type="button" class="btn btn-outline-primary" data-case-type=" ${ esc ( key ) } " aria-pressed="false"><span class="cc-type-name"> ${ esc ( options . find ( o => o . value === key ) . text ) } </span><small class="cc-type-hint"> ${ esc ( hints [ key ] || 'Opret sag' ) } </small></button> ` ;
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 ? ` <details id="cc-more"><summary>Flere</summary><div class="cc-types"> ${ keys . slice ( 4 ) . map ( button ) . join ( '' ) } </div></details> ` : '' ) ;
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 ) ; }
2026-09-20 03:33:10 +02:00
window . updateCaseTypeSections ? . ( true ) ; $ ( 'charCount' ) . textContent = ` ${ $ ( 'beskrivelse' ) . value . length } tegn ` ; scheduleSave ( ) ; scheduleDuplicates ( ) ;
2026-09-08 01:55:47 +02:00
}
async function restore ( draft ) {
state . restoring = true ;
try {
2026-09-20 03:33:10 +02:00
selectedCustomer = draft . customer ; selectedContacts = draft . contacts || { } ; selectedContactsCompanies = draft . contactCompanies || { } ; state . typeDetails = draft . typeDetails || { } ;
2026-09-08 01:55:47 +02:00
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 || '' ;
2026-09-20 03:33:10 +02:00
window . updateCaseTypeSections ? . ( true ) ; Object . entries ( draft . panels || { } ) . forEach ( ( [ k , v ] ) => { if ( state . panels [ k ] ) state . panels [ k ] . open = v ; } ) ;
2026-09-08 01:55:47 +02:00
$ ( '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' , ` <div class="text-danger small cc-error"> ${ esc ( text ) } </div> ` ) ; const detail = el . closest ( 'details' ) ; if ( detail ) { detail . open = true ; if ( ! detail . querySelector ( 'summary .cc-error' ) ) detail . querySelector ( 'summary' ) . insertAdjacentHTML ( 'beforeend' , '<span class="text-danger small cc-error"> · Kontrollér felter</span>' ) ; } } ;
if ( ! $ ( 'titel' ) . value . trim ( ) ) add ( $ ( 'titel' ) , 'Titel er påkrævet' ) ;
2026-09-20 03:33:10 +02:00
if ( [ 'ticket' , 'salg' , 'bogholderi' , 'projekt' , 'service' , 'pipeline' , 'ordre' , 'opgave' , 'abonnement' ] . includes ( $ ( 'type' ) . value ) && ! selectedCustomer && $ ( 'customerSearch' ) . value . trim ( ) . length < 2 ) add ( $ ( 'customerSearch' ) , 'Vælg et firma' ) ;
2026-09-08 01:55:47 +02:00
$ ( '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' ) ) ;
2026-09-20 03:33:10 +02:00
panel ( 'relations' , 'Kunde og kontakter' , [ headings [ 0 ] , headings [ 0 ] . nextElementSibling ] , false ) ;
for ( const [ key , id , title ] of [ [ 'type-details' , 'typeDetailsSection' , 'Oplysninger til sagstypen' ] , [ 'hardware' , 'hardwareSection' , 'Hardware og AnyDesk' ] , [ 'pipeline' , 'pipelineSection' , 'Pipeline' ] , [ 'order' , 'orderSection' , 'Indkøb og salg' ] ] ) panel ( key , title , [ $ ( id ) ] ) ;
const metadata = headings [ 1 ] ; panel ( 'metadata' , 'Ansvarlig' , [ metadata , metadata . nextElementSibling , metadata . nextElementSibling . nextElementSibling ] , true ) ;
const tags = document . createElement ( 'section' ) ; tags . innerHTML = '<div id="cc-tag-chips"></div><button type="button" id="cc-tag-add" class="btn btn-sm btn-outline-primary">Tilføj tag</button><div id="cc-tag-suggestions" class="cc-tag-suggestions"></div>' ; state . panels . hardware . before ( tags ) ; panel ( 'tags' , 'Tags' , [ tags ] , true ) ;
2026-09-08 01:55:47 +02:00
$ ( '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 = '<label class="form-label" for="cc-template">Hurtigskabelon</label><select id="cc-template" class="form-select"><option value="">Start uden skabelon</option></select><small id="cc-template-error" class="text-muted"></small>' ; form . prepend ( toolbar ) ;
try { templates = await api ( '/api/v1/case-create/templates' ) ; $ ( 'cc-template' ) . insertAdjacentHTML ( 'beforeend' , templates . map ( t => ` <option value=" ${ t . id } "> ${ esc ( t . name ) } </option> ` ) . 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' , '<small id="cc-save" role="status" class="me-auto align-self-center">Klargør kladde…</small>' ) ;
toolbar . classList . add ( 'cc-template-toolbar' ) ;
2026-09-20 03:33:10 +02:00
buildTypes ( ) ; updateCaseTypeSections ( false ) ;
2026-09-08 01:55:47 +02:00
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. <button type="button" class="btn btn-sm btn-primary" id="cc-restore">Gendan kladde</button> <button type="button" class="btn btn-sm btn-outline-secondary" id="cc-discard">Kassér</button>' ; 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 ( ) ;
}
2026-09-20 03:33:10 +02:00
window . caseCreateUI = { init , typeChanged , contactsChanged : contactOpenCases , validate , openMessage : ( ) => { const contacts = Object . values ( selectedContacts ) ; window . openInternalMessage ( { contact : contacts . length === 1 ? contacts [ 0 ] : null } ) ; } , relations : ( ) => { captureTypeDetails ( ) ; return { tag _ids : state . tags . map ( t => t . id ) , hardware _ids : state . hardware . map ( h => h . id ) , type _details : state . typeDetails [ $ ( 'type' ) . value ] || { } } ; } , created : result => {
2026-09-08 01:55:47 +02:00
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. */ }
} } ;
} ) ( ) ;