release: v2.8.0 case creation and messaging

This commit is contained in:
Christian 2026-09-08 01:55:47 +02:00
parent 9ca562745a
commit 9ed709c9e8
26 changed files with 1391 additions and 102 deletions

View File

@ -0,0 +1,18 @@
# BMC Hub v2.8.0
## Ny sag
- Redesignet, mere fokuseret oprettelsesflow med kompakte typevalg og progressive paneler.
- Browser-kladdesystem, hurtigskabeloner, tagvælger og tastaturgenvej til oprettelse.
- Duplikatindsigt for kunde og valgt kontakt samt arbejdsbelastning for ansvarlig medarbejder.
- Brand- og type-tagforslag ud fra sagens titel og beskrivelse.
## Interne beskeder
- Nyt, mere overskueligt beskedflow mellem medarbejdere.
- Understøttelse af korte telefonbeskeder med valgfri kontaktperson og tilbageringningsnummer.
## Database
- Kør `migrations/238_case_create_templates.sql`.
- Kør `migrations/239_internal_phone_messages.sql`.

View File

@ -1,8 +1,8 @@
from typing import Optional
from typing import Optional, Literal
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from pydantic import BaseModel, Field
from app.core.auth_service import AuthService
from app.core.auth_dependencies import get_current_user
@ -69,6 +69,10 @@ class BottomBarMessageCreatePayload(BaseModel):
message: str
recipient_user_id: Optional[int] = None
requires_manual_ack: bool = False
message_kind: Literal['message', 'phone'] = 'message'
contact_id: Optional[int] = Field(default=None, gt=0)
caller_name: str = Field(default='', max_length=200)
callback_phone: str = Field(default='', max_length=80)
class BottomBarMessageReadPayload(BaseModel):
@ -566,13 +570,17 @@ async def send_bottom_bar_message(
if recipient_user_id == int(current_user_id):
raise HTTPException(status_code=400, detail="Du kan ikke sende en besked til dig selv")
if payload.contact_id is not None:
if not execute_query_single('SELECT id FROM contacts WHERE id=%s', (payload.contact_id,)):
raise HTTPException(status_code=400, detail='Kontaktpersonen findes ikke')
row = execute_query_single(
"""
INSERT INTO bottom_bar_messages (sender_user_id, recipient_user_id, message_text, requires_manual_ack)
VALUES (%s, %s, %s, %s)
INSERT INTO bottom_bar_messages (sender_user_id, recipient_user_id, message_text, requires_manual_ack, message_kind, contact_id, caller_name, callback_phone)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, sender_user_id, recipient_user_id, message_text, requires_manual_ack, created_at
""",
(int(current_user_id), recipient_user_id, message_text, bool(payload.requires_manual_ack)),
(int(current_user_id), recipient_user_id, message_text, bool(payload.requires_manual_ack), payload.message_kind, payload.contact_id, payload.caller_name.strip(), payload.callback_phone.strip()),
) or {}
return {
@ -582,6 +590,10 @@ async def send_bottom_bar_message(
"from": _resolve_current_user_display_name(current_user),
"to": "Alle på vagt" if recipient_user_id is None else f"Bruger #{recipient_user_id}",
"text": row.get("message_text") or message_text,
"message_kind": payload.message_kind,
"contact_id": payload.contact_id,
"caller_name": payload.caller_name.strip(),
"callback_phone": payload.callback_phone.strip(),
"requires_manual_ack": bool(row.get("requires_manual_ack")),
"created_at": row.get("created_at"),
"is_own": True,

View File

@ -83,6 +83,10 @@ def ensure_bottom_bar_messages_schema() -> None:
sender_user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
recipient_user_id INTEGER NULL REFERENCES users(user_id) ON DELETE CASCADE,
message_text TEXT NOT NULL,
message_kind VARCHAR(16) NOT NULL DEFAULT 'message',
contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
caller_name VARCHAR(200) NOT NULL DEFAULT '',
callback_phone VARCHAR(80) NOT NULL DEFAULT '',
requires_manual_ack BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
read_at TIMESTAMP NULL
@ -92,6 +96,12 @@ def ensure_bottom_bar_messages_schema() -> None:
logger.warning("⚠️ bottom_bar_messages table was missing and has been created automatically")
else:
columns = set(_table_columns("bottom_bar_messages"))
if not {'message_kind', 'contact_id', 'caller_name', 'callback_phone'}.issubset(columns):
execute_query("""ALTER TABLE bottom_bar_messages
ADD COLUMN IF NOT EXISTS message_kind VARCHAR(16) NOT NULL DEFAULT 'message',
ADD COLUMN IF NOT EXISTS contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS caller_name VARCHAR(200) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS callback_phone VARCHAR(80) NOT NULL DEFAULT ''""")
if "requires_manual_ack" not in columns:
execute_query(
"""
@ -143,6 +153,8 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
m.sender_user_id,
m.recipient_user_id,
m.message_text,
m.message_kind, m.contact_id, m.caller_name, m.callback_phone,
NULLIF(CONCAT_WS(' ', contact.first_name, contact.last_name), '') AS contact_name,
m.requires_manual_ack,
m.created_at,
receipt.read_at,
@ -152,9 +164,10 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
FROM bottom_bar_messages m
JOIN users sender ON sender.user_id = m.sender_user_id
LEFT JOIN users recipient ON recipient.user_id = m.recipient_user_id
LEFT JOIN contacts contact ON contact.id = m.contact_id
LEFT JOIN bottom_bar_message_receipts receipt
ON receipt.message_id = m.id
AND receipt.user_id = %s
AND receipt.user_id = CASE WHEN m.sender_user_id = %s AND m.recipient_user_id IS NOT NULL THEN m.recipient_user_id ELSE %s END
WHERE m.sender_user_id = %s
OR m.recipient_user_id = %s
OR (m.recipient_user_id IS NULL AND EXISTS (
@ -166,7 +179,7 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
ORDER BY m.created_at DESC, m.id DESC
LIMIT %s
""",
(int(user_id), int(user_id), int(user_id), int(user_id), safe_limit),
(int(user_id), int(user_id), int(user_id), int(user_id), int(user_id), safe_limit),
) or []
unread_row = execute_query_single(
@ -200,9 +213,15 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
"from": row.get("sender_name") or "Ukendt",
"to": recipient_name,
"text": row.get("message_text") or "",
"message_kind": row.get("message_kind") or "message",
"contact_id": row.get("contact_id"),
"contact_name": row.get("contact_name"),
"caller_name": row.get("caller_name") or "",
"callback_phone": row.get("callback_phone") or "",
"requires_manual_ack": bool(row.get("requires_manual_ack")),
"created_at": row.get("created_at").isoformat() if row.get("created_at") else None,
"is_own": int(row.get("sender_user_id") or 0) == int(user_id),
"is_read": row.get("read_at") is not None,
"is_unread": row.get("read_at") is None and int(row.get("sender_user_id") or 0) != int(user_id),
"is_acknowledged": row.get("acknowledged_at") is not None,
}

View File

@ -0,0 +1,195 @@
"""Creation helpers: transactional associations and advisory case lookups."""
import json
import re
from difflib import SequenceMatcher
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field, StrictInt
from psycopg2.extras import Json
from app.core.auth_dependencies import require_any_permission
from app.core.database import execute_query, execute_query_single
read_access = require_any_permission('cases.view', 'tickets.view', 'cases.create', 'tickets.create', 'users.manage', 'system.admin')
admin_access = require_any_permission('users.manage', 'system.admin')
router = APIRouter(prefix='/case-create', dependencies=[Depends(read_access)])
def ids(value, field):
if not isinstance(value, list) or any(isinstance(v, bool) or not isinstance(v, int) or v <= 0 for v in value):
raise HTTPException(400, f'{field} skal være en liste med positive heltal')
return list(dict.fromkeys(value))
def attach_create_relations(cursor, case_id, data, user_id):
hardware_ids = ids(data.get('hardware_ids', []), 'hardware_ids')
tag_ids = ids(data.get('tag_ids', []), 'tag_ids')
if hardware_ids:
cursor.execute('SELECT id FROM hardware_assets WHERE id = ANY(%s) AND deleted_at IS NULL', (hardware_ids,))
if {r['id'] for r in cursor.fetchall()} != set(hardware_ids):
raise HTTPException(400, 'En valgt hardware findes ikke længere')
for hardware_id in hardware_ids:
cursor.execute('INSERT INTO sag_hardware (sag_id, hardware_id) VALUES (%s, %s) ON CONFLICT DO NOTHING', (case_id, hardware_id))
actions = []
if tag_ids:
cursor.execute('SELECT t.id, t.name, t.tag_group_id, g.behavior FROM tags t LEFT JOIN tag_groups g ON g.id=t.tag_group_id WHERE t.id=ANY(%s) AND t.is_active=TRUE', (tag_ids,))
tags = {r['id']: r for r in cursor.fetchall()}
if set(tags) != set(tag_ids):
raise HTTPException(400, 'Et valgt tag findes ikke længere eller er inaktivt')
# Same single/toggle group semantics as the global picker: last choice wins.
chosen = []
for tag_id in tag_ids:
tag = tags[tag_id]
if tag['behavior'] in ('single', 'toggle'):
chosen = [i for i in chosen if tags[i]['tag_group_id'] != tag['tag_group_id']]
chosen.append(tag_id)
for tag_id in chosen:
cursor.execute("INSERT INTO entity_tags (entity_type, entity_id, tag_id, tagged_by) VALUES ('case', %s, %s, %s) ON CONFLICT DO NOTHING", (case_id, tag_id, user_id))
cursor.execute("SELECT action_type, action_config FROM tag_workflows WHERE tag_id=%s AND trigger_event='on_add' AND is_active=TRUE ORDER BY id DESC LIMIT 1", (tag_id,))
action = cursor.fetchone()
if action:
actions.append({'tag': {'id': tag_id, 'name': tags[tag_id]['name']}, 'action': {'type': action['action_type'], 'config': action['action_config'] or {}}, 'entity_type': 'case', 'entity_id': case_id})
return actions
def closed_statuses():
row = execute_query_single("SELECT value FROM settings WHERE key='case_statuses'")
try:
configured = json.loads((row or {}).get('value') or '[]')
values = [str(s['value']).strip().lower() for s in configured if isinstance(s, dict) and s.get('is_closed') and s.get('value')]
except (ValueError, TypeError):
values = []
return values or ['lukket', 'løst', 'afsluttet', 'closed', 'resolved', 'done']
def title_similarity(left, right):
def normalize(value):
return ' '.join(re.findall(r'\w+', value.casefold()))
left, right = normalize(left), normalize(right)
if not left or not right:
return 0
a, b = set(left.split()), set(right.split())
return max(SequenceMatcher(None, left, right).ratio(), len(a & b) / len(a | b))
@router.get('/duplicates')
def duplicates(customer_id: int, title: str = Query(min_length=5, max_length=1000)):
rows = execute_query("""SELECT s.id,s.titel,s.status,COALESCE(u.full_name,u.username) AS ansvarlig_navn
FROM sag_sager s LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id
WHERE s.customer_id=%s AND s.deleted_at IS NULL AND NOT (LOWER(TRIM(s.status))=ANY(%s))""", (customer_id, closed_statuses())) or []
ranked = [(title_similarity(title, r['titel'] or ''), r) for r in rows]
ranked.sort(key=lambda item: (-item[0], -item[1]['id']))
return [dict(row, similarity=round(score, 3)) for score, row in ranked if score >= 0.45][:5]
@router.get('/workload')
def workload(user_id: int):
rows = execute_query("""SELECT s.id,s.titel,s.status,s.deadline,c.name AS customer_name,COUNT(*) OVER() AS total
FROM sag_sager s LEFT JOIN customers c ON c.id=s.customer_id
WHERE s.ansvarlig_bruger_id=%s AND s.deleted_at IS NULL AND NOT (LOWER(TRIM(s.status))=ANY(%s))
ORDER BY s.deadline ASC NULLS LAST,s.id DESC LIMIT 10""", (user_id, closed_statuses())) or []
return {'total': rows[0]['total'] if rows else 0, 'items': rows}
@router.get('/contacts-open-cases')
def contacts_open_cases(contact_ids: list[int] = Query(min_length=1, max_length=20)):
"""A small, advisory view used while selecting contacts on a new case."""
contact_ids = ids(contact_ids, 'contact_ids')
rows = execute_query("""WITH ranked AS (
SELECT sc.contact_id, s.id, s.titel, s.status, s.deadline,
COALESCE(u.full_name, u.username, 'Ingen ansvarlig') AS ansvarlig_navn,
COUNT(*) OVER (PARTITION BY sc.contact_id) AS total,
ROW_NUMBER() OVER (PARTITION BY sc.contact_id ORDER BY s.deadline ASC NULLS LAST, s.id DESC) AS position
FROM sag_kontakter sc
JOIN sag_sager s ON s.id=sc.sag_id
LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id
WHERE sc.contact_id=ANY(%s) AND sc.deleted_at IS NULL AND s.deleted_at IS NULL
AND NOT (LOWER(TRIM(s.status))=ANY(%s))
) SELECT contact_id,id,titel,status,deadline,ansvarlig_navn,total
FROM ranked WHERE position<=5 ORDER BY contact_id,position""", (contact_ids, closed_statuses())) or []
result = {contact_id: {'total': 0, 'items': []} for contact_id in contact_ids}
for row in rows:
bucket = result[row['contact_id']]
bucket['total'] = row['total']
bucket['items'].append({key: row[key] for key in ('id', 'titel', 'status', 'deadline', 'ansvarlig_navn')})
return result
class PipelineDefaults(BaseModel):
model_config = ConfigDict(extra='forbid')
stage_id: Optional[int] = Field(default=None, gt=0)
amount: Optional[float] = Field(default=None, ge=0, allow_inf_nan=False)
probability: Optional[int] = Field(default=None, ge=0, le=100)
description: Optional[str] = Field(default=None, max_length=10000)
class TemplateValues(BaseModel):
model_config = ConfigDict(extra='forbid')
type: str = Field(default='ticket', min_length=1, max_length=80)
titel: str = Field(default='', max_length=1000)
beskrivelse: str = Field(default='', max_length=50000)
status: str = Field(default='åben', min_length=1, max_length=80)
assigned_group_id: Optional[int] = Field(default=None, gt=0)
tag_ids: list[StrictInt] = Field(default_factory=list, max_length=100)
pipeline: Optional[PipelineDefaults] = None
class CaseTemplate(BaseModel):
model_config = ConfigDict(extra='forbid')
name: str = Field(min_length=1, max_length=120)
icon: str = Field(default='bi-lightning', pattern=r'^bi-[a-z0-9-]+$', max_length=80)
is_active: bool = True
sort_order: int = 0
values: TemplateValues
@router.get('/templates')
def templates():
return execute_query('SELECT *, template_values AS "values" FROM case_create_templates WHERE is_active=TRUE ORDER BY sort_order,name,id') or []
@router.get('/template-options', dependencies=[Depends(admin_access)])
def template_options():
return execute_query('SELECT id,name FROM groups ORDER BY name') or []
@router.get('/templates/admin', dependencies=[Depends(admin_access)])
def admin_templates():
return execute_query('SELECT *, template_values AS "values" FROM case_create_templates ORDER BY sort_order,name,id') or []
def template_args(data):
values = data.values.model_dump()
values['tag_ids'] = ids(values['tag_ids'], 'tag_ids')
if values['tag_ids']:
found = execute_query('SELECT id FROM tags WHERE id=ANY(%s) AND is_active=TRUE', (values['tag_ids'],)) or []
if {r['id'] for r in found} != set(values['tag_ids']):
raise HTTPException(400, 'Ugyldige tags')
if values['assigned_group_id'] and not execute_query_single('SELECT id FROM groups WHERE id=%s', (values['assigned_group_id'],)):
raise HTTPException(400, 'Ugyldig gruppe')
if values['pipeline'] and values['pipeline']['stage_id'] and not execute_query_single('SELECT id FROM pipeline_stages WHERE id=%s', (values['pipeline']['stage_id'],)):
raise HTTPException(400, 'Ugyldig pipeline-stage')
if not data.name.strip():
raise HTTPException(400, 'Navn er påkrævet')
return (data.name.strip(), data.icon, data.is_active, data.sort_order, Json(values))
@router.post('/templates', dependencies=[Depends(admin_access)])
def create_template(data: CaseTemplate):
return execute_query_single('INSERT INTO case_create_templates (name,icon,is_active,sort_order,template_values) VALUES (%s,%s,%s,%s,%s) RETURNING *, template_values AS "values"', template_args(data))
@router.put('/templates/{template_id}', dependencies=[Depends(admin_access)])
def update_template(template_id: int, data: CaseTemplate):
row = execute_query_single('UPDATE case_create_templates SET name=%s,icon=%s,is_active=%s,sort_order=%s,template_values=%s,updated_at=NOW() WHERE id=%s RETURNING *, template_values AS "values"', template_args(data) + (template_id,))
if not row:
raise HTTPException(404, 'Skabelonen findes ikke')
return row
@router.delete('/templates/{template_id}', dependencies=[Depends(admin_access)])
def delete_template(template_id: int):
row = execute_query_single('DELETE FROM case_create_templates WHERE id=%s RETURNING id', (template_id,))
if not row:
raise HTTPException(404, 'Skabelonen findes ikke')
return row

View File

@ -6,6 +6,7 @@ import re
import hashlib
import base64
import html
import math
from pathlib import Path
from datetime import datetime, timedelta, timezone
from typing import Any, List, Optional, Dict
@ -1069,6 +1070,8 @@ async def create_sag(request: Request, data: dict):
if pipeline_values["amount"] not in (None, ""):
try:
pipeline_values["amount"] = float(pipeline_values["amount"])
if not math.isfinite(pipeline_values["amount"]) or pipeline_values["amount"] < 0:
raise ValueError("Invalid amount")
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Pipeline-beløb skal være et tal") from exc
else:
@ -1123,6 +1126,8 @@ async def create_sag(request: Request, data: dict):
raise HTTPException(status_code=400, detail="Ordrelinjens talfelter er ugyldige") from exc
if normalized_items[-1]["status"] not in ("draft", "confirmed", "cancelled"):
raise HTTPException(status_code=400, detail="Ugyldig ordrelinjestatus")
if any(value is not None and (not math.isfinite(value) or value < 0) for value in (normalized_items[-1][key] for key in ("quantity", "unit_price", "amount"))):
raise HTTPException(status_code=400, detail="Ordrelinjens tal skal være endelige og mindst nul")
cursor.execute(
"""

View File

@ -4,6 +4,21 @@
{% block extra_css %}
<style>
.cc-panel { border: 1px solid var(--border-color, #dce3eb); border-radius: 10px; margin: 0 0 12px; background: var(--bg-surface, #fff); }
.cc-panel > summary { padding: 14px 16px; cursor: pointer; font-weight: 600; }
.cc-summary { display: block; color: var(--text-secondary, #65758a); font-weight: 400; margin-left: 18px; overflow-wrap: anywhere; }
.cc-panel-body { padding: 8px 16px 16px; }
.cc-panel-body > h5, .cc-panel-body > section > h5, .cc-panel-body > section > hr, #createForm > hr { display: none; }
.cc-types { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.cc-types .btn { min-height: 44px; }
#cc-more > summary { padding: 10px; cursor: pointer; }
.cc-actions { position: sticky; bottom: 55px; z-index: 1020; padding: 16px; border: 1px solid var(--border-color, #dce3eb); border-radius: 10px; background: var(--bg-surface, #fff); box-shadow: 0 -4px 18px #0001; flex-wrap: wrap; }
.cc-workload { margin: 12px 0; padding: 12px; border-radius: 8px; background: var(--bg-hover, #f5f7fa); }
.cc-workload summary { cursor: pointer; }
.cc-work-item { padding: 8px 0; border-bottom: 1px solid var(--border-color, #dce3eb); }
.cc-work-item small { display: block; }
[data-bs-theme="dark"] #createForm .bg-light { background: var(--bg-surface) !important; color: var(--text-primary); }
@media(max-width: 600px) { .cc-actions { bottom: 55px; padding: 10px; gap: 8px !important; } .cc-actions #cc-save { width: 100%; } .cc-actions .btn { padding: 10px 16px !important; } .cc-panel-body { padding: 8px 12px; } }
/* Gradient Header for the Card */
.card-header-custom {
background: linear-gradient(135deg, var(--bmc-blue, #0f4c75) 0%, #3282b8 100%);
@ -136,18 +151,144 @@
.case-top-alerts .alert-danger {
border-left-color: #e03131;
}
.cc-page { max-width: 1440px; padding-bottom: 100px !important; }
.cc-pagebar { display:flex; justify-content:space-between; align-items:center; gap:16px; margin:0 0 20px; }
.cc-pagebar > a { color:var(--text-secondary,#63758c); text-decoration:none; font-size:.88rem; }
.cc-pagebar .btn { border-radius:12px; font-size:.85rem; padding:10px 16px; }
.cc-page .card-custom { border:1px solid var(--border-color,#dce5ed); border-radius:22px; box-shadow:0 16px 48px #143b5610; }
.cc-page .card-header-custom { display:flex; justify-content:space-between; align-items:center; gap:20px; padding:34px 36px; border-radius:21px 21px 0 0; background:linear-gradient(115deg,#122f46,#164b62); }
.cc-eyebrow { display:block; color:#9ed7de; font-size:.66rem; font-weight:700; letter-spacing:.15em; margin-bottom:10px; }
.cc-page h1 { font-size:1.9rem; font-weight:700; letter-spacing:-.04em; margin-bottom:10px; }
.cc-page .card-header-custom p { color:#c5dce6; font-size:.9rem; max-width:520px; line-height:1.6; }
.cc-hero-icon { display:grid; place-items:center; flex-shrink:0; width:72px; height:72px; border-radius:22px; background:#ffffff0d; border:1px solid #ffffff25; font-size:2rem; color:#a9e4df; }
.cc-page .card-custom > .card-body { padding:32px 42px !important; }
.cc-page .form-control,.cc-page .form-select { border-radius:10px; min-height:43px; background-color:var(--bg-surface,#fff); border-color:var(--border-color,#dbe4ec); }
.cc-page .input-group-text { border-radius:10px 0 0 10px; background:transparent !important; color:#7891a5; }
.cc-page .input-group .form-control { border-top-left-radius:0; border-bottom-left-radius:0; }
.cc-page textarea.form-control { line-height:1.7; padding:14px 16px; }
.cc-page .form-label { font-size:.82rem; }
.cc-page .form-text { font-size:.75rem; line-height:1.5; }
.cc-page .cc-type-section > label { font-size:.72rem; text-transform:uppercase; letter-spacing:.08em; color:var(--text-secondary,#63758c); }
.cc-page #cc-types { align-items:stretch; gap:10px; }
.cc-page #cc-types > button { flex:1; border:1px solid var(--border-color,#dbe4ec); color:var(--text-primary,#26465d); border-radius:13px; text-align:left; padding:14px; background:var(--bg-surface,#fff); transition:border-color .15s,background .15s; }
.cc-type-name { display:block; font-size:.9rem; font-weight:600; }
.cc-type-hint { display:block; color:var(--text-secondary,#708397); margin-top:5px; font-size:.7rem; font-weight:400; }
.cc-page #cc-types > button.active { border-color:#258783; background:var(--accent-light,#eaf6f4); box-shadow:0 0 0 1px #258783; }
.cc-page #cc-types > .cc-message-type { border-style:dashed; border-color:#86aaa8; background:color-mix(in srgb,var(--accent-light,#eaf6f4) 62%,var(--bg-surface,#fff)); }
.cc-page #cc-types > .cc-message-type:hover { border-style:solid; background:var(--accent-light,#eaf6f4); }
.cc-message-type .cc-type-name { display:flex; align-items:center; gap:7px; }
.cc-message-type .cc-type-name::after { content:'INTERN'; padding:2px 5px; border-radius:4px; background:#267b74; color:#fff; font-size:.52rem; font-weight:700; letter-spacing:.08em; }
.cc-message-fallback { display:flex; width:100%; margin-top:12px; text-align:left; padding:14px; border-radius:13px; }
.cc-page #cc-types > button:hover { border-color:#258783; }
.cc-page #cc-more { flex-basis:100%; font-size:.78rem; }
.cc-page #cc-more > summary { padding:4px 0; color:var(--text-secondary,#63758c); }
.cc-page #cc-more .cc-types { padding-top:10px; }
.cc-page .cc-panel { border-radius:13px; margin-bottom:12px; }
.cc-page .cc-panel > summary { display:flex; align-items:center; gap:12px; list-style:none; padding:15px 17px; }
.cc-page .cc-panel > summary::-webkit-details-marker { display:none; }
.cc-page .cc-panel > summary::after { content:'⌄'; margin-left:auto; color:#8196a8; transition:transform .15s; }
.cc-page .cc-panel[open] > summary::after { transform:rotate(180deg); }
.cc-panel-icon { display:grid; place-items:center; flex-shrink:0; width:35px; height:35px; border-radius:10px; color:#3e777e; background:var(--accent-light,#eef5f6); }
.cc-panel-label { min-width:0; font-size:.86rem; }
.cc-page .cc-summary { margin:3px 0 0; font-size:.72rem; }
.cc-page .cc-panel[open] { border-color:#a7c5cc; }
.cc-page .cc-panel-body { padding:8px 18px 18px; }
.cc-page .cc-actions { border-radius:14px; box-shadow:0 8px 30px #143b561c; margin-top:28px !important; }
.cc-page .cc-actions .btn { border-radius:10px; }
.cc-page #submitBtn { background:#176967; border-color:#176967; }
.cc-page #cc-save { color:var(--text-secondary,#63758c); font-size:.73rem; }
.cc-template-toolbar { display:flex; align-items:center; gap:12px; flex-wrap:wrap; padding-bottom:20px; margin-bottom:20px !important; border-bottom:1px solid var(--border-color,#e3eaf0); }
.cc-template-toolbar .form-label { margin:0; }
.cc-template-toolbar .form-select { flex:1; width:auto; max-width:320px; min-width:180px; font-size:.83rem; }
.cc-page .cc-workload { background:var(--bg-hover,#f5f8fa); border:1px solid var(--border-color,#e3eaf0); font-size:.8rem; }
.cc-contact-cases { margin-top:14px; padding:0; border:1px solid #bdd9da; border-radius:12px; background:var(--bg-surface,#fff); overflow:hidden; font-size:.8rem; box-shadow:0 4px 14px #164b620c; }
.cc-contact-cases[hidden] { display:none; }
.cc-contact-cases__header { display:flex; align-items:center; gap:10px; padding:11px 13px; background:#edf8f7; color:#195f5e; }
.cc-contact-cases__header-icon { display:grid; place-items:center; width:29px; height:29px; border-radius:8px; background:#cfeceb; font-size:.85rem; }
.cc-contact-cases__eyebrow { display:block; color:#4d8382; font-size:.62rem; font-weight:700; letter-spacing:.07em; text-transform:uppercase; }
.cc-contact-cases__title { display:block; font-weight:700; line-height:1.25; }
.cc-contact-cases__body { padding:6px 13px 10px; }
.cc-contact-cases__group { padding-top:8px; }
.cc-contact-cases__group-name { display:block; color:var(--text-secondary,#65758a); font-size:.7rem; font-weight:600; margin:0 0 4px; }
.cc-contact-cases__item { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:9px 0; border-bottom:1px solid var(--border-color,#e6eff0); }
.cc-contact-cases__item:last-child { border-bottom:0; }
.cc-contact-cases__item a { color:var(--text-primary,#213547); font-weight:600; text-decoration:none; }
.cc-contact-cases__item a:hover { color:#176967; text-decoration:underline; }
.cc-contact-cases__item small { color:var(--text-secondary,#65758a); white-space:nowrap; font-size:.69rem; }
.cc-contact-cases__more { display:block; padding-top:6px; color:var(--text-secondary,#65758a); font-size:.69rem; }
.cc-contact-cases__empty { padding:11px 13px; color:var(--text-secondary,#65758a); }
.cc-tag-suggestions { margin:10px 0 4px; padding-top:10px; border-top:1px solid var(--border-color,#e3eaf0); }
.cc-tag-suggestions__label { color:var(--text-secondary,#65758a); font-size:.7rem; font-weight:600; margin-bottom:6px; }
.cc-tag-suggestions .btn { border-radius:999px; font-size:.72rem; padding:3px 8px; }
[data-bs-theme="dark"] .cc-contact-cases { background:var(--bg-surface); border-color:#326b6b; }
[data-bs-theme="dark"] .cc-contact-cases__header { background:#173e3e; color:#c9edeb; }
@media(min-width:1200px) {
.cc-page #cc-relations .row { grid-template-columns:minmax(0, 1fr) minmax(0, 1fr); display:grid; gap:28px !important; }
.cc-page #cc-relations .row > [class*="col-"] { width:auto; max-width:none; padding:0; }
.cc-page .cc-panel-body { padding:14px 24px 24px; }
.cc-contact-cases__item { display:grid; grid-template-columns:minmax(0, 1fr) auto; column-gap:14px; align-items:center; }
.cc-contact-cases__item small { text-align:right; margin:0; }
}
@media(max-width:600px) { .cc-page .card-header-custom { padding:25px 20px; } .cc-hero-icon { display:none; } .cc-page h1 { font-size:1.6rem; } .cc-page .card-custom > .card-body { padding:20px 16px !important; } .cc-page #cc-types > button { flex:1 1 40%; } .cc-pagebar { align-items:flex-start; } .cc-pagebar .btn { max-width:190px; } }
/* Calm, task-first layout: the form is the product, not the decoration. */
.cc-page { max-width:1240px; }
.cc-page .card-custom { border-radius:16px; box-shadow:0 8px 28px #143b560d; }
.cc-page .card-header-custom { min-height:auto; padding:20px 28px; border-radius:15px 15px 0 0; background:var(--bg-surface,#fff); color:var(--text-primary,#213547); border-bottom:1px solid var(--border-color,#dce5ed); }
.cc-page .card-header-custom p { display:none; }
.cc-page .card-header-custom .cc-eyebrow { color:#267b74; margin:0 0 4px; font-size:.59rem; }
.cc-page .card-header-custom h1 { margin:0; font-size:1.35rem; letter-spacing:-.02em; color:inherit; }
.cc-page .cc-hero-icon { width:42px; height:42px; border-radius:12px; font-size:1.15rem; color:#267b74; background:var(--accent-light,#eaf6f4); border:0; }
.cc-page .card-custom > .card-body { padding:24px 28px 32px !important; }
.cc-template-toolbar { padding:12px 14px; margin:0 0 16px !important; border:1px solid var(--border-color,#e3eaf0); border-radius:10px; background:var(--bg-hover,#f7fafb); }
.cc-template-toolbar .form-label { font-weight:600; font-size:.76rem; }
.cc-template-toolbar .form-select { min-height:36px; max-width:280px; font-size:.78rem; }
.cc-page .cc-type-section { padding:0 0 16px; margin-bottom:16px !important; border-bottom:1px solid var(--border-color,#e3eaf0); }
.cc-page .cc-type-section > label { display:block; margin-bottom:8px !important; text-transform:none; letter-spacing:0; color:var(--text-primary,#213547); font-weight:600; font-size:.82rem; }
.cc-page #caseTypeHelp { display:none; }
.cc-page #cc-types { gap:7px; }
.cc-page #cc-types > button { flex:0 1 auto; min-height:0; padding:8px 11px; border-radius:9px; font-size:.78rem; }
.cc-page .cc-type-name { display:inline; font-size:.78rem; }
.cc-page .cc-type-hint { display:none; }
.cc-page #cc-types > button.active { box-shadow:none; background:#176967; border-color:#176967; color:#fff; }
.cc-page #cc-types > button.active .cc-type-name::after { background:#fff3; color:#fff; }
.cc-page #cc-types > .cc-message-type { margin-left:auto; border-style:solid; background:transparent; }
.cc-page #cc-more { flex-basis:auto; }
.cc-page #cc-more > summary { padding:8px 4px; }
.cc-page .cc-panel { border-radius:10px; margin-bottom:8px; box-shadow:none; }
.cc-page .cc-panel > summary { padding:12px 14px; }
.cc-panel-icon { width:30px; height:30px; border-radius:8px; font-size:.82rem; }
.cc-page .cc-panel-label { font-size:.8rem; }
.cc-page .cc-summary { display:inline; margin-left:7px; font-size:.69rem; }
.cc-page .cc-panel-body { padding:8px 14px 16px; }
.cc-page .cc-panel-body > .row { margin-bottom:0 !important; }
.cc-page #cc-relations[open] { border-color:#9dc7c3; }
.cc-page #cc-metadata { margin-top:16px; }
.cc-page #cc-workload { margin:8px 0 16px; padding:10px 14px; }
.cc-page .cc-actions { margin-top:20px !important; padding:12px 14px; }
/* The panels have very different heights. Keep them in a single stable flow
instead of pretending they are equal dashboard cards. */
@media(min-width:992px) {
.cc-page .cc-panel:not(#cc-relations):not(#cc-metadata) { width:100%; display:block; margin-right:0; }
}
@media(max-width:700px) {
.cc-page .card-header-custom { padding:16px 18px; }
.cc-page .card-custom > .card-body { padding:18px 14px 28px !important; }
.cc-page #cc-types > .cc-message-type { margin-left:0; }
}
</style>
{% endblock %}
{% block content %}
<div class="container py-4">
<div class="container py-4 cc-page">
<div class="cc-pagebar"><a href="/sag"><i class="bi bi-arrow-left me-2"></i>Alle sager</a></div>
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="col-12">
<!-- Main Card -->
<div class="card card-custom">
<div class="card-header-custom">
<h2 class="mb-0 fs-4 fw-bold"><i class="bi bi-plus-circle me-2"></i>Opret Ny Sag</h2>
<p class="mb-0 opacity-75 small mt-1">Udfyld formularen for at oprette en ny sag i systemet.</p>
<div><span class="cc-eyebrow">ET GODT STED AT STARTE</span><h1>Opret en ny sag</h1><p class="mb-0">Saml opgaven, vælg de rette personer, og tilføj det, du har brug for.</p></div>
<span class="cc-hero-icon" aria-hidden="true"><i class="bi bi-folder-plus"></i></span>
</div>
<div class="card-body p-4">
@ -162,10 +303,14 @@
</div>
<form id="createForm" novalidate>
<div class="mb-4 p-3 rounded-3 border bg-light">
<div class="mb-4 cc-type-section">
<label for="type" class="form-label mb-2">Hvilken type sag vil du oprette?</label>
<select class="form-select form-select-lg" id="type" required></select>
<div class="form-text" id="caseTypeHelp">Vælg sagstype for at vise de relevante felter.</div>
<button type="button" id="caseMessageTypeButton" class="btn btn-outline-primary cc-message-type cc-message-fallback" onclick="window.caseCreateUI?.openMessage?.() || window.openInternalMessage?.()">
<span class="cc-type-name"><i class="bi bi-chat-square-text"></i>Besked til kollega</span>
<small class="cc-type-hint">Kort besked eller telefonbesked</small>
</button>
</div>
<!-- Section: Relations -->
@ -553,10 +698,13 @@
const titleInput = document.getElementById('titel');
const descriptionInput = document.getElementById('beskrivelse');
if (suggestedTitle) titleInput.value = suggestedTitle;
if (suggestedTitle) {
titleInput.value = suggestedTitle;
titleInput.dispatchEvent(new Event('input', { bubbles: true }));
}
if (suggestedDescription) {
descriptionInput.value = suggestedDescription;
descriptionInput.dispatchEvent(new Event('input'));
descriptionInput.dispatchEvent(new Event('input', { bubbles: true }));
}
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).hide();
});
@ -580,13 +728,20 @@
const contactInput = document.getElementById('contactSearch');
if (contactInput) {
contactInput.addEventListener('input', (e) => handleSearch(e, 'contact'));
contactInput.addEventListener('keydown', e => {
if (e.key === 'Escape') {
clearTimeout(contactSearchTimeout);
document.getElementById('contactResults').classList.add('d-none');
contactInput.blur();
}
});
contactInput.addEventListener('focus', () => {
if (selectedCustomer) {
if (selectedCustomer && contactInput.value.trim().length >= 2) {
renderCustomerContactResults(contactInput.value);
}
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-position-relative')) {
if (!contactInput.closest('.search-position-relative').contains(e.target)) {
const cr = document.getElementById('contactResults');
if(cr) cr.classList.add('d-none');
}
@ -605,6 +760,12 @@
clearTimeout(timeoutVar);
if (query.length < 2) {
resultsDiv.classList.add('d-none');
resultsDiv.innerHTML = '';
return;
}
if (type === 'contact' && selectedCustomer) {
renderCustomerContactResults(query);
return;
@ -616,6 +777,7 @@
}
const timeout = setTimeout(async () => {
if (event.target.value.trim() !== query || document.activeElement !== event.target) return;
try {
// Show loading state
resultsDiv.classList.remove('d-none');
@ -632,6 +794,7 @@
return;
}
const data = await response.json();
if (event.target.value.trim() !== query || document.activeElement !== event.target) return;
if (!Array.isArray(data)) {
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Fejl ved søgning</div>';
return;
@ -694,6 +857,10 @@
if (!resultsDiv || !selectedCustomer) return;
const normalizedQuery = String(query || '').trim().toLocaleLowerCase('da-DK');
if (normalizedQuery.length < 2 || document.activeElement !== document.getElementById('contactSearch')) {
resultsDiv.classList.add('d-none');
return;
}
const contacts = selectedCustomerContacts.filter((contact) => {
if (!normalizedQuery) return true;
return [
@ -739,9 +906,9 @@
selectedCustomerContacts = [];
contactInput.placeholder = `Søg blandt kontakter hos ${selectedCustomer.name}...`;
context.textContent = `Viser kontakter hos ${selectedCustomer.name}.`;
context.textContent = `Søg blandt kontakter hos ${selectedCustomer.name} (mindst 2 tegn).`;
resultsDiv.innerHTML = '<div class="p-3 text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Henter firmaets kontakter...</div>';
resultsDiv.classList.remove('d-none');
resultsDiv.classList.toggle('d-none', document.activeElement !== contactInput || contactInput.value.trim().length < 2);
try {
const response = await fetch(`/api/v1/customers/${customerId}/contacts`, { credentials: 'include' });
@ -755,7 +922,7 @@
if (loadToken !== customerContactsLoadToken) return;
console.error('Failed to load customer contacts:', error);
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Firmaets kontakter kunne ikke hentes.</div>';
resultsDiv.classList.remove('d-none');
resultsDiv.classList.toggle('d-none', document.activeElement !== contactInput || contactInput.value.trim().length < 2);
}
}
@ -835,6 +1002,7 @@
}
loadHardwareForContacts();
window.caseCreateUI?.contactsChanged();
}
function readTelefoniPrefill() {
@ -926,6 +1094,7 @@
delete selectedContactsCompanies[id];
renderSelections();
loadHardwareForContacts();
window.caseCreateUI?.contactsChanged();
}
function renderSelections() {
@ -1167,14 +1336,13 @@
function updateCaseTypeSections() {
const type = document.getElementById('type')?.value || 'ticket';
document.getElementById('hardwareSection')?.classList.toggle('d-none', type !== 'ticket');
document.getElementById('pipelineSection')?.classList.toggle('d-none', type !== 'pipeline');
document.getElementById('orderSection')?.classList.toggle('d-none', type !== 'ordre');
['hardwareSection', 'pipelineSection', 'orderSection'].forEach(id => document.getElementById(id)?.classList.remove('d-none'));
window.caseCreateUI?.typeChanged(type);
const help = document.getElementById('caseTypeHelp');
if (help) help.textContent = type === 'ticket' ? 'Hardware og AnyDesk vises for tickets.'
if (help) help.textContent = type === 'ticket' ? 'Tilføj eventuelt hardware og AnyDesk til din ticket. Alle ekstra funktioner er tilgængelige.'
: type === 'pipeline' ? 'Udfyld pipelineoplysninger for muligheden.'
: type === 'ordre' ? 'Tilføj indkøbs- og salgslinjer til ordren.'
: 'Denne sagstype bruger kun de fælles sagsfelter.';
: 'Tilføj hardware, pipeline, tags og køb/salg efter behov.';
}
function renderOrderLinesEmptyState() {
@ -1249,7 +1417,7 @@
const setting = typesRes.ok ? await typesRes.json() : { value: '[]' };
const profile = profileRes.ok ? await profileRes.json() : {};
const configured = JSON.parse(setting.value || '[]');
const types = Array.isArray(configured) ? configured.map(type => String(type).toLowerCase()) : [];
const types = Array.isArray(configured) && configured.length ? configured.map(type => String(type).trim().toLowerCase()).filter(Boolean) : Object.keys(caseTypeLabels);
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
if (!types.includes('abonnement')) types.push('abonnement');
const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels);
@ -1279,18 +1447,18 @@
}
// --- Initialization ---
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', async () => {
initializeSearch();
loadCaseTypesSelect();
loadPipelineStages();
selectCurrentUserAsResponsible();
await Promise.all([loadCaseTypesSelect(), loadPipelineStages(), selectCurrentUserAsResponsible()]);
document.getElementById('type')?.addEventListener('change', updateCaseTypeSections);
applyTelefoniPrefill();
await applyTelefoniPrefill();
await window.caseCreateUI?.init();
});
// --- Form Submission ---
document.getElementById('createForm').addEventListener('submit', async (e) => {
e.preventDefault();
if (window.caseCreateUI && !window.caseCreateUI.validate()) return;
// UI Reset
const errorDiv = document.getElementById('error');
@ -1371,7 +1539,7 @@
data.internet_connection_ids = [telefoniPrefill.internetConnectionId];
}
if (data.type === 'pipeline') {
if (['pipeline_stage_id', 'pipeline_amount', 'pipeline_probability', 'pipeline_description'].some(id => document.getElementById(id).value !== '')) {
data.pipeline = {
stage_id: document.getElementById('pipeline_stage_id').value || null,
amount: document.getElementById('pipeline_amount').value || null,
@ -1379,9 +1547,10 @@
description: document.getElementById('pipeline_description').value || null
};
}
if (data.type === 'ordre') {
if (document.querySelector('#orderLines .order-line')) {
data.order_items = collectOrderItems();
}
Object.assign(data, window.caseCreateUI?.relations() || {});
try {
const response = await fetch('/api/v1/sag', {
@ -1393,6 +1562,7 @@
if (response.ok) {
const result = await response.json();
window.caseCreateUI?.created(result);
successDiv.classList.remove('d-none');
document.getElementById('success-text').textContent = "Sag oprettet";
@ -1416,4 +1586,5 @@
}
});
</script>
<script src="/static/js/case-create.js?v=5"></script>
{% endblock %}

View File

@ -0,0 +1,30 @@
<div id="case-template-admin">
<h5>Sagsskabeloner</h5>
<p class="text-muted">Fælles hurtigskabeloner til Opret ny sag.</p>
<div id="cta-message" role="status"></div>
<div id="cta-list" class="list-group mb-3"></div>
<button type="button" id="cta-new" class="btn btn-outline-primary mb-3">Ny skabelon</button>
<form id="cta-form" class="card card-body">
<input id="cta-id" type="hidden">
<div class="row g-3">
<div class="col-md-6"><label for="cta-name" class="form-label">Navn</label><input id="cta-name" class="form-control" required maxlength="120"></div>
<div class="col-md-3"><label for="cta-icon" class="form-label">Ikon</label><select id="cta-icon" class="form-select"><option value="bi-lightning">Lyn</option><option value="bi-ticket">Ticket</option><option value="bi-router">Netværk</option><option value="bi-person-plus">Medarbejder</option><option value="bi-pc-display">Hardware</option><option value="bi-graph-up">Tilbud</option></select></div>
<div class="col-md-3"><label for="cta-sort" class="form-label">Rækkefølge</label><input id="cta-sort" type="number" step="1" class="form-control" value="0"></div>
<div class="col-md-4"><label for="cta-type" class="form-label">Sagstype</label><select id="cta-type" class="form-select"></select></div>
<div class="col-md-4"><label for="cta-status" class="form-label">Status</label><select id="cta-status" class="form-select"><option value="åben">Åben</option><option value="afventer">Afventer</option><option value="lukket">Lukket</option></select></div>
<div class="col-md-4"><label for="cta-group" class="form-label">Ansvarlig gruppe</label><select id="cta-group" class="form-select"><option value="">Ingen</option></select></div>
<div class="col-12"><label for="cta-title" class="form-label">Titel</label><input id="cta-title" class="form-control" maxlength="1000"></div>
<div class="col-12"><label for="cta-description" class="form-label">Beskrivelse</label><textarea id="cta-description" rows="5" class="form-control"></textarea></div>
<div class="col-12"><div id="cta-tags" class="mb-2"></div><button type="button" id="cta-add-tag" class="btn btn-sm btn-outline-primary">Tilføj tag</button></div>
<div class="col-12"><details><summary>Pipeline-standarder</summary><div class="row g-3 mt-1">
<div class="col-md-4"><label for="cta-stage" class="form-label">Stage</label><select id="cta-stage" class="form-select"><option value="">Ikke sat</option></select></div>
<div class="col-md-4"><label for="cta-amount" class="form-label">Beløb</label><input id="cta-amount" type="number" min="0" step="0.01" class="form-control"></div>
<div class="col-md-4"><label for="cta-probability" class="form-label">Sandsynlighed (%)</label><input id="cta-probability" type="number" min="0" max="100" step="1" class="form-control"></div>
<div class="col-12"><label for="cta-pipeline-description" class="form-label">Pipelinebeskrivelse</label><textarea id="cta-pipeline-description" class="form-control"></textarea></div>
</div></details></div>
<div class="col-12"><label><input id="cta-active" type="checkbox" checked> Aktiv</label></div>
<div class="col-12"><button type="submit" class="btn btn-primary">Gem skabelon</button></div>
</div>
</form>
</div>
<script src="/static/js/case-template-admin.js?v=1"></script>

View File

@ -192,6 +192,7 @@
<a class="nav-link" href="#task-templates" data-tab="task-templates">
<i class="bi bi-list-check me-2"></i>Opgave-templates
</a>
<a class="nav-link" href="#case-templates" data-tab="case-templates"><i class="bi bi-lightning me-2"></i>Sagsskabeloner</a>
<a class="nav-link" href="/admin/bmc-office-upload">
<i class="bi bi-cloud-upload me-2"></i>BMC Office Import
</a>
@ -675,6 +676,9 @@
</div>
<!-- Task Templates -->
<div class="tab-pane fade" id="case-templates">
{% include "settings/frontend/case_templates.html" %}
</div>
<div class="tab-pane fade" id="task-templates">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>

View File

@ -1440,6 +1440,7 @@
}
</style>
{% block extra_css %}{% endblock %}
<link rel="stylesheet" href="/static/messages.css?v=1">
</head>
{% set _xff = request.headers.get('x-forwarded-for') if request and request.headers else '' %}
{% set _xff_first = _xff.split(',')[0].strip() if _xff else '' %}
@ -1996,13 +1997,15 @@ if (bmcOriginalFetch) {
}
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/tag-picker.js?v=2.2"></script>
<script src="/static/js/tag-picker.js?v=2.3"></script>
<script src="/static/js/task-template-selector.js?v=1.1"></script>
<script src="/static/js/notifications.js?v=1.0"></script>
<script src="/static/js/telefoni.js?v=2.5"></script>
<script src="/static/js/sms.js?v=1.1"></script>
<script src="/static/js/bug-report.js?v=1.4"></script>
<script src="/static/js/bottom-bar.js?v=2.69"></script>
{% include "shared/frontend/internal_message.html" %}
<script src="/static/js/message-ui.js?v=1"></script>
<script src="/static/js/bottom-bar.js?v=2.71"></script>
<script>
// Dark Mode Toggle Logic
window.BMC_CAN_CLICK_TO_CALL = true;

View File

@ -0,0 +1,20 @@
<div class="modal fade" id="internalMessageModal" tabindex="-1" aria-labelledby="internalMessageTitle" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg modal-dialog-scrollable"><div class="modal-content rounded-4 border-0 shadow">
<div class="modal-header border-0 px-4 pt-4"><div><span class="text-uppercase small text-muted">BMC · INTERNE BESKEDER</span><h4 id="internalMessageTitle" class="mb-0">Hvad skal din kollega vide?</h4></div><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button></div>
<div class="modal-body px-4"><form id="internalMessageForm">
<div class="d-flex gap-2 mb-4" role="group" aria-label="Beskedtype"><button type="button" class="btn btn-primary" data-internal-kind="message" aria-pressed="true"><i class="bi bi-chat-text me-2"></i>Kort besked</button><button type="button" class="btn btn-outline-primary" data-internal-kind="phone" aria-pressed="false"><i class="bi bi-telephone me-2"></i>Telefonbesked</button></div>
<div id="im-error" class="alert alert-danger d-none" role="alert"></div>
<label for="im-recipient" class="form-label">Modtager <span class="text-danger">*</span></label><select id="im-recipient" class="form-select mb-3" required><option value="">Vælg medarbejder…</option></select>
<label for="im-text" class="form-label">Besked <span class="text-danger">*</span></label><textarea id="im-text" rows="4" maxlength="2000" required class="form-control mb-3" placeholder="Skriv din besked her…"></textarea>
<details id="im-contact-panel" class="mb-3"><summary class="small text-muted mb-2"><i class="bi bi-person-plus me-1"></i>Tilknyt en kontaktperson <span class="small">· valgfrit</span></summary>
<label for="im-contact-search" class="form-label">Kontaktperson <small class="text-muted fw-normal">· valgfrit</small></label>
<div class="position-relative mb-3"><input id="im-contact-search" class="form-control" placeholder="Søg navn, telefon eller e-mail…" autocomplete="off"><div id="im-contact-results" class="list-group shadow-sm mt-1 d-none" style="max-height:200px;overflow:auto"></div><div id="im-contact" class="mt-2"></div></div>
</details>
<div id="im-phone-fields" class="row g-3 mb-3 d-none"><div class="col-sm-6"><label for="im-caller" class="form-label">Hvem ringede?</label><input id="im-caller" class="form-control" maxlength="200" placeholder="Navn, også uden kontaktkort"></div><div class="col-sm-6"><label for="im-phone" class="form-label">Telefonnummer til tilbageringning</label><input id="im-phone" type="tel" class="form-control" maxlength="80" placeholder="Telefonnummer"></div></div>
<label class="form-check mt-3"><input id="im-ack" type="checkbox" class="form-check-input"><span class="form-check-label small">Bed modtageren bekræfte, at beskeden er læst</span></label>
<p class="small text-muted mt-3 mb-0">Beskeden leveres i kollegaens interne beskeder i bundlinjen.</p>
</form></div>
<div class="modal-footer border-0 px-4 pb-4"><button type="button" class="btn btn-light border" data-bs-dismiss="modal">Luk</button><button type="submit" form="internalMessageForm" id="im-send" class="btn btn-primary px-4"><i class="bi bi-send me-2"></i>Send besked</button></div>
</div></div>
</div>
<script src="/static/js/internal-message.js?v=2"></script>

View File

@ -502,6 +502,8 @@ app.include_router(bug_reports_api.router, prefix="/api/v1", tags=["Bug Reports"
# Module Routers
app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"])
app.include_router(sag_api.router, prefix="/api/v1", tags=["Cases"])
from app.modules.sag.backend.create_support import router as case_create_support_router
app.include_router(case_create_support_router, prefix="/api/v1", tags=["Cases"])
app.include_router(sag_reminders_api.router, tags=["Reminders"]) # No prefix - endpoints have full path
app.include_router(hardware_module_api.router, prefix="/api/v1", tags=["Hardware Module"])
app.include_router(locations_api, prefix="/api/v1", tags=["Locations"])

View File

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS case_create_templates (
id SERIAL PRIMARY KEY,
name VARCHAR(120) NOT NULL,
icon VARCHAR(80) NOT NULL DEFAULT 'bi-lightning',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
template_values JSONB NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(template_values) = 'object'),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

View File

@ -0,0 +1,5 @@
ALTER TABLE bottom_bar_messages
ADD COLUMN IF NOT EXISTS message_kind VARCHAR(16) NOT NULL DEFAULT 'message',
ADD COLUMN IF NOT EXISTS contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS caller_name VARCHAR(200) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS callback_phone VARCHAR(80) NOT NULL DEFAULT '';

View File

@ -47,6 +47,7 @@
activeThreadKey: ''
};
const LOCAL_NOTES_KEY = 'bmc_bottom_bar_notes_v1';
window.addEventListener('hub:internal-message-sent', function () { fetchBottomBarState(); });
let notesApiUnavailable = false;
let driftSummaryRefreshTimer = null;
let markMessagesReadPromise = null;
@ -198,7 +199,7 @@
messageItems.forEach(function (item) {
const own = !!item.is_own;
const partnerId = own ? Number(item.recipient_user_id || 0) : Number(item.sender_user_id || 0);
const partnerId = item.recipient_user_id == null ? 0 : (own ? Number(item.recipient_user_id || 0) : Number(item.sender_user_id || 0));
const key = partnerId > 0 ? ('user:' + partnerId) : 'broadcast';
const label = partnerId > 0
? String(own ? (item.to || ('Bruger #' + partnerId)) : (item.from || ('Bruger #' + partnerId)))
@ -309,20 +310,8 @@
}
function getRenderableMessageThreads(activeThread) {
const seen = new Set();
const out = [];
if (activeThread && activeThread.key) {
out.push(activeThread);
seen.add(activeThread.key);
}
getMessageThreads().forEach(function (thread) {
if (seen.has(thread.key)) return;
seen.add(thread.key);
out.push(thread);
});
const out = getMessageThreads();
if (activeThread?.key && !out.some(thread => thread.key === activeThread.key)) out.unshift(activeThread);
return out;
}
@ -917,39 +906,8 @@
const activeThread = ensureActiveMessageThread();
syncChatRecipientToActiveThread(activeThread);
const messageItems = activeThread && Array.isArray(activeThread.items) ? activeThread.items : [];
if (messageItems.length > 0) {
return messageItems.map(function (m) {
const own = !!m.is_own;
const unreadBadge = m.is_unread ? ' <span class="badge text-bg-warning ms-1">Ny</span>' : '';
const importantBadge = m.requires_manual_ack ? ' <span class="badge text-bg-danger ms-1">Vigtig</span>' : '';
const ackBadge = own && m.requires_manual_ack
? (m.is_acknowledged
? ' <span class="badge text-bg-success ms-1">Bekræftet læst</span>'
: ' <span class="badge text-bg-secondary ms-1">Afventer læst-bekræftelse</span>')
: '';
const targetMeta = own && m.to ? '<div class="small text-muted mb-1">Til: ' + esc(m.to) + '</div>' : '';
const replyTargetId = own ? Number(m.recipient_user_id || 0) : Number(m.sender_user_id || 0);
const replyBtn = replyTargetId > 0
? '<div class="mt-2"><button type="button" class="btn btn-sm ' + (own ? 'btn-light' : 'btn-outline-secondary') + '" data-bb-reply-message="' + Number(m.id || 0) + '"><i class="bi bi-reply me-1"></i>Svar</button></div>'
: '';
const ackBtn = (!own && m.requires_manual_ack && m.is_unread)
? '<div class="mt-2"><button type="button" class="btn btn-sm btn-danger" data-bb-ack-message="' + Number(m.id || 0) + '"><i class="bi bi-check2-circle me-1"></i>Bekræft læst</button></div>'
: '';
return ''
+ '<div class="' + (own ? 'text-end' : '') + '">'
+ targetMeta
+ '<div class="d-inline-block ' + (own ? 'bg-primary text-white' : (m.requires_manual_ack ? 'bg-warning-subtle border border-warning-subtle' : 'bg-light')) + ' p-2 rounded-3 text-start shadow-sm" style="max-width: 85%;">'
+ '<strong class="' + (own ? 'text-white' : 'text-accent') + '">' + esc(m.from) + ':</strong> '
+ esc(m.text)
+ unreadBadge
+ importantBadge
+ ackBadge
+ replyBtn
+ ackBtn
+ '</div></div>';
});
}
return ['Ingen beskeder i denne tråd endnu.'];
if (messageItems.length > 0) return messageItems.map(window.BmcMessageUI.message);
return ['<div class="msg-empty"><span><i class="bi bi-chat-square-text"></i></span><strong>Her starter samtalen</strong><p>Send en kort besked eller giv en telefonbesked videre.</p></div>'];
}
if (key === 'tasks') {
@ -1229,6 +1187,8 @@
}
let messageFocusState = null;
const previousMessages = innerContent.querySelector('.bb-messages-list');
const previousScroll = previousMessages ? {key:previousMessages.dataset.threadKey, top:previousMessages.scrollTop, bottom:previousMessages.scrollHeight-previousMessages.scrollTop-previousMessages.clientHeight < 40} : null;
if (activeKey === 'messages') {
const activeEl = document.activeElement;
const activeId = activeEl && activeEl.id ? activeEl.id : '';
@ -1329,17 +1289,18 @@
const threadItems = getRenderableMessageThreads(activeThread);
syncChatRecipientToActiveThread(activeThread);
if (threadItems.length > 0) {
{
const threadList = document.createElement('div');
threadList.className = 'bb-message-threads';
threadList.innerHTML = '<div class="msg-inbox-heading"><span>Samtaler</span><button type="button" title="Ny besked" aria-label="Ny besked" onclick="window.openInternalMessage()"><i class="bi bi-pencil-square"></i></button></div>';
if (!threadItems.length) threadList.insertAdjacentHTML('beforeend', '<p class="small text-muted px-2">Dine samtaler vises her.</p>');
threadItems.forEach(function (thread) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'bb-message-thread' + (activeThread && thread.key === activeThread.key ? ' is-active' : '');
button.setAttribute('data-bb-thread-key', thread.key);
button.innerHTML = ''
+ '<span class="bb-message-thread-label">' + escapeHtml(thread.label || 'Samtale') + '</span>'
+ (thread.unread > 0 ? '<span class="bb-message-thread-count">' + Number(thread.unread || 0) + '</span>' : '');
button.setAttribute('aria-current', activeThread && thread.key === activeThread.key ? 'true' : 'false');
button.innerHTML = window.BmcMessageUI.thread(thread);
threadList.appendChild(button);
});
chatContainer.appendChild(threadList);
@ -1347,6 +1308,9 @@
const replyBox = document.createElement('div');
replyBox.className = 'bb-messages-composer';
const conversation = document.createElement('div');
conversation.className = 'msg-conversation';
conversation.innerHTML = '<header class="msg-conversation-header"><div><strong>' + escapeHtml(activeThread?.label || 'Vælg en samtale') + '</strong><small>' + (!activeThread ? 'Brug blyanten til at skrive til en kollega' : activeThread.partnerUserId ? 'Intern samtale' : 'Fælles beskeder til alle på vagt') + '</small></div><button type="button" class="msg-header-action" onclick="window.openInternalMessage({kind:\'phone\',recipient:' + Number(activeThread?.partnerUserId || 0) + '})"><i class="bi bi-telephone-plus"></i><span>Telefonbesked</span></button></header>';
const threadMeta = activeThread
? '<div class="small text-muted mb-2"><i class="bi bi-chat-square-text me-1"></i>'
+ (activeThread.partnerUserId ? ('Samtale med ' + escapeHtml(activeThread.label || 'Bruger')) : 'Besked til alle på vagt')
@ -1359,27 +1323,23 @@
</div>`
: '';
replyBox.innerHTML = `
${threadMeta}
${replyBanner}
<div class="input-group input-group-sm mb-1">
<span class="input-group-text bg-light text-muted border-0"><i class="bi bi-person"></i></span>
<select id="chatRecipient" class="form-select border-0 bg-light">
<select id="chatRecipient" hidden aria-label="Modtager">
<option value="all">Alle vagt</option>
</select>
<div class="msg-compose-field">
<textarea id="chatInputQuick" rows="2" maxlength="2000" aria-label="Besked" ${!activeThread?'disabled':''} placeholder="${activeThread?'Skriv til '+escapeHtml(activeThread.label)+'…':'Vælg en samtale eller opret en ny besked'}">${escapeHtml(chatComposerState.draft || '')}</textarea>
<button type="button" class="msg-send" id="btnSendMsg" aria-label="Send besked" ${!activeThread?'disabled':''}><i class="bi bi-arrow-up"></i></button>
</div>
<label class="form-check form-switch small mb-2">
<input class="form-check-input" type="checkbox" id="chatRequiresAck" ${chatComposerState.requiresManualAck ? 'checked' : ''}>
<span class="form-check-label">Kræv manuel læst-bekræftelse</span>
</label>
<div class="input-group">
<input type="text" id="chatInputQuick" class="form-control form-control-sm" placeholder="Skriv en besked..." value="${escapeHtml(chatComposerState.draft || '')}">
<button class="btn btn-outline-primary btn-sm" id="btnSendMsg"><i class="bi bi-send"></i></button>
</div>
<div class="msg-compose-footer"><label><input type="checkbox" id="chatRequiresAck" ${chatComposerState.requiresManualAck ? 'checked' : ''}> Bed om læsebekræftelse</label><small>Ctrl / + Enter for at sende</small></div>
`;
chatContainer.appendChild(ul);
chatContainer.appendChild(replyBox);
conversation.appendChild(ul);
conversation.appendChild(replyBox);
chatContainer.appendChild(conversation);
innerContent.appendChild(chatContainer);
ul.dataset.threadKey = activeThread?.key || '';
window.requestAnimationFrame(function () { ul.scrollTop = previousScroll && previousScroll.key === ul.dataset.threadKey && !previousScroll.bottom ? previousScroll.top : ul.scrollHeight; });
const recipientSelect = document.getElementById('chatRecipient');
const input = document.getElementById('chatInputQuick');
@ -1396,6 +1356,9 @@
});
}
if (input) {
input.addEventListener('keydown', function (event) {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { event.preventDefault(); event.stopPropagation(); document.getElementById('btnSendMsg')?.click(); }
});
input.addEventListener('input', function () {
chatComposerState.draft = input.value || '';
});
@ -3513,7 +3476,7 @@
if (activeKey === 'messages') {
renderTabPanel();
}
const tabInner = document.getElementById('bbTabInnerContent');
const tabInner = document.querySelector('.bb-messages-list');
if (tabInner) {
tabInner.scrollTop = tabInner.scrollHeight + 500;
}

256
static/js/case-create.js Normal file
View File

@ -0,0 +1,256 @@
/* Progressive case creation. Existing searches and submit retain their API contracts. */
(() => {
'use strict';
const $ = id => document.getElementById(id);
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const state = {tags: [], hardware: [], tagCatalog: [], panels: {}, ready: false, restoring: false, key: null, pending: false, completed: false};
let saveTimer, duplicateTimer, duplicateToken = 0, workloadToken = 0, contactCasesToken = 0, tagSuggestionTimer, templates = [];
async function api(url, options) {
const response = await fetch(url, {credentials: 'include', ...options});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(typeof body.detail === 'string' ? body.detail : 'Kunne ikke hente oplysninger');
return body;
}
function panel(id, title, elements, open = false) {
const detail = document.createElement('details');
detail.className = 'cc-panel'; detail.id = `cc-${id}`; detail.open = open;
const icon = {relations:'bi-people',tags:'bi-tags',hardware:'bi-pc-display',pipeline:'bi-graph-up-arrow',order:'bi-bag',metadata:'bi-sliders'}[id];
detail.innerHTML = `<summary><span class="cc-panel-icon"><i class="bi ${icon}" aria-hidden="true"></i></span><span class="cc-panel-label">${esc(title)}<small class="cc-summary"></small></span></summary><div class="cc-panel-body"></div>`;
elements[0].before(detail);
elements.forEach(el => detail.lastElementChild.append(el));
state.panels[id] = detail;
detail.addEventListener('toggle', scheduleSave);
return detail;
}
function summaries() {
const values = {
relations: `${selectedCustomer?.name || 'Vælg firma'} · ${Object.keys(selectedContacts).length} kontakter`,
tags: state.tags.map(t => t.name).join(', ') || 'Ingen tags',
hardware: `${state.hardware.length} enheder valgt`,
pipeline: [$('pipeline_stage_id').selectedOptions[0]?.text, $('pipeline_amount').value ? `${$('pipeline_amount').value} kr.` : ''].filter(Boolean).join(' · '),
order: `${document.querySelectorAll('#orderLines .order-line').length} linjer`,
metadata: [$('status').selectedOptions[0]?.text, $('ansvarlig_bruger_id').selectedOptions[0]?.text, $('deadline').value.replace('T', ' ')].filter(Boolean).join(' · ')
};
Object.entries(values).forEach(([key, value]) => { if (state.panels[key]) state.panels[key].querySelector('.cc-summary').textContent = value; });
}
function fieldSnapshot() {
return Object.fromEntries(Array.from($('createForm').querySelectorAll('input[id],select[id],textarea[id]')).filter(el => !['customerSearch','contactSearch'].includes(el.id)).map(el => [el.id, el.type === 'checkbox' ? el.checked : el.value]));
}
function snapshot() {
return {version: 1, savedAt: Date.now(), fields: fieldSnapshot(), customer: selectedCustomer, contacts: selectedContacts, contactCompanies: selectedContactsCompanies,
tags: state.tags, hardware: state.hardware, orders: collectOrderItems(), prefill: telefoniPrefill,
customerSearch: $('customerSearch').value, contactSearch: $('contactSearch').value,
panels: Object.fromEntries(Object.entries(state.panels).map(([k, el]) => [k, el.open]))};
}
function save() {
if (!state.ready || state.pending || state.restoring || state.completed || !state.key) return;
try { localStorage.setItem(state.key, JSON.stringify(snapshot())); $('cc-save').textContent = 'Kladde gemt lokalt'; }
catch (error) { console.error('Kunne ikke gemme kladde', error); $('cc-save').textContent = 'Kladde kunne ikke gemmes i browseren'; }
}
function scheduleSave() {
summaries();
if (!state.ready || state.pending || state.restoring || state.completed) return;
$('cc-save').textContent = 'Gemmer kladde…'; clearTimeout(saveTimer); saveTimer = setTimeout(save, 500);
}
function renderTags() {
$('cc-tag-chips').innerHTML = state.tags.map(t => `<span class="badge me-1 mb-1" style="background-color:#64748b" data-tag-id="${Number(t.id)}">${/^bi-[a-z0-9-]+$/.test(t.icon || '') ? `<i class="bi ${esc(t.icon)}"></i> ` : ''}${esc(t.name)} <button type="button" class="btn-close btn-close-white" aria-label="Fjern ${esc(t.name)}"></button></span>`).join('');
state.tags.forEach(t => { const chip = $('cc-tag-chips').querySelector(`[data-tag-id="${Number(t.id)}"]`); if (/^#[0-9a-f]{6}$/i.test(t.color)) chip.style.backgroundColor = t.color; });
summaries();
renderTagSuggestions();
}
function renderTagSuggestions() {
const box = $('cc-tag-suggestions');
if (!box) return;
const haystack = `${$('titel')?.value || ''} ${$('beskrivelse')?.value || ''}`.toLocaleLowerCase('da-DK');
if (haystack.trim().length < 3 || !state.tagCatalog.length) { box.innerHTML = ''; return; }
const selected = new Set(state.tags.map(tag => Number(tag.id)));
const suggestions = state.tagCatalog.filter(tag => {
if (selected.has(Number(tag.id)) || !['brand', 'type'].includes(tag.type)) return false;
const words = Array.isArray(tag.catch_words) ? tag.catch_words : [];
return words.some(word => String(word).trim() && haystack.includes(String(word).trim().toLocaleLowerCase('da-DK')));
}).slice(0, 8);
box.innerHTML = suggestions.length ? `<div class="cc-tag-suggestions__label">Foreslået ud fra teksten</div><div class="d-flex flex-wrap gap-1">${suggestions.map(tag => `<button type="button" class="btn btn-sm btn-outline-primary" data-suggested-tag="${Number(tag.id)}" title="Tilføj ${esc(tag.name)}">${tag.icon ? `<i class="bi ${esc(tag.icon)}"></i> ` : ''}${esc(tag.name)} <i class="bi bi-plus-lg"></i></button>`).join('')}</div>` : '';
}
function scheduleTagSuggestions() {
clearTimeout(tagSuggestionTimer);
tagSuggestionTimer = setTimeout(renderTagSuggestions, 350);
}
async function addTag(tag) {
if (!state.tags.some(t => t.id === tag.id)) {
const groups = await api('/api/v1/tags/groups').catch(() => []);
if (groups.some(g => g.id === tag.tag_group_id && ['single','toggle'].includes(g.behavior))) state.tags = state.tags.filter(t => t.tag_group_id !== tag.tag_group_id);
state.tags.push(tag); renderTags(); scheduleSave();
}
}
function renderSelectedHardware() {
$('cc-hardware-chips').innerHTML = state.hardware.map(h => `<span class="badge bg-secondary me-1">${esc(h.name)} <button type="button" class="btn-close btn-close-white" data-remove-hardware="${Number(h.id)}" aria-label="Fjern hardware"></button></span>`).join('');
document.querySelectorAll('[data-hardware-choice]').forEach(el => { el.checked = state.hardware.some(h => h.id === Number(el.dataset.hardwareChoice)); });
summaries();
}
async function duplicates() {
const token = ++duplicateToken, customerId = selectedCustomer?.id, title = $('titel').value.trim();
$('cc-duplicates').innerHTML = '';
if (!customerId || title.length < 5) return;
try {
const rows = await api(`/api/v1/case-create/duplicates?customer_id=${customerId}&title=${encodeURIComponent(title)}`);
if (token !== duplicateToken) return;
$('cc-duplicates').innerHTML = rows.length ? `<div class="alert alert-warning"><strong>Mulige eksisterende sager</strong>${rows.map(r => `<div><a target="_blank" rel="noopener" href="/sag/${r.id}/v3">SAG-${r.id} · ${esc(r.titel)}</a><small> · ${esc(r.status)} · ${esc(r.ansvarlig_navn || 'Ingen ansvarlig')}</small></div>`).join('')}</div>` : '';
} catch { if (token === duplicateToken) $('cc-duplicates').textContent = 'Duplikatkontrol er midlertidigt utilgængelig. Du kan stadig oprette sagen.'; }
}
function scheduleDuplicates() { ++duplicateToken; clearTimeout(duplicateTimer); duplicateTimer = setTimeout(duplicates, 500); }
async function contactOpenCases() {
const box = $('cc-contact-cases');
if (!box) return;
const contacts = Object.values(selectedContacts);
const token = ++contactCasesToken;
if (!contacts.length) { box.hidden = true; box.innerHTML = ''; return; }
box.hidden = false;
box.innerHTML = '<span class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Tjekker kontaktens åbne sager…</span>';
const query = contacts.map(contact => `contact_ids=${encodeURIComponent(contact.id)}`).join('&');
try {
const data = await api(`/api/v1/case-create/contacts-open-cases?${query}`);
if (token !== contactCasesToken) return;
const groups = contacts.map(contact => ({contact, data: data[contact.id] || {total:0, items:[]}}));
const total = groups.reduce((sum, group) => sum + Number(group.data.total || 0), 0);
const title = total ? `${total} åben${total === 1 ? '' : 'ne'} sag${total === 1 ? '' : 'er'} på valgt kontakt` : 'Ingen åbne sager på valgt kontakt';
box.innerHTML = `<div class="cc-contact-cases__header"><span class="cc-contact-cases__header-icon"><i class="bi ${total ? 'bi-clipboard2-check' : 'bi-check2-circle'}"></i></span><div><span class="cc-contact-cases__eyebrow">Dublettjek</span><span class="cc-contact-cases__title">${title}</span></div></div>${total ? `<div class="cc-contact-cases__body">${groups.map(({contact,data}) => data.total ? `<div class="cc-contact-cases__group"><span class="cc-contact-cases__group-name">${esc(contact.name)}</span>${data.items.map(item => `<div class="cc-contact-cases__item"><a href="/sag/${Number(item.id)}/v3" target="_blank" rel="noopener">SAG-${Number(item.id)} · ${esc(item.titel)}</a><small>${esc(item.status || '')} · ${esc(item.ansvarlig_navn || 'Ingen ansvarlig')}${item.deadline ? ` · ${esc(new Date(item.deadline).toLocaleDateString('da-DK'))}` : ''}</small></div>`).join('')}${data.total > data.items.length ? `<span class="cc-contact-cases__more">Viser ${data.items.length} af ${data.total} åbne sager</span>` : ''}</div>` : '').join('')}</div>` : '<div class="cc-contact-cases__empty">Du kan oprette sagen uden risiko for en kendt, åben sag på kontakten.</div>'}`;
} catch {
if (token === contactCasesToken) box.innerHTML = '<span class="text-muted">Kunne ikke tjekke kontaktens åbne sager. Du kan stadig oprette sagen.</span>';
}
}
async function workload() {
const token = ++workloadToken, id = $('ansvarlig_bruger_id').value;
$('cc-workload').textContent = id ? 'Henter åbne sager…' : 'Vælg en ansvarlig for at se åbne sager.';
if (!id) return;
try {
const data = await api(`/api/v1/case-create/workload?user_id=${id}`);
if (token !== workloadToken) return;
$('cc-workload').innerHTML = `<details><summary>${data.total} åbne sager · ${esc($('ansvarlig_bruger_id').selectedOptions[0].text)}</summary>${data.items.map(r => `<div class="cc-work-item"><a target="_blank" rel="noopener" href="/sag/${r.id}/v3">SAG-${r.id} · ${esc(r.titel)}</a><small>${esc(r.customer_name || '')} · ${esc(r.status)} <span class="${r.deadline && new Date(r.deadline) < new Date() ? 'text-danger' : ''}">${r.deadline ? esc(new Date(r.deadline).toLocaleString('da-DK')) : ''}</span></small></div>`).join('') || '<p class="text-success">Ingen åbne sager.</p>'}${data.total > 10 ? `<a href="/sag?ansvarlig_bruger_id=${id}" target="_blank" rel="noopener">Se alle ${data.total} sager</a>` : ''}</details>`;
} catch { if (token === workloadToken) $('cc-workload').textContent = 'Kunne ikke hente åbne sager.'; }
}
function typeChanged(type) {
document.querySelectorAll('[data-case-type]').forEach(b => { const active = b.dataset.caseType === type; b.classList.toggle('active', active); b.setAttribute('aria-pressed', String(active)); });
const key = {ticket:'hardware', pipeline:'pipeline', ordre:'order'}[type];
if (key && state.panels[key] && !state.restoring) state.panels[key].open = true;
if ($('cc-more')) $('cc-more').open = Boolean($('cc-more').querySelector(`[data-case-type="${CSS.escape(type)}"]`));
scheduleSave();
}
function buildTypes() {
const options = Array.from($('type').options), current = $('type').value;
const keys = [...new Set([current, 'ticket','pipeline','ordre','opgave', ...options.map(o => o.value)])].filter(k => options.some(o => o.value === k));
const hints = {ticket:'Support og hjælp',pipeline:'Muligheder og tilbud',ordre:'Indkøb og salg',opgave:'Noget der skal løses',projekt:'Større forløb',service:'Service og vedligehold',abonnement:'Løbende aftaler'};
const button = key => `<button type="button" class="btn btn-outline-primary" data-case-type="${esc(key)}" aria-pressed="false"><span class="cc-type-name">${esc(options.find(o => o.value === key).text)}</span><small class="cc-type-hint">${esc(hints[key] || 'Opret sag')}</small></button>`;
const messageButton = $('caseMessageTypeButton');
$('type').classList.add('d-none'); $('type').removeAttribute('required');
const div = document.createElement('div'); div.id = 'cc-types'; div.className = 'cc-types'; div.setAttribute('role', 'group'); div.setAttribute('aria-label', 'Sagstype');
div.innerHTML = keys.slice(0,4).map(button).join('') + (keys.length > 4 ? `<details id="cc-more"><summary>Flere</summary><div class="cc-types">${keys.slice(4).map(button).join('')}</div></details>` : '');
if (messageButton) { messageButton.classList.remove('cc-message-fallback'); messageButton.dataset.caseMessage = 'true'; div.insertBefore(messageButton, div.querySelector('#cc-more')); }
$('type').after(div);
div.addEventListener('click', e => { const message = e.target.closest('[data-case-message]'); if (message) { window.caseCreateUI?.openMessage(); return; } const b = e.target.closest('[data-case-type]'); if (b) { $('type').value = b.dataset.caseType; $('type').dispatchEvent(new Event('change', {bubbles:true})); } });
}
async function applyTemplate(id) {
const template = templates.find(t => t.id === Number(id)); if (!template) return;
const values = template.values, fields = {type: values.type, titel: values.titel, beskrivelse: values.beskrivelse, status: values.status, assigned_group_id: values.assigned_group_id ?? ''};
Object.entries(values.pipeline || {}).forEach(([k,v]) => { fields[`pipeline_${k === 'stage_id' ? 'stage_id' : k}`] = v ?? ''; });
if (Object.entries(fields).some(([k,v]) => $(k)?.value && String(v) !== $(k).value) && !confirm('Skabelonen ændrer udfyldte felter. Vil du bruge den?')) return;
Object.entries(fields).forEach(([k,v]) => { const el = $(k); if (el && (el.tagName !== 'SELECT' || Array.from(el.options).some(o => o.value === String(v)))) el.value = v; });
const tags = await api('/api/v1/tags?is_active=true');
for (const id of values.tag_ids || []) { const tag = tags.find(t => t.id === id); if (tag) await addTag(tag); }
updateCaseTypeSections(); $('charCount').textContent = `${$('beskrivelse').value.length} tegn`; scheduleSave(); scheduleDuplicates();
}
async function restore(draft) {
state.restoring = true;
try {
selectedCustomer = draft.customer; selectedContacts = draft.contacts || {}; selectedContactsCompanies = draft.contactCompanies || {};
telefoniPrefill = draft.prefill || telefoniPrefill;
Object.entries(draft.fields || {}).forEach(([id,value]) => { const el = $(id); if (el && !id.startsWith('cc-')) { if (el.type === 'checkbox') el.checked = value; else el.value = value; } });
state.tags = draft.tags || []; state.hardware = draft.hardware || [];
$('orderLines').innerHTML = '';
(draft.orders || []).forEach(item => { addOrderLine(item.type); const row = $('orderLines').lastElementChild; Object.entries({description:'description',quantity:'quantity',unit:'unit',unit_price:'unit-price',amount:'amount',currency:'currency',status:'status',external_ref:'reference',purchase_purpose:'purpose'}).forEach(([k,cls]) => { const el = row.querySelector(`.order-${cls}`); if (el) el.value = item[k] ?? ''; }); });
renderSelections(); await loadHardwareForContacts(); renderTags(); renderSelectedHardware(); renderOrderLinesEmptyState();
await loadCreateTopAlertsForCustomer(selectedCustomer?.id || null);
if (selectedCustomer) await loadSelectedCustomerContacts(selectedCustomer.id);
$('customerSearch').value = draft.customerSearch || ''; $('contactSearch').value = draft.contactSearch || '';
updateCaseTypeSections(); Object.entries(draft.panels || {}).forEach(([k,v]) => { if (state.panels[k]) state.panels[k].open = v; });
$('charCount').textContent = `${$('beskrivelse').value.length} tegn`;
} finally { state.restoring = false; }
state.pending = false; $('cc-draft').remove(); scheduleSave(); scheduleDuplicates(); workload();
}
function validate() {
document.querySelectorAll('.cc-error').forEach(el => el.remove());
document.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid'));
const errors = [];
const add = (el, text) => { errors.push(text); el.classList.add('is-invalid'); el.insertAdjacentHTML('afterend', `<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 (!selectedCustomer && $('customerSearch').value.trim().length < 2) add($('customerSearch'), 'Vælg et firma');
$('createForm').querySelectorAll('input[type=number]').forEach(el => { if (!el.checkValidity()) add(el, 'Kontrollér tallets interval og format'); });
document.querySelectorAll('#orderLines .order-line').forEach(row => { for (const cls of ['description','amount']) { const el = row.querySelector(`.order-${cls}`); if (!el.value.trim()) add(el, cls === 'description' ? 'Ordrelinjen mangler beskrivelse' : 'Ordrelinjen mangler beløb'); } });
if (errors.length) { $('error').classList.remove('d-none'); $('error-text').textContent = [...new Set(errors)].join(' · '); document.querySelector('.is-invalid')?.focus(); return false; }
return true;
}
async function init() {
const form = $('createForm');
try {
const setting = await api('/api/v1/settings/case_statuses');
const statuses = JSON.parse(setting.value || '[]');
if (Array.isArray(statuses)) statuses.forEach(s => { const value = typeof s === 'string' ? s : s.value; if (value && !Array.from($('status').options).some(o => o.value === value)) $('status').add(new Option(typeof s === 'string' ? s : (s.label || value), value)); });
} catch { /* Keep the built-in statuses available. */ }
const headings = Array.from(form.querySelectorAll(':scope > h5'));
panel('relations', 'Kunde og kontakter', [headings[0], headings[0].nextElementSibling], true);
for (const [key,id,title] of [['hardware','hardwareSection','Hardware og AnyDesk'],['pipeline','pipelineSection','Pipeline'],['order','orderSection','Indkøb og salg']]) panel(key, title, [$(id)]);
const metadata = headings[1]; panel('metadata', 'Status, ansvar og deadline', [metadata,metadata.nextElementSibling,metadata.nextElementSibling.nextElementSibling]);
const tags = document.createElement('section'); tags.innerHTML = '<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]);
$('cc-tag-add').onclick = () => window.tagPicker.showSelection(addTag);
window.setTagPickerContext?.('case', null, addTag);
$('cc-tag-chips').onclick = e => { const chip = e.target.closest('[data-tag-id]'); if (chip && e.target.closest('button')) { state.tags = state.tags.filter(t => t.id !== Number(chip.dataset.tagId)); renderTags(); scheduleSave(); } };
$('cc-tag-suggestions').onclick = e => { const button = e.target.closest('[data-suggested-tag]'); const tag = state.tagCatalog.find(item => item.id === Number(button?.dataset.suggestedTag)); if (tag) addTag(tag).catch(error => showCreateError(error.message)); };
const chips = document.createElement('div'); chips.id = 'cc-hardware-chips'; $('hardwareList').before(chips);
chips.onclick = e => { const b = e.target.closest('[data-remove-hardware]'); if (b) { state.hardware = state.hardware.filter(h => h.id !== Number(b.dataset.removeHardware)); renderSelectedHardware(); scheduleSave(); } };
const originalRenderHardware = window.renderHardwareList;
window.renderHardwareList = items => { originalRenderHardware(items); Array.from($('hardwareList').children).forEach((row,i) => { if (!items[i]) return; const item = items[i]; const input = document.createElement('input'); input.type = 'checkbox'; input.className = 'form-check-input me-2'; input.dataset.hardwareChoice = item.id; input.setAttribute('aria-label', `Vælg ${item.model || item.brand || item.id}`); input.onchange = () => { state.hardware = state.hardware.filter(h => h.id !== item.id); if (input.checked) state.hardware.push({id:item.id,name:item.model || item.brand || `Hardware #${item.id}`}); renderSelectedHardware(); scheduleSave(); }; row.prepend(input); }); renderSelectedHardware(); };
window.quickCreateHardware = async () => {
const name = $('hardwareNameInput').value.trim(), customerId = selectedCustomer?.id || getSingleContactCompanyId();
if (!name || !customerId) { showCreateError('Udfyld hardwarenavn og vælg et firma'); return; }
const anydeskId = $('hardwareAnyDeskIdInput').value.trim();
const button = $('hardwareNameInput').closest('.card-body').querySelector('button'); button.disabled = true;
try { const item = await api('/api/v1/hardware/quick', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,customer_id:customerId,anydesk_id:anydeskId || null,anydesk_link:anydeskId ? `anydesk://${anydeskId}` : null})}); state.hardware.push({id:item.id,name}); $('hardwareNameInput').value = ''; $('hardwareAnyDeskIdInput').value = ''; renderSelectedHardware(); scheduleSave(); } catch(e) { showCreateError(e.message); } finally { button.disabled = false; }
};
const originalRender = window.renderSelections;
window.renderSelections = () => { originalRender(); if (state.ready) { scheduleSave(); scheduleDuplicates(); contactOpenCases(); } };
const contactCases = document.createElement('aside'); contactCases.id = 'cc-contact-cases'; contactCases.className = 'cc-contact-cases'; contactCases.hidden = true; $('selectedContacts').after(contactCases);
const workloadEl = document.createElement('div'); workloadEl.id = 'cc-workload'; workloadEl.className = 'cc-workload'; state.panels.metadata.after(workloadEl);
const duplicateEl = document.createElement('div'); duplicateEl.id = 'cc-duplicates'; $('titel').closest('.row').after(duplicateEl);
const toolbar = document.createElement('div'); toolbar.className = 'mb-3'; toolbar.innerHTML = '<label class="form-label" for="cc-template">Hurtigskabelon</label><select id="cc-template" class="form-select"><option value="">Start uden skabelon</option></select><small id="cc-template-error" class="text-muted"></small>'; form.prepend(toolbar);
try { templates = await api('/api/v1/case-create/templates'); $('cc-template').insertAdjacentHTML('beforeend',templates.map(t => `<option value="${t.id}">${esc(t.name)}</option>`).join('')); } catch { $('cc-template-error').textContent = 'Skabeloner er ikke tilgængelige.'; }
$('cc-template').onchange = e => applyTemplate(e.target.value).catch(err => showCreateError(err.message));
const actions = $('submitBtn').parentElement; actions.classList.add('cc-actions'); actions.insertAdjacentHTML('afterbegin','<small id="cc-save" role="status" class="me-auto align-self-center">Klargør kladde…</small>');
toolbar.classList.add('cc-template-toolbar');
buildTypes(); updateCaseTypeSections();
form.addEventListener('input', scheduleSave); form.addEventListener('change', scheduleSave); form.addEventListener('click', () => setTimeout(scheduleSave,0));
$('titel').addEventListener('input', () => { scheduleDuplicates(); scheduleTagSuggestions(); });
$('beskrivelse').addEventListener('input', scheduleTagSuggestions); $('ansvarlig_bruger_id').addEventListener('change', workload);
document.addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !document.querySelector('.modal.show')) { e.preventDefault(); if (!$('submitBtn').disabled) form.requestSubmit(); } });
window.addEventListener('pagehide', save);
try {
const user = await api('/api/v1/auth/me');
if (!(user.user_id || user.id)) throw new Error('Mangler bruger');
const params = new URLSearchParams(location.search); params.sort();
state.key = `bmc:case-draft:v1:${user.user_id || user.id}:${params.toString() || 'new'}`;
const raw = localStorage.getItem(state.key); let draft;
try { draft = raw ? JSON.parse(raw) : null; } catch { localStorage.removeItem(state.key); }
if (draft && (draft.version !== 1 || !Number.isFinite(draft.savedAt) || !draft.fields || !Array.isArray(draft.orders) || Date.now()-draft.savedAt > 7*86400000)) { localStorage.removeItem(state.key); draft=null; }
if (draft) {
state.pending = true; const prompt = document.createElement('div'); prompt.id = 'cc-draft'; prompt.className = 'alert alert-info'; prompt.innerHTML = 'Der findes en lokal kladde. <button type="button" class="btn btn-sm btn-primary" id="cc-restore">Gendan kladde</button> <button type="button" class="btn btn-sm btn-outline-secondary" id="cc-discard">Kassér</button>'; form.prepend(prompt);
$('cc-restore').onclick = () => restore(draft).catch(e => showCreateError(e.message));
$('cc-discard').onclick = () => { localStorage.removeItem(state.key); state.pending=false; prompt.remove(); scheduleSave(); };
}
$('cc-save').textContent = draft ? 'Vælg om kladden skal gendannes' : 'Kladde gemmes i denne browser';
} catch { $('cc-save').textContent = 'Autosave er ikke tilgængelig'; }
state.tagCatalog = await api('/api/v1/tags?is_active=true').catch(() => []);
state.ready = true; summaries(); renderTagSuggestions(); workload(); scheduleDuplicates(); contactOpenCases(); await loadHardwareForContacts();
}
window.caseCreateUI = {init,typeChanged,contactsChanged:contactOpenCases,validate,openMessage:() => { const contacts=Object.values(selectedContacts); window.openInternalMessage({contact:contacts.length===1?contacts[0]:null}); },relations:() => ({tag_ids:state.tags.map(t=>t.id),hardware_ids:state.hardware.map(h=>h.id)}),created: result => {
state.completed = true; clearTimeout(saveTimer);
try { if (state.key) localStorage.removeItem(state.key); if (result.tag_actions?.length) sessionStorage.setItem(`bmc:case-tag-actions:${result.id}`, JSON.stringify(result.tag_actions)); } catch { /* Case is already saved. */ }
}};
})();

View File

@ -0,0 +1,39 @@
(() => {
const $ = id => document.getElementById(`cta-${id}`);
const esc = v => String(v ?? '').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
let rows = [], tags = [], selected = [];
async function api(path, options) { const r = await fetch(`/api/v1/${path}`, {credentials:'include',...options}); const data = await r.json(); if (!r.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Kontrollér skabelonens felter'); return data; }
function message(text, error=false) { $('message').textContent=text; $('message').className=error?'alert alert-danger':'alert alert-success'; }
function renderTags() { $('tags').innerHTML=selected.map(id=>`<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-tag="${id}">${esc(tags.find(t=>t.id===id)?.name || `Tag #${id}`)} ×</button>`).join(''); }
function edit(row) {
$('form').reset(); const v=row?.values || {};
for (const [key,value] of Object.entries({id:row?.id || '',name:row?.name || '',icon:row?.icon || 'bi-lightning',sort:row?.sort_order || 0,type:v.type || 'ticket',status:v.status || 'åben',group:v.assigned_group_id || '',title:v.titel || '',description:v.beskrivelse || '',stage:v.pipeline?.stage_id || '',amount:v.pipeline?.amount ?? '',probability:v.pipeline?.probability ?? '', 'pipeline-description':v.pipeline?.description || ''})) $(key).value=value;
$('active').checked=row?.is_active ?? true; selected=[...(v.tag_ids || [])]; renderTags();
}
async function load() { rows=await api('case-create/templates/admin'); $('list').innerHTML=rows.map(r=>`<div class="list-group-item d-flex align-items-center gap-2"><button type="button" class="btn btn-link flex-grow-1 text-start" data-edit="${r.id}"><i class="bi ${esc(r.icon)} me-2"></i>${esc(r.name)} · ${r.is_active?'Aktiv':'Inaktiv'} · ${r.sort_order}</button><button type="button" class="btn btn-sm btn-outline-danger" data-delete="${r.id}">Slet</button></div>`).join('') || '<p class="text-muted">Ingen skabeloner endnu.</p>'; }
async function init() {
try {
const [types, groups, stages, tagRows] = await Promise.all([api('settings/case_types'),api('case-create/template-options'),api('pipeline/stages'),api('tags?is_active=true')]);
let typeKeys; try { typeKeys=JSON.parse(types.value || '[]'); } catch { typeKeys=[]; }
if (!Array.isArray(typeKeys) || !typeKeys.length) typeKeys=['ticket','pipeline','ordre','opgave','projekt','service','abonnement'];
$('type').innerHTML=[...new Set([...typeKeys,'pipeline','abonnement'])].map(t=>`<option value="${esc(t)}">${esc(t)}</option>`).join('');
$('group').insertAdjacentHTML('beforeend',groups.map(g=>`<option value="${g.id}">${esc(g.name)}</option>`).join(''));
$('stage').insertAdjacentHTML('beforeend',stages.map(s=>`<option value="${s.id}">${esc(s.name)}</option>`).join(''));
tags=tagRows; await load(); edit(null);
const setting = await api('settings/case_statuses');
const statuses = JSON.parse(setting.value || '[]');
if (Array.isArray(statuses)) statuses.forEach(s => { const value=typeof s==='string'?s:s.value; if(value && !Array.from($('status').options).some(o=>o.value===value)) $('status').add(new Option(typeof s==='string'?s:(s.label || value),value)); });
} catch(e) { message(e.message,true); }
$('new').onclick=()=>edit(null);
$('tags').onclick=e=>{ const b=e.target.closest('[data-tag]'); if(b){selected=selected.filter(id=>id!==Number(b.dataset.tag));renderTags();} };
$('add-tag').onclick=()=>window.tagPicker.showSelection(t=>{if(!selected.includes(t.id))selected.push(t.id);renderTags();});
$('list').onclick=async e=>{try{const b=e.target.closest('[data-edit],[data-delete]');if(!b)return;if(b.dataset.edit)edit(rows.find(r=>r.id===Number(b.dataset.edit)));else if(confirm('Slet denne fælles skabelon?')){await api(`case-create/templates/${b.dataset.delete}`,{method:'DELETE'});await load();edit(null);}}catch(err){message(err.message,true);}};
$('form').onsubmit=async e=>{e.preventDefault();const button=$('form').querySelector('[type=submit]');button.disabled=true;try{
const number=k=>$(k).value===''?null:Number($(k).value);
const pipeline={stage_id:number('stage'),amount:number('amount'),probability:number('probability'),description:$('pipeline-description').value || null};
const body={name:$('name').value.trim(),icon:$('icon').value,sort_order:number('sort') || 0,is_active:$('active').checked,values:{type:$('type').value,titel:$('title').value,beskrivelse:$('description').value,status:$('status').value,assigned_group_id:number('group'),tag_ids:selected,pipeline:Object.values(pipeline).some(v=>v!==null)?pipeline:null}};
const id=$('id').value;const saved=await api(`case-create/templates${id?`/${id}`:''}`,{method:id?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});await load();edit(saved);message('Skabelon gemt');
}catch(err){message(err.message,true);}finally{button.disabled=false;}};
}
document.addEventListener('DOMContentLoaded',init);
})();

View File

@ -0,0 +1,68 @@
(() => {
'use strict';
const $ = id => document.getElementById(id);
let kind = 'message', contact = null, searchTimer, searchVersion = 0, sending = false;
async function api(url, options = {}) {
const response = await fetch(`/api/v1/${url}`, {credentials:'include',...options});
const data = await response.json().catch(()=>({}));
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Handlingen mislykkedes. Prøv igen.');
return data;
}
function showError(message) { $('im-error').textContent=message; $('im-error').classList.remove('d-none'); }
function setKind(value) {
kind=value;
$('im-phone-fields').classList.toggle('d-none',kind!=='phone');
document.querySelectorAll('[data-internal-kind]').forEach(b=>{const active=b.dataset.internalKind===kind;b.classList.toggle('btn-primary',active);b.classList.toggle('btn-outline-primary',!active);b.setAttribute('aria-pressed',String(active));});
$('im-text').placeholder=kind==='phone'?'Fx: Har ringet om tilbuddet og vil gerne ringes op i eftermiddag.':'Hvad skal din kollega vide?';
}
function selectContact(value) {
contact=value; ++searchVersion; clearTimeout(searchTimer);
$('im-contact').replaceChildren(); $('im-contact-search').value=''; $('im-contact-results').classList.add('d-none');
if (!contact) return;
$('im-contact-panel').open = true;
const chip=document.createElement('button');chip.type='button';chip.className='btn btn-sm btn-outline-primary';chip.textContent=`${contact.name} ×`;chip.setAttribute('aria-label',`Fjern ${contact.name}`);chip.onclick=()=>selectContact(null);$('im-contact').append(chip);
if (!$('im-caller').value) $('im-caller').value=contact.name;
if (!$('im-phone').value) $('im-phone').value=contact.phone || '';
}
window.openInternalMessage = async (context={}) => {
if (sending) return;
$('im-error').classList.add('d-none');
if (!$('im-text').value.trim() && context.kind) setKind(context.kind);
if (!$('im-text').value.trim() && context.contact) selectContact(context.contact);
bootstrap.Modal.getOrCreateInstance($('internalMessageModal')).show();
try {
const [payload, me]=await Promise.all([api('users?is_active=true'),api('auth/me')]);
const selected=!$('im-text').value.trim() && context.recipient ? String(context.recipient) : $('im-recipient').value;
const users=Array.isArray(payload)?payload:(payload.data || []);
$('im-recipient').replaceChildren(new Option('Vælg medarbejder…',''));
users.filter(u=>Number(u.id || u.user_id)!==Number(me.id || me.user_id)).forEach(u=>$('im-recipient').add(new Option(u.full_name || u.username || u.email, u.id || u.user_id)));
$('im-recipient').value=selected;
} catch(error) { showError(error.message); }
};
document.querySelectorAll('[data-internal-kind]').forEach(b=>b.onclick=()=>setKind(b.dataset.internalKind));
$('internalMessageModal').addEventListener('hidden.bs.modal',()=>{++searchVersion;clearTimeout(searchTimer);$('im-contact-results').classList.add('d-none');});
$('im-contact-search').addEventListener('input',()=>{
clearTimeout(searchTimer);const version=++searchVersion,query=$('im-contact-search').value.trim();
$('im-contact-results').classList.add('d-none');
if(query.length<2)return;
searchTimer=setTimeout(async()=>{try{
const rows=await api(`search/contacts?q=${encodeURIComponent(query)}`);
if(version!==searchVersion || document.activeElement!==$('im-contact-search'))return;
$('im-contact-results').replaceChildren();
if(!Array.isArray(rows) || !rows.length){$('im-contact-results').textContent='Ingen kontakter fundet. Du kan sende uden kontaktperson.';}
else rows.slice(0,10).forEach(r=>{const name=[r.first_name,r.last_name].filter(Boolean).join(' ') || r.name || `Kontakt #${r.id}`,phone=r.mobile || r.phone || '';const b=document.createElement('button');b.type='button';b.className='list-group-item list-group-item-action';b.textContent=`${name}${phone?' · '+phone:''}`;b.onclick=()=>selectContact({id:r.id,name,phone});$('im-contact-results').append(b);});
$('im-contact-results').classList.remove('d-none');
}catch(error){if(version===searchVersion)showError(error.message);}},300);
});
$('internalMessageForm').addEventListener('submit',async e=>{
e.preventDefault();if(sending)return;
if(!$('im-recipient').value || !$('im-text').value.trim()){showError('Vælg en modtager og skriv en besked.');return;}
sending=true;$('im-send').disabled=true;$('im-send').textContent='Sender…';$('im-error').classList.add('d-none');
try {
await api('bottom-bar/messages',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:$('im-text').value.trim(),recipient_user_id:Number($('im-recipient').value),requires_manual_ack:$('im-ack').checked,message_kind:kind,contact_id:contact?.id || null,caller_name:kind==='phone'?$('im-caller').value.trim():'',callback_phone:kind==='phone'?$('im-phone').value.trim():''})});
$('internalMessageForm').reset();selectContact(null);$('im-contact-panel').open=false;setKind('message');bootstrap.Modal.getOrCreateInstance($('internalMessageModal')).hide();
window.dispatchEvent(new CustomEvent('hub:internal-message-sent'));
if(window.showNotification)window.showNotification('Besked sendt til din kollega','success');
}catch(error){showError(error.message);}finally{sending=false;$('im-send').disabled=false;$('im-send').innerHTML='<i class="bi bi-send me-2"></i>Send besked';}
});
})();

23
static/js/message-ui.js Normal file
View File

@ -0,0 +1,23 @@
/* Presentational helpers shared by the internal inbox. No network or mutations. */
window.BmcMessageUI = (() => {
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const initials = name => String(name || '?').trim().split(/\s+/).slice(0,2).map(p=>p[0]).join('').toUpperCase();
const time = value => { const d=new Date(value); return value && !Number.isNaN(d.getTime()) ? d.toLocaleTimeString('da-DK',{hour:'2-digit',minute:'2-digit'}) : ''; };
function message(m) {
const own=!!m.is_own, phone=m.message_kind==='phone';
const number=String(m.callback_phone || '').replace(/[^+0-9*#,;]/g,'');
const person=m.caller_name || m.contact_name || 'Telefonbesked';
const contact=Number(m.contact_id)>0?`<a class="msg-contact" href="/contacts/${Number(m.contact_id)}"><i class="bi bi-person"></i> ${escape(m.contact_name || 'Åbn kontakt')}</a>`:'';
const replyId=own?Number(m.recipient_user_id):Number(m.sender_user_id);
const stamp=time(m.created_at);
const status=own?(m.is_acknowledged?'Bekræftet læst':m.is_read?'Læst':'Sendt'):'';
const phoneCard=phone?`<div class="msg-call"><span class="msg-call-icon"><i class="bi bi-telephone"></i></span><div><small>TELEFONBESKED</small><strong>${escape(person)}</strong>${m.callback_phone?`<span>${escape(m.callback_phone)}</span>`:''}</div>${number?`<a class="msg-call-action" href="tel:${escape(number)}" aria-label="Ring tilbage til ${escape(person)}"><i class="bi bi-telephone-outbound"></i> Ring tilbage</a>`:''}</div>`:'';
return `<article class="msg-message ${own?'is-own':''} ${phone?'is-phone':''}"><div class="msg-byline"><span>${own?'Dig':escape(m.from || 'Kollega')}</span>${stamp?`<time datetime="${escape(m.created_at)}" title="${escape(new Date(m.created_at).toLocaleString('da-DK'))}">${stamp}</time>`:''}${m.is_unread?'<span class="msg-new-dot" title="Ulæst" aria-label="Ulæst"></span>':''}</div><div class="msg-bubble">${phoneCard}<div class="msg-text">${escape(m.text)}</div>${contact}</div><div class="msg-message-actions">${status?`<span class="msg-delivery"><i class="bi bi-check2${m.is_read || m.is_acknowledged?'-all':''}"></i> ${status}</span>`:''}${m.requires_manual_ack && !m.is_acknowledged?'<span class="msg-ack-hint">Læsebekræftelse ønskes</span>':''}${!own && m.requires_manual_ack && !m.is_acknowledged?`<button type="button" class="msg-ack" data-bb-ack-message="${Number(m.id)}"><i class="bi bi-check2-circle"></i> Bekræft læst</button>`:''}${replyId>0?`<button type="button" class="msg-reply" data-bb-reply-message="${Number(m.id)}"><i class="bi bi-reply"></i> Svar</button>`:''}</div></article>`;
}
function thread(t, active) {
const last=t.items?.[t.items.length-1];
const preview=last?`${last.message_kind==='phone'?'Telefonbesked · ':''}${last.text || ''}`:'Start en samtale';
return `<span class="msg-avatar ${t.partnerUserId?'':'is-group'}">${t.partnerUserId?escape(initials(t.label)):'<i class="bi bi-people"></i>'}</span><span class="msg-thread-copy"><span class="msg-thread-top"><strong>${escape(t.label)}</strong><time>${time(t.lastCreatedAt)}</time></span><span class="msg-thread-preview">${escape(preview)}</span></span>${t.unread?`<span class="bb-message-thread-count">${Number(t.unread)}</span>`:''}`;
}
return {message,thread,escape,initials};
})();

View File

@ -256,6 +256,11 @@ class TagPicker {
async selectTag(tag) {
if (!tag) return;
if (this.selectionOnly) {
this.onSelectCallback?.(tag, null);
this.hide();
return;
}
console.log(`🏷️ Selecting tag ${tag.name} for context: ${this.contextType} #${this.contextId}`);
@ -356,6 +361,7 @@ class TagPicker {
}
show(contextType = null, contextId = null, onSelect = null) {
this.selectionOnly = false;
// Use provided context OR fall back to page defaults
// Note: arguments are undefined if not passed, so check for null/undefined
this.contextType = (contextType !== null && contextType !== undefined) ? contextType : this.defaultContextType;
@ -380,6 +386,13 @@ class TagPicker {
this.defaultOnSelectCallback = onSelect;
}
showSelection(onSelect) {
this.show(null, null, onSelect);
this.selectionOnly = true;
this.contextType = null;
this.contextId = null;
}
hide() {
const modalInstance = bootstrap.Modal.getInstance(this.modal);
if (modalInstance) {
@ -413,6 +426,18 @@ class TagPicker {
// Initialize global tag picker
window.tagPicker = new TagPicker();
// Deliver creation-time workflow events on the saved case, where handlers exist.
document.addEventListener('DOMContentLoaded', () => {
const match = location.pathname.match(/^\/sag\/(\d+)(?:\/v3)?\/?$/);
if (!match) return;
const key = `bmc:case-tag-actions:${match[1]}`;
try {
const actions = JSON.parse(sessionStorage.getItem(key) || '[]');
sessionStorage.removeItem(key);
setTimeout(() => actions.forEach(detail => window.dispatchEvent(new CustomEvent('hub:tag-action', {detail}))), 0);
} catch { /* Invalid or unavailable session storage must not block the page. */ }
});
// Helper function to show tag picker with context
window.showTagPicker = function(entityType, entityId, onSelect = null) {
window.tagPicker.show(entityType, entityId, onSelect);

82
static/messages.css Normal file
View File

@ -0,0 +1,82 @@
/* Internal messages: a quiet inbox, a readable conversation, one composer. */
.global-bottom-bar .bb-messages-layout{display:grid;grid-template-columns:248px minmax(0,1fr);gap:0;border:1px solid var(--border-color,#dce4e9);border-radius:14px;background:var(--bg-card,#fff);height:100%;min-height:0;overflow:hidden}
.global-bottom-bar .bb-message-threads{display:flex;flex-direction:column;gap:3px;overflow:auto;border-right:1px solid var(--border-color,#e2e8ed);padding:0 8px 10px;background:color-mix(in srgb,var(--bg-card,#fff) 97%,#59798c);scrollbar-width:thin}
.msg-inbox-heading{display:flex;align-items:center;justify-content:space-between;padding:16px 8px 12px;font-size:.72rem;font-weight:650;letter-spacing:.06em;text-transform:uppercase;color:var(--text-secondary,#718293)}
.msg-inbox-heading button,.msg-header-action{border:0;background:transparent;color:var(--text-secondary,#657b8b);border-radius:8px;padding:7px;cursor:pointer}
.msg-inbox-heading button:hover,.msg-header-action:hover{background:var(--accent-light,#eaf1f4)}
.global-bottom-bar .bb-message-thread{width:100%;border:0;border-radius:10px;padding:12px 9px;gap:10px;white-space:normal;box-shadow:none;text-align:left;background:transparent;flex-shrink:0;min-height:66px}
.global-bottom-bar .bb-message-thread:hover{background:var(--bg-hover,#edf2f5)}
.global-bottom-bar .bb-message-thread.is-active{background:color-mix(in srgb,var(--bg-card,#fff) 90%,#278e87);color:var(--text-primary,#233f50);box-shadow:none;border:0}
.msg-avatar{display:grid;place-items:center;width:34px;height:34px;border-radius:11px;flex-shrink:0;background:color-mix(in srgb,var(--bg-card,#fff) 84%,#607a8e);font-size:.7rem;letter-spacing:.03em;color:var(--text-primary,#3d5868)}
.msg-avatar.is-group{background:color-mix(in srgb,var(--bg-card,#fff) 88%,#319387);color:var(--text-primary,#347369)}
.msg-thread-copy{min-width:0;flex:1;display:block}
.msg-thread-top{display:flex;align-items:center;justify-content:space-between;gap:8px}
.msg-thread-top strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;font-size:.79rem}
.msg-thread-top time{font-size:.62rem;flex-shrink:0;color:var(--text-secondary,#7d8d99);font-weight:400}
.msg-thread-preview{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-secondary,#7d8d99);font-weight:400;font-size:.71rem;margin-top:4px}
.global-bottom-bar .bb-message-thread-count,.global-bottom-bar .bb-message-thread.is-active .bb-message-thread-count{background:#267f79;color:white;font-size:.63rem;min-width:17px;height:17px;padding:0 4px}
.msg-conversation{display:flex;flex-direction:column;min-height:0;min-width:0;overflow:hidden}
.msg-conversation-header{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-shrink:0;padding:14px 20px;border-bottom:1px solid var(--border-color,#e6ebef)}
.msg-conversation-header strong{font-size:.87rem;font-weight:650;color:var(--text-primary,#233f50)}
.msg-conversation-header small{display:block;font-size:.68rem;color:var(--text-secondary,#7a8c99);margin-top:3px}
.msg-header-action{display:flex;gap:7px;align-items:center;font-size:.7rem;white-space:nowrap}
.global-bottom-bar .bb-messages-list{display:flex;flex-direction:column;gap:17px;flex:1;min-height:0;overflow-y:auto;padding:20px!important;margin:0;background:color-mix(in srgb,var(--bg-card,#fff) 98%,#678190)}
.global-bottom-bar .bb-messages-list>li{display:block!important;flex-shrink:0;margin:0;padding:0!important;border:0!important;box-shadow:none!important;background:transparent!important;list-style:none}
.msg-message{max-width:84%;width:fit-content;min-width:130px}
.msg-message.is-own{margin-left:auto}
.msg-byline{display:flex;align-items:center;gap:9px;padding:0 3px 5px;font-size:.64rem;color:var(--text-secondary,#7a8a97)}
.msg-byline>span:first-child{font-weight:600;color:var(--text-primary,#466072)}
.msg-message.is-own .msg-byline{justify-content:flex-end}
.msg-new-dot{width:5px;height:5px;border-radius:50%;background:#2c9589}
.msg-bubble{padding:12px 15px;border:1px solid var(--border-color,#e0e7ec);border-radius:3px 13px 13px;background:var(--bg-card,#fff);color:var(--text-primary,#304b5d)}
.msg-message.is-own .msg-bubble{background:color-mix(in srgb,var(--bg-card,#fff) 92%,#349b90);border-color:color-mix(in srgb,var(--border-color,#e0e7ec) 65%,#349b90);border-radius:13px 3px 13px 13px}
.msg-text{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.82rem;line-height:1.65}
.msg-message-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:5px;padding:0 3px;min-height:18px;color:var(--text-secondary,#81909c);font-size:.61rem}
.is-own .msg-message-actions{justify-content:flex-end}
.msg-message-actions button{font-size:.64rem;background:transparent;border:0;padding:1px 0;color:var(--text-secondary,#6b8291)}
.msg-message-actions .msg-ack{color:var(--text-primary,#266e65);font-weight:600}
.msg-reply:hover,.msg-ack:hover{text-decoration:underline}
.msg-contact{display:inline-flex;gap:5px;margin-top:9px;text-decoration:none;font-size:.68rem;color:var(--text-primary,#397480)}
.msg-call{display:flex;gap:10px;align-items:center;padding-bottom:12px;margin-bottom:10px;border-bottom:1px solid var(--border-color,#dce5e8)}
.msg-call-icon{width:33px;height:33px;border-radius:10px;display:grid;place-items:center;background:color-mix(in srgb,var(--bg-card,#fff) 83%,#cda65e);color:var(--text-primary,#80662f);flex-shrink:0}
.msg-call>div{min-width:0;flex:1}
.msg-call small{display:block;font-size:.54rem;letter-spacing:.08em;color:var(--text-secondary,#88919a);margin-bottom:3px}
.msg-call strong{display:block;font-weight:600;font-size:.8rem;overflow-wrap:anywhere}
.msg-call>div>span{display:block;font-size:.71rem;color:var(--text-secondary,#7a8c99);margin-top:3px}
.msg-call-action{display:flex;align-items:center;gap:5px;flex-shrink:0;text-decoration:none;color:var(--text-primary,#286e65);font-size:.66rem;padding:6px 8px;border:1px solid var(--border-color,#d4e0e4);border-radius:7px}
.global-bottom-bar .bb-messages-composer{margin:0;padding:12px 18px 10px;flex-shrink:0;border:0;border-top:1px solid var(--border-color,#e5ebee);background:var(--bg-card,#fff);border-radius:0}
.msg-compose-field{display:flex;align-items:flex-end;border:1px solid var(--border-color,#dbe4ea);border-radius:11px;background:var(--bg-card,#fff);padding:8px;gap:9px}
.msg-compose-field:focus-within{border-color:#639f99;box-shadow:0 0 0 2px #349b9012}
.msg-compose-field textarea{width:100%;min-width:0;resize:vertical;max-height:110px;min-height:43px;border:0;background:transparent;outline:none;padding:3px 5px;color:var(--text-primary,#304b5d);font-size:.82rem;line-height:1.6}
.msg-send{height:33px;width:33px;border:0;border-radius:9px;background:#267b74;color:white;flex-shrink:0}
.msg-send:disabled{opacity:.5}
.msg-compose-footer{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:7px;font-size:.63rem;color:var(--text-secondary,#84919c)}
.msg-compose-footer label{display:flex;gap:6px;align-items:center;cursor:pointer}
.msg-compose-footer input{accent-color:#267b74}
.msg-empty{display:grid;place-items:center;text-align:center;color:var(--text-secondary,#81919e);padding:24px 10px}
.msg-empty>span{width:46px;height:46px;border-radius:15px;background:var(--accent-light,#edf4f4);display:grid;place-items:center;margin-bottom:13px;font-size:1.25rem}
.msg-empty strong{font-size:.84rem;color:var(--text-primary,#3d5667)}
.msg-empty p{font-size:.73rem;max-width:260px;margin:7px 0}
/* Compose dialog shares the inbox palette and places the message first. */
#internalMessageModal .modal-dialog{max-width:640px}
#internalMessageModal .modal-content{border:1px solid var(--border-color,#dce4e9)!important;border-radius:18px!important;background:var(--bg-card,#fff);overflow:hidden}
#internalMessageModal .modal-header{padding:24px 28px 20px!important;align-items:flex-start}
#internalMessageModal .modal-header h4{font-size:1.3rem;letter-spacing:-.03em;font-weight:650;color:var(--text-primary,#233f50)}
#internalMessageModal .modal-header .text-uppercase{font-size:.6rem!important;letter-spacing:.12em;display:block;margin-bottom:7px}
#internalMessageModal .modal-body{padding:0 28px 8px!important}
#internalMessageModal .form-label{font-size:.76rem;font-weight:550;color:var(--text-primary,#445f71);margin-bottom:7px}
#internalMessageModal .form-control,#internalMessageModal .form-select{font-size:.83rem;border:1px solid var(--border-color,#dce4e9);border-radius:9px;background:var(--bg-card,#fff);color:var(--text-primary,#304b5d);padding:10px 12px}
#internalMessageModal .form-control:focus,#internalMessageModal .form-select:focus{border-color:#639f99;box-shadow:0 0 0 3px #349b9012}
#internalMessageModal #im-text{min-height:145px;line-height:1.7;resize:vertical}
#internalMessageModal [role=group]{display:flex;gap:4px!important;background:var(--bg-hover,#f1f5f6);padding:4px;border-radius:10px;margin-bottom:22px!important}
#internalMessageModal [data-internal-kind]{flex:1;border:0!important;background:transparent;color:var(--text-secondary,#7c8b96);border-radius:7px;font-size:.78rem;padding:10px}
#internalMessageModal [data-internal-kind][aria-pressed=true]{background:var(--bg-card,#fff);color:var(--text-primary,#2d635d);box-shadow:0 1px 5px #163b4912}
#internalMessageModal #im-phone-fields{padding:14px 4px;background:var(--bg-hover,#f5f7f8);border-radius:10px;margin:0 0 17px}
#internalMessageModal .modal-footer{padding:16px 28px 22px!important;background:var(--bg-hover,#f7f9fa);gap:8px}
#internalMessageModal .modal-footer .btn{border-radius:9px;font-size:.8rem;padding:10px 19px}
#internalMessageModal #im-send{background:#267b74;border-color:#267b74}
#internalMessageModal .form-check-label,#internalMessageModal p.small{font-size:.7rem!important;color:var(--text-secondary,#7c8c98)}
#internalMessageModal #im-contact .btn{font-size:.7rem;border-radius:7px;border-color:var(--border-color,#d6e4e5);color:var(--text-primary,#397480)}
#internalMessageModal .form-check-input{accent-color:#267b74}
@media(max-width:820px){.global-bottom-bar .bb-messages-layout{grid-template-columns:195px minmax(0,1fr)}.msg-avatar{display:none}.msg-conversation-header{padding:11px 13px}.global-bottom-bar .bb-messages-list{padding:14px!important}.msg-message{max-width:94%}.msg-compose-footer>small{display:none}.msg-call{flex-wrap:wrap}.msg-call-action{margin-left:43px}}
@media(max-width:600px){.global-bottom-bar .bb-messages-layout{display:flex;flex-direction:column}.global-bottom-bar .bb-message-threads{flex-direction:row;flex-shrink:0;overflow-x:auto;max-height:66px;border-right:0;border-bottom:1px solid var(--border-color,#e2e8ed);padding:5px;gap:4px}.msg-inbox-heading{padding:0 5px}.msg-inbox-heading>span{display:none}.global-bottom-bar .bb-message-thread{min-height:50px;width:160px;padding:7px 9px}.msg-thread-top time{display:none}.msg-conversation{flex:1}.msg-header-action span{display:none}.global-bottom-bar .bb-messages-composer{padding:8px 10px}.msg-compose-field textarea{min-height:32px}.msg-message-actions{gap:6px}.msg-ack-hint{display:none}#internalMessageModal .modal-body{padding:0 20px 8px!important}#internalMessageModal .modal-header{padding:22px 20px 18px!important}}

View File

@ -0,0 +1,87 @@
// Run with NODE_PATH pointing to a jsdom installation (no application dependency).
const {JSDOM} = require('jsdom');
const fs = require('node:fs');
const assert = require('node:assert/strict');
const source = fs.readFileSync('app/modules/sag/templates/create.html','utf8');
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1];
const html = source.split('{% block content %}')[1].split('<script>')[0]
.replace(/\{% for user[\s\S]*?\{% endfor %\}/, '<option value="7">Test bruger</option>')
.replace(/\{% for group[\s\S]*?\{% endfor %\}/, '<option value="2">Support</option>');
const extension = fs.readFileSync('static/js/case-create.js','utf8');
const delay = ms => new Promise(r=>setTimeout(r,ms));
async function page(storage, userId=7, search='') {
const dom = new JSDOM(html,{url:`http://localhost:8001/sag/new${search}`,runScripts:'outside-only'});
const w=dom.window;
await new Promise(r=>w.addEventListener('DOMContentLoaded',r));
w.CSS = {escape:s=>s}; w.confirm=()=>true; w.alert=()=>{};
w.setTagPickerContext=()=>{}; w.tagPicker={showSelection:cb=>cb({id:9,name:'Hardware',color:'#112233'})};
w.bootstrap={Modal:class {show(){} hide(){}}};
w.fetch=async url=>({ok:true,json:async()=>{
if(url.includes('/settings/case_types'))return {value:'["ticket","pipeline","ordre","opgave","service","abonnement"]'};
if(url.includes('/auth/me/profile'))return {default_case_type:'ticket'};
if(url.includes('/auth/me'))return {id:userId};
if(url.includes('/pipeline/stages'))return [{id:1,name:'Tilbud'}];
if(url.includes('/case-create/workload'))return {total:0,items:[]};
if(url.includes('/case-create/contacts-open-cases'))return {5:{total:2,items:[{id:44,titel:'Eksisterende routerfejl',status:'åben',ansvarlig_navn:'Test bruger',deadline:null}]}};
if(url.includes('/case-create/templates'))return [{id:1,name:'Test',values:{type:'pipeline',titel:'Tilbud',beskrivelse:'Tekst',status:'åben',tag_ids:[9]}}];
if(url.includes('/tags?'))return [{id:9,name:'Hardware'},{id:10,name:'Router',type:'brand',catch_words:['router'],color:'#112233'}];
return [];
}});
if(storage)for(const [k,v] of Object.entries(storage))w.localStorage.setItem(k,v);
w.eval(script + '\n' + extension + '\nwindow.testSetCustomer = () => { selectedCustomer={id:11,name:"Firma"}; selectedContacts={}; renderSelections(); }; window.testSetContact = () => { selectedContacts={5:{id:5,name:"Ada"}}; renderSelections(); window.caseCreateUI.contactsChanged(); };'); w.document.dispatchEvent(new w.Event('DOMContentLoaded'));
await delay(60);
return dom;
}
(async()=>{
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-message]').length,1);
assert.equal(d.querySelectorAll('.cc-panel').length,6);
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);
assert.match(d.getElementById('cc-workload').textContent,/0 åbne sager/);
assert.equal(w.caseCreateUI.validate(),false);
assert.equal(d.getElementById('cc-relations').open,true);
w.testSetCustomer();
await w.loadSelectedCustomerContacts(11);
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='';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('beskrivelse').value='Router mister forbindelsen';d.getElementById('beskrivelse').dispatchEvent(new w.Event('input',{bubbles:true}));await delay(400);
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);
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}));
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 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 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.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);
const other=await page(saved,8);assert.equal(other.window.document.getElementById('cc-restore'),null);
const prefill=await page(saved,7,'?title=Other');assert.equal(prefill.window.document.getElementById('cc-restore'),null);
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);});

View File

@ -0,0 +1,125 @@
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from app.modules.sag.backend import create_support as support
def test_similarity_handles_danish_case_and_punctuation():
assert support.title_similarity('NETVÆRK: fejl!', 'netværk fejl') == 1
assert support.title_similarity('', '') == 0
assert support.title_similarity('Router virker ikke', 'Router virker ikke hos kunde') > .6
def test_template_rejects_forbidden_relationships_and_invalid_numbers():
with pytest.raises(ValidationError):
support.TemplateValues(customer_id=12)
with pytest.raises(ValidationError):
support.PipelineDefaults(probability=101)
with pytest.raises(ValidationError):
support.PipelineDefaults(amount=float('nan'))
@pytest.mark.parametrize('value', [None, {}, [True], [0], [-1], ['1']])
def test_relationship_ids_are_strict(value):
with pytest.raises(HTTPException):
support.ids(value, 'ids')
def test_duplicates_rank_and_limit(monkeypatch):
monkeypatch.setattr(support, 'closed_statuses', lambda: ['lukket'])
def query(sql, params):
assert 'deleted_at IS NULL' in sql
assert params == (7, ['lukket'])
return [{'id': i, 'titel': 'Netværk fejl'} for i in range(8)] + [{'id': 9, 'titel': 'xyz'}]
monkeypatch.setattr(support, 'execute_query', query)
assert [r['id'] for r in support.duplicates(7, 'Netværk fejl')] == [7, 6, 5, 4, 3]
def test_workload_empty_and_total(monkeypatch):
monkeypatch.setattr(support, 'closed_statuses', lambda: ['closed'])
monkeypatch.setattr(support, 'execute_query', lambda *args: [])
assert support.workload(7) == {'total': 0, 'items': []}
monkeypatch.setattr(support, 'execute_query', lambda *args: [{'id': 4, 'total': 25}])
assert support.workload(7)['total'] == 25
def test_contact_open_cases_returns_only_active_cases_and_first_five(monkeypatch):
monkeypatch.setattr(support, 'closed_statuses', lambda: ['lukket'])
def query(sql, params):
assert 'FROM sag_kontakter' in sql
assert 'position<=5' in sql
assert params == ([3], ['lukket'])
return [
{'contact_id': 3, 'id': index, 'titel': f'Sag {index}', 'status': 'åben',
'deadline': None, 'ansvarlig_navn': 'Ada', 'total': 7}
for index in range(1, 6)
]
monkeypatch.setattr(support, 'execute_query', query)
result = support.contacts_open_cases([3])
assert result[3]['total'] == 7
assert [item['id'] for item in result[3]['items']] == [1, 2, 3, 4, 5]
def test_associations_deduplicate_and_single_group_last_wins():
class Cursor:
def __init__(self): self.calls = []
def execute(self, sql, args): self.calls.append((sql, args))
def fetchall(self):
if 'hardware_assets' in self.calls[-1][0]: return [{'id': 4}]
return [{'id': i, 'name': str(i), 'tag_group_id': 5, 'behavior': 'single'} for i in [8, 9]]
def fetchone(self): return {'action_type': 'open_task_template_modal', 'action_config': {}}
cursor = Cursor()
actions = support.attach_create_relations(cursor, 20, {'hardware_ids': [4, 4], 'tag_ids': [8, 9]}, 7)
inserts = [args for sql, args in cursor.calls if 'INSERT INTO entity_tags' in sql]
assert inserts == [(20, 9, 7)]
assert len([sql for sql, _ in cursor.calls if 'INSERT INTO sag_hardware' in sql]) == 1
assert actions[0]['entity_id'] == 20
def test_missing_hardware_fails_before_association_insert():
class Cursor:
def execute(self, sql, args): assert 'SELECT' in sql
def fetchall(self): return []
with pytest.raises(HTTPException):
support.attach_create_relations(Cursor(), 20, {'hardware_ids': [999]}, 7)
@pytest.mark.parametrize('missing_hardware', [False, True])
def test_ticket_creation_commits_all_optional_data_or_rolls_back(monkeypatch, missing_hardware):
import asyncio
from app.modules.sag.backend import router as cases
class Cursor:
def __init__(self): self.calls = []
def __enter__(self): return self
def __exit__(self, *_): pass
def execute(self, sql, args): self.calls.append((sql, args))
def fetchone(self):
if 'INSERT INTO sag_sager' in self.calls[-1][0]: return {'id': 20}
return None
def fetchall(self):
if 'hardware_assets' in self.calls[-1][0]: return [] if missing_hardware else [{'id': 4}]
return [{'id': 9, 'name': 'Test', 'tag_group_id': None, 'behavior': None}]
class Connection:
def __init__(self): self.cur = Cursor(); self.committed = False; self.rolled_back = False
def cursor(self, **kwargs): return self.cur
def commit(self): self.committed = True
def rollback(self): self.rolled_back = True
conn = Connection()
monkeypatch.setattr(cases, 'get_db_connection', lambda: conn)
monkeypatch.setattr(cases, 'release_db_connection', lambda c: None)
monkeypatch.setattr(cases, '_normalize_case_status', lambda v: 'åben')
monkeypatch.setattr(cases, '_get_user_id_from_request', lambda r: 7)
monkeypatch.setattr(cases, '_validate_user_id', lambda v: None)
monkeypatch.setattr(cases, '_validate_group_id', lambda v: None)
monkeypatch.setattr(cases, 'table_has_column', lambda *a: True)
data = {'titel': 'Test', 'customer_id': 3, 'type': 'ticket', 'pipeline': {'amount': 123}, 'hardware_ids': [4], 'tag_ids': [9], 'order_items': [{'description': 'Router', 'amount': 50}]}
if missing_hardware:
with pytest.raises(HTTPException): asyncio.run(cases.create_sag(None, data))
assert conn.rolled_back and not conn.committed
else:
result = asyncio.run(cases.create_sag(None, data))
assert result['id'] == 20 and conn.committed and not conn.rolled_back
sql = '\n'.join(query for query, _ in conn.cur.calls)
assert all(table in sql for table in ['sag_sager','sag_salgsvarer','sag_hardware','entity_tags'])
case_args = next(args for query, args in conn.cur.calls if 'INSERT INTO sag_sager' in query)
assert 123.0 in case_args

View File

@ -0,0 +1,30 @@
const {JSDOM}=require('jsdom');
const fs=require('node:fs');
const assert=require('node:assert/strict');
(async()=>{
const dom=new JSDOM(fs.readFileSync('app/shared/frontend/internal_message.html','utf8'),{url:'http://localhost:8001',runScripts:'outside-only'});
const w=dom.window,d=w.document;let sent=null,fail=false;
w.bootstrap={Modal:{getOrCreateInstance:()=>({show(){},hide(){}})}};
w.fetch=async(url,options={})=>{
if(options.method==='POST'){sent=JSON.parse(options.body);return {ok:!fail,json:async()=>fail?{detail:'Testfejl'}:{message:'Besked sendt'}};}
return {ok:true,json:async()=>url.includes('auth/me')?{id:1}:url.includes('users')?[{id:1,full_name:'Mig'},{id:2,full_name:'Kollega'}]:[]};
};
w.eval(fs.readFileSync('static/js/internal-message.js','utf8'));
await w.openInternalMessage();
assert.equal(d.getElementById('im-recipient').options.length,2);
assert.equal(sent,null,'Opening must not send anything');
d.getElementById('im-recipient').value='2';d.getElementById('im-text').value='Kort besked';
d.getElementById('internalMessageForm').dispatchEvent(new w.Event('submit',{cancelable:true}));await new Promise(r=>setTimeout(r,10));
assert.equal(sent.contact_id,null);assert.equal(sent.message_kind,'message');assert.equal(sent.recipient_user_id,2);
await w.openInternalMessage({contact:{id:4,name:'Kontakt'}});
d.querySelector('[data-internal-kind="phone"]').click();
assert.ok(!d.getElementById('im-phone-fields').classList.contains('d-none'));
d.getElementById('im-recipient').value='2';d.getElementById('im-phone').value='12345678';d.getElementById('im-text').value='Ring venligst tilbage';
fail=true;d.getElementById('internalMessageForm').dispatchEvent(new w.Event('submit',{cancelable:true}));await new Promise(r=>setTimeout(r,10));
assert.equal(sent.contact_id,4);assert.equal(sent.caller_name,'Kontakt');assert.equal(sent.callback_phone,'12345678');assert.equal(sent.message_kind,'phone');
assert.equal(d.getElementById('im-text').value,'Ring venligst tilbage','Failure must preserve draft');
d.getElementById('im-contact').querySelector('button').click();fail=false;
d.getElementById('internalMessageForm').dispatchEvent(new w.Event('submit',{cancelable:true}));await new Promise(r=>setTimeout(r,10));
assert.equal(sent.contact_id,null);assert.equal(sent.caller_name,'Kontakt');assert.equal(d.getElementById('im-text').value,'');
dom.window.close();console.log('PASS: explicit sending, recipient choice, normal/phone messages, optional/removable contact, failure preserves text');
})().catch(e=>{console.error(e);process.exit(1);});

View File

@ -0,0 +1,68 @@
"""Internal message tests use a fake database; no messages are delivered."""
import asyncio
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from app.modules.bottom_bar.backend import router as messages
@pytest.mark.parametrize('contact_id', [None, 4])
def test_phone_message_stores_optional_contact(monkeypatch, contact_id):
calls = []
monkeypatch.setattr(messages, 'ensure_bottom_bar_messages_schema', lambda: None)
monkeypatch.setattr(messages, '_ensure_user_exists', lambda uid: None)
def query(sql, args):
calls.append((sql, args))
if 'SELECT id FROM contacts' in sql:
return {'id': 4}
return {'id': 8}
monkeypatch.setattr(messages, 'execute_query_single', query)
payload = messages.BottomBarMessageCreatePayload(message=' Ring tilbage ', recipient_user_id=2, contact_id=contact_id, message_kind='phone', caller_name='Navn', callback_phone='12345678')
result = asyncio.run(messages.send_bottom_bar_message(payload, {'id': 1, 'full_name': 'Test'}))
args = next(args for sql, args in calls if 'INSERT' in sql)
assert args == (1, 2, 'Ring tilbage', False, 'phone', contact_id, 'Navn', '12345678')
assert result['item']['message_kind'] == 'phone'
def test_invalid_contact_does_not_send(monkeypatch):
monkeypatch.setattr(messages, 'ensure_bottom_bar_messages_schema', lambda: None)
monkeypatch.setattr(messages, '_ensure_user_exists', lambda uid: None)
def query(sql, args):
assert 'INSERT' not in sql
return None
monkeypatch.setattr(messages, 'execute_query_single', query)
payload = messages.BottomBarMessageCreatePayload(message='Test', recipient_user_id=2, contact_id=999)
with pytest.raises(HTTPException) as error:
asyncio.run(messages.send_bottom_bar_message(payload, {'id': 1}))
assert error.value.status_code == 400
def test_legacy_message_defaults_are_preserved():
payload = messages.BottomBarMessageCreatePayload(message='Almindelig besked')
assert payload.message_kind == 'message'
assert payload.contact_id is None
assert payload.recipient_user_id is None
with pytest.raises(ValidationError):
messages.BottomBarMessageCreatePayload(message='Test', message_kind='unknown')
def test_self_message_is_rejected(monkeypatch):
monkeypatch.setattr(messages, 'ensure_bottom_bar_messages_schema', lambda: None)
monkeypatch.setattr(messages, '_ensure_user_exists', lambda uid: None)
payload = messages.BottomBarMessageCreatePayload(message='Test', recipient_user_id=1)
with pytest.raises(HTTPException):
asyncio.run(messages.send_bottom_bar_message(payload, {'id': 1}))
def test_outgoing_status_uses_recipient_receipt(monkeypatch):
from datetime import datetime
from app.modules.bottom_bar.backend import service
monkeypatch.setattr(service, 'ensure_bottom_bar_messages_schema', lambda: None)
def query(sql, args):
assert 'THEN m.recipient_user_id ELSE %s END' in sql
assert len(args) == 6
return [{'id': 8, 'sender_user_id': 1, 'recipient_user_id': 2, 'message_text': 'Hej', 'created_at': datetime(2026,9,8), 'read_at': datetime(2026,9,8), 'acknowledged_at': None}]
monkeypatch.setattr(service, 'execute_query', query)
monkeypatch.setattr(service, 'execute_query_single', lambda *a: {'count': 0})
item = service.get_user_messages_summary(1)['list'][0]
assert item['is_own'] and item['is_read'] and not item['is_unread']

View File

@ -0,0 +1,29 @@
const {JSDOM}=require('jsdom');
const fs=require('node:fs');
const assert=require('node:assert/strict');
(async()=>{
const dom=new JSDOM('<div class="global-bottom-bar"><div id="bbTabTitle"><span class="bb-tab-title-text"></span></div><div id="bbTabDescription"></div><div id="bbTabInnerContent"></div></div>',{url:'http://localhost:8001',runScripts:'outside-only',pretendToBeVisual:true});
const w=dom.window,d=w.document;
await new Promise(r=>w.addEventListener('DOMContentLoaded',r));
w.fetch=async()=>({ok:true,json:async()=>[]});
w.eval(fs.readFileSync('static/js/message-ui.js','utf8'));
const src=fs.readFileSync('static/js/bottom-bar.js','utf8').replace(/\}\)\(\);\s*$/, 'window.inboxTest={render(items,key=""){latestSections={messages:{list:items,count:0}};activeKey="messages";chatComposerState.activeThreadKey=key;chatComposerState.loaded=true;renderTabPanel();},threads:getMessageThreads};})();');
w.eval(src);
w.inboxTest.render([]);
assert.ok(d.querySelector('.bb-message-threads'));
assert.ok(d.querySelector('.msg-conversation'));
assert.ok(d.getElementById('btnSendMsg').disabled,'Empty inbox must not silently broadcast');
const items=[{id:1,sender_user_id:2,recipient_user_id:null,from:'Kollega',text:'Fælles besked',created_at:'2026-09-08T10:00:00'}, {id:2,sender_user_id:2,recipient_user_id:1,from:'Kollega',text:'Ring tilbage <script>alert(1)</script>',message_kind:'phone',caller_name:'Anne',callback_phone:'+45 12345678',contact_id:4,created_at:'2026-09-08T11:00:00'}, {id:3,sender_user_id:1,recipient_user_id:2,to:'Kollega',from:'Dig',text:'Jeg ringer',is_own:true,is_read:true,created_at:'2026-09-08T11:01:00'}];
w.inboxTest.render(items,'user:2');
assert.equal(w.inboxTest.threads().length,2,'Broadcast is separate from sender conversation');
assert.equal(d.querySelectorAll('.msg-message').length,2);
assert.equal(d.querySelectorAll('.msg-call').length,1);
assert.equal(d.querySelector('.msg-call-action').getAttribute('href'),'tel:+4512345678');
assert.equal(d.querySelector('.msg-bubble script'),null,'Messages must be escaped');
assert.match(d.querySelector('.is-own .msg-delivery').textContent,/Læst/);
assert.equal(d.getElementById('chatInputQuick').tagName,'TEXTAREA');
let sends=0;d.getElementById('btnSendMsg').onclick=()=>sends++;
d.getElementById('chatInputQuick').dispatchEvent(new w.KeyboardEvent('keydown',{key:'Enter',bubbles:true}));assert.equal(sends,0);
d.getElementById('chatInputQuick').dispatchEvent(new w.KeyboardEvent('keydown',{key:'Enter',ctrlKey:true,bubbles:true}));assert.equal(sends,1);
dom.window.close();console.log('PASS: inbox layout, empty-state safety, separate broadcasts, phone cards, escaping, read status, multiline and shortcut');
})().catch(e=>{console.error(e);process.exit(1);});

View File

@ -159,7 +159,7 @@ def test_case_create_lists_contacts_for_selected_customer():
def test_case_create_defaults_responsible_to_current_user():
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")
assert "selectCurrentUserAsResponsible();" in template
assert "selectCurrentUserAsResponsible()" in template
assert "raw_responsible = data.get" in router
assert 'if "ansvarlig_bruger_id" in data else current_user_id' in router