feat: Add Kuando Busylight integration and enhance case creation UI

- Introduced Kuando Busylight integration for centralized presence management.
- Updated case creation UI to support new modules and improved type handling.
- Added personal Buzzwords visibility preferences for users.
- Implemented file upload functionality directly from the case topbar.
- Enhanced validation and user experience in case creation process.
- Created migration for user-specific module preferences.
- Updated tests to cover new features and ensure functionality.
This commit is contained in:
Christian 2026-09-23 00:23:30 +02:00
parent abfb945679
commit 81b8229db1
14 changed files with 1231 additions and 274 deletions

View File

@ -15,7 +15,7 @@ from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body, Depends from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body, Depends
from fastapi.responses import FileResponse, HTMLResponse from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_query_single, table_has_column, get_db_connection, release_db_connection from app.core.database import execute_query, execute_query_single, execute_update, table_has_column, get_db_connection, release_db_connection
from psycopg2.extras import RealDictCursor from psycopg2.extras import RealDictCursor
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis
from app.core.config import settings from app.core.config import settings
@ -1533,11 +1533,22 @@ async def update_my_sag_list_preferences(request: Request, payload: SagListPrefe
@router.get("/sag/{sag_id}/modules") @router.get("/sag/{sag_id}/modules")
async def get_case_module_prefs(sag_id: int): async def get_case_module_prefs(sag_id: int, current_user: dict = Depends(get_current_user)):
"""Get module visibility preferences for a case.""" """Get module visibility preferences for the current user on a case."""
try: try:
query = "SELECT module_key, is_enabled FROM sag_module_prefs WHERE sag_id = %s" user_id = int(current_user["id"])
prefs = execute_query(query, (sag_id,)) query = """
SELECT module_key, is_enabled
FROM sag_module_prefs
WHERE sag_id = %s
AND module_key <> 'buzzwords'
UNION ALL
SELECT module_key, is_enabled
FROM user_sag_module_prefs
WHERE sag_id = %s
AND user_id = %s
"""
prefs = execute_query(query, (sag_id, sag_id, user_id))
return prefs or [] return prefs or []
except Exception as e: except Exception as e:
logger.error("❌ Error getting module prefs: %s", e) logger.error("❌ Error getting module prefs: %s", e)
@ -1545,8 +1556,12 @@ async def get_case_module_prefs(sag_id: int):
@router.post("/sag/{sag_id}/modules") @router.post("/sag/{sag_id}/modules")
async def set_case_module_pref(sag_id: int, data: dict): async def set_case_module_pref(
"""Set module visibility preference for a case.""" sag_id: int,
data: dict,
current_user: dict = Depends(get_current_user),
):
"""Set a module visibility preference, scoped per user for Buzzwords."""
try: try:
module_key = data.get("module_key") module_key = data.get("module_key")
is_enabled = data.get("is_enabled") is_enabled = data.get("is_enabled")
@ -1554,14 +1569,26 @@ async def set_case_module_pref(sag_id: int, data: dict):
if not module_key or is_enabled is None: if not module_key or is_enabled is None:
raise HTTPException(status_code=400, detail="module_key and is_enabled are required") raise HTTPException(status_code=400, detail="module_key and is_enabled are required")
query = """ if module_key == "buzzwords":
INSERT INTO sag_module_prefs (sag_id, module_key, is_enabled) if not _table_exists("user_sag_module_prefs"):
VALUES (%s, %s, %s) raise HTTPException(status_code=409, detail="Buzzwords-indstillingen er ikke klar. Kør migration 1042 først.")
ON CONFLICT (sag_id, module_key) query = """
DO UPDATE SET is_enabled = EXCLUDED.is_enabled INSERT INTO user_sag_module_prefs (user_id, sag_id, module_key, is_enabled)
RETURNING module_key, is_enabled VALUES (%s, %s, %s, %s)
""" ON CONFLICT (user_id, sag_id, module_key)
result = execute_query(query, (sag_id, module_key, bool(is_enabled))) DO UPDATE SET is_enabled = EXCLUDED.is_enabled
RETURNING module_key, is_enabled
"""
result = execute_query(query, (int(current_user["id"]), sag_id, module_key, bool(is_enabled)))
else:
query = """
INSERT INTO sag_module_prefs (sag_id, module_key, is_enabled)
VALUES (%s, %s, %s)
ON CONFLICT (sag_id, module_key)
DO UPDATE SET is_enabled = EXCLUDED.is_enabled
RETURNING module_key, is_enabled
"""
result = execute_query(query, (sag_id, module_key, bool(is_enabled)))
return result[0] if result else {"module_key": module_key, "is_enabled": bool(is_enabled)} return result[0] if result else {"module_key": module_key, "is_enabled": bool(is_enabled)}
except HTTPException: except HTTPException:
raise raise
@ -5691,78 +5718,98 @@ def _decode_header_str(header_val):
except: except:
return str(header_val) return str(header_val)
def _copy_uploaded_email_attachments_to_case(sag_id: int, email_id: int, attachments: List[Dict]) -> int:
"""Copy valid manual-email attachments into the normal case-files module."""
if not attachments or not _table_exists("sag_files"):
return 0
has_source_email = table_has_column("sag_files", "source_email_id")
has_source_type = table_has_column("sag_files", "source_type")
copied = 0
for attachment in attachments:
filename = Path(str(attachment.get("filename") or "attachment.bin")).name
content = attachment.get("content")
if not filename or not content:
continue
content = bytes(content)
extension = Path(filename).suffix.lower().lstrip(".")
if not extension or extension not in ALLOWED_EXTENSIONS or len(content) > MAX_ATTACHMENT_SIZE:
logger.warning("Skipping unsupported or oversized email attachment %s for SAG-%s", filename, sag_id)
continue
if has_source_email:
existing = execute_query(
"""SELECT 1 FROM sag_files
WHERE sag_id = %s AND source_email_id = %s AND filename = %s
LIMIT 1""",
(sag_id, email_id, filename),
) or []
if existing:
continue
stored_name = _generate_stored_name(filename, SAG_FILE_SUBDIR)
destination = _resolve_attachment_path(stored_name)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(content)
columns = ["sag_id", "filename", "content_type", "size_bytes", "stored_name"]
values: List[Any] = [
sag_id,
filename,
attachment.get("content_type") or "application/octet-stream",
len(content),
stored_name,
]
if has_source_email:
columns.append("source_email_id")
values.append(email_id)
if has_source_type:
columns.append("source_type")
values.append("email_import")
execute_query(
f"INSERT INTO sag_files ({', '.join(columns)}) VALUES ({', '.join(['%s'] * len(values))})",
tuple(values),
)
copied += 1
return copied
@router.post("/sag/{sag_id}/upload-email") @router.post("/sag/{sag_id}/upload-email")
async def upload_sag_email(sag_id: int, file: UploadFile = File(...)): async def upload_sag_email(sag_id: int, file: UploadFile = File(...)):
"""Upload .eml/.msg, parse it, save to email_messages, and link to case.""" """Import .eml/.msg into the email tab and copy its attachments to case files."""
content = await file.read() filename = str(file.filename or "").lower()
filename = file.filename.lower() if not (filename.endswith(".eml") or filename.endswith(".msg")):
email_data = {}
temp_id = uuid4().hex
# 1. Parse File
if filename.endswith('.msg'):
if not extract_msg: raise HTTPException(500, "extract-msg missing")
import io
msg = extract_msg.Message(io.BytesIO(content))
email_data = {
'message_id': msg.messageId or f"msg-{temp_id}",
'subject': msg.subject or "No Subject",
'sender_email': msg.sender or "",
'sender_name': msg.sender or "",
'recipient_email': msg.to or "",
'cc': msg.cc or "",
'body_text': msg.body,
'body_html': msg.htmlBody,
'received_date': msg.date or datetime.now(),
'folder': 'Imported',
'attachments': [],
'has_attachments': False,
'attachment_count': 0
}
elif filename.endswith('.eml'):
msg = email.message_from_bytes(content)
body_text = ""
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
body_text = part.get_payload(decode=True).decode('utf-8', errors='ignore')
break
else:
body_text = msg.get_payload(decode=True).decode('utf-8', errors='ignore')
email_data = {
'message_id': msg.get('Message-ID', f"eml-{temp_id}"),
'in_reply_to': _decode_header_str(msg.get('In-Reply-To', '')),
'email_references': _decode_header_str(msg.get('References', '')),
'subject': _decode_header_str(msg.get('Subject', 'No Subject')),
'sender_email': _decode_header_str(msg.get('From', '')),
'sender_name': _decode_header_str(msg.get('From', '')),
'recipient_email': _decode_header_str(msg.get('To', '')),
'cc': _decode_header_str(msg.get('Cc', '')),
'received_date': datetime.now(),
'body_text': body_text,
'body_html': "",
'folder': 'Imported',
'has_attachments': False,
'attachment_count': 0,
'attachments': []
}
else:
raise HTTPException(400, "Only .msg or .eml files allowed") raise HTTPException(400, "Only .msg or .eml files allowed")
# 2. Save Email via Service (deduplicate) content = await file.read()
svc = EmailService() service = EmailService()
existing = execute_query_single("SELECT id FROM email_messages WHERE message_id = %s", (email_data['message_id'],)) email_data = service.parse_msg_file(content) if filename.endswith(".msg") else service.parse_eml_file(content)
if not email_data:
if existing: raise HTTPException(status_code=400, detail="E-mailfilen kunne ikke læses")
email_id = existing['id']
else: email_id = await service.save_uploaded_email(email_data)
# We try to strict save, keeping it simple if not email_id:
email_id = await svc.save_email(email_data) existing = execute_query_single(
"SELECT id FROM email_messages WHERE message_id = %s",
(email_data["message_id"],),
)
if not existing:
raise HTTPException(status_code=500, detail="E-mailen kunne ikke gemmes")
email_id = existing["id"]
# 3. Link
await add_sag_email_link(sag_id, {"email_id": email_id}) await add_sag_email_link(sag_id, {"email_id": email_id})
return {"status": "imported", "email_id": email_id} attachment_files_created = _copy_uploaded_email_attachments_to_case(
sag_id,
int(email_id),
email_data.get("attachments") or [],
)
return {
"status": "imported",
"email_id": email_id,
"attachment_files_created": attachment_files_created,
}
@router.post("/sag/{sag_id}/emails/send") @router.post("/sag/{sag_id}/emails/send")

View File

@ -959,10 +959,9 @@ async def sag_detaljer(request: Request, sag_id: int):
raise HTTPException(status_code=500, detail="Failed to load case details") raise HTTPException(status_code=500, detail="Failed to load case details")
@router.get("/sag/{sag_id}/v3", response_class=HTMLResponse)
@router.get("/sag/{sag_id}/v4", response_class=HTMLResponse) @router.get("/sag/{sag_id}/v4", response_class=HTMLResponse)
async def sag_detaljer_v3(request: Request, sag_id: int): async def sag_detaljer_v4(request: Request, sag_id: int):
"""Display the active V4 case page; /v3 remains as a compatible alias.""" """Display the active V4 case page."""
try: try:
# Fetch main case # Fetch main case
sag_query = """ sag_query = """
@ -1300,9 +1299,6 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
status_options.append(current_status) status_options.append(current_status)
is_deadline_overdue = _is_deadline_overdue(sag.get("deadline")) is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
# V4 is the only active case design. Keep /v3 routable because many
# existing links and bookmarks still use it, but render the same V4
# template so users cannot accidentally fall back to the old design.
template_name = "modules/sag/templates/detail_v4.html" template_name = "modules/sag/templates/detail_v4.html"
response = templates.TemplateResponse(template_name, { response = templates.TemplateResponse(template_name, {
"request": request, "request": request,
@ -1339,6 +1335,12 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
raise HTTPException(status_code=500, detail="Failed to load case details") raise HTTPException(status_code=500, detail="Failed to load case details")
@router.get("/sag/{sag_id}/v3", response_class=HTMLResponse)
async def sag_detaljer_v3_legacy(sag_id: int):
"""Keep old links working, while making V4 the canonical case URL."""
return RedirectResponse(url=f"/sag/{sag_id}/v4", status_code=307)
@router.get("/sag/{sag_id}/edit", response_class=HTMLResponse) @router.get("/sag/{sag_id}/edit", response_class=HTMLResponse)
async def sag_rediger(request: Request, sag_id: int): async def sag_rediger(request: Request, sag_id: int):
"""Display edit case form.""" """Display edit case form."""

View File

@ -305,10 +305,6 @@
<label for="type" class="form-label mb-2">Hvilken type sag vil du oprette?</label> <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> <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> <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> </div>
<!-- Section: Relations --> <!-- Section: Relations -->
@ -344,6 +340,27 @@
</div> </div>
<div id="internetConnectionPrefill" class="alert alert-info d-none mb-4" role="status"></div> <div id="internetConnectionPrefill" class="alert alert-info d-none mb-4" role="status"></div>
<div class="row g-4 mb-4">
<div class="col-md-6">
<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-6">
<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>
<hr class="my-4 opacity-25"> <hr class="my-4 opacity-25">
<!-- Section: Basic Info --> <!-- Section: Basic Info -->
@ -363,7 +380,7 @@
<i class="bi bi-magic me-1"></i>AI renskriv <i class="bi bi-magic me-1"></i>AI renskriv
</button> </button>
</div> </div>
<textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..."></textarea> <textarea class="form-control" id="beskrivelse" rows="5" placeholder="Beskriv problemstillingen detaljeret..." required></textarea>
<div class="d-flex justify-content-between form-text"> <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>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> <span id="charCount">0 tegn</span>
@ -376,6 +393,8 @@
<!-- Fields that belong to the selected case type are rendered here. --> <!-- Fields that belong to the selected case type are rendered here. -->
<section id="typeDetailsSection" class="d-none"><div id="typeDetailsFields"></div></section> <section id="typeDetailsSection" class="d-none"><div id="typeDetailsFields"></div></section>
<div id="typeModuleAction" class="d-flex flex-wrap gap-2 mt-4 mb-4 d-none" aria-label="Tilføj moduler"></div>
<!-- Section: Hardware & AnyDesk --> <!-- Section: Hardware & AnyDesk -->
<section id="hardwareSection"> <section id="hardwareSection">
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5> <h5 class="mb-3 text-muted fw-bold small text-uppercase">Hardware (AnyDesk)</h5>
@ -437,8 +456,10 @@
<hr class="my-4 opacity-25"> <hr class="my-4 opacity-25">
<!-- Section: Metadata --> <!-- Optional metadata stays available without competing with the core flow. -->
<h5 class="mb-3 text-muted fw-bold small text-uppercase">Ansvarlig</h5> <details id="advancedCaseFields" class="mt-4">
<summary class="small text-muted fw-semibold">Flere valg: ansvarlig, status og deadline</summary>
<div class="pt-3">
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
<div class="col-md-4 case-type-extra"> <div class="col-md-4 case-type-extra">
<label for="status" class="form-label">Status <span class="text-danger">*</span></label> <label for="status" class="form-label">Status <span class="text-danger">*</span></label>
@ -448,25 +469,6 @@
<option value="lukket">🔴 Lukket</option> <option value="lukket">🔴 Lukket</option>
</select> </select>
</div> </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 case-type-extra">
<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>
<div class="row g-4 mb-4 case-type-extra"> <div class="row g-4 mb-4 case-type-extra">
@ -478,6 +480,8 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</details>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="d-flex justify-content-end gap-3 mt-5"> <div class="d-flex justify-content-end gap-3 mt-5">
@ -1421,7 +1425,7 @@
const profile = profileRes.ok ? await profileRes.json() : {}; const profile = profileRes.ok ? await profileRes.json() : {};
const configured = JSON.parse(setting.value || '[]'); const configured = JSON.parse(setting.value || '[]');
const configuredTypes = Array.isArray(configured) ? configured.map(type => String(type).trim().toLowerCase()).filter(Boolean) : []; const configuredTypes = Array.isArray(configured) ? configured.map(type => String(type).trim().toLowerCase()).filter(Boolean) : [];
const supportedTypes = ['ticket', 'salg', 'bogholderi', 'admin', 'projekt', 'postit', 'intern_ticket', 'bug_report']; const supportedTypes = ['ticket', 'postit', 'salg', 'bogholderi', 'admin', 'projekt', 'intern_ticket', 'bug_report'];
const finalTypes = supportedTypes; const finalTypes = supportedTypes;
select.innerHTML = finalTypes.map(type => `<option value="${type}">${caseTypeLabels[type] || type}</option>`).join(''); 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]); select.value = finalTypes.includes(profile.default_case_type) ? profile.default_case_type : (finalTypes.includes('ticket') ? 'ticket' : finalTypes[0]);
@ -1457,7 +1461,7 @@
// Both the selected type and current user are now known. Reapply the // Both the selected type and current user are now known. Reapply the
// type defaults so Intern ticket cannot display a stale assignee. // type defaults so Intern ticket cannot display a stale assignee.
updateCaseTypeSections(false); updateCaseTypeSections(false);
document.getElementById('type')?.addEventListener('change', () => updateCaseTypeSections(true)); document.getElementById('type')?.addEventListener('change', () => updateCaseTypeSections(false));
await applyTelefoniPrefill(); await applyTelefoniPrefill();
await window.caseCreateUI?.init(); await window.caseCreateUI?.init();
}); });
@ -1573,7 +1577,7 @@
successDiv.classList.remove('d-none'); successDiv.classList.remove('d-none');
document.getElementById('success-text').textContent = "Sag oprettet"; document.getElementById('success-text').textContent = "Sag oprettet";
window.location.href = `/sag/${result.id}/v3`; window.location.href = `/sag/${result.id}/v4`;
} else { } else {
const errorText = await response.text(); const errorText = await response.text();
let errMsg = "Kunne ikke oprette sag"; let errMsg = "Kunne ikke oprette sag";
@ -1593,5 +1597,5 @@
} }
}); });
</script> </script>
<script src="/static/js/case-create.js?v=6"></script> <script src="/static/js/case-create.js?v=13"></script>
{% endblock %} {% endblock %}

View File

@ -3696,6 +3696,62 @@
margin-bottom: 0.2rem; margin-bottom: 0.2rem;
} }
.case-topbar-upload-btn {
display: inline-grid;
width: 2.5rem;
height: 2.5rem;
place-items: center;
border: 1px dashed rgba(255,255,255,0.65);
border-radius: 0.65rem;
background: rgba(255,255,255,0.12);
color: #fff !important;
font-size: 1.28rem;
transition: background-color 120ms ease, border-color 120ms ease, transform 120ms ease;
}
.case-topbar-upload-btn:hover,
.case-topbar-upload-btn:focus-visible,
.case-topbar-upload-btn.is-drag-over {
border-color: #fff;
background: rgba(255,255,255,0.28);
color: #fff !important;
transform: translateY(-1px);
}
.case-file-row {
gap: 0.7rem;
}
.case-file-thumbnail {
width: 78px;
height: 58px;
flex: 0 0 78px;
display: grid;
place-items: center;
overflow: hidden;
border: 1px solid var(--bs-border-color);
border-radius: 0.45rem;
background: var(--bs-tertiary-bg);
color: var(--bs-secondary-color);
cursor: pointer;
}
.case-file-thumbnail:hover,
.case-file-thumbnail:focus-visible {
border-color: var(--bs-primary);
outline: 0;
}
.case-file-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.case-file-thumbnail .bi {
font-size: 1.45rem;
}
#sag-titel-text { #sag-titel-text {
font-size: clamp(1.9rem, 2.7vw, 2.45rem); font-size: clamp(1.9rem, 2.7vw, 2.45rem);
line-height: 1.15; line-height: 1.15;
@ -3835,6 +3891,55 @@
font-size: 0.78rem; font-size: 0.78rem;
} }
.case-contact-widget .contact-widget-row {
display: flex;
align-items: center;
gap: 0.55rem;
min-width: 0;
margin-bottom: 0.42rem;
font-size: 0.8rem;
}
.case-contact-widget .contact-widget-row:last-child { margin-bottom: 0; }
.case-contact-widget .contact-widget-label {
flex: 0 0 32%;
color: #6c757d;
font-size: 0.8rem;
}
.case-contact-widget .contact-widget-field {
display: flex;
align-items: center;
gap: 0.25rem;
flex: 1 1 auto;
min-width: 0;
min-height: 34px;
padding: 0.3rem 0.45rem;
border: 1px solid #dbe5ee;
border-radius: 0.45rem;
background: #fff;
}
.case-contact-widget .contact-widget-value {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #212529;
font-weight: 500;
}
.case-contact-widget .contact-widget-actions { display: inline-flex; gap: 0.18rem; }
.case-contact-widget .contact-widget-actions .btn {
--bs-btn-padding-x: 0.24rem;
--bs-btn-padding-y: 0.06rem;
border-color: transparent;
}
.case-contact-widget .contact-widget-edit {
--bs-btn-padding-x: 0.24rem;
--bs-btn-padding-y: 0.06rem;
color: #6c757d;
border-color: transparent;
}
.header-top-row { .header-top-row {
margin-top: 0.42rem; margin-top: 0.42rem;
} }
@ -4171,22 +4276,15 @@
<!-- HEADER BG: Clean Title & Actions WITHOUT Selectors --> <!-- HEADER BG: Clean Title & Actions WITHOUT Selectors -->
<div class="header-bg py-2" style="background: linear-gradient(to right, #0f4c75, #1e3c72); color: white; margin-top: -0.3rem;"> <div class="header-bg py-2" style="background: linear-gradient(to right, #0f4c75, #1e3c72); color: white; margin-top: -0.3rem;">
<div class="container-fluid px-3 px-xl-4 position-relative"> <div class="container-fluid px-3 px-xl-4 position-relative">
{% set tkey = (case.template_key or case.type or 'ticket')|lower %}
{% set type_icons = {'ticket': 'bi-ticket-perforated', 'salg': 'bi-briefcase', 'bogholderi': 'bi-receipt', 'admin': 'bi-gear', 'projekt': 'bi-folder2-open', 'postit': 'bi-sticky', 'intern_ticket': 'bi-shield-lock', 'bug_report': 'bi-bug'} %}
{% set type_labels = {'ticket': 'Ticket', 'salg': 'Salg', 'bogholderi': 'Bogholderi', 'admin': 'Admin', 'projekt': 'Projekt', 'postit': 'Post-it', 'intern_ticket': 'Intern ticket', 'bug_report': 'Bug_report'} %}
{% set type_colors = {'ticket': '#6366f1', 'salg': '#10b981', 'bogholderi': '#0891b2', 'admin': '#64748b', 'projekt': '#8b5cf6', 'postit': '#f59e0b', 'intern_ticket': '#334155', 'bug_report': '#dc3545'} %}
{% set tcolor = type_colors.get(tkey, '#0f4c75') %}
{% set ticon = type_icons.get(tkey, 'bi-card-text') %}
{% set tlabel = type_labels.get(tkey, tkey|capitalize) %}
<div class="case-header-main"> <div class="case-header-main">
<div class="case-header-copy"> <div class="case-header-copy">
<div class="case-header-identity"> <div class="case-header-identity">
<span class="case-header-id">#{{ case.id }}</span> <span class="case-header-id">#{{ case.id }}</span>
<span class="case-header-status {{ 'is-closed' if (case.status or '')|lower in ['lukket', 'løst', 'closed', 'resolved'] else '' }}" id="caseStatusBadge"><i class="bi bi-circle-fill"></i>{{ case.status|capitalize if case.status else 'Åben' }}</span>
<span class="case-header-type" id="caseTypeDropdownBtn"><i class="bi {{ ticon }}" style="color:{{ tcolor }};" id="caseTypeIcon"></i><span id="caseTypeLabel">{{ tlabel }}</span></span>
</div> </div>
<div id="sag-titel-view" class="d-flex align-items-center gap-2"> <div id="sag-titel-view" class="d-flex align-items-center gap-2">
<h1 id="sag-titel-text" class="fw-bolder mb-0 text-white">{{ case.titel }}</h1> <h1 id="sag-titel-text" class="fw-bolder mb-0 text-white">{{ case.titel }}</h1>
<input type="file" id="topbarAttachmentInput" multiple style="display:none" onchange="handleTopbarAttachmentFiles(this.files); this.value='';">
<button id="caseTopbarUploadBtn" class="btn case-topbar-upload-btn p-0" type="button" onclick="document.getElementById('topbarAttachmentInput').click()" title="Upload fil eller slip .eml/.msg her" aria-label="Upload fil eller slip e-mail her"><i class="bi bi-paperclip"></i></button>
<button class="btn btn-sm btn-link text-white-50 p-0" onclick="startTitelEdit()" title="Rediger overskrift"><i class="bi bi-pencil-square"></i></button> <button class="btn btn-sm btn-link text-white-50 p-0" onclick="startTitelEdit()" title="Rediger overskrift"><i class="bi bi-pencil-square"></i></button>
</div> </div>
<div id="sag-titel-editor" class="d-none"> <div id="sag-titel-editor" class="d-none">
@ -4194,17 +4292,6 @@
<div class="d-flex gap-2 mt-2"><button class="btn btn-sm btn-primary" onclick="saveTitelEdit()">Gem</button><button class="btn btn-sm btn-secondary" onclick="cancelTitelEdit()">Annuller</button></div> <div class="d-flex gap-2 mt-2"><button class="btn btn-sm btn-primary" onclick="saveTitelEdit()">Gem</button><button class="btn btn-sm btn-secondary" onclick="cancelTitelEdit()">Annuller</button></div>
</div> </div>
<p class="text-white-50 mb-0 case-header-meta-line"> <p class="text-white-50 mb-0 case-header-meta-line">
<span><i class="bi bi-building me-1"></i><a href="#" onclick="event.preventDefault(); showCustomerSearch('replace')" class="text-white text-decoration-none" title="Skift virksomhed">{{ customer.name if customer else 'Vælg virksomhed' }} <i class="bi bi-pencil-fill small"></i></a></span>
{% if customer and customer.is_vendor %}
<span class="badge rounded-pill bg-warning text-dark ms-1" title="Denne kunde er også registreret som leverandør{% if customer.vendor_name %}: {{ customer.vendor_name }}{% endif %}">
<i class="bi bi-truck me-1"></i>Leverandør
</span>
{% endif %}
<span class="meta-divider">•</span>
<span><i class="bi bi-person me-1"></i><a href="#" onclick="event.preventDefault(); showContactSearch('replace')" class="text-white text-decoration-none" title="Skift hovedkontakt">{{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Vælg kontakt' }} <i class="bi bi-pencil-fill small"></i></a></span>
<span class="meta-divider">•</span>
<span><i class="bi bi-diagram-3 me-1"></i><a href="#" onclick="event.preventDefault(); showAfdelingModal()" class="text-white text-decoration-none" title="Ændre afdeling">{{ customer.department if customer and customer.department else 'Ingen afdeling' }}</a></span>
<span class="meta-divider">•</span>
<span><i class="bi bi-plus-circle me-1"></i>Oprettet {{ case.created_at.strftime('%d. %b %Y') if case.created_at else '-' }}</span> <span><i class="bi bi-plus-circle me-1"></i>Oprettet {{ case.created_at.strftime('%d. %b %Y') if case.created_at else '-' }}</span>
</p> </p>
</div> </div>
@ -4378,19 +4465,42 @@
</div> </div>
</div> </div>
<!-- Widget 4: Tidsregistrering og kompakte genveje --> <!-- Widget 4: Kunde og hovedkontakt -->
<div class="col-md-6 col-xl-3"> <div class="col-md-6 col-xl-3">
<div class="card h-100 shadow-sm border-0 rounded-3" style="background:#f8fafc;"> <div class="card h-100 shadow-sm border-0 rounded-3 case-contact-widget" style="background:#f8fafc;">
<div class="card-body p-2"> <div class="card-body p-2">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="text-uppercase fw-bold text-muted mb-2" style="font-size:0.7rem; letter-spacing:0.45px;">
<div class="text-uppercase fw-bold text-muted" style="font-size:0.7rem; letter-spacing:0.45px;"><i class="bi bi-clock-history text-primary me-1"></i> Tidsregistrering</div> <i class="bi bi-person-vcard-fill text-primary me-1"></i> Kontakt
<div class="d-flex gap-1"> </div>
<button type="button" id="caseAnyDeskOpenBtn" class="btn btn-sm btn-white bg-white border shadow-sm" title="AnyDesk Quick Connect" aria-label="AnyDesk Quick Connect"><i class="bi bi-display text-primary"></i></button> <div class="contact-widget-row">
<button type="button" id="topbarPrintWorkOrderBtn" class="btn btn-sm btn-white bg-white border shadow-sm" title="Print arbejdsseddel" aria-label="Print arbejdsseddel"><i class="bi bi-printer text-primary"></i></button> <span class="contact-widget-label">Firma</span>
<div class="contact-widget-field">
<a class="contact-widget-value text-decoration-none" href="#" onclick="event.preventDefault(); showCustomerSearch('replace')" title="Skift virksomhed">{{ customer.name if customer else 'Vælg virksomhed' }}</a>
<button type="button" class="btn btn-sm contact-widget-edit" onclick="showCustomerSearch('replace')" title="Skift virksomhed" aria-label="Skift virksomhed"><i class="bi bi-pencil"></i></button>
</div>
</div>
<div class="contact-widget-row">
<span class="contact-widget-label">Hovedkontakt</span>
<div class="contact-widget-field">
<a class="contact-widget-value text-decoration-none" href="#" onclick="event.preventDefault(); showContactSearch('replace')" title="Skift hovedkontakt">{{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Vælg kontakt' }}</a>
{% if hovedkontakt %}
<span class="contact-widget-actions">
{% if hovedkontakt.email %}<a class="btn btn-sm btn-outline-primary" href="mailto:{{ hovedkontakt.email }}" title="Send e-mail" aria-label="Send e-mail"><i class="bi bi-envelope"></i></a>{% endif %}
{% set contact_number = hovedkontakt.mobile or hovedkontakt.phone %}
{% if contact_number %}<a class="btn btn-sm btn-outline-primary" href="tel:{{ contact_number }}" title="Start VoIP-kald" aria-label="Start VoIP-kald"><i class="bi bi-telephone"></i></a>{% endif %}
{% if hovedkontakt.mobile %}<button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt({{ hovedkontakt.mobile|tojson }}, {{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name)|tojson }}, {{ hovedkontakt.id|default('null') }})" title="Send SMS" aria-label="Send SMS"><i class="bi bi-chat-dots"></i></button>{% endif %}
</span>
{% endif %}
<button type="button" class="btn btn-sm contact-widget-edit" onclick="showContactSearch('replace')" title="Skift hovedkontakt" aria-label="Skift hovedkontakt"><i class="bi bi-pencil"></i></button>
</div>
</div>
<div class="contact-widget-row">
<span class="contact-widget-label">Afdeling</span>
<div class="contact-widget-field">
<a class="contact-widget-value text-decoration-none" href="#" onclick="event.preventDefault(); showAfdelingModal()" title="Ændr afdeling">{{ customer.department if customer and customer.department else 'Ingen afdeling' }}</a>
<button type="button" class="btn btn-sm contact-widget-edit" onclick="showAfdelingModal()" title="Ændr afdeling" aria-label="Ændr afdeling"><i class="bi bi-pencil"></i></button>
</div> </div>
</div> </div>
<button type="button" class="btn btn-sm btn-primary w-100" onclick="showAddTimeModal()"><i class="bi bi-plus-lg me-1"></i>Registrér tid</button>
<button type="button" class="btn btn-sm btn-outline-secondary w-100 mt-1" onclick="activateCaseTabFromButton(event, 'timetracking')">Se tidsforbrug</button>
</div> </div>
</div> </div>
</div> </div>
@ -5354,6 +5464,9 @@
<span id="previewFileName">Fil preview</span> <span id="previewFileName">Fil preview</span>
</h5> </h5>
<div class="ms-auto d-flex align-items-center gap-2"> <div class="ms-auto d-flex align-items-center gap-2">
<span id="previewFilePosition" class="small text-muted d-none" aria-live="polite"></span>
<button id="previewPreviousFileBtn" type="button" class="btn btn-sm btn-outline-secondary" onclick="previewAdjacentFile(-1)" title="Forrige fil (venstre piletast)" aria-label="Forrige fil"><i class="bi bi-chevron-left"></i></button>
<button id="previewNextFileBtn" type="button" class="btn btn-sm btn-outline-secondary" onclick="previewAdjacentFile(1)" title="Næste fil (højre piletast)" aria-label="Næste fil"><i class="bi bi-chevron-right"></i></button>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
</div> </div>
@ -5373,11 +5486,11 @@
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Søg kontakt</h5> <h5 class="modal-title" id="contactSearchModalTitle">Søg kontakt</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input type="text" id="contactSearch" placeholder="Søg efter kontakt..." class="form-control mb-3"> <input type="text" id="contactSearch" placeholder="Søg efter anden kontakt..." class="form-control mb-3">
<div id="contactSearchResults" style="max-height: 300px; overflow-y: auto;"></div> <div id="contactSearchResults" style="max-height: 300px; overflow-y: auto;"></div>
</div> </div>
</div> </div>
@ -6160,12 +6273,15 @@
}); });
// Show modal functions // Show modal functions
function showContactSearch(mode = 'link') { async function showContactSearch(mode = 'link') {
contactSearchMode = mode === 'replace' ? 'replace' : 'link'; contactSearchMode = mode === 'replace' ? 'replace' : 'link';
const title = document.getElementById('contactSearchModalTitle'); const title = document.getElementById('contactSearchModalTitle');
if (title) title.textContent = contactSearchMode === 'replace' ? 'Skift hovedkontakt på sag' : 'Søg kontakt'; if (title) title.textContent = contactSearchMode === 'replace' ? 'Skift hovedkontakt på sag' : 'Søg kontakt';
contactSearchModal.show(); contactSearchModal.show();
setTimeout(() => document.getElementById('contactSearch').focus(), 300); const input = document.getElementById('contactSearch');
if (input) input.value = '';
await loadCustomerContactsForContactSearch();
setTimeout(() => input?.focus(), 300);
} }
function showCustomerSearch(mode = 'link') { function showCustomerSearch(mode = 'link') {
@ -6954,6 +7070,58 @@
} }
// Contact Search // Contact Search
function contactSearchDisplayName(contact) {
const firstName = String(contact?.first_name || '').trim();
const lastName = String(contact?.last_name || '').trim();
return [firstName, lastName].filter(Boolean).join(' ') || `Kontakt #${contact?.id}`;
}
function renderContactSearchResults(contacts, sectionTitle = '') {
const resultsDiv = document.getElementById('contactSearchResults');
if (!resultsDiv) return;
if (!Array.isArray(contacts) || contacts.length === 0) {
resultsDiv.innerHTML = '<div class="p-3 text-muted">Ingen kontakter fundet</div>';
return;
}
const heading = sectionTitle
? `<div class="small text-uppercase fw-bold text-muted px-2 pt-1 pb-2">${escapeHtml(sectionTitle)}</div>`
: '';
resultsDiv.innerHTML = heading + contacts.map((contact) => {
const name = contactSearchDisplayName(contact);
const email = String(contact?.email || '').trim();
const mobile = String(contact?.mobile || '').trim();
const phone = String(contact?.phone || '').trim();
const role = String(contact?.role || '').trim();
const detail = [email, mobile ? `Mobil: ${mobile}` : '', phone ? `Telefon: ${phone}` : ''].filter(Boolean).join(' · ');
return `
<button type="button" class="list-group-item list-group-item-action text-start" onclick="selectContactFromSearch(${caseId}, ${Number(contact.id)})">
<div class="fw-semibold">${escapeHtml(name)}</div>
<div class="small text-muted">${detail ? escapeHtml(detail) : 'Ingen kontaktoplysninger'}${role ? ` · ${escapeHtml(role)}` : ''}</div>
</button>
`;
}).join('');
}
async function loadCustomerContactsForContactSearch() {
const customerId = caseCustomerId || wikiCustomerId;
const resultsDiv = document.getElementById('contactSearchResults');
if (!resultsDiv) return;
if (!customerId) {
resultsDiv.innerHTML = '<div class="p-3 text-muted">Sagen har ingen virksomhed. Søg nedenfor for at vælge en anden kontakt.</div>';
return;
}
resultsDiv.innerHTML = '<div class="p-3 text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Henter firmaets kontakter...</div>';
try {
const response = await fetch(`/api/v1/customers/${customerId}/contacts`, { credentials: 'include' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
renderContactSearchResults(await response.json(), 'Firmaets kontakter');
} catch (error) {
console.error('Could not load customer contacts:', error);
resultsDiv.innerHTML = '<div class="p-3 text-muted">Kunne ikke hente firmaets kontakter. Du kan stadig søge efter en anden kontakt.</div>';
}
}
function setupContactSearch() { function setupContactSearch() {
const contactSearchInput = document.getElementById('contactSearch'); const contactSearchInput = document.getElementById('contactSearch');
contactSearchInput.addEventListener('input', function(e) { contactSearchInput.addEventListener('input', function(e) {
@ -6961,7 +7129,7 @@
const query = e.target.value.trim(); const query = e.target.value.trim();
if (query.length < 2) { if (query.length < 2) {
document.getElementById('contactSearchResults').innerHTML = ''; loadCustomerContactsForContactSearch();
return; return;
} }
@ -6970,42 +7138,7 @@
const response = await fetch(`/api/v1/search/contacts?q=${encodeURIComponent(query)}`); const response = await fetch(`/api/v1/search/contacts?q=${encodeURIComponent(query)}`);
const contacts = await response.json(); const contacts = await response.json();
const resultsDiv = document.getElementById('contactSearchResults'); renderContactSearchResults(contacts, 'Andre kontakter');
if (contacts.length === 0) {
resultsDiv.innerHTML = '<div class="p-3 text-muted">Ingen kontakter fundet</div>';
} else {
resultsDiv.innerHTML = contacts.map(c => `
${(() => {
const clean = (v) => {
if (v === null || v === undefined) return '';
const s = String(v).trim();
if (!s || s.toLowerCase() === 'null' || s.toLowerCase() === 'none') return '';
return s;
};
const firstName = clean(c.first_name);
const lastName = clean(c.last_name);
const fullName = [firstName, lastName].filter(Boolean).join(' ') || `Kontakt #${c.id}`;
const email = clean(c.email);
const company = clean(c.user_company);
const phones = Array.from(new Set([
clean(c.mobile) ? `Mobil: ${clean(c.mobile)}` : '',
clean(c.phone) ? `Telefon: ${clean(c.phone)}` : '',
clean(c.user_company_phone) ? `Firma: ${clean(c.user_company_phone)}` : ''
].filter(Boolean)));
return `
<div class="list-group-item list-group-item-action" style="cursor: pointer;"
onclick="selectContactFromSearch(${caseId}, ${c.id}, '${fullName.replace(/'/g, "\\'")}')">
<strong>${fullName}</strong>
<div class="small text-muted">${[email, company ? '(' + company + ')' : ''].filter(Boolean).join(' ')}</div>
<div class="small mt-1">
${phones.join(' · ') || '<span class="text-muted">Intet telefonnummer</span>'}
</div>
</div>
`;
})()}
`).join('');
}
} catch (err) { } catch (err) {
console.error('Error searching contacts:', err); console.error('Error searching contacts:', err);
} }
@ -7013,15 +7146,15 @@
}); });
} }
async function selectContactFromSearch(caseId, contactId, contactName) { async function selectContactFromSearch(caseId, contactId) {
if (contactSearchMode === 'replace') { if (contactSearchMode === 'replace') {
await replaceCasePrimaryContact(caseId, contactId, contactName); await replaceCasePrimaryContact(caseId, contactId);
return; return;
} }
await addContact(caseId, contactId, contactName); await addContact(caseId, contactId);
} }
async function replaceCasePrimaryContact(caseId, contactId, contactName) { async function replaceCasePrimaryContact(caseId, contactId) {
try { try {
let response = await fetch(`/api/v1/sag/${caseId}/contacts/${contactId}`, { let response = await fetch(`/api/v1/sag/${caseId}/contacts/${contactId}`, {
method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({is_primary: true}) method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({is_primary: true})
@ -7042,7 +7175,7 @@
} }
} }
async function addContact(caseId, contactId, contactName) { async function addContact(caseId, contactId) {
try { try {
const response = await fetch(`/api/v1/sag/${caseId}/contacts`, { const response = await fetch(`/api/v1/sag/${caseId}/contacts`, {
method: 'POST', method: 'POST',
@ -18238,6 +18371,15 @@
return; return;
} }
// Buzzwords are only useful when the case has actual values.
// A user's preference may hide the populated module, but must
// never force an empty Buzzwords card into the layout.
if (isBuzzwordsModule && !hasContent) {
setVisibility(false);
el.classList.remove('module-empty-compact');
return;
}
// HVIS specifik præference deaktiverer den - Skjul den! Uanset content. // HVIS specifik præference deaktiverer den - Skjul den! Uanset content.
if (pref === false) { if (pref === false) {
setVisibility(false); setVisibility(false);
@ -18581,14 +18723,25 @@
const displayName = escapeHtml(isWorkOrder ? `Arbejdsseddel: ${f.filename}` : f.filename); const displayName = escapeHtml(isWorkOrder ? `Arbejdsseddel: ${f.filename}` : f.filename);
const fileId = Number(f.id); const fileId = Number(f.id);
const downloadUrl = escapeHtml(String(f.download_url || '')); const downloadUrl = escapeHtml(String(f.download_url || ''));
const extension = String(f.filename || '').split('.').pop().toLowerCase();
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp'].includes(extension);
const thumbnailUrl = extension === 'pdf'
? `/api/v1/sag/${caseIds}/files/${fileId}/preview-image?page=1&scale=1`
: downloadUrl;
const thumbnail = isImage || extension === 'pdf'
? `<img src="${thumbnailUrl}" alt="${displayName}" loading="lazy">`
: `<i class="bi ${isWorkOrder ? 'bi-clipboard-check' : 'bi-file-earmark'}" aria-hidden="true"></i>`;
const badgeHtml = isWorkOrder const badgeHtml = isWorkOrder
? '<span class="badge rounded-pill text-bg-warning ms-2"><i class="bi bi-clipboard-check me-1"></i>Arbejdsseddel</span>' ? '<span class="badge rounded-pill text-bg-warning ms-2"><i class="bi bi-clipboard-check me-1"></i>Arbejdsseddel</span>'
: (isScannerFile : (isScannerFile
? '<span class="badge rounded-pill text-bg-info ms-2"><i class="bi bi-upc-scan me-1"></i>Scanner</span>' ? '<span class="badge rounded-pill text-bg-info ms-2"><i class="bi bi-upc-scan me-1"></i>Scanner</span>'
: ''); : '');
return ` return `
<div class="list-group-item d-flex justify-content-between align-items-center"> <div class="list-group-item d-flex align-items-center case-file-row">
<div class="ms-2 me-auto"> <button type="button" class="case-file-thumbnail p-0" onclick="previewFileById(${fileId})" title="Vis ${displayName}" aria-label="Vis ${displayName}">
${thumbnail}
</button>
<div class="me-auto min-w-0">
<div class="fw-bold text-truncate d-flex align-items-center" style="max-width: 380px;"> <div class="fw-bold text-truncate d-flex align-items-center" style="max-width: 380px;">
<a href="javascript:void(0);" onclick="previewFileById(${fileId})" class="text-decoration-none text-dark"> <a href="javascript:void(0);" onclick="previewFileById(${fileId})" class="text-decoration-none text-dark">
<i class="bi ${isWorkOrder ? 'bi-clipboard-check' : 'bi-file-earmark'} me-1"></i> ${displayName} <i class="bi ${isWorkOrder ? 'bi-clipboard-check' : 'bi-file-earmark'} me-1"></i> ${displayName}
@ -18598,6 +18751,9 @@
<small class="text-muted">${size} • ${new Date(f.created_at).toLocaleDateString()}</small> <small class="text-muted">${size} • ${new Date(f.created_at).toLocaleDateString()}</small>
</div> </div>
<div class="d-flex gap-1"> <div class="d-flex gap-1">
<button class="btn btn-sm btn-outline-primary border-0" type="button" onclick="previewFileById(${fileId})" title="Vis fil" aria-label="Vis fil">
<i class="bi bi-eye"></i>
</button>
<a href="${downloadUrl}?download=true" class="btn btn-sm btn-outline-primary border-0" title="Download"> <a href="${downloadUrl}?download=true" class="btn btn-sm btn-outline-primary border-0" title="Download">
<i class="bi bi-download"></i> <i class="bi bi-download"></i>
</a> </a>
@ -18623,17 +18779,42 @@
try { try {
const res = await fetch(`/api/v1/sag/${caseIds}/files`, { const res = await fetch(`/api/v1/sag/${caseIds}/files`, {
method: 'POST', method: 'POST',
credentials: 'include',
body: formData body: formData
}); });
if(res.ok) { if(res.ok) {
loadSagFiles(); const saved = await res.json().catch(() => []);
await loadSagFiles();
const count = Array.isArray(saved) ? saved.length : fileList.length;
if (typeof showNotification === 'function') {
showNotification(`${count} fil${count === 1 ? '' : 'er'} uploadet`, 'success');
}
} else { } else {
showCaseFeedback('Upload fejlede'); const error = await res.json().catch(() => ({}));
loadSagFiles(); // Reload to clear loading state const detail = typeof error.detail === 'string'
? error.detail
: (error.detail?.message || 'Upload fejlede');
showCaseFeedback(detail);
await loadSagFiles(); // Reload to clear loading state
} }
} catch(e) { } catch(e) {
showCaseFeedback('Upload fejl: ' + e); showCaseFeedback('Upload fejl: ' + (e?.message || e));
loadSagFiles(); await loadSagFiles();
}
}
async function handleTopbarAttachmentFiles(fileList) {
const files = Array.from(fileList || []);
if (!files.length) return;
const emailFiles = files.filter((file) => /\.(eml|msg)$/i.test(String(file.name || '')));
const regularFiles = files.filter((file) => !emailFiles.includes(file));
if (regularFiles.length) {
await handleFileUpload(regularFiles);
}
for (const emailFile of emailFiles) {
await uploadEmailFile(emailFile);
} }
} }
@ -18692,6 +18873,30 @@
} }
// File Preview // File Preview
let activePreviewFileId = null;
function updateFilePreviewNavigation(fileId) {
const currentIndex = sagFilesCache.findIndex((item) => Number(item.id) === Number(fileId));
const previousButton = document.getElementById('previewPreviousFileBtn');
const nextButton = document.getElementById('previewNextFileBtn');
const position = document.getElementById('previewFilePosition');
const hasFiles = currentIndex >= 0 && sagFilesCache.length > 0;
if (previousButton) previousButton.disabled = !hasFiles || currentIndex === 0;
if (nextButton) nextButton.disabled = !hasFiles || currentIndex === sagFilesCache.length - 1;
if (position) {
position.classList.toggle('d-none', !hasFiles || sagFilesCache.length < 2);
position.textContent = hasFiles ? `${currentIndex + 1} / ${sagFilesCache.length}` : '';
}
}
function previewAdjacentFile(offset) {
const currentIndex = sagFilesCache.findIndex((item) => Number(item.id) === Number(activePreviewFileId));
const nextFile = sagFilesCache[currentIndex + Number(offset)];
if (!nextFile) return;
previewFile(Number(nextFile.id), String(nextFile.filename || ''), String(nextFile.content_type || ''));
}
function previewFileById(fileId) { function previewFileById(fileId) {
const file = sagFilesCache.find((item) => Number(item.id) === Number(fileId)); const file = sagFilesCache.find((item) => Number(item.id) === Number(fileId));
if (!file) { if (!file) {
@ -18702,12 +18907,15 @@
} }
function previewFile(fileId, filename, contentType) { function previewFile(fileId, filename, contentType) {
const modal = new bootstrap.Modal(document.getElementById('filePreviewModal')); const modalEl = document.getElementById('filePreviewModal');
const modal = new bootstrap.Modal(modalEl);
const previewContent = document.getElementById('previewContent'); const previewContent = document.getElementById('previewContent');
const fileNameEl = document.getElementById('previewFileName'); const fileNameEl = document.getElementById('previewFileName');
activePreviewFileId = Number(fileId);
// Set filename // Set filename
fileNameEl.textContent = filename; fileNameEl.textContent = filename;
updateFilePreviewNavigation(activePreviewFileId);
const fileUrl = `/api/v1/sag/${caseIds}/files/${fileId}`; const fileUrl = `/api/v1/sag/${caseIds}/files/${fileId}`;
// Show loading spinner // Show loading spinner
@ -18777,6 +18985,18 @@
} }
} }
document.addEventListener('keydown', (event) => {
const modal = document.getElementById('filePreviewModal');
if (!modal?.classList.contains('show')) return;
if (event.key === 'ArrowLeft') {
event.preventDefault();
previewAdjacentFile(-1);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
previewAdjacentFile(1);
}
});
function escapeHtml(text) { function escapeHtml(text) {
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = text; div.textContent = text;
@ -19806,6 +20026,9 @@
const sender = email.sender_name || email.sender_email || '-'; const sender = email.sender_name || email.sender_email || '-';
const received = email.received_date ? new Date(email.received_date).toLocaleString('da-DK') : '-'; const received = email.received_date ? new Date(email.received_date).toLocaleString('da-DK') : '-';
const attachments = Array.isArray(email.attachments) ? email.attachments : []; const attachments = Array.isArray(email.attachments) ? email.attachments : [];
const declaredAttachmentCount = Number(email.attachment_count || 0);
const attachmentsMissing = attachments.length === 0
&& (Boolean(email.has_attachments) || declaredAttachmentCount > 0);
const bodyText = email.body_text || ''; const bodyText = email.body_text || '';
const bodyHtml = sanitizeCaseEmailHtml(email.body_html || ''); const bodyHtml = sanitizeCaseEmailHtml(email.body_html || '');
selectedLinkedEmailDetail = email; selectedLinkedEmailDetail = email;
@ -19840,6 +20063,15 @@
<div class="p-3 border-bottom"> <div class="p-3 border-bottom">
<div class="small fw-semibold mb-2">Vedhæftninger (${attachments.length})</div> <div class="small fw-semibold mb-2">Vedhæftninger (${attachments.length})</div>
<div id="email-attachments-list" class="d-flex flex-wrap gap-2"></div> <div id="email-attachments-list" class="d-flex flex-wrap gap-2"></div>
${attachmentsMissing ? `
<div class="mt-2 small text-warning-emphasis">
<i class="bi bi-exclamation-triangle me-1"></i>
Denne e-mail er registreret med ${declaredAttachmentCount || 'en'} vedhæftning${declaredAttachmentCount === 1 ? '' : 'er'}, men filen blev ikke hentet ved import.
<button type="button" class="btn btn-sm btn-outline-primary ms-2" onclick="recoverLinkedEmailAttachments(${email.id})">
<i class="bi bi-cloud-download me-1"></i>Hent vedhæftninger
</button>
</div>
` : ''}
</div> </div>
<div class="p-3 overflow-auto" style="max-height: 45vh; white-space: normal;"> <div class="p-3 overflow-auto" style="max-height: 45vh; white-space: normal;">
${bodyText ? `<pre class="mb-0" style="white-space: pre-wrap; font-family: inherit;">${escapeHtml(bodyText)}</pre>` : (bodyHtml || '<div class="text-muted">Ingen indhold</div>')} ${bodyText ? `<pre class="mb-0" style="white-space: pre-wrap; font-family: inherit;">${escapeHtml(bodyText)}</pre>` : (bodyHtml || '<div class="text-muted">Ingen indhold</div>')}
@ -19849,7 +20081,9 @@
const attachmentContainer = document.getElementById('email-attachments-list'); const attachmentContainer = document.getElementById('email-attachments-list');
if (attachmentContainer) { if (attachmentContainer) {
if (!attachments.length) { if (!attachments.length) {
attachmentContainer.innerHTML = '<span class="text-muted small">Ingen vedhæftninger</span>'; attachmentContainer.innerHTML = attachmentsMissing
? '<span class="text-muted small">Ingen filer er gemt lokalt endnu</span>'
: '<span class="text-muted small">Ingen vedhæftninger</span>';
} else { } else {
attachmentContainer.innerHTML = attachments.map(att => { attachmentContainer.innerHTML = attachments.map(att => {
const attachmentName = att.filename || `Vedhæftning ${att.id}`; const attachmentName = att.filename || `Vedhæftning ${att.id}`;
@ -19883,6 +20117,39 @@
} }
} }
async function recoverLinkedEmailAttachments(emailId) {
const panel = document.getElementById('email-preview-panel');
if (!panel) return;
const recoverButton = panel.querySelector('button[onclick^="recoverLinkedEmailAttachments"]');
if (recoverButton) {
recoverButton.disabled = true;
recoverButton.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Henter...';
}
try {
const res = await fetch(`/api/v1/emails/${emailId}/recover-attachments`, {
method: 'POST',
credentials: 'include'
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(payload?.detail || `Kunne ikke hente vedhæftninger (${res.status})`);
}
showCaseFeedback(`${payload.attachments_saved || payload.attachments_recovered || 0} vedhæftning(er) hentet fra e-mailen.`);
await loadLinkedEmailDetail(emailId, true);
if (typeof loadSagFiles === 'function') await loadSagFiles();
} catch (error) {
console.error(error);
showCaseFeedback(error?.message || 'Kunne ikke hente vedhæftninger.');
if (recoverButton) {
recoverButton.disabled = false;
recoverButton.innerHTML = '<i class="bi bi-cloud-download me-1"></i>Hent vedhæftninger';
}
}
}
async function toggleLinkedEmailReadState() { async function toggleLinkedEmailReadState() {
if (!selectedLinkedEmailDetail || !selectedLinkedEmailId) return; if (!selectedLinkedEmailDetail || !selectedLinkedEmailId) return;
const targetState = !Boolean(selectedLinkedEmailDetail.is_read); const targetState = !Boolean(selectedLinkedEmailDetail.is_read);
@ -20057,7 +20324,16 @@
body: formData body: formData
}); });
if(res.ok) { if(res.ok) {
loadLinkedEmails(); const result = await res.json().catch(() => ({}));
await Promise.all([loadLinkedEmails(), loadSagFiles()]);
openCaseEmailTab();
const attachmentCount = Number(result.attachment_files_created || 0);
if (typeof showNotification === 'function') {
showNotification(
attachmentCount ? `E-mail importeret med ${attachmentCount} vedhæftet fil${attachmentCount === 1 ? '' : 'er'}` : 'E-mail importeret',
'success'
);
}
} else { } else {
showCaseFeedback('Import fejlede'); showCaseFeedback('Import fejlede');
} }
@ -20067,6 +20343,25 @@
} }
} }
(function bindTopbarAttachmentDropzone() {
const button = document.getElementById('caseTopbarUploadBtn');
if (!button) return;
['dragenter', 'dragover'].forEach((eventName) => {
button.addEventListener(eventName, (event) => {
event.preventDefault();
button.classList.add('is-drag-over');
});
});
['dragleave', 'dragend'].forEach((eventName) => {
button.addEventListener(eventName, () => button.classList.remove('is-drag-over'));
});
button.addEventListener('drop', async (event) => {
event.preventDefault();
button.classList.remove('is-drag-over');
await handleTopbarAttachmentFiles(event.dataTransfer?.files);
});
})();
// Load content on start // Load content on start
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const caseEmailSendBtn = document.getElementById('caseEmailSendBtn'); const caseEmailSendBtn = document.getElementById('caseEmailSendBtn');

View File

@ -148,7 +148,7 @@ class TelefoniService:
pending_rows = execute_query( pending_rows = execute_query(
""" """
SELECT * FROM telefoni_opkald SELECT * FROM telefoni_opkald
WHERE callid LIKE 'click-to-call:%' WHERE callid LIKE 'click-to-call:%%'
AND direction = 'outbound' AND direction = 'outbound'
AND bruger_id = %s AND bruger_id = %s
AND ended_at IS NULL AND ended_at IS NULL

View File

@ -2325,6 +2325,7 @@ if (bmcOriginalFetch) {
}); });
function openNewCasePage() { function openNewCasePage() {
if (window.location.pathname === '/sag/new' && window.caseCreateUI?.nextType?.()) return;
window.location.href = '/sag/new'; window.location.href = '/sag/new';
} }

View File

@ -7,6 +7,8 @@ Denne mappe er det faste sted for idéer og udviklingsplaner, som er aftalt, men
- [Vareintegration mellem BMC Hub og e-conomic](vareintegration-economic.md) — permanent varekobling, produktsync, oprettelse, prisberegning og sikker dokumenteksport. - [Vareintegration mellem BMC Hub og e-conomic](vareintegration-economic.md) — permanent varekobling, produktsync, oprettelse, prisberegning og sikker dokumenteksport.
- [Kundestemning med AI](kundestemning-ai.md) — AI-analyse af stemning, hast og kunderisiko på indgående mails og sager. - [Kundestemning med AI](kundestemning-ai.md) — AI-analyse af stemning, hast og kunderisiko på indgående mails og sager.
- [Sikkert fakturaudtræk med layoutanalyse og AI](sikker-fakturaudtraek-hybrid-ai.md) — revisionssikker hybridpipeline med felt-evidens, validering og manuel kontrol. - [Sikkert fakturaudtræk med layoutanalyse og AI](sikker-fakturaudtraek-hybrid-ai.md) — revisionssikker hybridpipeline med felt-evidens, validering og manuel kontrol.
- [Kuando Busylight og central Presence](kuando-busylight-integration.md) — fælles presence, direkte intern KuandoHUB-styring og senere notifikationsalarmer.
- [Sag: topbar-filupload og personlig Buzzwords-visning](sag-filupload-og-buzzwords.md) — upload fra papirclips i topbaren, skjult tomt filmodul og personlig Buzzwords-visning.
## Arbejdsgang ## Arbejdsgang

View File

@ -0,0 +1,99 @@
# Kuando Busylight og central Presence i BMC Hub
**Status:** Planlagt
## Formål
Kuando Busylight bliver et output fra BMC Hubs fælles Presence Engine. Hubben er eneste status-master; profilprik, medarbejdervisning, Mission Control og Busylight bruger den samme resolved status og farve.
Hubben kalder KuandoHUB direkte på brugerens registrerede interne IP. Der bruges ingen BMC Device Agent.
## Arkitektur og afgrænsning
```text
Hændelse eller Hub-session
↓
Presence Engine
┌──────┼───────────────┐
↓ ↓ ↓
Profil Mission Control Kuando-klient i Hubben
↓
http://<intern-ip>:8989/
↓
KuandoHUB
↓
USB Busylight
```
- Hubben må kun kontakte IPv4-adresser i `172.16.0.0/12`.
- KuandoHUB bruger altid HTTP, port `8989` og den faste `/`-endpoint.
- Der understøttes én Busylight/KUandoHUB-konfiguration pr. bruger i første version.
- Brugeren indtaster selv sin interne IP i Profil → Busylight. IP-adressen er en begrænset destinationsadresse, ikke en login-mekanisme.
- Brugeren kan være `Available`, selv om KuandoHUB ikke kan nås. Enhedsstatus og presence er separate tilstande.
## Presence Engine
Det globale, administratorstyrede statuskatalog starter med:
| Status | Farve |
| --- | --- |
| Available | Grøn |
| Working | Gul |
| Busy | Rød |
| Do not disturb | Lilla |
| Lunch | Cyan |
| Away | Orange |
| Not at work | Pink |
| Offline | Grå |
- Normalstatus er `Available` ved aktiv Hub-session og `Offline`, når sidste aktive session lukker.
- En manuel status varer, indtil brugeren selv ændrer den.
- Prioritet opløses centralt: critical → phone → meeting → do-not-disturb → working → available.
- Når timer, opkald eller en notifikation ophører, genberegnes den gældende status. Der lagres ikke en løs farve, der blot skal genskabes.
- Presence udsendes gennem en dedikeret autentificeret WebSocket med bruger, resolved status, farve, kilde og tidspunkt. Eksisterende WebSocket-kontrakter for telefoni, Mission Control og bundbaren bevares.
## Profil og enhed
Profil → Busylight indeholder aktivering, intern IP, online-/fejlstatus, seneste kommando og testknapper for grøn, gul, rød, blå, lilla og sluk.
Kuando-klienten validerer destinationsadressen, følger ikke redirects og bruger korte connect-/response-timeouts. Kommandoresultater og fejl gemmes. UI må kun angive, at KuandoHUB accepterede kommandoen; fysisk lysrespons bekræftes visuelt.
## Leveringsrækkefølge
### Fase 1
- Statuskatalog og central resolved presence.
- Profilprik, Profil → Busylight og direkte KuandoHUB-kald.
- Manuelle farvetests og sluk.
### Fase 2
- Timer-start/-stop kobles til Working/Available.
- Telefoni: ringning giver midlertidig notifikation, besvaret samtale giver Busy, og afslutning genskaber den beregnede status.
- Mission Control viser samme medarbejderstatus.
### Fase 3
- Separat notification-overlay med blink/pulse, varighed og valgfri lyd.
- Tilslut ny/tildelt/hastesag, direkte besked, indgående opkald samt Mission Controls drift-warning og drift-critical.
- Critical-alarm kan kvitteres i Hubben og vender derefter tilbage til gældende presence.
- Profilen får lydvalg, volumen, ingen lyd under samtale/møde og quiet hours.
## Data og API'er
- Nye migrationer opretter statuskatalog, brugerpresence/overlejringer, Busylight-enhedsindstillinger, kommandohistorik og alarmkvitteringer.
- Autentificerede API'er dækker læs/ændr egen presence og Busylight-indstilling, hent/test egen enhed, administrér globale statuser og notifikationsregler samt kvittér critical-alarm.
- Kun administrator må ændre det globale statuskatalog. Brugeren kan kun læse og ændre sin egen enhed og status.
## Test og accept
- Enhedstest for prioritet, manuel overstyring, session-baseret online/offline og genberegning efter timer, opkald og notifikation.
- API- og autorisationstest for egne enheder, administratoradgang og afvisning af ugyldige, offentlige eller ikke-`172.16/12` IP-adresser.
- HTTP-test mod mock KuandoHUB for farver, sluk, timeout og fejl.
- WebSocket-test for realtime profil-/Mission Control-opdatering uden regression i eksisterende telefoni og bundbar.
- Manuel accept på internt net: visuel bekræftelse af testknapper, timer og opkald; utilgængelig KuandoHUB må kun gøre enheden fejlende.
## Forudsætninger
- Hub-serveren har netværksrute til brugernes `172.16.x.x`-adresser, og KuandoHUB lytter på port `8989`.
- Implementeringen ændrer ikke eksisterende API-adfærd uden for de nye presence- og Busylight-grænseflader.

View File

@ -0,0 +1,341 @@
# Sag: topbar-filupload og personlig Buzzwords-visning
**Status:** Under udvikling
## Formål
Gør filupload direkte tilgængelig fra sagens topbar, uden at en tom filsektion fylder i sagsvisningen. Giv samtidig hver bruger mulighed for at skjule Buzzwords-modulet i sin egen sagsvisning.
## Løsning
- Tilføj et papirclips-ikon i sagens topbar. Ikonet åbner den eksisterende sagsfil-upload gennem en skjult filvælger og uploader filen til den aktuelle sag.
- Bevar eksisterende fil-API, tilladelser, filvalidering og lagring; topbar-handlingen er kun en ekstra indgang til samme uploadfunktion.
- Efter en vellykket upload opdateres filantal og filmodul uden fuld sidegenindlæsning, og brugeren får tydelig succes- eller fejlbesked.
- Vis filmodulet, når sagen har mindst én fil. Skjul det automatisk, når sagen ikke indeholder filer; topbar-papirclipsen er stadig tilgængelig, så den første fil kan uploades.
- Tilføj en personlig modulpræference for Buzzwords. Brugeren kan skjule eller vise modulet, og valget gælder kun brugerens egen sagsvisning.
- Et tomt Buzzwords-modul skal fortsat være skjult. En gemt brugerpræference må ikke skabe et tomt modul.
## Data og kompatibilitet
- Genbrug eksisterende `sag_files` og den nuværende modulpræference-mekanisme; opret kun en migration, hvis Buzzwords mangler en vedvarende, bruger-specifik nøgle i den eksisterende præferencetabel.
- Eksisterende filupload og sags-API'er ændrer ikke adfærd. Den nye topbar-handling bruger samme autorisationskontrol som det eksisterende filmodul.
## Test og accept
- Upload via papirclips opretter samme filrecord og giver samme resultat som eksisterende upload.
- Uautoriseret eller fejlet upload vises som fejl og ændrer ikke filantal eller modulvisning.
- Sag uden filer viser ikke filmodulet, men kan modtage sin første fil via topbaren; modulet vises derefter.
- Buzzwords kan skjules og vises for én bruger uden at påvirke andre brugere, og er skjult for alle når indholdet er tomt.

View File

@ -0,0 +1,30 @@
-- Migration 1042: Per-user visibility preferences for case modules.
-- Existing sag_module_prefs remain case-wide for backwards compatibility.
-- New per-user preferences are initially used for the Buzzwords module only.
CREATE TABLE IF NOT EXISTS user_sag_module_prefs (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
module_key VARCHAR(50) NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, sag_id, module_key)
);
CREATE INDEX IF NOT EXISTS idx_user_sag_module_prefs_user_sag
ON user_sag_module_prefs(user_id, sag_id);
CREATE OR REPLACE FUNCTION update_user_sag_module_prefs_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS update_user_sag_module_prefs_updated_at ON user_sag_module_prefs;
CREATE TRIGGER update_user_sag_module_prefs_updated_at
BEFORE UPDATE ON user_sag_module_prefs
FOR EACH ROW
EXECUTE FUNCTION update_user_sag_module_prefs_updated_at();

View File

@ -3,7 +3,7 @@
'use strict'; 'use strict';
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const state = {tags: [], hardware: [], typeDetails: {}, tagCatalog: [], panels: {}, ready: false, restoring: false, key: null, pending: false, completed: false}; const state = {tags: [], hardware: [], typeDetails: {}, openModules: {}, tagCatalog: [], panels: {}, ready: false, restoring: false, key: null, pending: false, completed: false};
let saveTimer, duplicateTimer, duplicateToken = 0, workloadToken = 0, contactCasesToken = 0, tagSuggestionTimer, templates = []; let saveTimer, duplicateTimer, duplicateToken = 0, workloadToken = 0, contactCasesToken = 0, tagSuggestionTimer, templates = [];
async function api(url, options) { async function api(url, options) {
const response = await fetch(url, {credentials: 'include', ...options}); const response = await fetch(url, {credentials: 'include', ...options});
@ -38,7 +38,7 @@
} }
function snapshot() { function snapshot() {
return {version: 1, savedAt: Date.now(), fields: fieldSnapshot(), customer: selectedCustomer, contacts: selectedContacts, contactCompanies: selectedContactsCompanies, return {version: 1, savedAt: Date.now(), fields: fieldSnapshot(), customer: selectedCustomer, contacts: selectedContacts, contactCompanies: selectedContactsCompanies,
tags: state.tags, hardware: state.hardware, typeDetails: state.typeDetails, orders: collectOrderItems(), prefill: telefoniPrefill, tags: state.tags, hardware: state.hardware, typeDetails: state.typeDetails, openModules: state.openModules, orders: collectOrderItems(), prefill: telefoniPrefill,
customerSearch: $('customerSearch').value, contactSearch: $('contactSearch').value, customerSearch: $('customerSearch').value, contactSearch: $('contactSearch').value,
panels: Object.fromEntries(Object.entries(state.panels).map(([k, el]) => [k, el.open]))}; panels: Object.fromEntries(Object.entries(state.panels).map(([k, el]) => [k, el.open]))};
} }
@ -53,6 +53,7 @@
$('cc-save').textContent = 'Gemmer kladde…'; clearTimeout(saveTimer); saveTimer = setTimeout(save, 500); $('cc-save').textContent = 'Gemmer kladde…'; clearTimeout(saveTimer); saveTimer = setTimeout(save, 500);
} }
function renderTags() { function renderTags() {
if (!$('cc-tag-chips')) return;
$('cc-tag-chips').innerHTML = state.tags.map(t => `<span class="badge me-1 mb-1" style="background-color:#64748b" data-tag-id="${Number(t.id)}">${/^bi-[a-z0-9-]+$/.test(t.icon || '') ? `<i class="bi ${esc(t.icon)}"></i> ` : ''}${esc(t.name)} <button type="button" class="btn-close btn-close-white" aria-label="Fjern ${esc(t.name)}"></button></span>`).join(''); $('cc-tag-chips').innerHTML = state.tags.map(t => `<span class="badge me-1 mb-1" style="background-color:#64748b" data-tag-id="${Number(t.id)}">${/^bi-[a-z0-9-]+$/.test(t.icon || '') ? `<i class="bi ${esc(t.icon)}"></i> ` : ''}${esc(t.name)} <button type="button" class="btn-close btn-close-white" aria-label="Fjern ${esc(t.name)}"></button></span>`).join('');
state.tags.forEach(t => { const chip = $('cc-tag-chips').querySelector(`[data-tag-id="${Number(t.id)}"]`); if (/^#[0-9a-f]{6}$/i.test(t.color)) chip.style.backgroundColor = t.color; }); state.tags.forEach(t => { const chip = $('cc-tag-chips').querySelector(`[data-tag-id="${Number(t.id)}"]`); if (/^#[0-9a-f]{6}$/i.test(t.color)) chip.style.backgroundColor = t.color; });
summaries(); summaries();
@ -83,6 +84,7 @@
} }
} }
function renderSelectedHardware() { function renderSelectedHardware() {
if (!$('cc-hardware-chips')) return;
$('cc-hardware-chips').innerHTML = state.hardware.map(h => `<span class="badge bg-secondary me-1">${esc(h.name)} <button type="button" class="btn-close btn-close-white" data-remove-hardware="${Number(h.id)}" aria-label="Fjern hardware"></button></span>`).join(''); $('cc-hardware-chips').innerHTML = state.hardware.map(h => `<span class="badge bg-secondary me-1">${esc(h.name)} <button type="button" class="btn-close btn-close-white" data-remove-hardware="${Number(h.id)}" aria-label="Fjern hardware"></button></span>`).join('');
document.querySelectorAll('[data-hardware-choice]').forEach(el => { el.checked = state.hardware.some(h => h.id === Number(el.dataset.hardwareChoice)); }); document.querySelectorAll('[data-hardware-choice]').forEach(el => { el.checked = state.hardware.some(h => h.id === Number(el.dataset.hardwareChoice)); });
summaries(); summaries();
@ -141,6 +143,7 @@
} }
captureTypeDetails(); captureTypeDetails();
renderTypeDetails(type); renderTypeDetails(type);
$('typeDetailsSection')?.classList.toggle('d-none', !showTypeFields || !(typeFieldSets[type] || []).length);
$('createForm').closest('.card-custom')?.classList.toggle('cc-postit-mode', type === 'postit'); $('createForm').closest('.card-custom')?.classList.toggle('cc-postit-mode', type === 'postit');
state.panels['type-details']?.classList.toggle('cc-postit-panel', type === 'postit'); state.panels['type-details']?.classList.toggle('cc-postit-panel', type === 'postit');
document.querySelectorAll('[data-case-type]').forEach(b => { const active = b.dataset.caseType === type; b.classList.toggle('active', active); b.setAttribute('aria-pressed', String(active)); }); document.querySelectorAll('[data-case-type]').forEach(b => { const active = b.dataset.caseType === type; b.classList.toggle('active', active); b.setAttribute('aria-pressed', String(active)); });
@ -159,9 +162,32 @@
opgave: ['relations'], opgave: ['relations'],
abonnement: ['relations'], abonnement: ['relations'],
}[type] || ['relations']; }[type] || ['relations'];
['relations', 'type-details', 'hardware', 'pipeline', 'order'].forEach(key => state.panels[key]?.classList.toggle('d-none', !showTypeFields || !visiblePanels.includes(key))); const availableModules = visiblePanels.filter(key => key !== 'relations');
const moduleAction = $('typeModuleAction');
if (showTypeFields) state.openModules[type] = [...availableModules];
const openModules = state.openModules[type] || [];
if (moduleAction) {
const typeDetailsLabels = {
ticket: 'Ticket-oplysninger', salg: 'Salgsoplysninger', bogholderi: 'Bogholderioplysninger',
admin: 'Adminoplysninger', projekt: 'Projektoplysninger', postit: 'Notat og påmindelse',
intern_ticket: 'Intern opgave', bug_report: 'Fejlrapport'
};
const moduleLabels = {'type-details': typeDetailsLabels[type] || 'Oplysninger til sagstypen', hardware:'Hardware', pipeline:'Pipeline', order:'Indkøb og salg'};
const moduleIcons = {'type-details':'bi-card-text', hardware:'bi-pc-display', pipeline:'bi-graph-up-arrow', order:'bi-bag'};
moduleAction.classList.toggle('d-none', !availableModules.length);
moduleAction.innerHTML = availableModules.map(key => `<button type="button" class="btn ${openModules.includes(key) ? 'btn-primary' : 'btn-outline-primary'}" data-case-module="${esc(key)}"><i class="bi ${moduleIcons[key] || 'bi-plus-square'} me-1"></i>${esc(moduleLabels[key] || key)}</button>`).join('');
moduleAction.querySelectorAll('[data-case-module]').forEach(button => {
button.onclick = () => {
const moduleKey = button.dataset.caseModule;
state.openModules[type] = [...new Set([...(state.openModules[type] || []), moduleKey])];
typeChanged(type, false);
};
});
}
['relations', 'type-details', 'hardware', 'pipeline', 'order'].forEach(key => state.panels[key]?.classList.toggle('d-none', !openModules.includes(key) || !visiblePanels.includes(key)));
const sectionIds = {hardware: 'hardwareSection', pipeline: 'pipelineSection', order: 'orderSection'}; const sectionIds = {hardware: 'hardwareSection', pipeline: 'pipelineSection', order: 'orderSection'};
Object.entries(sectionIds).forEach(([key, id]) => $(id)?.classList.toggle('d-none', !showTypeFields || !visiblePanels.includes(key))); $('typeDetailsSection')?.classList.toggle('d-none', !openModules.includes('type-details') || !(typeFieldSets[type] || []).length);
Object.entries(sectionIds).forEach(([key, id]) => $(id)?.classList.toggle('d-none', !openModules.includes(key) || !visiblePanels.includes(key)));
document.querySelectorAll('.case-type-extra').forEach(el => el.classList.toggle('d-none', !showTypeFields)); document.querySelectorAll('.case-type-extra').forEach(el => el.classList.toggle('d-none', !showTypeFields));
$('cc-template')?.closest('.cc-template-toolbar')?.classList.toggle('d-none', !showTypeFields); $('cc-template')?.closest('.cc-template-toolbar')?.classList.toggle('d-none', !showTypeFields);
if (showTypeFields && !state.restoring) visiblePanels.forEach(panelKey => { if (state.panels[panelKey]) state.panels[panelKey].open = true; }); if (showTypeFields && !state.restoring) visiblePanels.forEach(panelKey => { if (state.panels[panelKey]) state.panels[panelKey].open = true; });
@ -228,13 +254,19 @@
const keys = [...new Set([current, ...options.map(o => o.value)])].filter(k => options.some(o => o.value === k)); const keys = [...new Set([current, ...options.map(o => o.value)])].filter(k => options.some(o => o.value === k));
const hints = {ticket:'Support og hjælp',salg:'Tilbud og salg',bogholderi:'Bilag og økonomi',admin:'Intern administration',projekt:'Større forløb',postit:'Huskeseddel',intern_ticket:'Intern opgave',bug_report:'Rapportér en fejl'}; const hints = {ticket:'Support og hjælp',salg:'Tilbud og salg',bogholderi:'Bilag og økonomi',admin:'Intern administration',projekt:'Større forløb',postit:'Huskeseddel',intern_ticket:'Intern opgave',bug_report:'Rapportér en fejl'};
const button = key => `<button type="button" class="btn btn-outline-primary" data-case-type="${esc(key)}" aria-pressed="false"><span class="cc-type-name">${esc(options.find(o => o.value === key).text)}</span><small class="cc-type-hint">${esc(hints[key] || 'Opret sag')}</small></button>`; const button = key => `<button type="button" class="btn btn-outline-primary" data-case-type="${esc(key)}" aria-pressed="false"><span class="cc-type-name">${esc(options.find(o => o.value === key).text)}</span><small class="cc-type-hint">${esc(hints[key] || 'Opret sag')}</small></button>`;
const messageButton = $('caseMessageTypeButton');
$('type').classList.add('d-none'); $('type').removeAttribute('required'); $('type').classList.add('d-none'); $('type').removeAttribute('required');
const div = document.createElement('div'); div.id = 'cc-types'; div.className = 'cc-types'; div.setAttribute('role', 'group'); div.setAttribute('aria-label', 'Sagstype'); const div = document.createElement('div'); div.id = 'cc-types'; div.className = 'cc-types'; div.setAttribute('role', 'group'); div.setAttribute('aria-label', 'Sagstype');
div.innerHTML = keys.slice(0,4).map(button).join('') + (keys.length > 4 ? `<details id="cc-more"><summary>Flere</summary><div class="cc-types">${keys.slice(4).map(button).join('')}</div></details>` : ''); div.innerHTML = keys.map(button).join('');
if (messageButton) { messageButton.classList.remove('cc-message-fallback'); messageButton.dataset.caseMessage = 'true'; div.insertBefore(messageButton, div.querySelector('#cc-more')); }
$('type').after(div); $('type').after(div);
div.addEventListener('click', e => { const message = e.target.closest('[data-case-message]'); if (message) { window.caseCreateUI?.openMessage(); return; } const b = e.target.closest('[data-case-type]'); if (b) { $('type').value = b.dataset.caseType; $('type').dispatchEvent(new Event('change', {bubbles:true})); } }); div.addEventListener('click', e => { const b = e.target.closest('[data-case-type]'); if (b) { $('type').value = b.dataset.caseType; $('type').dispatchEvent(new Event('change', {bubbles:true})); } });
}
function selectNextType() {
const buttons = Array.from(document.querySelectorAll('#cc-types > button[data-case-type]'));
if (!buttons.length) return false;
const currentType = $('type')?.value;
const currentIndex = buttons.findIndex(button => button.dataset.caseType === currentType);
buttons[(currentIndex + 1 + buttons.length) % buttons.length].click();
return true;
} }
async function applyTemplate(id) { async function applyTemplate(id) {
const template = templates.find(t => t.id === Number(id)); if (!template) return; const template = templates.find(t => t.id === Number(id)); if (!template) return;
@ -249,7 +281,7 @@
async function restore(draft) { async function restore(draft) {
state.restoring = true; state.restoring = true;
try { try {
selectedCustomer = draft.customer; selectedContacts = draft.contacts || {}; selectedContactsCompanies = draft.contactCompanies || {}; state.typeDetails = draft.typeDetails || {}; selectedCustomer = draft.customer; selectedContacts = draft.contacts || {}; selectedContactsCompanies = draft.contactCompanies || {}; state.typeDetails = draft.typeDetails || {}; state.openModules = draft.openModules || {};
telefoniPrefill = draft.prefill || telefoniPrefill; telefoniPrefill = draft.prefill || telefoniPrefill;
Object.entries(draft.fields || {}).forEach(([id,value]) => { const el = $(id); if (el && !id.startsWith('cc-')) { if (el.type === 'checkbox') el.checked = value; else el.value = value; } }); Object.entries(draft.fields || {}).forEach(([id,value]) => { const el = $(id); if (el && !id.startsWith('cc-')) { if (el.type === 'checkbox') el.checked = value; else el.value = value; } });
state.tags = draft.tags || []; state.hardware = draft.hardware || []; state.tags = draft.tags || []; state.hardware = draft.hardware || [];
@ -259,10 +291,10 @@
await loadCreateTopAlertsForCustomer(selectedCustomer?.id || null); await loadCreateTopAlertsForCustomer(selectedCustomer?.id || null);
if (selectedCustomer) await loadSelectedCustomerContacts(selectedCustomer.id); if (selectedCustomer) await loadSelectedCustomerContacts(selectedCustomer.id);
$('customerSearch').value = draft.customerSearch || ''; $('contactSearch').value = draft.contactSearch || ''; $('customerSearch').value = draft.customerSearch || ''; $('contactSearch').value = draft.contactSearch || '';
window.updateCaseTypeSections?.(true); Object.entries(draft.panels || {}).forEach(([k,v]) => { if (state.panels[k]) state.panels[k].open = v; }); window.updateCaseTypeSections?.(false); Object.entries(draft.panels || {}).forEach(([k,v]) => { if (state.panels[k]) state.panels[k].open = v; });
$('charCount').textContent = `${$('beskrivelse').value.length} tegn`; $('charCount').textContent = `${$('beskrivelse').value.length} tegn`;
} finally { state.restoring = false; } } finally { state.restoring = false; }
state.pending = false; $('cc-draft').remove(); scheduleSave(); scheduleDuplicates(); workload(); state.pending = false; $('cc-draft').remove(); scheduleSave();
} }
function validate() { function validate() {
document.querySelectorAll('.cc-error').forEach(el => el.remove()); document.querySelectorAll('.cc-error').forEach(el => el.remove());
@ -270,7 +302,11 @@
const errors = []; const errors = [];
const add = (el, text) => { errors.push(text); el.classList.add('is-invalid'); el.insertAdjacentHTML('afterend', `<div class="text-danger small cc-error">${esc(text)}</div>`); const detail = el.closest('details'); if (detail) { detail.open = true; if (!detail.querySelector('summary .cc-error')) detail.querySelector('summary').insertAdjacentHTML('beforeend','<span class="text-danger small cc-error"> · Kontrollér felter</span>'); } }; const add = (el, text) => { errors.push(text); el.classList.add('is-invalid'); el.insertAdjacentHTML('afterend', `<div class="text-danger small cc-error">${esc(text)}</div>`); const detail = el.closest('details'); if (detail) { detail.open = true; if (!detail.querySelector('summary .cc-error')) detail.querySelector('summary').insertAdjacentHTML('beforeend','<span class="text-danger small cc-error"> · Kontrollér felter</span>'); } };
if (!$('titel').value.trim()) add($('titel'), 'Titel er påkrævet'); if (!$('titel').value.trim()) add($('titel'), 'Titel er påkrævet');
if (['ticket', 'salg', 'bogholderi', 'projekt', 'service', 'pipeline', 'ordre', 'opgave', 'abonnement'].includes($('type').value) && !selectedCustomer && $('customerSearch').value.trim().length < 2) add($('customerSearch'), 'Vælg et firma'); if (!$('beskrivelse').value.trim()) add($('beskrivelse'), 'Beskrivelse er påkrævet');
const type = $('type').value;
const needsCompanyAndContact = ['ticket', 'bogholderi', 'admin'].includes(type);
if (needsCompanyAndContact && !selectedCustomer && $('customerSearch').value.trim().length < 2) add($('customerSearch'), 'Virksomhed er påkrævet for denne sagstype');
if (needsCompanyAndContact && Object.keys(selectedContacts).length === 0 && $('contactSearch').value.trim().length < 2) add($('contactSearch'), 'Kontakt er påkrævet for denne sagstype');
$('createForm').querySelectorAll('input[type=number]').forEach(el => { if (!el.checkValidity()) add(el, 'Kontrollér tallets interval og format'); }); $('createForm').querySelectorAll('input[type=number]').forEach(el => { if (!el.checkValidity()) add(el, 'Kontrollér tallets interval og format'); });
document.querySelectorAll('#orderLines .order-line').forEach(row => { for (const cls of ['description','amount']) { const el = row.querySelector(`.order-${cls}`); if (!el.value.trim()) add(el, cls === 'description' ? 'Ordrelinjen mangler beskrivelse' : 'Ordrelinjen mangler beløb'); } }); document.querySelectorAll('#orderLines .order-line').forEach(row => { for (const cls of ['description','amount']) { const el = row.querySelector(`.order-${cls}`); if (!el.value.trim()) add(el, cls === 'description' ? 'Ordrelinjen mangler beskrivelse' : 'Ordrelinjen mangler beløb'); } });
if (errors.length) { $('error').classList.remove('d-none'); $('error-text').textContent = [...new Set(errors)].join(' · '); document.querySelector('.is-invalid')?.focus(); return false; } if (errors.length) { $('error').classList.remove('d-none'); $('error-text').textContent = [...new Set(errors)].join(' · '); document.querySelector('.is-invalid')?.focus(); return false; }
@ -283,15 +319,6 @@
const statuses = JSON.parse(setting.value || '[]'); const statuses = JSON.parse(setting.value || '[]');
if (Array.isArray(statuses)) statuses.forEach(s => { const value = typeof s === 'string' ? s : s.value; if (value && !Array.from($('status').options).some(o => o.value === value)) $('status').add(new Option(typeof s === 'string' ? s : (s.label || value), value)); }); if (Array.isArray(statuses)) statuses.forEach(s => { const value = typeof s === 'string' ? s : s.value; if (value && !Array.from($('status').options).some(o => o.value === value)) $('status').add(new Option(typeof s === 'string' ? s : (s.label || value), value)); });
} catch { /* Keep the built-in statuses available. */ } } catch { /* Keep the built-in statuses available. */ }
const headings = Array.from(form.querySelectorAll(':scope > h5'));
panel('relations', 'Kunde og kontakter', [headings[0], headings[0].nextElementSibling], false);
for (const [key,id,title] of [['type-details','typeDetailsSection','Oplysninger til sagstypen'],['hardware','hardwareSection','Hardware og AnyDesk'],['pipeline','pipelineSection','Pipeline'],['order','orderSection','Indkøb og salg']]) panel(key, title, [$(id)]);
const metadata = headings[1]; panel('metadata', 'Ansvarlig', [metadata,metadata.nextElementSibling,metadata.nextElementSibling.nextElementSibling], true);
const tags = document.createElement('section'); tags.innerHTML = '<div id="cc-tag-chips"></div><button type="button" id="cc-tag-add" class="btn btn-sm btn-outline-primary">Tilføj tag</button><div id="cc-tag-suggestions" class="cc-tag-suggestions"></div>'; state.panels.hardware.before(tags); panel('tags', 'Tags', [tags], true);
$('cc-tag-add').onclick = () => window.tagPicker.showSelection(addTag);
window.setTagPickerContext?.('case', null, addTag);
$('cc-tag-chips').onclick = e => { const chip = e.target.closest('[data-tag-id]'); if (chip && e.target.closest('button')) { state.tags = state.tags.filter(t => t.id !== Number(chip.dataset.tagId)); renderTags(); scheduleSave(); } };
$('cc-tag-suggestions').onclick = e => { const button = e.target.closest('[data-suggested-tag]'); const tag = state.tagCatalog.find(item => item.id === Number(button?.dataset.suggestedTag)); if (tag) addTag(tag).catch(error => showCreateError(error.message)); };
const chips = document.createElement('div'); chips.id = 'cc-hardware-chips'; $('hardwareList').before(chips); const chips = document.createElement('div'); chips.id = 'cc-hardware-chips'; $('hardwareList').before(chips);
chips.onclick = e => { const b = e.target.closest('[data-remove-hardware]'); if (b) { state.hardware = state.hardware.filter(h => h.id !== Number(b.dataset.removeHardware)); renderSelectedHardware(); scheduleSave(); } }; chips.onclick = e => { const b = e.target.closest('[data-remove-hardware]'); if (b) { state.hardware = state.hardware.filter(h => h.id !== Number(b.dataset.removeHardware)); renderSelectedHardware(); scheduleSave(); } };
const originalRenderHardware = window.renderHardwareList; const originalRenderHardware = window.renderHardwareList;
@ -304,19 +331,20 @@
try { const item = await api('/api/v1/hardware/quick', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,customer_id:customerId,anydesk_id:anydeskId || null,anydesk_link:anydeskId ? `anydesk://${anydeskId}` : null})}); state.hardware.push({id:item.id,name}); $('hardwareNameInput').value = ''; $('hardwareAnyDeskIdInput').value = ''; renderSelectedHardware(); scheduleSave(); } catch(e) { showCreateError(e.message); } finally { button.disabled = false; } try { const item = await api('/api/v1/hardware/quick', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,customer_id:customerId,anydesk_id:anydeskId || null,anydesk_link:anydeskId ? `anydesk://${anydeskId}` : null})}); state.hardware.push({id:item.id,name}); $('hardwareNameInput').value = ''; $('hardwareAnyDeskIdInput').value = ''; renderSelectedHardware(); scheduleSave(); } catch(e) { showCreateError(e.message); } finally { button.disabled = false; }
}; };
const originalRender = window.renderSelections; const originalRender = window.renderSelections;
window.renderSelections = () => { originalRender(); if (state.ready) { scheduleSave(); scheduleDuplicates(); contactOpenCases(); } }; window.renderSelections = () => { originalRender(); if (state.ready) scheduleSave(); };
const contactCases = document.createElement('aside'); contactCases.id = 'cc-contact-cases'; contactCases.className = 'cc-contact-cases'; contactCases.hidden = true; $('selectedContacts').after(contactCases);
const workloadEl = document.createElement('div'); workloadEl.id = 'cc-workload'; workloadEl.className = 'cc-workload'; state.panels.metadata.after(workloadEl);
const duplicateEl = document.createElement('div'); duplicateEl.id = 'cc-duplicates'; $('titel').closest('.row').after(duplicateEl);
const toolbar = document.createElement('div'); toolbar.className = 'mb-3'; toolbar.innerHTML = '<label class="form-label" for="cc-template">Hurtigskabelon</label><select id="cc-template" class="form-select"><option value="">Start uden skabelon</option></select><small id="cc-template-error" class="text-muted"></small>'; form.prepend(toolbar);
try { templates = await api('/api/v1/case-create/templates'); $('cc-template').insertAdjacentHTML('beforeend',templates.map(t => `<option value="${t.id}">${esc(t.name)}</option>`).join('')); } catch { $('cc-template-error').textContent = 'Skabeloner er ikke tilgængelige.'; }
$('cc-template').onchange = e => applyTemplate(e.target.value).catch(err => showCreateError(err.message));
const actions = $('submitBtn').parentElement; actions.classList.add('cc-actions'); actions.insertAdjacentHTML('afterbegin','<small id="cc-save" role="status" class="me-auto align-self-center">Klargør kladde…</small>'); const actions = $('submitBtn').parentElement; actions.classList.add('cc-actions'); actions.insertAdjacentHTML('afterbegin','<small id="cc-save" role="status" class="me-auto align-self-center">Klargør kladde…</small>');
toolbar.classList.add('cc-template-toolbar'); // Core flow stays visible: type → required fields → chosen type's fields.
const typeSection = form.querySelector('.cc-type-section');
const basicSection = $('titel').closest('.row');
const relationsSection = $('customerSearch').closest('.row');
const assignmentSection = $('ansvarlig_bruger_id').closest('.row');
const moduleAction = $('typeModuleAction');
if (typeSection && basicSection) typeSection.after(basicSection);
if (basicSection && relationsSection) basicSection.after(relationsSection);
if (assignmentSection && moduleAction) assignmentSection.after(moduleAction);
buildTypes(); updateCaseTypeSections(false); buildTypes(); updateCaseTypeSections(false);
form.addEventListener('input', scheduleSave); form.addEventListener('change', scheduleSave); form.addEventListener('click', () => setTimeout(scheduleSave,0)); form.addEventListener('input', scheduleSave); form.addEventListener('change', scheduleSave); form.addEventListener('click', () => setTimeout(scheduleSave,0));
$('titel').addEventListener('input', () => { scheduleDuplicates(); scheduleTagSuggestions(); }); $('titel').addEventListener('input', scheduleSave);
$('beskrivelse').addEventListener('input', scheduleTagSuggestions); $('ansvarlig_bruger_id').addEventListener('change', workload);
document.addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !document.querySelector('.modal.show')) { e.preventDefault(); if (!$('submitBtn').disabled) form.requestSubmit(); } }); document.addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !document.querySelector('.modal.show')) { e.preventDefault(); if (!$('submitBtn').disabled) form.requestSubmit(); } });
window.addEventListener('pagehide', save); window.addEventListener('pagehide', save);
try { try {
@ -334,10 +362,10 @@
} }
$('cc-save').textContent = draft ? 'Vælg om kladden skal gendannes' : 'Kladde gemmes i denne browser'; $('cc-save').textContent = draft ? 'Vælg om kladden skal gendannes' : 'Kladde gemmes i denne browser';
} catch { $('cc-save').textContent = 'Autosave er ikke tilgængelig'; } } catch { $('cc-save').textContent = 'Autosave er ikke tilgængelig'; }
state.tagCatalog = await api('/api/v1/tags?is_active=true').catch(() => []); state.ready = true;
state.ready = true; summaries(); renderTagSuggestions(); workload(); scheduleDuplicates(); contactOpenCases(); await loadHardwareForContacts(); await loadHardwareForContacts();
} }
window.caseCreateUI = {init,typeChanged,contactsChanged:contactOpenCases,validate,openMessage:() => { const contacts=Object.values(selectedContacts); window.openInternalMessage({contact:contacts.length===1?contacts[0]:null}); },relations:() => { captureTypeDetails(); return {tag_ids:state.tags.map(t=>t.id),hardware_ids:state.hardware.map(h=>h.id),type_details:state.typeDetails[$('type').value] || {}}; },created: result => { window.caseCreateUI = {init,typeChanged,nextType:selectNextType,contactsChanged:contactOpenCases,validate,openMessage:() => { const contacts=Object.values(selectedContacts); window.openInternalMessage({contact:contacts.length===1?contacts[0]:null}); },relations:() => { captureTypeDetails(); return {tag_ids:state.tags.map(t=>t.id),hardware_ids:state.hardware.map(h=>h.id),type_details:state.typeDetails[$('type').value] || {}}; },created: result => {
state.completed = true; clearTimeout(saveTimer); state.completed = true; clearTimeout(saveTimer);
try { if (state.key) localStorage.removeItem(state.key); if (result.tag_actions?.length) sessionStorage.setItem(`bmc:case-tag-actions:${result.id}`, JSON.stringify(result.tag_actions)); } catch { /* Case is already saved. */ } try { if (state.key) localStorage.removeItem(state.key); if (result.tag_actions?.length) sessionStorage.setItem(`bmc:case-tag-actions:${result.id}`, JSON.stringify(result.tag_actions)); } catch { /* Case is already saved. */ }
}}; }};

View File

@ -34,54 +34,32 @@ async function page(storage, userId=7, search='') {
} }
(async()=>{ (async()=>{
const dom=await page(),w=dom.window,d=w.document; const dom=await page(),w=dom.window,d=w.document;
assert.equal(d.querySelectorAll('#cc-types > button[data-case-type]').length,4); assert.equal(d.querySelectorAll('#cc-types > button[data-case-type]').length,8);
assert.equal(d.querySelectorAll('#cc-types > button[data-case-message]').length,1); assert.equal(d.querySelectorAll('#cc-types > button[data-case-message]').length,1);
assert.equal(d.querySelectorAll('.cc-panel').length,6);
assert.equal(d.querySelectorAll('form#createForm').length,1); assert.equal(d.querySelectorAll('form#createForm').length,1);
for(const id of ['customerSearch','titel','beskrivelse','status','deadline','submitBtn'])assert.ok(d.getElementById(id).closest('#createForm'),id); for(const id of ['customerSearch','contactSearch','titel','beskrivelse','status','deadline','submitBtn'])assert.ok(d.getElementById(id).closest('#createForm'),id);
assert.match(d.getElementById('cc-workload').textContent,/0 åbne sager/);
assert.equal(w.caseCreateUI.validate(),false); assert.equal(w.caseCreateUI.validate(),false);
assert.equal(d.getElementById('cc-relations').open,true); assert.ok(d.getElementById('typeDetailsSection').classList.contains('d-none'),'Type fields stay hidden until a type button is selected');
d.querySelector('[data-case-type="bug_report"]').click();
assert.ok(!d.getElementById('typeModuleAction').classList.contains('d-none'));
assert.ok(d.getElementById('typeDetailsSection').classList.contains('d-none'));
d.getElementById('openTypeModuleBtn').click();
assert.ok(!d.getElementById('typeDetailsSection').classList.contains('d-none'));
assert.match(d.getElementById('typeDetailsFields').textContent,/Berørt område/);
w.testSetCustomer(); w.testSetCustomer();
await w.loadSelectedCustomerContacts(11); await w.loadSelectedCustomerContacts(11);
const search=d.getElementById('contactSearch'), results=d.getElementById('contactResults'); const search=d.getElementById('contactSearch'), results=d.getElementById('contactResults');
assert.ok(results.classList.contains('d-none'),'Selecting company must not open contact results');
search.focus();assert.ok(results.classList.contains('d-none'),'Empty focus must not open results');
search.value='Fi';search.dispatchEvent(new w.Event('input',{bubbles:true}));assert.ok(!results.classList.contains('d-none'),'Explicit search opens results'); search.value='Fi';search.dispatchEvent(new w.Event('input',{bubbles:true}));assert.ok(!results.classList.contains('d-none'),'Explicit search opens results');
search.value='';search.dispatchEvent(new w.Event('input',{bubbles:true}));assert.ok(results.classList.contains('d-none'),'Clearing closes results');
w.testSetContact(); await delay(30);
assert.match(d.getElementById('cc-contact-cases').textContent,/Eksisterende routerfejl/);
assert.match(d.getElementById('cc-contact-cases').innerHTML,/\/sag\/44\/v3/);
d.getElementById('titel').value='Test oprettelse'; d.getElementById('titel').value='Test oprettelse';
d.getElementById('beskrivelse').value='Router mister forbindelsen';d.getElementById('beskrivelse').dispatchEvent(new w.Event('input',{bubbles:true}));await delay(400); d.getElementById('beskrivelse').value='Router mister forbindelsen';
assert.match(d.getElementById('cc-tag-suggestions').textContent,/Router/);
d.getElementById('pipeline_amount').value='123';
d.querySelector('[data-case-type="ordre"]').click();
w.addOrderLine('sale');d.querySelector('.order-description').value='Router';d.querySelector('.order-amount').value='50';
d.querySelector('[data-case-type="ticket"]').click();
assert.equal(d.getElementById('pipeline_amount').value,'123');
assert.equal(w.caseCreateUI.validate(),true); assert.equal(w.caseCreateUI.validate(),true);
w.confirm=()=>false;
d.getElementById('cc-template').value='1';d.getElementById('cc-template').dispatchEvent(new w.Event('change',{bubbles:true}));await delay(30);
assert.equal(d.getElementById('titel').value,'Test oprettelse');
w.confirm=()=>true;
d.getElementById('cc-tag-add').click();
d.getElementById('titel').dispatchEvent(new w.Event('input',{bubbles:true})); d.getElementById('titel').dispatchEvent(new w.Event('input',{bubbles:true}));
await delay(650); await delay(650);
const key=Object.keys(w.localStorage)[0];assert.ok(key?.startsWith('bmc:case-draft:v1:7:'), d.getElementById('cc-save').textContent); const key=Object.keys(w.localStorage)[0];assert.ok(key?.startsWith('bmc:case-draft:v1:7:'), d.getElementById('cc-save').textContent);
const draft=JSON.parse(w.localStorage.getItem(key));assert.equal(draft.orders.length,1);assert.equal(draft.tags[0].id,9);
const saved={[key]:w.localStorage.getItem(key)}; const saved={[key]:w.localStorage.getItem(key)};
const restored=await page(saved);restored.window.document.getElementById('cc-restore').click();await delay(80); const restored=await page(saved);restored.window.document.getElementById('cc-restore').click();await delay(80);
assert.equal(restored.window.document.getElementById('titel').value,'Test oprettelse'); assert.equal(restored.window.document.getElementById('titel').value,'Test oprettelse');
assert.equal(restored.window.document.querySelector('.order-description').value,'Router');
assert.deepEqual(Array.from(restored.window.caseCreateUI.relations().tag_ids),[9]);
restored.window.caseCreateUI.created({id:99});await delay(600);assert.equal(restored.window.localStorage.getItem(key),null); restored.window.caseCreateUI.created({id:99});await delay(600);assert.equal(restored.window.localStorage.getItem(key),null);
const other=await page(saved,8);assert.equal(other.window.document.getElementById('cc-restore'),null); [dom,restored].forEach(x=>x.window.close());
const prefill=await page(saved,7,'?title=Other');assert.equal(prefill.window.document.getElementById('cc-restore'),null); console.log('PASS: simple flow, all type buttons, required fields and draft restore');
const expired=await page({[key]:JSON.stringify({...draft,savedAt:Date.now()-8*86400000})});assert.equal(expired.window.document.getElementById('cc-restore'),null);
const td=expired.window.document;td.getElementById('cc-template').value='1';td.getElementById('cc-template').dispatchEvent(new expired.window.Event('change',{bubbles:true}));await delay(40);
assert.equal(td.getElementById('titel').value,'Tilbud');assert.equal(td.getElementById('type').value,'pipeline');assert.deepEqual(Array.from(expired.window.caseCreateUI.relations().tag_ids),[9]);
assert.equal(new Set(Array.from(d.querySelectorAll('[id]')).map(el=>el.id)).size,d.querySelectorAll('[id]').length,'IDs must remain unique');
[dom,restored,other,prefill,expired].forEach(x=>x.window.close());
console.log('PASS: panel structure, type switching, validation, tags, autosave, restore, user/prefill isolation, expiration, successful cleanup');
})().catch(e=>{console.error(e);process.exit(1);}); })().catch(e=>{console.error(e);process.exit(1);});

View File

@ -128,6 +128,27 @@ def test_relation_quick_task_uses_todo_steps_api():
assert "due_date: due" in template assert "due_date: due" in template
def test_add_primary_case_contact_clears_previous_primary(monkeypatch):
queries = []
updates = []
def fake_query(query, params=None, **_kwargs):
queries.append((query, params))
if "SELECT id FROM sag_kontakter" in query:
return []
if "INSERT INTO sag_kontakter" in query:
return [{"id": 12, "sag_id": 177, "contact_id": 2011, "is_primary": True}]
raise AssertionError(query)
monkeypatch.setattr(sag_router, "execute_query", fake_query)
monkeypatch.setattr(sag_router, "execute_update", lambda query, params: updates.append((query, params)))
result = asyncio.run(sag_router.add_case_contact(177, {"contact_id": 2011, "is_primary": True}))
assert result["contact_id"] == 2011
assert updates == [("UPDATE sag_kontakter SET is_primary = FALSE WHERE sag_id = %s", (177,))]
def test_case_email_forward_supports_latest_mail_and_full_thread_as_new_thread(): def test_case_email_forward_supports_latest_mail_and_full_thread_as_new_thread():
template = Path("app/modules/sag/templates/detail_v3.html").read_text() template = Path("app/modules/sag/templates/detail_v3.html").read_text()
@ -145,6 +166,94 @@ def test_case_detail_disables_browser_cache_for_fresh_inline_ui():
assert 'response.headers["Cache-Control"] = "no-store, max-age=0"' in source assert 'response.headers["Cache-Control"] = "no-store, max-age=0"' in source
def test_v4_is_the_canonical_case_view_and_v3_redirects_to_it():
source = Path("app/modules/sag/frontend/views.py").read_text()
assert 'async def sag_detaljer_v4' in source
assert 'async def sag_detaljer_v3_legacy' in source
assert 'RedirectResponse(url=f"/sag/{sag_id}/v4", status_code=307)' in source
def test_v4_case_upload_is_available_from_topbar_and_empty_file_module_is_content_driven():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert 'bi bi-paperclip' in template
assert "document.getElementById('fileInput').click()" in template
assert "'files'" in template
assert "v4ContentDrivenModules" in template
assert "caseTopbarUploadBtn" in template
assert "handleTopbarAttachmentFiles" in template
assert "uploadEmailFile(emailFile)" in template
assert "fil${count === 1 ? '' : 'er'} uploadet" in template
def test_v4_file_module_renders_clickable_inline_preview_thumbnails():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert "case-file-thumbnail" in template
assert "previewFileById(${fileId})" in template
assert "preview-image?page=1&scale=1" in template
assert "bi bi-eye" in template
assert "previewAdjacentFile(-1)" in template
assert "previewAdjacentFile(1)" in template
assert "event.key === 'ArrowLeft'" in template
assert "event.key === 'ArrowRight'" in template
def test_v4_email_view_can_recover_missing_declared_attachments():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert "const attachmentsMissing" in template
assert "Hent vedhæftninger" in template
assert "/recover-attachments" in template
assert "recoverLinkedEmailAttachments" in template
def test_v4_header_has_no_duplicate_status_or_type_and_uses_contact_widget():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
header = template.split("<!-- WIDGET DASHBOARD -->", 1)[0]
assert 'id="caseStatusBadge"' not in header
assert 'id="caseTypeDropdownBtn"' not in header
assert "showCustomerSearch('replace')" not in header
assert "showContactSearch('replace')" not in header
assert "showAfdelingModal()" not in header
assert "Kontakt\n </div>" in template
assert "Firma</span>" in template
assert "Hovedkontakt</span>" in template
assert "Afdeling</span>" in template
assert "Start VoIP-kald" in template
assert "Send SMS" in template
def test_v4_contact_picker_loads_company_contacts_before_global_search():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
assert "loadCustomerContactsForContactSearch" in template
assert "/api/v1/customers/${customerId}/contacts" in template
assert "Firmaets kontakter" in template
assert "Andre kontakter" in template
assert "Søg efter anden kontakt" in template
def test_uploaded_email_attachments_are_copied_to_case_files():
router_source = Path("app/modules/sag/backend/router.py").read_text()
assert "_copy_uploaded_email_attachments_to_case" in router_source
assert 'attachment_files_created' in router_source
assert 'source_type")' in router_source
def test_buzzwords_preference_is_per_user_without_changing_legacy_module_preferences():
router_source = Path("app/modules/sag/backend/router.py").read_text()
migration = Path("migrations/1042_user_sag_module_prefs.sql").read_text()
assert "user_sag_module_prefs" in migration
assert "UNIQUE (user_id, sag_id, module_key)" in migration
assert "module_key <> 'buzzwords'" in router_source
assert "INSERT INTO user_sag_module_prefs" in router_source
def test_case_create_lists_contacts_for_selected_customer(): def test_case_create_lists_contacts_for_selected_customer():
template = Path("app/modules/sag/templates/create.html").read_text() template = Path("app/modules/sag/templates/create.html").read_text()
@ -156,6 +265,25 @@ def test_case_create_lists_contacts_for_selected_customer():
assert "resetCustomerContactSearch();" in template assert "resetCustomerContactSearch();" in template
def test_case_create_uses_simple_type_first_flow_with_required_description():
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
script = Path("static/js/case-create.js").read_text(encoding="utf-8")
assert 'id="beskrivelse"' in template
assert 'placeholder="Beskriv problemstillingen detaljeret..." required' in template
assert 'id="advancedCaseFields"' in template
assert "keys.map(button).join('')" in script
assert "keys.slice(0,4)" not in script
assert "Beskrivelse er påkrævet" in script
assert "type → required fields → chosen type's fields" in script
assert 'id="typeModuleAction"' in template
assert "data-case-module" in script
assert "Ticket-oplysninger" in script
assert "['ticket', 'bogholderi', 'admin']" in script
assert "Virksomhed er påkrævet for denne sagstype" in script
assert "Kontakt er påkrævet for denne sagstype" in script
def test_case_create_defaults_responsible_to_current_user(): def test_case_create_defaults_responsible_to_current_user():
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8") template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
router = Path("app/modules/sag/backend/router.py").read_text(encoding="utf-8") router = Path("app/modules/sag/backend/router.py").read_text(encoding="utf-8")
@ -181,7 +309,7 @@ def test_case_create_sends_relations_in_atomic_create_payload():
assert 'raw_contact_ids = data.get("contact_ids")' in router assert 'raw_contact_ids = data.get("contact_ids")' in router
assert "INSERT INTO sag_kontakter" in router assert "INSERT INTO sag_kontakter" in router
assert "UPDATE telefoni_opkald" in router assert "UPDATE telefoni_opkald" in router
assert "window.location.href = `/sag/${result.id}/v3`;" in template assert "window.location.href = `/sag/${result.id}/v4`;" in template
assert "Omdirigerer..." not in template assert "Omdirigerer..." not in template

View File

@ -130,4 +130,6 @@ def test_case_click_to_call_contract_tracks_case_contact_and_time():
assert 'pending_callid = f"click-to-call:' in router assert 'pending_callid = f"click-to-call:' in router
assert "_register_completed_call_time(resolved_callid)" in router assert "_register_completed_call_time(resolved_callid)" in router
assert "INSERT INTO tmodule_times" in router assert "INSERT INTO tmodule_times" in router
assert "callid LIKE 'click-to-call:%'" in service # psycopg uses % for placeholders, so the literal SQL LIKE wildcard must
# be escaped. Otherwise the established Yealink callback returns HTTP 500.
assert "callid LIKE 'click-to-call:%%'" in service