bmc_hub/app/modules/sag/templates/create.html

1591 lines
82 KiB
HTML
Raw Normal View History

{% extends "shared/frontend/base.html" %}
{% block title %}Ny Sag - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.cc-panel { border: 1px solid var(--border-color, #dce3eb); border-radius: 10px; margin: 0 0 12px; background: var(--bg-surface, #fff); }
.cc-panel > summary { padding: 14px 16px; cursor: pointer; font-weight: 600; }
.cc-summary { display: block; color: var(--text-secondary, #65758a); font-weight: 400; margin-left: 18px; overflow-wrap: anywhere; }
.cc-panel-body { padding: 8px 16px 16px; }
.cc-panel-body > h5, .cc-panel-body > section > h5, .cc-panel-body > section > hr, #createForm > hr { display: none; }
.cc-types { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.cc-types .btn { min-height: 44px; }
#cc-more > summary { padding: 10px; cursor: pointer; }
.cc-actions { position: sticky; bottom: 55px; z-index: 1020; padding: 16px; border: 1px solid var(--border-color, #dce3eb); border-radius: 10px; background: var(--bg-surface, #fff); box-shadow: 0 -4px 18px #0001; flex-wrap: wrap; }
.cc-workload { margin: 12px 0; padding: 12px; border-radius: 8px; background: var(--bg-hover, #f5f7fa); }
.cc-workload summary { cursor: pointer; }
.cc-work-item { padding: 8px 0; border-bottom: 1px solid var(--border-color, #dce3eb); }
.cc-work-item small { display: block; }
[data-bs-theme="dark"] #createForm .bg-light { background: var(--bg-surface) !important; color: var(--text-primary); }
@media(max-width: 600px) { .cc-actions { bottom: 55px; padding: 10px; gap: 8px !important; } .cc-actions #cc-save { width: 100%; } .cc-actions .btn { padding: 10px 16px !important; } .cc-panel-body { padding: 8px 12px; } }
/* Gradient Header for the Card */
.card-header-custom {
background: linear-gradient(135deg, var(--bmc-blue, #0f4c75) 0%, #3282b8 100%);
color: white;
padding: 1.5rem;
border-top-left-radius: 12px;
border-top-right-radius: 12px;
}
.card-custom {
border: none;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.08);
overflow: visible; /* Changed from hidden to visible for shadows/tooltips */
}
.form-label {
font-weight: 600;
color: var(--text-primary);
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.form-control:focus, .form-select:focus {
border-color: #3282b8;
box-shadow: 0 0 0 0.25rem rgba(50, 130, 184, 0.25);
}
/* Search Results Dropdown */
.search-position-relative {
position: relative;
}
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-surface, #ffffff);
border: 1px solid var(--border-color, rgba(0,0,0,0.1));
border-radius: 8px;
max-height: 250px;
overflow-y: auto;
z-index: 1050;
margin-top: 5px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
}
.search-result-item {
padding: 10px 15px;
border-bottom: 1px solid var(--border-color, rgba(0,0,0,0.05));
cursor: pointer;
transition: background-color 0.15s ease;
}
.search-result-item:hover {
background-color: var(--bg-hover, #f8f9fa);
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-name {
font-weight: 600;
color: var(--text-primary);
font-size: 0.95rem;
}
.search-result-meta {
font-size: 0.8rem;
color: var(--text-secondary, #6c757d);
}
/* Selected Items (Tags) */
.selected-item {
display: inline-flex;
align-items: center;
background-color: #e3f2fd;
color: #0f4c75;
padding: 6px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
margin-right: 8px;
margin-bottom: 8px;
border: 1px solid #bbdefb;
}
.selected-item button {
background: none;
border: none;
color: #0f4c75;
margin-left: 8px;
padding: 0;
line-height: 1;
font-size: 1.1rem;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s;
}
.selected-item button:hover {
opacity: 1;
}
/* Dark Mode Adjustments */
[data-bs-theme="dark"] .card-custom {
background-color: var(--bg-surface);
}
[data-bs-theme="dark"] .selected-item {
background-color: rgba(50, 130, 184, 0.2);
color: #a6d5fa;
border-color: rgba(50, 130, 184, 0.4);
}
[data-bs-theme="dark"] .selected-item button {
color: #a6d5fa;
}
.case-top-alerts .alert {
border-left: 6px solid;
}
.case-top-alerts .alert-warning {
border-left-color: #f59f00;
}
.case-top-alerts .alert-danger {
border-left-color: #e03131;
}
.cc-page { max-width: 1440px; padding-bottom: 100px !important; }
.cc-pagebar { display:flex; justify-content:space-between; align-items:center; gap:16px; margin:0 0 20px; }
.cc-pagebar > a { color:var(--text-secondary,#63758c); text-decoration:none; font-size:.88rem; }
.cc-pagebar .btn { border-radius:12px; font-size:.85rem; padding:10px 16px; }
.cc-page .card-custom { border:1px solid var(--border-color,#dce5ed); border-radius:22px; box-shadow:0 16px 48px #143b5610; }
.cc-page .card-header-custom { display:flex; justify-content:space-between; align-items:center; gap:20px; padding:34px 36px; border-radius:21px 21px 0 0; background:linear-gradient(115deg,#122f46,#164b62); }
.cc-eyebrow { display:block; color:#9ed7de; font-size:.66rem; font-weight:700; letter-spacing:.15em; margin-bottom:10px; }
.cc-page h1 { font-size:1.9rem; font-weight:700; letter-spacing:-.04em; margin-bottom:10px; }
.cc-page .card-header-custom p { color:#c5dce6; font-size:.9rem; max-width:520px; line-height:1.6; }
.cc-hero-icon { display:grid; place-items:center; flex-shrink:0; width:72px; height:72px; border-radius:22px; background:#ffffff0d; border:1px solid #ffffff25; font-size:2rem; color:#a9e4df; }
.cc-page .card-custom > .card-body { padding:32px 42px !important; }
.cc-page .form-control,.cc-page .form-select { border-radius:10px; min-height:43px; background-color:var(--bg-surface,#fff); border-color:var(--border-color,#dbe4ec); }
.cc-page .input-group-text { border-radius:10px 0 0 10px; background:transparent !important; color:#7891a5; }
.cc-page .input-group .form-control { border-top-left-radius:0; border-bottom-left-radius:0; }
.cc-page textarea.form-control { line-height:1.7; padding:14px 16px; }
.cc-page .form-label { font-size:.82rem; }
.cc-page .form-text { font-size:.75rem; line-height:1.5; }
.cc-page .cc-type-section > label { font-size:.72rem; text-transform:uppercase; letter-spacing:.08em; color:var(--text-secondary,#63758c); }
.cc-page #cc-types { align-items:stretch; gap:10px; }
.cc-page #cc-types > button { flex:1; border:1px solid var(--border-color,#dbe4ec); color:var(--text-primary,#26465d); border-radius:13px; text-align:left; padding:14px; background:var(--bg-surface,#fff); transition:border-color .15s,background .15s; }
.cc-type-name { display:block; font-size:.9rem; font-weight:600; }
.cc-type-hint { display:block; color:var(--text-secondary,#708397); margin-top:5px; font-size:.7rem; font-weight:400; }
.cc-page #cc-types > button.active { border-color:#258783; background:var(--accent-light,#eaf6f4); box-shadow:0 0 0 1px #258783; }
.cc-page #cc-types > .cc-message-type { border-style:dashed; border-color:#86aaa8; background:color-mix(in srgb,var(--accent-light,#eaf6f4) 62%,var(--bg-surface,#fff)); }
.cc-page #cc-types > .cc-message-type:hover { border-style:solid; background:var(--accent-light,#eaf6f4); }
.cc-message-type .cc-type-name { display:flex; align-items:center; gap:7px; }
.cc-message-type .cc-type-name::after { content:'INTERN'; padding:2px 5px; border-radius:4px; background:#267b74; color:#fff; font-size:.52rem; font-weight:700; letter-spacing:.08em; }
.cc-message-fallback { display:flex; width:100%; margin-top:12px; text-align:left; padding:14px; border-radius:13px; }
.cc-page #cc-types > button:hover { border-color:#258783; }
.cc-page #cc-more { flex-basis:100%; font-size:.78rem; }
.cc-page #cc-more > summary { padding:4px 0; color:var(--text-secondary,#63758c); }
.cc-page #cc-more .cc-types { padding-top:10px; }
.cc-page .cc-panel { border-radius:13px; margin-bottom:12px; }
.cc-page .cc-panel > summary { display:flex; align-items:center; gap:12px; list-style:none; padding:15px 17px; }
.cc-page .cc-panel > summary::-webkit-details-marker { display:none; }
.cc-page .cc-panel > summary::after { content:'⌄'; margin-left:auto; color:#8196a8; transition:transform .15s; }
.cc-page .cc-panel[open] > summary::after { transform:rotate(180deg); }
.cc-panel-icon { display:grid; place-items:center; flex-shrink:0; width:35px; height:35px; border-radius:10px; color:#3e777e; background:var(--accent-light,#eef5f6); }
.cc-panel-label { min-width:0; font-size:.86rem; }
.cc-page .cc-summary { margin:3px 0 0; font-size:.72rem; }
.cc-page .cc-panel[open] { border-color:#a7c5cc; }
.cc-page .cc-panel-body { padding:8px 18px 18px; }
.cc-page .cc-actions { border-radius:14px; box-shadow:0 8px 30px #143b561c; margin-top:28px !important; }
.cc-page .cc-actions .btn { border-radius:10px; }
.cc-page #submitBtn { background:#176967; border-color:#176967; }
.cc-page #cc-save { color:var(--text-secondary,#63758c); font-size:.73rem; }
.cc-template-toolbar { display:flex; align-items:center; gap:12px; flex-wrap:wrap; padding-bottom:20px; margin-bottom:20px !important; border-bottom:1px solid var(--border-color,#e3eaf0); }
.cc-template-toolbar .form-label { margin:0; }
.cc-template-toolbar .form-select { flex:1; width:auto; max-width:320px; min-width:180px; font-size:.83rem; }
.cc-page .cc-workload { background:var(--bg-hover,#f5f8fa); border:1px solid var(--border-color,#e3eaf0); font-size:.8rem; }
.cc-contact-cases { margin-top:14px; padding:0; border:1px solid #bdd9da; border-radius:12px; background:var(--bg-surface,#fff); overflow:hidden; font-size:.8rem; box-shadow:0 4px 14px #164b620c; }
.cc-contact-cases[hidden] { display:none; }
.cc-contact-cases__header { display:flex; align-items:center; gap:10px; padding:11px 13px; background:#edf8f7; color:#195f5e; }
.cc-contact-cases__header-icon { display:grid; place-items:center; width:29px; height:29px; border-radius:8px; background:#cfeceb; font-size:.85rem; }
.cc-contact-cases__eyebrow { display:block; color:#4d8382; font-size:.62rem; font-weight:700; letter-spacing:.07em; text-transform:uppercase; }
.cc-contact-cases__title { display:block; font-weight:700; line-height:1.25; }
.cc-contact-cases__body { padding:6px 13px 10px; }
.cc-contact-cases__group { padding-top:8px; }
.cc-contact-cases__group-name { display:block; color:var(--text-secondary,#65758a); font-size:.7rem; font-weight:600; margin:0 0 4px; }
.cc-contact-cases__item { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:9px 0; border-bottom:1px solid var(--border-color,#e6eff0); }
.cc-contact-cases__item:last-child { border-bottom:0; }
.cc-contact-cases__item a { color:var(--text-primary,#213547); font-weight:600; text-decoration:none; }
.cc-contact-cases__item a:hover { color:#176967; text-decoration:underline; }
.cc-contact-cases__item small { color:var(--text-secondary,#65758a); white-space:nowrap; font-size:.69rem; }
.cc-contact-cases__more { display:block; padding-top:6px; color:var(--text-secondary,#65758a); font-size:.69rem; }
.cc-contact-cases__empty { padding:11px 13px; color:var(--text-secondary,#65758a); }
.cc-tag-suggestions { margin:10px 0 4px; padding-top:10px; border-top:1px solid var(--border-color,#e3eaf0); }
.cc-tag-suggestions__label { color:var(--text-secondary,#65758a); font-size:.7rem; font-weight:600; margin-bottom:6px; }
.cc-tag-suggestions .btn { border-radius:999px; font-size:.72rem; padding:3px 8px; }
[data-bs-theme="dark"] .cc-contact-cases { background:var(--bg-surface); border-color:#326b6b; }
[data-bs-theme="dark"] .cc-contact-cases__header { background:#173e3e; color:#c9edeb; }
@media(min-width:1200px) {
.cc-page #cc-relations .row { grid-template-columns:minmax(0, 1fr) minmax(0, 1fr); display:grid; gap:28px !important; }
.cc-page #cc-relations .row > [class*="col-"] { width:auto; max-width:none; padding:0; }
.cc-page .cc-panel-body { padding:14px 24px 24px; }
.cc-contact-cases__item { display:grid; grid-template-columns:minmax(0, 1fr) auto; column-gap:14px; align-items:center; }
.cc-contact-cases__item small { text-align:right; margin:0; }
}
@media(max-width:600px) { .cc-page .card-header-custom { padding:25px 20px; } .cc-hero-icon { display:none; } .cc-page h1 { font-size:1.6rem; } .cc-page .card-custom > .card-body { padding:20px 16px !important; } .cc-page #cc-types > button { flex:1 1 40%; } .cc-pagebar { align-items:flex-start; } .cc-pagebar .btn { max-width:190px; } }
/* Calm, task-first layout: the form is the product, not the decoration. */
.cc-page { max-width:1240px; }
.cc-page .card-custom { border-radius:16px; box-shadow:0 8px 28px #143b560d; }
.cc-page .card-header-custom { min-height:auto; padding:20px 28px; border-radius:15px 15px 0 0; background:var(--bg-surface,#fff); color:var(--text-primary,#213547); border-bottom:1px solid var(--border-color,#dce5ed); }
.cc-page .card-header-custom p { display:none; }
.cc-page .card-header-custom .cc-eyebrow { color:#267b74; margin:0 0 4px; font-size:.59rem; }
.cc-page .card-header-custom h1 { margin:0; font-size:1.35rem; letter-spacing:-.02em; color:inherit; }
.cc-page .cc-hero-icon { width:42px; height:42px; border-radius:12px; font-size:1.15rem; color:#267b74; background:var(--accent-light,#eaf6f4); border:0; }
.cc-page .card-custom > .card-body { padding:24px 28px 32px !important; }
.cc-template-toolbar { padding:12px 14px; margin:0 0 16px !important; border:1px solid var(--border-color,#e3eaf0); border-radius:10px; background:var(--bg-hover,#f7fafb); }
.cc-template-toolbar .form-label { font-weight:600; font-size:.76rem; }
.cc-template-toolbar .form-select { min-height:36px; max-width:280px; font-size:.78rem; }
.cc-page .cc-type-section { padding:0 0 16px; margin-bottom:16px !important; border-bottom:1px solid var(--border-color,#e3eaf0); }
.cc-page .cc-type-section > label { display:block; margin-bottom:8px !important; text-transform:none; letter-spacing:0; color:var(--text-primary,#213547); font-weight:600; font-size:.82rem; }
.cc-page #caseTypeHelp { display:none; }
.cc-page #cc-types { gap:7px; }
.cc-page #cc-types > button { flex:0 1 auto; min-height:0; padding:8px 11px; border-radius:9px; font-size:.78rem; }
.cc-page .cc-type-name { display:inline; font-size:.78rem; }
.cc-page .cc-type-hint { display:none; }
.cc-page #cc-types > button.active { box-shadow:none; background:#176967; border-color:#176967; color:#fff; }
.cc-page #cc-types > button.active .cc-type-name::after { background:#fff3; color:#fff; }
.cc-page #cc-types > .cc-message-type { margin-left:auto; border-style:solid; background:transparent; }
.cc-page #cc-more { flex-basis:auto; }
.cc-page #cc-more > summary { padding:8px 4px; }
.cc-page .cc-panel { border-radius:10px; margin-bottom:8px; box-shadow:none; }
.cc-page .cc-panel > summary { padding:12px 14px; }
.cc-panel-icon { width:30px; height:30px; border-radius:8px; font-size:.82rem; }
.cc-page .cc-panel-label { font-size:.8rem; }
.cc-page .cc-summary { display:inline; margin-left:7px; font-size:.69rem; }
.cc-page .cc-panel-body { padding:8px 14px 16px; }
.cc-page .cc-panel-body > .row { margin-bottom:0 !important; }
.cc-page #cc-relations[open] { border-color:#9dc7c3; }
.cc-page #cc-metadata { margin-top:16px; }
.cc-page #cc-workload { margin:8px 0 16px; padding:10px 14px; }
.cc-page .cc-actions { margin-top:20px !important; padding:12px 14px; }
/* The panels have very different heights. Keep them in a single stable flow
instead of pretending they are equal dashboard cards. */
@media(min-width:992px) {
.cc-page .cc-panel:not(#cc-relations):not(#cc-metadata) { width:100%; display:block; margin-right:0; }
}
@media(max-width:700px) {
.cc-page .card-header-custom { padding:16px 18px; }
.cc-page .card-custom > .card-body { padding:18px 14px 28px !important; }
.cc-page #cc-types > .cc-message-type { margin-left:0; }
}
</style>
{% endblock %}
{% block content %}
<div class="container py-4 cc-page">
<div class="cc-pagebar"><a href="/sag"><i class="bi bi-arrow-left me-2"></i>Alle sager</a></div>
<div class="row justify-content-center">
<div class="col-12">
<!-- Main Card -->
<div class="card card-custom">
<div class="card-header-custom">
<div><span class="cc-eyebrow">ET GODT STED AT STARTE</span><h1>Opret en ny sag</h1><p class="mb-0">Saml opgaven, vælg de rette personer, og tilføj det, du har brug for.</p></div>
<span class="cc-hero-icon" aria-hidden="true"><i class="bi bi-folder-plus"></i></span>
</div>
<div class="card-body p-4">
<div id="caseTopAlerts" class="case-top-alerts d-none mb-3"></div>
<!-- Notifications -->
<div id="error" class="alert alert-danger d-none shadow-sm" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i><span id="error-text"></span>
</div>
<div id="success" class="alert alert-success d-none shadow-sm" role="alert">
<i class="bi bi-check-circle-fill me-2"></i><span id="success-text"></span>
</div>
<form id="createForm" novalidate>
<div class="mb-4 cc-type-section">
<label for="type" class="form-label mb-2">Hvilken type sag vil du oprette?</label>
<select class="form-select form-select-lg" id="type" required></select>
<div class="form-text" id="caseTypeHelp">Vælg sagstype for at vise de relevante felter.</div>
<button type="button" id="caseMessageTypeButton" class="btn btn-outline-primary cc-message-type cc-message-fallback" onclick="window.caseCreateUI?.openMessage?.() || window.openInternalMessage?.()">
<span class="cc-type-name"><i class="bi bi-chat-square-text"></i>Besked til kollega</span>
<small class="cc-type-hint">Kort besked eller telefonbesked</small>
</button>
</div>
<!-- Section: Relations -->
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Relationer</h5>
<div class="row g-4 mb-4">
<!-- Contact Search -->
<div class="col-md-6">
<label class="form-label">Kontaktpersoner</label>
<div class="search-position-relative">
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-person-plus"></i></span>
<input type="text" id="contactSearch" class="form-control border-start-0 ps-0" placeholder="Søg kontakt...">
</div>
<div id="contactResults" class="search-results shadow-sm d-none"></div>
</div>
2026-08-18 20:57:49 +02:00
<div id="contactSearchContext" class="form-text">Søg blandt alle kontakter.</div>
<div id="selectedContacts" class="mt-2 text-wrap"></div>
</div>
<!-- Company Search -->
<div class="col-md-6">
<label class="form-label">Firma</label>
<div class="search-position-relative">
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-building"></i></span>
<input type="text" id="customerSearch" class="form-control border-start-0 ps-0" placeholder="Søg firma (min. 2 tegn)...">
</div>
<div id="customerResults" class="search-results shadow-sm d-none"></div>
</div>
<div id="selectedCustomer" class="mt-2 min-vh-20"></div>
<input type="hidden" id="customer_id" name="customer_id">
</div>
</div>
<div id="internetConnectionPrefill" class="alert alert-info d-none mb-4" role="status"></div>
<hr class="my-4 opacity-25">
<!-- Section: Basic Info -->
<div class="row g-4 mb-4">
<div class="col-md-12">
<label for="titel" class="form-label">Titel <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-type-h1"></i></span>
<input type="text" class="form-control border-start-0 ps-0" id="titel" placeholder="Kort og præcis titel (f.eks. 'Netværksproblemer hos X')" required>
</div>
</div>
<div class="col-md-12">
<div class="d-flex justify-content-between align-items-center mb-2">
<label for="beskrivelse" class="form-label mb-0">Beskrivelse</label>
<button type="button" id="caseCreateRewriteBtn" class="btn btn-sm btn-outline-primary" title="Renskriv kun det, du allerede har skrevet">
<i class="bi bi-magic me-1"></i>AI renskriv
</button>
</div>
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..."></textarea>
<div class="d-flex justify-content-between form-text">
<span>AI retter kun sprog og foreslår en titel ud fra din tekst. Den må ikke tilføje oplysninger.</span>
<span id="charCount">0 tegn</span>
</div>
</div>
</div>
<hr class="my-4 opacity-25">
<!-- Section: Hardware & AnyDesk -->
<section id="hardwareSection">
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
<div class="row g-4 mb-4">
<div class="col-12">
<div id="hardwareList" class="border rounded-3 p-3 bg-light">
<div class="text-muted small">Vælg en kontakt for at se relateret hardware.</div>
</div>
</div>
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h6 class="fw-bold mb-3">Quick opret hardware</h6>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Navn *</label>
<input type="text" class="form-control" id="hardwareNameInput" placeholder="PC, NAS, Server...">
</div>
<div class="col-md-4">
<label class="form-label">AnyDesk ID</label>
<input type="text" class="form-control" id="hardwareAnyDeskIdInput" placeholder="123-456-789">
<div class="form-text small text-muted">Link genereres automatisk.</div>
</div>
<div class="col-12 d-flex justify-content-end">
<button type="button" class="btn btn-outline-primary" onclick="quickCreateHardware()">
<i class="bi bi-plus-circle me-2"></i>Opret hardware
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section id="pipelineSection" class="d-none">
<hr class="my-4 opacity-25">
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Pipeline</h5>
<div class="row g-4 mb-4">
<div class="col-md-4"><label class="form-label">Stage</label><select id="pipeline_stage_id" class="form-select"><option value="">Ikke sat</option></select></div>
<div class="col-md-4"><label class="form-label">Beløb</label><input id="pipeline_amount" type="number" min="0" step="0.01" class="form-control" placeholder="0,00"></div>
<div class="col-md-4"><label class="form-label">Sandsynlighed (%)</label><input id="pipeline_probability" type="number" min="0" max="100" step="1" class="form-control" placeholder="0-100"></div>
<div class="col-12"><label class="form-label">Pipelinebeskrivelse</label><textarea id="pipeline_description" class="form-control" rows="3" placeholder="Næste skridt, tilbud eller forventning..."></textarea></div>
</div>
</section>
<section id="orderSection" class="d-none">
<hr class="my-4 opacity-25">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0 text-muted fw-bold small text-uppercase">Indkøb og salg</h5>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-primary" onclick="addOrderLine('sale')"><i class="bi bi-plus-lg me-1"></i>Salgslinje</button>
<button type="button" class="btn btn-outline-secondary" onclick="addOrderLine('purchase')"><i class="bi bi-plus-lg me-1"></i>Indkøbslinje</button>
</div>
</div>
<div id="orderLines" class="vstack gap-3 mb-4"></div>
<div id="orderLinesEmpty" class="text-muted small border rounded-3 p-3">Tilføj en indkøbs- eller salgslinje efter behov.</div>
</section>
<hr class="my-4 opacity-25">
<!-- Section: Metadata -->
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Type, Status & Ansvar</h5>
<div class="row g-4 mb-4">
<div class="col-md-4">
<label for="status" class="form-label">Status <span class="text-danger">*</span></label>
<select class="form-select" id="status" required>
<option value="åben" selected>🟢 Åben</option>
<option value="afventer">🟡 Afventer</option>
<option value="lukket">🔴 Lukket</option>
</select>
</div>
<div class="col-md-4">
<label for="ansvarlig_bruger_id" class="form-label">Ansvarlig medarbejder</label>
<select class="form-select" id="ansvarlig_bruger_id">
<option value="">Ingen</option>
{% for user in assignment_users or [] %}
<option value="{{ user.user_id }}">{{ user.display_name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label for="assigned_group_id" class="form-label">Ansvarlig gruppe</label>
<select class="form-select" id="assigned_group_id">
<option value="">Ingen</option>
{% for group in assignment_groups or [] %}
<option value="{{ group.id }}">{{ group.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row g-4 mb-4">
<div class="col-md-4">
<label for="deadline" class="form-label">Deadline</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-calendar-event"></i></span>
<input type="datetime-local" class="form-control border-start-0 ps-0" id="deadline">
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="d-flex justify-content-end gap-3 mt-5">
<a href="/sag" class="btn btn-light border px-4 fw-bold">Annuller</a>
<button type="submit" class="btn btn-primary px-5 fw-bold shadow-sm" id="submitBtn">
<span class="d-flex align-items-center">
<i class="bi bi-check-lg me-2"></i>Opret Sag
</span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="caseCreateRewriteModal" tabindex="-1" aria-labelledby="caseCreateRewriteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="caseCreateRewriteModalLabel"><i class="bi bi-magic me-2"></i>AI-forslag til sag</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div class="alert alert-info small">
Gennemgå forslaget før du bruger det. AI må kun have rettet formulering og foreslået en titel ud fra din egen tekst.
</div>
<div class="mb-3">
<label for="caseCreateSuggestedTitle" class="form-label">Foreslået titel</label>
<input id="caseCreateSuggestedTitle" class="form-control" type="text">
</div>
<div>
<label for="caseCreateSuggestedDescription" class="form-label">Renskrevet beskrivelse</label>
<textarea id="caseCreateSuggestedDescription" class="form-control" rows="10"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" id="caseCreateApplyRewriteBtn" class="btn btn-primary"><i class="bi bi-check2 me-1"></i>Brug forslag</button>
</div>
</div>
</div>
</div>
<script>
let selectedCustomer = null;
let selectedContacts = {};
let selectedContactsCompanies = {};
let customerSearchTimeout;
let contactSearchTimeout;
2026-08-18 20:57:49 +02:00
let selectedCustomerContacts = [];
let customerContactsLoadToken = 0;
let successAlertTimeout;
let orderLineCounter = 0;
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null, internetConnectionId: null };
let topAlertLoadToken = 0;
function escapeTopAlertHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
async function loadCreateTopAlertsForCustomer(customerId) {
const container = document.getElementById('caseTopAlerts');
if (!container) {
return;
}
if (!customerId) {
container.classList.add('d-none');
container.innerHTML = '';
return;
}
const loadToken = ++topAlertLoadToken;
container.classList.remove('d-none');
container.innerHTML = '<div class="alert alert-info mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Henter kunde-alerts...</div>';
try {
const response = await fetch(`/api/v1/alert-notes/check?entity_type=customer&entity_id=${customerId}`, {
credentials: 'include'
});
if (loadToken !== topAlertLoadToken) {
return;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const alerts = (data?.alerts || []).filter((alert) => ['critical', 'warning'].includes(String(alert?.severity || '').toLowerCase()));
if (!alerts.length) {
container.classList.add('d-none');
container.innerHTML = '';
return;
}
container.innerHTML = alerts.map((alert) => {
const isCritical = String(alert.severity || '').toLowerCase() === 'critical';
const klass = isCritical ? 'alert-danger' : 'alert-warning';
const label = isCritical ? 'KRITISK' : 'ADVARSEL';
const title = escapeTopAlertHtml(alert.title || 'Vigtig kundeinformation');
const message = escapeTopAlertHtml(alert.message || '');
return `
<div class="alert ${klass} mb-2" role="alert">
<strong>${label}:</strong> ${title}
${message ? `<div class="small mt-1">${message}</div>` : ''}
</div>
`;
}).join('');
container.classList.remove('d-none');
} catch (error) {
if (loadToken !== topAlertLoadToken) {
return;
}
console.error('Failed to load customer alerts on sag create:', error);
container.innerHTML = '<div class="alert alert-warning mb-0" role="alert"><strong>Advarsel:</strong> Kunde-alerts kunne ikke hentes.</div>';
container.classList.remove('d-none');
}
}
// Helper function to show success alert
function showSuccessAlert(message, duration = 3000) {
if (successAlertTimeout) {
clearTimeout(successAlertTimeout);
}
const successDiv = document.getElementById('success');
successDiv.classList.remove('d-none');
document.getElementById('success-text').textContent = message;
successAlertTimeout = setTimeout(() => {
successDiv.classList.add('d-none');
successAlertTimeout = null;
}, duration);
}
function showCreateError(message) {
const errorDiv = document.getElementById('error');
errorDiv.classList.remove('d-none');
document.getElementById('error-text').textContent = String(message || 'Ukendt fejl');
errorDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// --- Character Counter ---
const beskrInput = document.getElementById('beskrivelse');
if (beskrInput) {
beskrInput.addEventListener('input', function(e) {
document.getElementById('charCount').textContent = e.target.value.length + " tegn";
});
}
// --- AI renskrivning ved oprettelse ---
// Forslaget bliver altid vist først; formularens titel og beskrivelse ændres
// først når brugeren aktivt vælger "Brug forslag".
async function requestCaseCreateRewrite() {
const descriptionInput = document.getElementById('beskrivelse');
const titleInput = document.getElementById('titel');
const button = document.getElementById('caseCreateRewriteBtn');
const source = (descriptionInput?.value || '').trim();
if (!source) {
descriptionInput?.focus();
showCreateError('Skriv en beskrivelse først.');
return;
}
const originalButton = button?.innerHTML || '';
if (button) {
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Renskriver...';
}
try {
const response = await fetch('/api/v1/sag/rewrite-case-create', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: titleInput?.value || '', description: source })
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.detail || `HTTP ${response.status}`);
}
const suggestion = {
title: String(payload?.title || '').trim(),
description: String(payload?.description || '').trim()
};
if (!suggestion.description) {
throw new Error('AI returnerede ikke en beskrivelse');
}
document.getElementById('caseCreateSuggestedTitle').value = suggestion.title || titleInput?.value || '';
document.getElementById('caseCreateSuggestedDescription').value = suggestion.description;
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).show();
} catch (error) {
console.error('Case create rewrite failed:', error);
showCreateError(`Kunne ikke renskrive beskrivelsen: ${error.message || 'Ukendt fejl'}`);
} finally {
if (button) {
button.disabled = false;
button.innerHTML = originalButton;
}
}
}
document.getElementById('caseCreateRewriteBtn')?.addEventListener('click', requestCaseCreateRewrite);
document.getElementById('caseCreateApplyRewriteBtn')?.addEventListener('click', () => {
const suggestedTitle = document.getElementById('caseCreateSuggestedTitle').value.trim();
const suggestedDescription = document.getElementById('caseCreateSuggestedDescription').value.trim();
const titleInput = document.getElementById('titel');
const descriptionInput = document.getElementById('beskrivelse');
if (suggestedTitle) {
titleInput.value = suggestedTitle;
titleInput.dispatchEvent(new Event('input', { bubbles: true }));
}
if (suggestedDescription) {
descriptionInput.value = suggestedDescription;
descriptionInput.dispatchEvent(new Event('input', { bubbles: true }));
}
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).hide();
});
// --- Search Logic ---
function initializeSearch() {
// Customer Search
const customerInput = document.getElementById('customerSearch');
if (customerInput) {
customerInput.addEventListener('input', (e) => handleSearch(e, 'customer'));
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-position-relative')) {
const cr = document.getElementById('customerResults');
if(cr) cr.classList.add('d-none');
}
});
}
// Contact Search
const contactInput = document.getElementById('contactSearch');
if (contactInput) {
contactInput.addEventListener('input', (e) => handleSearch(e, 'contact'));
contactInput.addEventListener('keydown', e => {
if (e.key === 'Escape') {
clearTimeout(contactSearchTimeout);
document.getElementById('contactResults').classList.add('d-none');
contactInput.blur();
}
});
2026-08-18 20:57:49 +02:00
contactInput.addEventListener('focus', () => {
if (selectedCustomer && contactInput.value.trim().length >= 2) {
2026-08-18 20:57:49 +02:00
renderCustomerContactResults(contactInput.value);
}
});
document.addEventListener('click', (e) => {
if (!contactInput.closest('.search-position-relative').contains(e.target)) {
const cr = document.getElementById('contactResults');
if(cr) cr.classList.add('d-none');
}
});
}
}
function handleSearch(event, type) {
const query = event.target.value.trim();
const resultsId = type === 'customer' ? 'customerResults' : 'contactResults';
const timeoutVar = type === 'customer' ? customerSearchTimeout : contactSearchTimeout;
const resultsDiv = document.getElementById(resultsId);
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};
clearTimeout(timeoutVar);
2026-08-18 20:57:49 +02:00
if (query.length < 2) {
resultsDiv.classList.add('d-none');
resultsDiv.innerHTML = '';
return;
}
2026-08-18 20:57:49 +02:00
if (type === 'contact' && selectedCustomer) {
renderCustomerContactResults(query);
return;
}
if (query.length < 2) {
resultsDiv.classList.add('d-none');
return;
}
const timeout = setTimeout(async () => {
if (event.target.value.trim() !== query || document.activeElement !== event.target) return;
try {
// Show loading state
resultsDiv.classList.remove('d-none');
resultsDiv.innerHTML = '<div class="p-3 text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Søger...</div>';
const endpoint = type === 'customer' ? '/api/v1/search/customers' : '/api/v1/search/contacts';
const response = await fetch(`${endpoint}?q=${encodeURIComponent(query)}`, {
headers: authHeaders,
credentials: 'include'
});
if (!response.ok) {
const errorText = await response.text();
resultsDiv.innerHTML = `<div class="p-3 text-danger small">Fejl ved søgning: ${errorText}</div>`;
return;
}
const data = await response.json();
if (event.target.value.trim() !== query || document.activeElement !== event.target) return;
if (!Array.isArray(data)) {
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Fejl ved søgning</div>';
return;
}
if (data.length === 0) {
const quickAction = type === 'customer'
? `<button class="btn btn-sm btn-outline-primary mt-2" onclick="quickCreateCustomer('${query.replace(/'/g, "\\'")}')">Opret nyt firma</button>`
: `<button class="btn btn-sm btn-outline-primary mt-2" onclick="quickCreateContact('${query.replace(/'/g, "\\'")}')">Opret ny kontakt</button>`;
resultsDiv.innerHTML = `
<div class="p-3 text-muted small">Ingen fundet</div>
<div class="p-3 pt-0">${quickAction}</div>
`;
} else {
resultsDiv.innerHTML = data.map(item => {
const name = type === 'customer' ? item.name : `${item.first_name} ${item.last_name}`;
const meta = type === 'customer'
? (item.email || ('CVR: ' + (item.cvr_nummer || '-')))
: `${item.user_company || 'Ingen firma'} · Tlf: ${item.mobile || item.phone || item.user_company_phone || '-'}`;
// Handle escaping for JS function call
const safeName = name.replace(/'/g, "\\'");
const fn = type === 'customer' ? `selectCustomer(${item.id}, '${safeName}')` : `selectContact(${item.id}, '${safeName}')`;
return `
<div class="search-result-item" onclick="${fn}">
<div class="search-result-name">${name}</div>
<div class="search-result-meta">${meta}</div>
</div>
`;
}).join('');
}
} catch (err) {
console.error('Search error:', err);
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Fejl ved søgning</div>';
}
}, 300);
if (type === 'customer') customerSearchTimeout = timeout;
else contactSearchTimeout = timeout;
}
2026-08-18 20:57:49 +02:00
function contactDisplayName(contact) {
return `${contact.first_name || ''} ${contact.last_name || ''}`.trim()
|| contact.name
|| `Kontakt #${contact.id}`;
}
function contactDisplayMeta(contact) {
const details = [];
if (contact.role) details.push(contact.role);
if (contact.email) details.push(contact.email);
const phone = contact.mobile || contact.phone;
if (phone) details.push(phone);
return details.join(' · ') || 'Ingen kontaktoplysninger';
}
function renderCustomerContactResults(query = '') {
const resultsDiv = document.getElementById('contactResults');
if (!resultsDiv || !selectedCustomer) return;
const normalizedQuery = String(query || '').trim().toLocaleLowerCase('da-DK');
if (normalizedQuery.length < 2 || document.activeElement !== document.getElementById('contactSearch')) {
resultsDiv.classList.add('d-none');
return;
}
2026-08-18 20:57:49 +02:00
const contacts = selectedCustomerContacts.filter((contact) => {
if (!normalizedQuery) return true;
return [
contactDisplayName(contact),
contact.email,
contact.mobile,
contact.phone,
contact.role
].some((value) => String(value || '').toLocaleLowerCase('da-DK').includes(normalizedQuery));
});
if (!contacts.length) {
resultsDiv.innerHTML = `<div class="p-3 text-muted small">${
selectedCustomerContacts.length
? 'Ingen af firmaets kontakter matcher søgningen.'
: 'Firmaet har ingen registrerede kontakter.'
}</div>`;
} else {
resultsDiv.innerHTML = contacts.map((contact) => {
const name = contactDisplayName(contact);
return `
<div class="search-result-item" data-contact-id="${Number(contact.id)}">
<div class="search-result-name">${escapeTopAlertHtml(name)}</div>
<div class="search-result-meta">${escapeTopAlertHtml(contactDisplayMeta(contact))}</div>
</div>
`;
}).join('');
resultsDiv.querySelectorAll('[data-contact-id]').forEach((row) => {
row.addEventListener('click', () => {
const contact = contacts.find((item) => Number(item.id) === Number(row.dataset.contactId));
if (contact) selectContact(contact.id, contactDisplayName(contact));
});
});
}
resultsDiv.classList.remove('d-none');
}
async function loadSelectedCustomerContacts(customerId) {
const contactInput = document.getElementById('contactSearch');
const context = document.getElementById('contactSearchContext');
const resultsDiv = document.getElementById('contactResults');
const loadToken = ++customerContactsLoadToken;
selectedCustomerContacts = [];
contactInput.placeholder = `Søg blandt kontakter hos ${selectedCustomer.name}...`;
context.textContent = `Søg blandt kontakter hos ${selectedCustomer.name} (mindst 2 tegn).`;
2026-08-18 20:57:49 +02:00
resultsDiv.innerHTML = '<div class="p-3 text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Henter firmaets kontakter...</div>';
resultsDiv.classList.toggle('d-none', document.activeElement !== contactInput || contactInput.value.trim().length < 2);
2026-08-18 20:57:49 +02:00
try {
const response = await fetch(`/api/v1/customers/${customerId}/contacts`, { credentials: 'include' });
if (loadToken !== customerContactsLoadToken) return;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const contacts = await response.json();
selectedCustomerContacts = Array.isArray(contacts) ? contacts : [];
renderCustomerContactResults(contactInput.value);
} catch (error) {
if (loadToken !== customerContactsLoadToken) return;
console.error('Failed to load customer contacts:', error);
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Firmaets kontakter kunne ikke hentes.</div>';
resultsDiv.classList.toggle('d-none', document.activeElement !== contactInput || contactInput.value.trim().length < 2);
2026-08-18 20:57:49 +02:00
}
}
function resetCustomerContactSearch() {
customerContactsLoadToken += 1;
selectedCustomerContacts = [];
const contactInput = document.getElementById('contactSearch');
const context = document.getElementById('contactSearchContext');
const resultsDiv = document.getElementById('contactResults');
contactInput.value = '';
contactInput.placeholder = 'Søg kontakt...';
context.textContent = 'Søg blandt alle kontakter.';
resultsDiv.innerHTML = '';
resultsDiv.classList.add('d-none');
}
// --- Selection Logic ---
function selectCustomer(id, name, skipAlert = false) {
selectedCustomer = { id, name };
document.getElementById('customer_id').value = id;
document.getElementById('customerSearch').value = '';
document.getElementById('customerResults').classList.add('d-none');
renderSelections();
loadCreateTopAlertsForCustomer(id);
2026-08-18 20:57:49 +02:00
loadSelectedCustomerContacts(id);
// Show notification
if (!skipAlert) {
showSuccessAlert(`Valgte firma: ${name}`);
}
}
function removeCustomer() {
selectedCustomer = null;
document.getElementById('customer_id').value = '';
2026-08-18 20:57:49 +02:00
resetCustomerContactSearch();
renderSelections();
loadCreateTopAlertsForCustomer(null);
}
async function selectContact(id, name) {
const isNewContact = !selectedContacts[id];
if (isNewContact) {
selectedContacts[id] = { id, name };
}
document.getElementById('contactSearch').value = '';
document.getElementById('contactResults').classList.add('d-none');
renderSelections();
// Check for associated company (auto-select if single match)
try {
const response = await fetch(`/api/v1/contacts/${id}`, { credentials: 'include' });
if (response.ok) {
const data = await response.json();
selectedContactsCompanies[id] = data.companies || [];
if (data.companies && data.companies.length === 1) {
const company = data.companies[0];
if (!selectedCustomer && isNewContact) {
// Auto-select company silently, then show combined alert
selectCustomer(company.id, company.name, true);
showSuccessAlert(`Valgte kontakt: ${name} + firma: ${company.name}`, 4000);
} else if (isNewContact) {
// Just show contact alert
showSuccessAlert(`Valgte kontakt: ${name}`);
}
} else if (isNewContact) {
// No auto-select, just show contact alert
showSuccessAlert(`Valgte kontakt: ${name}`);
}
}
} catch (e) {
console.error("Auto-select company failed", e);
if (isNewContact) {
showSuccessAlert(`Valgte kontakt: ${name}`);
}
}
loadHardwareForContacts();
window.caseCreateUI?.contactsChanged();
}
function readTelefoniPrefill() {
const params = new URLSearchParams(window.location.search || '');
const contactIdRaw = params.get('contact_id');
const titleRaw = params.get('title');
const callIdRaw = params.get('telefoni_opkald_id');
const customerIdRaw = params.get('customer_id');
const descriptionRaw = params.get('description');
const internetConnectionIdRaw = params.get('internet_connection_id');
const contactId = contactIdRaw ? parseInt(contactIdRaw) : null;
const customerId = customerIdRaw ? parseInt(customerIdRaw) : null;
telefoniPrefill.contactId = Number.isFinite(contactId) ? contactId : null;
telefoniPrefill.customerId = Number.isFinite(customerId) ? customerId : null;
telefoniPrefill.title = titleRaw ? String(titleRaw) : null;
telefoniPrefill.callId = callIdRaw ? String(callIdRaw) : null;
telefoniPrefill.description = descriptionRaw ? String(descriptionRaw) : null;
const internetConnectionId = internetConnectionIdRaw ? parseInt(internetConnectionIdRaw) : null;
telefoniPrefill.internetConnectionId = Number.isFinite(internetConnectionId) ? internetConnectionId : null;
}
async function applyTelefoniPrefill() {
readTelefoniPrefill();
if (telefoniPrefill.title) {
const titelInput = document.getElementById('titel');
if (titelInput && !titelInput.value.trim()) {
titelInput.value = telefoniPrefill.title;
}
}
if (telefoniPrefill.description) {
const beskrInput = document.getElementById('beskrivelse');
if (beskrInput && !beskrInput.value.trim()) {
beskrInput.value = telefoniPrefill.description;
const charCount = document.getElementById('charCount');
if (charCount) {
charCount.textContent = beskrInput.value.length + " tegn";
}
}
}
if (telefoniPrefill.customerId && !selectedCustomer) {
try {
const customerRes = await fetch(`/api/v1/customers/${telefoniPrefill.customerId}`, { credentials: 'include' });
if (customerRes.ok) {
const customer = await customerRes.json();
const customerName = customer.name || `Kunde #${telefoniPrefill.customerId}`;
selectCustomer(telefoniPrefill.customerId, customerName);
}
} catch (e) {
console.error('Customer prefill failed', e);
}
}
if (telefoniPrefill.contactId) {
try {
const res = await fetch(`/api/v1/contacts/${telefoniPrefill.contactId}`, { credentials: 'include' });
if (!res.ok) return;
const c = await res.json();
const name = `${c.first_name || ''} ${c.last_name || ''}`.trim() || `Kontakt #${telefoniPrefill.contactId}`;
await selectContact(telefoniPrefill.contactId, name);
} catch (e) {
console.error('Telefoni prefill failed', e);
}
}
if (telefoniPrefill.internetConnectionId) {
const panel = document.getElementById('internetConnectionPrefill');
try {
const res = await fetch(`/api/v1/internet-connections/${telefoniPrefill.internetConnectionId}`, { credentials: 'include' });
if (!res.ok) throw new Error('Forbindelsen findes ikke');
const connection = await res.json();
const reference = connection.circuit_number || connection.provider_reference || `#${telefoniPrefill.internetConnectionId}`;
panel.innerHTML = `<i class="bi bi-router me-2"></i><strong>Internetforbindelse knyttes til sagen:</strong> ${escapeTopAlertHtml(connection.name || 'Internetforbindelse')} · ${escapeTopAlertHtml(reference)}${connection.address ? ` · ${escapeTopAlertHtml(connection.address)}` : ''}. <a href="/economy/internet-connections/${telefoniPrefill.internetConnectionId}" class="alert-link">Åbn forbindelse</a><div class="small mt-1">Kunden vælges stadig manuelt og ændres ikke på forbindelsen.</div>`;
panel.classList.remove('d-none');
const titleInput = document.getElementById('titel');
if (titleInput && !titleInput.value.trim()) titleInput.value = `Vedr. forbindelse ${reference}`;
} catch (error) {
panel.textContent = `Internetforbindelse #${telefoniPrefill.internetConnectionId} kunne ikke hentes.`;
panel.className = 'alert alert-warning mb-4';
}
}
}
function removeContact(id) {
delete selectedContacts[id];
delete selectedContactsCompanies[id];
renderSelections();
loadHardwareForContacts();
window.caseCreateUI?.contactsChanged();
}
function renderSelections() {
// Customer
const custDiv = document.getElementById('selectedCustomer');
if (selectedCustomer) {
custDiv.innerHTML = `
<div class="selected-item border-primary bg-primary bg-opacity-10 text-primary">
<i class="bi bi-building me-2"></i>${selectedCustomer.name}
<button type="button" onclick="removeCustomer()" class="text-primary hover-opacity"><i class="bi bi-x"></i></button>
</div>`;
} else {
custDiv.innerHTML = '';
}
// Contacts
const contDiv = document.getElementById('selectedContacts');
contDiv.innerHTML = Object.values(selectedContacts).map(c => `
<div class="selected-item">
<i class="bi bi-person me-2"></i>${c.name}
<button type="button" onclick="removeContact(${c.id})"><i class="bi bi-x"></i></button>
</div>
`).join('');
}
async function loadHardwareForContacts() {
const hardwareList = document.getElementById('hardwareList');
if (!hardwareList) return;
const contactIds = Object.keys(selectedContacts).map(id => parseInt(id));
if (contactIds.length === 0) {
hardwareList.innerHTML = '<div class="text-muted small">Vælg en kontakt for at se relateret hardware.</div>';
return;
}
hardwareList.innerHTML = '<div class="text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Henter hardware...</div>';
try {
const responses = await Promise.all(
contactIds.map(contactId => fetch(`/api/v1/hardware/by-contact/${contactId}`, { credentials: 'include' }))
);
const datasets = await Promise.all(responses.map(r => r.ok ? r.json() : []));
const merged = new Map();
datasets.flat().forEach(item => {
if (!merged.has(item.id)) {
merged.set(item.id, item);
}
});
renderHardwareList(Array.from(merged.values()));
} catch (err) {
console.error('Failed to load hardware:', err);
hardwareList.innerHTML = '<div class="text-danger small">Kunne ikke hente hardware.</div>';
}
}
function renderHardwareList(items) {
const hardwareList = document.getElementById('hardwareList');
if (!hardwareList) return;
if (!items || items.length === 0) {
hardwareList.innerHTML = '<div class="text-muted small">Ingen hardware fundet for valgte kontakt(er).</div>';
return;
}
hardwareList.innerHTML = items.map(item => {
const name = item.model || item.brand || `Hardware #${item.id}`;
const anydeskId = item.anydesk_id || '-';
const anydeskLink = item.anydesk_link || '';
const linkBtn = anydeskLink
? `<button type="button" class="btn btn-sm btn-outline-primary" onclick="openAnyDeskLink('${anydeskLink.replace(/'/g, "\\'")}')">Connect</button>`
: '';
const copyBtn = item.anydesk_id
? `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="copyAnyDeskId('${item.anydesk_id.replace(/'/g, "\\'")}')">Kopiér ID</button>`
: '';
return `
<div class="d-flex flex-wrap align-items-center justify-content-between border-bottom py-2">
<div>
<div class="fw-bold">${name}</div>
<div class="text-muted small">AnyDesk ID: ${anydeskId}</div>
</div>
<div class="d-flex gap-2">
${linkBtn}
${copyBtn}
</div>
</div>
`;
}).join('');
}
function openAnyDeskLink(link) {
if (!link) return;
window.open(link, '_blank');
}
async function copyAnyDeskId(anydeskId) {
if (!anydeskId) return;
try {
await navigator.clipboard.writeText(anydeskId);
showSuccessAlert('AnyDesk ID kopieret');
} catch (err) {
console.error('Copy failed', err);
}
}
async function quickCreateHardware() {
const name = document.getElementById('hardwareNameInput').value.trim();
const anydeskId = document.getElementById('hardwareAnyDeskIdInput').value.trim();
const anydeskLink = anydeskId ? `anydesk://${anydeskId}` : null;
if (!name) {
showCreateError('Navn er påkrævet');
return;
}
const customerId = selectedCustomer?.id || getSingleContactCompanyId();
if (!customerId) {
showCreateError('Vælg et firma før du opretter hardware');
return;
}
try {
const response = await fetch('/api/v1/hardware/quick', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
customer_id: customerId,
anydesk_id: anydeskId || null,
anydesk_link: anydeskLink || null
})
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Kunne ikke oprette hardware');
}
document.getElementById('hardwareNameInput').value = '';
document.getElementById('hardwareAnyDeskIdInput').value = '';
await loadHardwareForContacts();
} catch (err) {
showCreateError('Fejl: ' + err.message);
}
}
function getSingleContactCompanyId() {
const contactIds = Object.keys(selectedContactsCompanies);
if (contactIds.length !== 1) return null;
const companies = selectedContactsCompanies[contactIds[0]] || [];
if (companies.length !== 1) return null;
return companies[0].id;
}
async function quickCreateCustomer(name) {
if (!name || name.trim().length < 2) return;
const resultsDiv = document.getElementById('customerResults');
resultsDiv.innerHTML = '<div class="p-3 text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Opretter firma...</div>';
try {
const response = await fetch('/api/v1/customers', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim() })
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Kunne ikke oprette firma');
}
const created = await response.json();
selectCustomer(created.id, created.name);
const contactIds = Object.keys(selectedContacts).map(id => parseInt(id));
if (contactIds.length) {
const linkResponses = await Promise.all(contactIds.map(contactId =>
fetch(`/api/v1/contacts/${contactId}/companies`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer_id: created.id, is_primary: false })
})
));
const failed = linkResponses.find(res => !res.ok);
if (failed) {
const err = await failed.json();
throw new Error(err.detail || 'Kunne ikke linke kontakt til firma');
}
}
return created;
} catch (err) {
resultsDiv.innerHTML = `<div class="p-3 text-danger small">${err.message}</div>`;
throw err;
}
}
async function quickCreateContact(fullName) {
if (!fullName || fullName.trim().length < 2) return;
const resultsDiv = document.getElementById('contactResults');
resultsDiv.innerHTML = '<div class="p-3 text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Opretter kontakt...</div>';
const parts = fullName.trim().split(/\s+/);
const firstName = parts.shift() || '';
const lastName = parts.join(' ');
try {
const payload = {
first_name: firstName,
last_name: lastName,
company_id: selectedCustomer ? selectedCustomer.id : null
};
const response = await fetch('/api/v1/contacts', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Kunne ikke oprette kontakt');
}
const created = await response.json();
await selectContact(created.id, `${created.first_name} ${created.last_name}`.trim());
return created;
} catch (err) {
resultsDiv.innerHTML = `<div class="p-3 text-danger small">${err.message}</div>`;
throw err;
}
}
const caseTypeLabels = {
ticket: '🎫 Ticket', pipeline: '📈 Pipeline', opgave: '🧩 Opgave',
ordre: '🧾 Ordre', projekt: '📁 Projekt', service: '🛠️ Service',
abonnement: '🔁 Abonnement'
};
function updateCaseTypeSections() {
const type = document.getElementById('type')?.value || 'ticket';
['hardwareSection', 'pipelineSection', 'orderSection'].forEach(id => document.getElementById(id)?.classList.remove('d-none'));
window.caseCreateUI?.typeChanged(type);
const help = document.getElementById('caseTypeHelp');
if (help) help.textContent = type === 'ticket' ? 'Tilføj eventuelt hardware og AnyDesk til din ticket. Alle ekstra funktioner er tilgængelige.'
: type === 'pipeline' ? 'Udfyld pipelineoplysninger for muligheden.'
: type === 'ordre' ? 'Tilføj indkøbs- og salgslinjer til ordren.'
: 'Tilføj hardware, pipeline, tags og køb/salg efter behov.';
}
function renderOrderLinesEmptyState() {
const hasLines = document.querySelectorAll('#orderLines .order-line').length > 0;
document.getElementById('orderLinesEmpty')?.classList.toggle('d-none', hasLines);
}
function addOrderLine(type = 'sale') {
const id = ++orderLineCounter;
const label = type === 'purchase' ? 'Indkøb' : 'Salg';
const purpose = type === 'purchase' ? `
<div class="col-md-4"><label class="form-label">Indkøbsformål</label><select class="form-select order-purpose"><option value="">Vælg formål</option><option value="salg">Salg</option><option value="lager">Lager</option><option value="asset">Asset</option><option value="intern_brug">Intern brug</option><option value="retur_reklamation">Retur/reklamation</option><option value="projekt_omkostning">Projektomkostning</option></select></div>` : '';
const container = document.getElementById('orderLines');
container.insertAdjacentHTML('beforeend', `
<div class="order-line border rounded-3 p-3 bg-light" data-line-id="${id}">
<div class="d-flex justify-content-between align-items-center mb-3"><strong>${label}</strong><button type="button" class="btn btn-sm btn-outline-danger" onclick="removeOrderLine(${id})"><i class="bi bi-trash"></i></button></div>
<input type="hidden" class="order-type" value="${type}">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Beskrivelse <span class="text-danger">*</span></label><input class="form-control order-description" placeholder="F.eks. switch, licens eller montage"></div>
<div class="col-md-2"><label class="form-label">Antal</label><input type="number" min="0" step="0.01" class="form-control order-quantity"></div>
<div class="col-md-2"><label class="form-label">Enhed</label><input class="form-control order-unit" placeholder="stk"></div>
<div class="col-md-2"><label class="form-label">Enhedspris</label><input type="number" min="0" step="0.01" class="form-control order-unit-price"></div>
<div class="col-md-3"><label class="form-label">Beløb <span class="text-danger">*</span></label><input type="number" min="0" step="0.01" class="form-control order-amount"></div>
<div class="col-md-2"><label class="form-label">Valuta</label><input class="form-control order-currency" value="DKK"></div>
<div class="col-md-3"><label class="form-label">Status</label><select class="form-select order-status"><option value="draft">Kladde</option><option value="confirmed">Bekræftet</option><option value="cancelled">Annulleret</option></select></div>
<div class="col-md-4"><label class="form-label">Reference</label><input class="form-control order-reference"></div>
${purpose}
</div>
</div>`);
renderOrderLinesEmptyState();
}
function removeOrderLine(id) {
document.querySelector(`.order-line[data-line-id="${id}"]`)?.remove();
renderOrderLinesEmptyState();
}
function collectOrderItems() {
return Array.from(document.querySelectorAll('#orderLines .order-line')).map(line => ({
type: line.querySelector('.order-type').value,
description: line.querySelector('.order-description').value.trim(),
quantity: line.querySelector('.order-quantity').value || null,
unit: line.querySelector('.order-unit').value.trim() || null,
unit_price: line.querySelector('.order-unit-price').value || null,
amount: line.querySelector('.order-amount').value || null,
currency: line.querySelector('.order-currency').value.trim() || 'DKK',
status: line.querySelector('.order-status').value,
external_ref: line.querySelector('.order-reference').value.trim() || null,
purchase_purpose: line.querySelector('.order-purpose')?.value || null
}));
}
async function loadPipelineStages() {
const select = document.getElementById('pipeline_stage_id');
if (!select) return;
try {
const response = await fetch('/api/v1/pipeline/stages', { credentials: 'include' });
if (!response.ok) return;
const stages = await response.json();
select.innerHTML = '<option value="">Ikke sat</option>' + (stages || []).map(stage => `<option value="${stage.id}">${stage.name}</option>`).join('');
} catch (err) { console.error('Failed to load pipeline stages', err); }
}
async function loadCaseTypesSelect() {
const select = document.getElementById('type');
if (!select) return;
try {
const [typesRes, profileRes] = await Promise.all([
fetch('/api/v1/settings/case_types', { credentials: 'include' }),
fetch('/api/v1/auth/me/profile', { credentials: 'include' })
]);
const setting = typesRes.ok ? await typesRes.json() : { value: '[]' };
const profile = profileRes.ok ? await profileRes.json() : {};
const configured = JSON.parse(setting.value || '[]');
const types = Array.isArray(configured) && configured.length ? configured.map(type => String(type).trim().toLowerCase()).filter(Boolean) : Object.keys(caseTypeLabels);
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
if (!types.includes('abonnement')) types.push('abonnement');
const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels);
select.innerHTML = finalTypes.map(type => `<option value="${type}">${caseTypeLabels[type] || type}</option>`).join('');
select.value = finalTypes.includes(profile.default_case_type) ? profile.default_case_type : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]);
} catch (err) {
console.error('Failed to load case types', err);
select.innerHTML = Object.entries(caseTypeLabels).map(([type, label]) => `<option value="${type}">${label}</option>`).join('');
}
updateCaseTypeSections();
}
2026-08-28 20:49:55 +02:00
async function selectCurrentUserAsResponsible() {
const select = document.getElementById('ansvarlig_bruger_id');
if (!select || select.value) return;
try {
const response = await fetch('/api/v1/auth/me', { credentials: 'include' });
if (!response.ok) return;
const user = await response.json();
const userId = user.user_id ?? user.id;
if (userId && Array.from(select.options).some(option => option.value === String(userId))) {
select.value = String(userId);
}
} catch (error) {
console.error('Kunne ikke forvælge ansvarlig bruger', error);
}
}
// --- Initialization ---
document.addEventListener('DOMContentLoaded', async () => {
initializeSearch();
await Promise.all([loadCaseTypesSelect(), loadPipelineStages(), selectCurrentUserAsResponsible()]);
document.getElementById('type')?.addEventListener('change', updateCaseTypeSections);
await applyTelefoniPrefill();
await window.caseCreateUI?.init();
});
// --- Form Submission ---
document.getElementById('createForm').addEventListener('submit', async (e) => {
e.preventDefault();
if (window.caseCreateUI && !window.caseCreateUI.validate()) return;
// UI Reset
const errorDiv = document.getElementById('error');
const successDiv = document.getElementById('success');
const btn = document.getElementById('submitBtn');
errorDiv.classList.add('d-none');
successDiv.classList.add('d-none');
// Basic Validation
const titelInput = document.getElementById('titel');
const titel = titelInput.value;
const status = document.getElementById('status').value;
if (!titel.trim()) {
titelInput.classList.add('is-invalid');
errorDiv.classList.remove('d-none');
document.getElementById('error-text').textContent = "Titel er påkrævet";
return;
} else {
titelInput.classList.remove('is-invalid');
}
// Loading State
btn.disabled = true;
const originalBtnText = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Opretter...';
try {
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};
const customerQuery = document.getElementById('customerSearch').value.trim();
if (!selectedCustomer && customerQuery.length >= 2) {
const res = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(customerQuery)}`, {
headers: authHeaders,
credentials: 'include'
});
const matches = await res.json();
if (Array.isArray(matches) && matches.length > 0) {
throw new Error('Firma findes allerede. Vælg det fra listen.');
}
await quickCreateCustomer(customerQuery);
}
const contactQuery = document.getElementById('contactSearch').value.trim();
if (Object.keys(selectedContacts).length === 0 && contactQuery.length >= 2) {
const res = await fetch(`/api/v1/search/contacts?q=${encodeURIComponent(contactQuery)}`, {
headers: authHeaders,
credentials: 'include'
});
const matches = await res.json();
if (Array.isArray(matches) && matches.length > 0) {
throw new Error('Kontakt findes allerede. Vælg den fra listen.');
}
await quickCreateContact(contactQuery);
}
} catch (err) {
errorDiv.classList.remove('d-none');
document.getElementById('error-text').textContent = err.message;
btn.disabled = false;
btn.innerHTML = originalBtnText;
return;
}
const data = {
titel: titel,
beskrivelse: document.getElementById('beskrivelse').value || '',
type: document.getElementById('type').value,
status: status,
customer_id: selectedCustomer ? selectedCustomer.id : null,
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
assigned_group_id: document.getElementById('assigned_group_id').value ? parseInt(document.getElementById('assigned_group_id').value) : null,
deadline: document.getElementById('deadline').value || null,
contact_ids: Object.keys(selectedContacts).map(id => parseInt(id)).filter(Number.isFinite),
telefoni_opkald_id: telefoniPrefill.callId ? parseInt(telefoniPrefill.callId) : null
};
if (telefoniPrefill.internetConnectionId) {
data.internet_connection_ids = [telefoniPrefill.internetConnectionId];
}
if (['pipeline_stage_id', 'pipeline_amount', 'pipeline_probability', 'pipeline_description'].some(id => document.getElementById(id).value !== '')) {
data.pipeline = {
stage_id: document.getElementById('pipeline_stage_id').value || null,
amount: document.getElementById('pipeline_amount').value || null,
probability: document.getElementById('pipeline_probability').value || null,
description: document.getElementById('pipeline_description').value || null
};
}
if (document.querySelector('#orderLines .order-line')) {
data.order_items = collectOrderItems();
}
Object.assign(data, window.caseCreateUI?.relations() || {});
try {
const response = await fetch('/api/v1/sag', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
const result = await response.json();
window.caseCreateUI?.created(result);
successDiv.classList.remove('d-none');
document.getElementById('success-text').textContent = "Sag oprettet";
window.location.href = `/sag/${result.id}/v3`;
} else {
const errorText = await response.text();
let errMsg = "Kunne ikke oprette sag";
try {
const json = JSON.parse(errorText);
errMsg = json.detail || errMsg;
} catch(e) {}
throw new Error(errMsg);
}
} catch (err) {
console.error('Submit error:', err);
errorDiv.classList.remove('d-none');
document.getElementById('error-text').textContent = err.message;
btn.disabled = false;
btn.innerHTML = originalBtnText;
}
});
</script>
<script src="/static/js/case-create.js?v=5"></script>
{% endblock %}