1591 lines
82 KiB
HTML
1591 lines
82 KiB
HTML
{% 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>
|
|
<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;
|
|
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
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();
|
|
}
|
|
});
|
|
contactInput.addEventListener('focus', () => {
|
|
if (selectedCustomer && contactInput.value.trim().length >= 2) {
|
|
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);
|
|
|
|
if (query.length < 2) {
|
|
resultsDiv.classList.add('d-none');
|
|
resultsDiv.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
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).`;
|
|
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);
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
loadSelectedCustomerContacts(id);
|
|
|
|
// Show notification
|
|
if (!skipAlert) {
|
|
showSuccessAlert(`Valgte firma: ${name}`);
|
|
}
|
|
}
|
|
|
|
function removeCustomer() {
|
|
selectedCustomer = null;
|
|
document.getElementById('customer_id').value = '';
|
|
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();
|
|
}
|
|
|
|
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 %}
|