feat: integrate bug reporting module and enhance screenshot functionality

- Added bug reporting API router to main application.
- Enhanced screenshot functionality in bug-report.js with timeout handling and dynamic loading of html2canvas library.
- Improved screenshot capture strategies and error handling for better user experience.
- Updated modal handling for bug reports to include screenshot previews and status messages.
- Refactored code for better readability and maintainability.

feat: implement user-specific case status and preferences

- Created migration to allow dynamic case statuses in sag_sager table.
- Added user_sag_list_preferences table for per-user preferences on case list filters.
- Introduced user_menu_preferences table to manage per-user menu visibility preferences with triggers for updated_at timestamps.
This commit is contained in:
Christian 2026-06-18 22:20:39 +02:00
parent 70a01db422
commit ce75f12f56
16 changed files with 2678 additions and 329 deletions

View File

@ -273,6 +273,10 @@ class AnyDeskIdAdd(BaseModel):
label: Optional[str] = None
class MenuPreferencesUpdate(BaseModel):
hidden_menu_keys: list[str] = []
@router.get("/me/anydesk-ids")
async def get_my_anydesk_ids(current_user: dict = Depends(get_current_user)):
rows = execute_query(
@ -306,3 +310,73 @@ async def delete_my_anydesk_id(entry_id: int, current_user: dict = Depends(get_c
if not rows:
raise HTTPException(status_code=404, detail="Ikke fundet")
return {"message": "Slettet"}
@router.get("/me/menu-preferences")
async def get_my_menu_preferences(current_user: dict = Depends(get_current_user)):
"""Get current user's menu visibility preferences."""
try:
rows = execute_query(
"""
SELECT menu_key
FROM user_menu_preferences
WHERE user_id = %s
AND visible = FALSE
ORDER BY menu_key ASC
""",
(current_user["id"],),
) or []
return {"hidden_menu_keys": [str(r.get("menu_key") or "") for r in rows if r.get("menu_key")]}
except Exception as exc:
if "user_menu_preferences" in str(exc):
logger.warning("⚠️ user_menu_preferences table not found; returning defaults")
return {"hidden_menu_keys": []}
raise
@router.patch("/me/menu-preferences")
async def update_my_menu_preferences(
payload: MenuPreferencesUpdate,
current_user: dict = Depends(get_current_user)
):
"""Replace current user's hidden menu keys."""
keys = []
seen = set()
for raw in payload.hidden_menu_keys or []:
key = str(raw or "").strip().lower()
if not key:
continue
if len(key) > 120:
continue
if any(ch for ch in key if not (ch.isalnum() or ch in {"-", "_"})):
continue
if key in seen:
continue
seen.add(key)
keys.append(key)
try:
execute_query(
"DELETE FROM user_menu_preferences WHERE user_id = %s",
(current_user["id"],),
)
for key in keys:
execute_query(
"""
INSERT INTO user_menu_preferences (user_id, menu_key, visible)
VALUES (%s, %s, FALSE)
ON CONFLICT (user_id, menu_key)
DO UPDATE SET visible = EXCLUDED.visible, updated_at = NOW()
""",
(current_user["id"], key),
)
return {"message": "Menuindstillinger gemt", "hidden_menu_keys": keys}
except Exception as exc:
if "user_menu_preferences" in str(exc):
raise HTTPException(
status_code=409,
detail="Menuindstillinger er ikke klar endnu. Kør migration 191 først.",
)
raise

View File

@ -507,6 +507,218 @@ async def get_related_contacts(contact_id: int):
raise HTTPException(status_code=500, detail=str(e))
@router.get("/contacts/{contact_id}/cases")
async def get_contact_cases(contact_id: int):
"""Get cases linked directly to a contact and cases from the contact's primary company."""
try:
contact_row = execute_query(
"""
SELECT
c.id,
(
SELECT cu.id
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) AS company_id,
(
SELECT cu.name
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) AS company_name
FROM contacts c
WHERE c.id = %s
""",
(contact_id,),
)
if not contact_row:
raise HTTPException(status_code=404, detail="Contact not found")
company_id = contact_row[0].get("company_id")
contact_cases = execute_query(
"""
SELECT
s.id,
s.titel,
s.status,
s.customer_id,
cu.name AS customer_name,
s.created_at,
s.updated_at
FROM sag_sager s
INNER JOIN sag_kontakter sk ON s.id = sk.sag_id
LEFT JOIN customers cu ON cu.id = s.customer_id
WHERE sk.contact_id = %s
AND s.deleted_at IS NULL
AND sk.deleted_at IS NULL
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
LIMIT 10
""",
(contact_id,),
) or []
company_cases = []
if company_id:
company_cases = execute_query(
"""
SELECT
s.id,
s.titel,
s.status,
s.customer_id,
cu.name AS customer_name,
s.created_at,
s.updated_at
FROM sag_sager s
LEFT JOIN customers cu ON cu.id = s.customer_id
WHERE s.customer_id = %s
AND s.deleted_at IS NULL
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
LIMIT 10
""",
(company_id,),
) or []
return {
"contact": {
"id": contact_row[0]["id"],
"company_id": company_id,
"company_name": contact_row[0].get("company_name"),
},
"contact_cases": contact_cases,
"company_cases": company_cases,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get cases for contact {contact_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/contacts/{contact_id}/case-context")
async def get_contact_case_context(contact_id: int):
"""Get case suggestions for a contact: contact cases, company cases and related contacts."""
try:
contact_rows = execute_query(
"""
SELECT
c.id,
c.first_name,
c.last_name,
c.email,
c.phone,
c.mobile,
c.title,
c.department,
c.is_active,
c.created_at,
c.updated_at,
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) AS company_names
FROM contacts c
LEFT JOIN contact_companies cc ON c.id = cc.contact_id
LEFT JOIN customers cu ON cc.customer_id = cu.id
WHERE c.id = %s
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile, c.title, c.department, c.is_active, c.created_at, c.updated_at
""",
(contact_id,),
) or []
if not contact_rows:
return {"contact_cases": [], "company_cases": [], "related_contacts": []}
customer_ids = get_contact_customer_ids(contact_id)
placeholders = ",".join(["%s"] * len(customer_ids)) if customer_ids else ""
contact_cases = execute_query(
"""
SELECT
s.id,
s.titel,
s.status,
s.customer_id,
cu.name AS customer_name,
s.created_at,
s.updated_at
FROM sag_sager s
INNER JOIN sag_kontakter sk ON s.id = sk.sag_id
LEFT JOIN customers cu ON cu.id = s.customer_id
WHERE sk.contact_id = %s
AND s.deleted_at IS NULL
AND sk.deleted_at IS NULL
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
LIMIT 10
""",
(contact_id,),
) or []
company_cases = []
related_contacts = []
if customer_ids:
company_cases = execute_query(
f"""
SELECT
s.id,
s.titel,
s.status,
s.customer_id,
cu.name AS customer_name,
s.created_at,
s.updated_at
FROM sag_sager s
LEFT JOIN customers cu ON cu.id = s.customer_id
WHERE s.customer_id IN ({placeholders})
AND s.deleted_at IS NULL
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
LIMIT 10
""",
tuple(customer_ids),
) or []
related_contacts = execute_query(
f"""
SELECT
c.id,
c.first_name,
c.last_name,
c.email,
c.phone,
c.mobile,
c.title,
c.department,
c.is_active,
c.created_at,
c.updated_at,
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) AS company_names
FROM contacts c
JOIN contact_companies cc ON c.id = cc.contact_id
JOIN customers cu ON cc.customer_id = cu.id
WHERE cc.customer_id IN ({placeholders})
AND c.id <> %s
AND c.is_active = TRUE
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile, c.title, c.department, c.is_active, c.created_at, c.updated_at
ORDER BY c.last_name, c.first_name
LIMIT 10
""",
tuple(customer_ids + [contact_id]),
) or []
return {
"contact_cases": contact_cases,
"company_cases": company_cases,
"related_contacts": related_contacts,
}
except Exception as e:
logger.error(f"Failed to get case context for contact {contact_id}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/contacts/{contact_id}/subscriptions")
async def get_contact_subscriptions(contact_id: int):
customer_id = get_primary_customer_id(contact_id)

View File

@ -225,6 +225,70 @@
.btn-edit-customer:hover i {
transform: rotate(-15deg) scale(1.1);
}
.contacts-panel {
background: var(--bg-card);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 14px;
overflow: hidden;
}
.contacts-table {
margin-bottom: 0;
}
.contacts-table thead th {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(15, 76, 117, 0.06);
color: var(--text-secondary);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
padding: 0.82rem 0.8rem;
}
.contacts-table tbody td {
border-color: rgba(0, 0, 0, 0.06);
padding: 0.85rem 0.8rem;
}
.contacts-table .contact-name {
font-weight: 600;
color: var(--text-primary);
}
.contacts-table .contact-email a {
overflow-wrap: anywhere;
}
.contacts-table .contact-number {
white-space: nowrap;
}
.contacts-table .contact-mobile-wrap {
display: inline-flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.contacts-table .contact-mobile-wrap .btn {
padding: 0.18rem 0.52rem;
line-height: 1.2;
}
.contacts-table .primary-pill {
font-size: 0.72rem;
font-weight: 700;
}
@media (max-width: 992px) {
#contactsContainer {
min-width: 920px;
}
}
</style>
{% endblock %}
@ -550,31 +614,36 @@
<!-- Contacts Tab -->
<div class="tab-pane fade" id="contacts">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold mb-0">Kontaktpersoner</h5>
<div>
<h5 class="fw-bold mb-0">Kontaktpersoner</h5>
<small class="text-muted">Direkte kontaktoplysninger for denne kunde</small>
</div>
<button class="btn btn-primary btn-sm" onclick="showAddContactModal()">
<i class="bi bi-plus-lg me-2"></i>Tilføj Kontakt
</button>
</div>
<div class="table-responsive" id="contactsContainer">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Navn</th>
<th>Titel</th>
<th>Email</th>
<th>Telefon</th>
<th>Mobil</th>
<th>Primær</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" class="text-center py-4">
<div class="spinner-border text-primary"></div>
</td>
</tr>
</tbody>
</table>
<div class="contacts-panel">
<div class="table-responsive" id="contactsContainer">
<table class="table table-hover align-middle contacts-table">
<thead>
<tr>
<th>Navn</th>
<th>Titel</th>
<th>Email</th>
<th>Telefon</th>
<th>Mobil</th>
<th>Primær</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" class="text-center py-4">
<div class="spinner-border text-primary"></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@ -2588,9 +2657,10 @@ function displayUtilityCompany(payload) {
async function loadContacts() {
const container = document.getElementById('contactsContainer');
container.innerHTML = `
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
const renderContactsTable = (bodyHtml) => `
<table class="table table-hover align-middle contacts-table">
<thead>
<tr>
<th>Navn</th>
<th>Titel</th>
@ -2601,14 +2671,20 @@ async function loadContacts() {
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" class="text-center py-4">
<div class="spinner-border text-primary"></div>
</td>
</tr>
${bodyHtml}
</tbody>
</table>
`;
container.innerHTML = `
${renderContactsTable(`
<tr>
<td colspan="6" class="text-center py-4">
<div class="spinner-border text-primary"></div>
</td>
</tr>
`)}
`;
try {
const response = await fetch(`/api/v1/customers/${customerId}/contacts`);
@ -2620,20 +2696,27 @@ async function loadContacts() {
}
const rows = contacts.map(contact => {
const firstName = String(contact.first_name || '').trim();
const lastName = String(contact.last_name || '').trim();
const displayName = [firstName, lastName].filter(Boolean).join(' ') || String(contact.name || '').trim() || '—';
const mobileValue = String(contact.mobile || contact.mobile_phone || '').trim();
const phoneValue = String(contact.phone || '').trim();
const titleValue = String(contact.title || contact.role || '').trim();
const email = contact.email ? `<a href="mailto:${contact.email}">${escapeHtml(contact.email)}</a>` : '—';
const phone = contact.phone ? `<a href="tel:${contact.phone}">${escapeHtml(contact.phone)}</a>` : '—';
const mobile = contact.mobile
? `<div class="d-flex align-items-center gap-2 flex-wrap"><a href="tel:${contact.mobile}">${escapeHtml(contact.mobile)}</a><button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt('${escapeHtml(contact.mobile)}', '${escapeHtml(contact.name || '')}', ${contact.id || 'null'})">SMS</button></div>`
const phone = phoneValue ? `<span class="contact-number"><a href="tel:${phoneValue}">${escapeHtml(phoneValue)}</a></span>` : '—';
const mobile = mobileValue
? `<div class="contact-mobile-wrap"><span class="contact-number"><a href="tel:${mobileValue}">${escapeHtml(mobileValue)}</a></span><button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt('${escapeHtml(mobileValue)}', '${escapeHtml(displayName)}', ${contact.id || 'null'})">SMS</button></div>`
: '—';
const title = contact.title ? escapeHtml(contact.title) : '—';
const primaryBadge = contact.is_primary ? '<span class="badge bg-primary">Primær</span>' : '—';
const title = titleValue ? escapeHtml(titleValue) : '—';
const primaryBadge = contact.is_primary ? '<span class="badge bg-primary primary-pill">Primær</span>' : '—';
return `
<tr>
<td class="fw-semibold">${escapeHtml(contact.name || '-') }</td>
<td class="contact-name">${escapeHtml(displayName)}</td>
<td>${title}</td>
<td>${email}</td>
<td>${phone}</td>
<td class="contact-email">${email}</td>
<td class="contact-number">${phone}</td>
<td>${mobile}</td>
<td>${primaryBadge}</td>
</tr>
@ -2641,21 +2724,7 @@ async function loadContacts() {
}).join('');
container.innerHTML = `
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Navn</th>
<th>Titel</th>
<th>Email</th>
<th>Telefon</th>
<th>Mobil</th>
<th>Primær</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
${renderContactsTable(rows)}
`;
} catch (error) {
console.error('Failed to load contacts:', error);

View File

@ -97,24 +97,34 @@ def _normalize_case_status(status_value: Optional[str]) -> str:
allowed_statuses = ["åben", "under behandling", "afventer", "løst", "lukket"]
allowed_map = {s.lower(): s for s in allowed_statuses}
open_aliases = {"åben", "open", "under behandling", "afventer", "i_gang", "on_hold"}
closed_aliases = {"lukket", "closed", "løst", "afsluttet", "resolved", "done"}
open_default = allowed_map.get("åben", allowed_statuses[0])
closed_default = allowed_map.get("lukket", allowed_map.get("løst", open_default))
if not status_value:
return allowed_map.get("åben", allowed_statuses[0])
return open_default
normalized = str(status_value).strip().lower()
if normalized in allowed_map:
return allowed_map[normalized]
if normalized in open_aliases:
return open_default
if normalized in closed_aliases:
return closed_default
# Backward compatibility for legacy mapping
if normalized == "afventer" and "åben" in allowed_map:
return allowed_map["åben"]
return open_default
# Do not force unknown values back to default; preserve user-entered/custom DB values
raw_value = str(status_value).strip()
if raw_value:
return raw_value
return allowed_map.get("åben", allowed_statuses[0])
return open_default
def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]:
@ -332,6 +342,10 @@ class SagBuzzwordSelectionRequest(BaseModel):
selected_text: str = Field(..., min_length=1, max_length=2000)
class SagListPreferencesUpdate(BaseModel):
type_filters: List[str] = Field(default_factory=list)
def _normalize_email_list(values: List[str], field_name: str) -> List[str]:
cleaned: List[str] = []
for value in values or []:
@ -1064,6 +1078,80 @@ async def list_recent_sager(request: Request, limit: int = Query(10, ge=1, le=10
logger.error("❌ Error listing recent cases for user %s: %s", user_id, e)
raise HTTPException(status_code=500, detail="Failed to list recent cases")
@router.get("/sag/me/list-preferences")
async def get_my_sag_list_preferences(request: Request):
user_id = _get_user_id_from_request(request)
try:
rows = execute_query(
"""
SELECT type_filters
FROM user_sag_list_preferences
WHERE user_id = %s
""",
(user_id,),
) or []
if not rows:
return {"type_filters": []}
raw = rows[0].get("type_filters")
parsed = []
if isinstance(raw, list):
parsed = raw
elif isinstance(raw, str):
try:
payload = json.loads(raw)
if isinstance(payload, list):
parsed = payload
except Exception:
parsed = []
normalized = []
seen = set()
for value in parsed:
item = str(value or "").strip().lower()
if not item or item in seen:
continue
seen.add(item)
normalized.append(item)
return {"type_filters": normalized}
except Exception as e:
if "user_sag_list_preferences" in str(e):
return {"type_filters": []}
logger.error("❌ Could not load sag list preferences for user %s: %s", user_id, e)
raise HTTPException(status_code=500, detail="Failed to load list preferences")
@router.patch("/sag/me/list-preferences")
async def update_my_sag_list_preferences(request: Request, payload: SagListPreferencesUpdate):
user_id = _get_user_id_from_request(request)
try:
normalized = []
seen = set()
for value in payload.type_filters or []:
item = str(value or "").strip().lower()
if not item or item in seen:
continue
if len(item) > 80:
continue
seen.add(item)
normalized.append(item)
execute_query(
"""
INSERT INTO user_sag_list_preferences (user_id, type_filters, updated_at)
VALUES (%s, %s::jsonb, NOW())
ON CONFLICT (user_id)
DO UPDATE SET
type_filters = EXCLUDED.type_filters,
updated_at = NOW()
""",
(user_id, json.dumps(normalized)),
)
return {"type_filters": normalized}
except Exception as e:
logger.error("❌ Could not update sag list preferences for user %s: %s", user_id, e)
raise HTTPException(status_code=500, detail="Failed to save list preferences")
@router.get("/sag/{sag_id}/modules")
async def get_case_module_prefs(sag_id: int):

View File

@ -158,6 +158,38 @@ def _fetch_case_status_options() -> list[str]:
return values
def _fetch_closed_case_statuses() -> list[str]:
values = []
seen = set()
def _add(value: Optional[str]) -> None:
candidate = str(value or "").strip().lower()
if not candidate or candidate in seen:
return
seen.add(candidate)
values.append(candidate)
setting_row = execute_query(
"SELECT value FROM settings WHERE key = %s",
("case_statuses",)
)
if setting_row and setting_row[0].get("value"):
try:
parsed = json.loads(setting_row[0].get("value") or "[]")
for item in parsed if isinstance(parsed, list) else []:
if isinstance(item, dict) and item.get("is_closed"):
_add(item.get("value"))
except Exception:
pass
if not values:
for fallback in ["lukket", "løst", "afsluttet", "closed", "resolved", "done"]:
_add(fallback)
return values
@router.get("/sag", response_class=HTMLResponse)
async def sager_liste(
request: Request,
@ -171,6 +203,7 @@ async def sager_liste(
):
"""Display list of all cases."""
try:
closed_statuses = _fetch_closed_case_statuses()
# Coerce string params to optional ints
customer_id_int = _coerce_optional_int(customer_id)
requested_unassigned = bool(unassigned) or str(ansvarlig_bruger_id or "").strip().upper() == "__UNASSIGNED__"
@ -241,9 +274,16 @@ async def sager_liste(
query += ")"
query += " AND (s.start_date IS NULL OR s.start_date <= NOW())"
if status:
normalized_status = str(status or "").strip().lower()
if normalized_status == "all":
pass
elif normalized_status:
query += " AND s.status = %s"
params.append(status)
else:
placeholders = ", ".join(["%s"] * len(closed_statuses))
query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})"
params.extend(closed_statuses)
if customer_id_int:
query += " AND s.customer_id = %s"
params.append(customer_id_int)
@ -298,9 +338,15 @@ async def sager_liste(
fallback_query += " AND (s.deferred_until IS NULL OR s.deferred_until <= NOW())"
fallback_query += " AND (s.start_date IS NULL OR s.start_date <= NOW())"
if status:
if normalized_status == "all":
pass
elif normalized_status:
fallback_query += " AND s.status = %s"
fallback_params.append(status)
else:
placeholders = ", ".join(["%s"] * len(closed_statuses))
fallback_query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})"
fallback_params.extend(closed_statuses)
if customer_id_int:
fallback_query += " AND s.customer_id = %s"
fallback_params.append(customer_id_int)
@ -389,6 +435,7 @@ async def sager_liste(
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int,
"current_unassigned": requested_unassigned,
"closed_statuses": closed_statuses,
})
except Exception:
logger.exception("❌ Error displaying case list")
@ -409,6 +456,7 @@ async def sager_liste(
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int,
"current_unassigned": requested_unassigned,
"closed_statuses": _fetch_closed_case_statuses(),
})
@router.get("/sag/new", response_class=HTMLResponse)

View File

@ -65,6 +65,16 @@
border: none;
white-space: nowrap;
}
.sag-table thead th.col-expand,
.sag-table tbody td.col-expand {
width: 44px;
min-width: 44px;
max-width: 44px;
text-align: center;
padding-left: 0.45rem;
padding-right: 0.45rem;
}
.sag-table tbody tr {
border-bottom: 1px solid rgba(0,0,0,0.05);
@ -177,11 +187,6 @@
content: none;
}
.tree-row.has-children td:first-child {
position: relative;
padding-left: 2.5rem !important;
}
.tree-toggle {
display: inline-flex;
align-items: center;
@ -200,6 +205,11 @@
top: 50%;
transform: translateY(-50%);
}
.col-expand .tree-toggle {
position: static;
transform: none;
}
.tree-toggle:hover {
background: var(--accent);
@ -215,19 +225,11 @@
border-top: none !important;
}
.tree-child td:first-child {
position: relative;
padding-left: 2.5rem !important;
}
.tree-child td:first-child:before {
content: '└';
position: absolute;
left: 0.5rem;
top: 50%;
transform: translateY(-50%);
color: rgba(0,0,0,0.3);
font-size: 1.2rem;
.tree-child .child-branch {
color: rgba(0,0,0,0.4);
font-size: 1rem;
font-weight: 600;
line-height: 1;
}
.relation-badge {
@ -250,6 +252,8 @@
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
background: #e9ecef;
color: #495057;
}
.status-åben {
@ -261,6 +265,28 @@
background: #d4edda;
color: #155724;
}
.status-under-behandling,
.status-i-gang,
.status-in-progress {
background: #dbeafe;
color: #1e3a8a;
}
.status-afventer,
.status-on-hold {
background: #fef3c7;
color: #92400e;
}
.status-løst,
.status-afsluttet,
.status-resolved,
.status-done,
.status-closed {
background: #d1fae5;
color: #065f46;
}
.filter-pills {
display: flex;
@ -322,20 +348,229 @@
letter-spacing: 0.35px;
}
.owner-cell {
.type-filter-wrap {
min-width: 200px;
max-width: 280px;
}
.type-filter-header {
display: none;
}
.type-filter-dropdown .dropdown-toggle {
width: 100%;
text-align: left;
border-radius: 0.375rem;
border: 1px solid #ced4da;
background: #fff;
color: var(--text-primary);
font-weight: 400;
font-size: 0.86rem;
padding: 0.28rem 2rem 0.28rem 0.65rem;
min-height: calc(1.4em + 0.56rem + 2px);
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
box-shadow: none;
}
.type-filter-dropdown .dropdown-toggle:hover,
.type-filter-dropdown .dropdown-toggle:focus,
.type-filter-dropdown .dropdown-toggle:active,
.type-filter-dropdown .dropdown-toggle.show {
border-color: #86b7fe;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
color: var(--text-primary);
background: #fff;
}
.type-filter-dropdown .dropdown-toggle::after {
margin-left: 0.5rem;
}
.type-filter-dropdown .dropdown-menu {
width: min(340px, 92vw);
border-radius: 0.5rem;
border: 1px solid rgba(0, 0, 0, 0.15);
padding: 0.45rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
.type-filter-checkbox-list {
max-height: 12.5rem;
overflow-y: auto;
padding-right: 0.2rem;
}
.type-filter-checkbox-item {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.34rem 0.42rem;
border-radius: 10px;
cursor: pointer;
margin-bottom: 0.2rem;
transition: background-color 0.15s ease;
}
.type-filter-checkbox-item:hover {
background: rgba(15, 76, 117, 0.08);
}
.type-filter-checkbox-item input {
margin-top: 0;
cursor: pointer;
}
.type-filter-checkbox-label {
font-size: 0.8rem;
font-weight: 600;
color: #16384f;
user-select: none;
}
.type-filter-menu-actions {
border-top: 1px solid rgba(0, 0, 0, 0.08);
margin-top: 0.35rem;
padding-top: 0.38rem;
display: flex;
justify-content: flex-end;
gap: 0.3rem;
}
.type-filter-menu-actions .btn {
font-size: 0.72rem;
line-height: 1.2;
padding: 0.2rem 0.45rem;
}
.type-filter-footer {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.6rem;
margin-top: 0.25rem;
}
.type-filter-selection {
font-size: 0.73rem;
color: var(--text-secondary);
min-height: 1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.type-filter-empty {
font-size: 0.8rem;
color: var(--text-secondary);
padding: 0.22rem 0.1rem;
}
.mini-filter-wrap {
min-width: 190px;
max-width: 260px;
}
.mini-filter-dropdown .dropdown-toggle {
width: 100%;
text-align: left;
border-radius: 0.375rem;
border: 1px solid #ced4da;
background: #fff;
color: var(--text-primary);
font-weight: 400;
font-size: 0.86rem;
padding: 0.28rem 2rem 0.28rem 0.65rem;
min-height: calc(1.4em + 0.56rem + 2px);
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
box-shadow: none;
}
.mini-filter-dropdown .dropdown-toggle:hover,
.mini-filter-dropdown .dropdown-toggle:focus,
.mini-filter-dropdown .dropdown-toggle:active,
.mini-filter-dropdown .dropdown-toggle.show {
border-color: #86b7fe;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
color: var(--text-primary);
background: #fff;
}
.mini-filter-dropdown .dropdown-menu {
width: min(320px, 92vw);
border-radius: 0.5rem;
border: 1px solid rgba(0, 0, 0, 0.15);
padding: 0.45rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
.mini-filter-list {
max-height: 12.5rem;
overflow-y: auto;
padding-right: 0.2rem;
}
.mini-filter-item {
display: flex;
align-items: center;
gap: 0.52rem;
padding: 0.34rem 0.42rem;
border-radius: 10px;
cursor: pointer;
margin-bottom: 0.15rem;
transition: background-color 0.15s ease;
}
.mini-filter-item:hover {
background: rgba(15, 76, 117, 0.08);
}
.mini-filter-item input {
margin-top: 0;
cursor: pointer;
}
.mini-filter-label {
font-size: 0.8rem;
font-weight: 600;
color: #16384f;
user-select: none;
}
.mini-filter-footer {
border-top: 1px solid rgba(0, 0, 0, 0.08);
margin-top: 0.35rem;
padding-top: 0.35rem;
display: flex;
justify-content: flex-end;
}
.mini-filter-clear {
font-size: 0.72rem;
line-height: 1.2;
padding: 0.2rem 0.45rem;
}
.owner-cell {
display: inline-flex;
align-items: center;
justify-content: center;
}
.owner-avatar {
width: 1.6rem;
height: 1.6rem;
width: 1.75rem;
height: 1.75rem;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.66rem;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.02em;
color: #fff;
@ -343,6 +578,15 @@
flex-shrink: 0;
}
.owner-avatar.group-avatar {
background: #7c3aed;
}
.owner-avatar.empty-avatar {
background: #cbd5e1;
color: #475569;
}
.owner-name {
color: var(--text-secondary);
font-size: 0.85rem;
@ -391,6 +635,17 @@
{% endblock %}
{% block content %}
{% macro initials_bubble(label, group=false) -%}
{% if label %}
{% set norm = label.strip() %}
{% set parts = norm.split() %}
{% set initials = ((parts[0][0] if parts|length > 0 else norm[0]) ~ (parts[1][0] if parts|length > 1 else ''))|upper %}
<span class="owner-avatar {% if group %}group-avatar{% endif %}" title="{{ norm }}">{{ initials }}</span>
{% else %}
<span class="owner-avatar empty-avatar" title="Ikke sat">-</span>
{% endif %}
{%- endmacro %}
<div class="container-fluid" style="max-width: none; padding-top: 0.65rem;">
<div id="sagTopAlerts" class="sag-top-alerts d-none"></div>
@ -428,39 +683,66 @@
</div>
</div>
<div class="d-flex flex-wrap align-items-center gap-3 mb-3">
<div class="d-flex flex-wrap align-items-center gap-2 mb-3">
<div class="filter-pills">
<div class="filter-pill active" data-filter="all">Alle</div>
<div class="filter-pill" data-filter="åben">Åbne</div>
<div class="filter-pill" data-filter="lukket">Lukkede</div>
</div>
<div style="min-width: 200px;">
<select class="form-select" id="typeFilter">
<option value="all">Alle typer</option>
</select>
<div class="type-filter-wrap">
<div class="dropdown type-filter-dropdown mb-1">
<button class="btn dropdown-toggle" type="button" id="typeFilterDropdownBtn" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
<span id="typeFilterDropdownLabel">Vælg typer</span>
</button>
<div class="dropdown-menu" aria-labelledby="typeFilterDropdownBtn">
<div class="type-filter-checkbox-list" id="typeFilterCheckboxList"></div>
<div class="type-filter-menu-actions">
<button class="btn btn-sm btn-outline-secondary" type="button" id="clearTypeFilterBtn">Ryd</button>
<button class="btn btn-sm btn-primary" type="button" id="saveTypeFilterDefaultBtn">Gem</button>
</div>
</div>
</div>
<select class="d-none" id="typeFilter" multiple title="Type filter data source"></select>
</div>
<form id="assignmentFilterForm" class="d-flex flex-wrap gap-2 align-items-center" method="get" action="/sag">
<div style="min-width: 220px;">
<select class="form-select" name="ansvarlig_bruger_id" id="assigneeFilter">
<div class="mini-filter-wrap">
<div class="dropdown mini-filter-dropdown mb-1">
<button class="btn dropdown-toggle" type="button" id="assigneeDropdownBtn" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
<span id="assigneeDropdownLabel">Ansvarlig</span>
</button>
<div class="dropdown-menu" aria-labelledby="assigneeDropdownBtn">
<div class="mini-filter-list" id="assigneeFilterList"></div>
<div class="mini-filter-footer">
<button class="btn btn-sm btn-outline-secondary mini-filter-clear" type="button" id="clearAssigneeFilterBtn">Ryd</button>
</div>
</div>
</div>
<select class="d-none" name="ansvarlig_bruger_id" id="assigneeFilter" multiple>
<option value="">Alle medarbejdere</option>
<option value="__UNASSIGNED__" {% if current_unassigned %}selected{% endif %}>Uden ansvarlig</option>
{% for user in assignment_users or [] %}
<option value="{{ user.user_id }}" {% if current_ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>
{% endfor %}
</select>
</select>
</div>
<div class="mini-filter-wrap">
<div class="dropdown mini-filter-dropdown mb-1">
<button class="btn dropdown-toggle" type="button" id="groupDropdownBtn" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
<span id="groupDropdownLabel">Grupper</span>
</button>
<div class="dropdown-menu" aria-labelledby="groupDropdownBtn">
<div class="mini-filter-list" id="groupFilterList"></div>
<div class="mini-filter-footer">
<button class="btn btn-sm btn-outline-secondary mini-filter-clear" type="button" id="clearGroupFilterBtn">Ryd</button>
</div>
</div>
</div>
<div style="min-width: 220px;">
<select class="form-select" name="assigned_group_id" id="groupFilter">
<select class="d-none" name="assigned_group_id" id="groupFilter" multiple>
<option value="">Alle grupper</option>
{% for group in assignment_groups or [] %}
<option value="{{ group.id }}" {% if current_assigned_group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
{% endfor %}
</select>
</div>
{% if include_deferred %}
<input type="hidden" name="include_deferred" value="1">
{% endif %}
</form>
</select>
</div>
<a class="btn btn-sm btn-outline-secondary" href="{{ toggle_include_deferred_url }}">
{% if include_deferred %}Skjul udsatte{% else %}Vis udsatte{% endif %}
</a>
@ -472,12 +754,14 @@
<table class="sag-table">
<thead>
<tr>
<th class="col-expand"></th>
<th style="width: 90px;">SagsID</th>
<th style="width: 180px;">Virksom.</th>
<th style="width: 150px;">Kontakt</th>
<th style="width: 300px;">Beskr.</th>
<th style="width: 120px;">Type</th>
<th style="width: 110px;">Prioritet</th>
<th style="width: 120px;">Status</th>
<th style="width: 160px;">Ansvarl.</th>
<th style="width: 170px;">Gruppe/Level</th>
<th style="width: 240px;">Næste todo</th>
@ -494,12 +778,16 @@
<tr class="tree-row {% if has_relations %}has-children{% endif %}"
data-sag-id="{{ sag.id }}"
data-status="{{ sag.status }}"
data-type="{{ sag.template_key or sag.type or 'ticket' }}">
<td>
data-type="{{ sag.template_key or sag.type or 'ticket' }}"
data-assignee-id="{{ sag.ansvarlig_bruger_id if sag.ansvarlig_bruger_id else '' }}"
data-group-id="{{ sag.assigned_group_id if sag.assigned_group_id else '' }}">
<td class="col-expand" onclick="event.stopPropagation();">
{% if has_relations %}
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
{% endif %}
<span class="sag-id">#{{ sag.id }}</span>
</td>
<td>
<span class="sag-id" role="button" onclick="window.location.href='/sag/{{ sag.id }}/v3'">#{{ sag.id }}</span>
{% if (sag.unread_email_count or 0) > 0 %}
{% set unread_level = sag.unread_email_level or 'fresh' %}
<span class="sag-unread-badge sag-unread-{{ unread_level }}" title="{{ sag.unread_email_count }} ulæste e-mails">
@ -522,22 +810,20 @@
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
{{ sag.priority if sag.priority else 'normal' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'">
{% set status_raw = sag.status if sag.status else 'åben' %}
{% set status_class = status_raw|lower|replace(' ', '-') %}
<span class="status-badge status-{{ status_class }}">{{ status_raw }}</span>
</td>
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}/v3'">
{% if sag.ansvarlig_navn %}
{% set owner_name = sag.ansvarlig_navn.strip() %}
{% set owner_parts = owner_name.split() %}
<div class="owner-cell">
<span class="owner-avatar">
{{ ((owner_parts[0][0] if owner_parts|length > 0 else owner_name[0]) ~ (owner_parts[1][0] if owner_parts|length > 1 else ''))|upper }}
</span>
<span class="owner-name">{{ owner_name }}</span>
{{ initials_bubble(sag.ansvarlig_navn) }}
</div>
{% else %}
-
{% endif %}
</td>
<td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.assigned_group_name if sag.assigned_group_name else '-' }}
<div class="owner-cell">
{{ initials_bubble(sag.assigned_group_name, true) }}
</div>
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem; white-space: normal; max-width: 240px;">
{% if sag.next_todo_title %}
@ -569,9 +855,10 @@
{% if related_sag and rel.target_id not in seen_targets %}
{% set _ = seen_targets.append(rel.target_id) %}
{% set all_rel_types = relations_map[sag.id]|selectattr('target_id', 'equalto', rel.target_id)|map(attribute='type')|list %}
<tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.template_key or related_sag.type or 'ticket' }}" style="display: none;">
<tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.template_key or related_sag.type or 'ticket' }}" data-assignee-id="{{ related_sag.ansvarlig_bruger_id if related_sag.ansvarlig_bruger_id else '' }}" data-group-id="{{ related_sag.assigned_group_id if related_sag.assigned_group_id else '' }}" style="display: none;">
<td class="col-expand"><span class="child-branch"></span></td>
<td>
<span class="sag-id">#{{ related_sag.id }}</span>
<span class="sag-id" role="button" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">#{{ related_sag.id }}</span>
{% if (related_sag.unread_email_count or 0) > 0 %}
{% set child_unread_level = related_sag.unread_email_level or 'fresh' %}
<span class="sag-unread-badge sag-unread-{{ child_unread_level }}" title="{{ related_sag.unread_email_count }} ulæste e-mails">
@ -597,22 +884,20 @@
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
{{ related_sag.priority if related_sag.priority else 'normal' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
{% set related_status_raw = related_sag.status if related_sag.status else 'åben' %}
{% set related_status_class = related_status_raw|lower|replace(' ', '-') %}
<span class="status-badge status-{{ related_status_class }}">{{ related_status_raw }}</span>
</td>
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
{% if related_sag.ansvarlig_navn %}
{% set owner_name = related_sag.ansvarlig_navn.strip() %}
{% set owner_parts = owner_name.split() %}
<div class="owner-cell">
<span class="owner-avatar">
{{ ((owner_parts[0][0] if owner_parts|length > 0 else owner_name[0]) ~ (owner_parts[1][0] if owner_parts|length > 1 else ''))|upper }}
</span>
<span class="owner-name">{{ owner_name }}</span>
{{ initials_bubble(related_sag.ansvarlig_navn) }}
</div>
{% else %}
-
{% endif %}
</td>
<td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }}
<div class="owner-cell">
{{ initials_bubble(related_sag.assigned_group_name, true) }}
</div>
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem; white-space: normal; max-width: 240px;">
{% if related_sag.next_todo_title %}
@ -741,30 +1026,41 @@
const allRows = document.querySelectorAll('.tree-row');
let currentSearch = '';
let currentFilter = 'all';
let currentType = 'all';
let currentTypes = new Set();
let currentAssignees = new Set();
let currentGroups = new Set();
const closedStatuses = new Set({{ (closed_statuses or ['lukket', 'løst', 'afsluttet', 'closed', 'resolved', 'done'])|tojson }});
const assigneeFilter = document.getElementById('assigneeFilter');
const groupFilter = document.getElementById('groupFilter');
const assignmentFilterForm = document.getElementById('assignmentFilterForm');
if (assigneeFilter && assignmentFilterForm) {
assigneeFilter.addEventListener('change', () => assignmentFilterForm.submit());
}
if (groupFilter && assignmentFilterForm) {
groupFilter.addEventListener('change', () => assignmentFilterForm.submit());
}
const assigneeFilterList = document.getElementById('assigneeFilterList');
const groupFilterList = document.getElementById('groupFilterList');
const assigneeDropdownLabel = document.getElementById('assigneeDropdownLabel');
const groupDropdownLabel = document.getElementById('groupDropdownLabel');
const clearAssigneeFilterBtn = document.getElementById('clearAssigneeFilterBtn');
const clearGroupFilterBtn = document.getElementById('clearGroupFilterBtn');
function applyFilters() {
const search = currentSearch;
allRows.forEach(row => {
const text = row.textContent.toLowerCase();
const status = row.dataset.status;
const status = String(row.dataset.status || '').toLowerCase();
const type = row.dataset.type || 'ticket';
const assigneeRaw = String(row.dataset.assigneeId || '').trim();
const groupRaw = String(row.dataset.groupId || '').trim();
const assigneeId = assigneeRaw || '__UNASSIGNED__';
const groupId = groupRaw;
const matchesSearch = text.includes(search);
const matchesFilter = currentFilter === 'all' || status === currentFilter;
const matchesType = currentType === 'all' || type === currentType;
const visible = matchesSearch && matchesFilter && matchesType;
const isClosed = closedStatuses.has(status);
const matchesFilter = currentFilter === 'all'
|| (currentFilter === 'åben' && !isClosed)
|| (currentFilter === 'lukket' && isClosed)
|| status === currentFilter;
const matchesType = currentTypes.size === 0 || currentTypes.has(type);
const matchesAssignee = currentAssignees.size === 0 || currentAssignees.has(assigneeId);
const matchesGroup = currentGroups.size === 0 || currentGroups.has(groupId);
const visible = matchesSearch && matchesFilter && matchesType && matchesAssignee && matchesGroup;
row.style.display = visible ? '' : 'none';
@ -773,18 +1069,130 @@
const children = document.querySelectorAll(`tr[data-parent="${sagId}"]`);
children.forEach(child => {
const childText = child.textContent.toLowerCase();
const childStatus = child.dataset.status;
const childStatus = String(child.dataset.status || '').toLowerCase();
const childType = child.dataset.type || 'ticket';
const childAssigneeRaw = String(child.dataset.assigneeId || '').trim();
const childGroupRaw = String(child.dataset.groupId || '').trim();
const childAssigneeId = childAssigneeRaw || '__UNASSIGNED__';
const childGroupId = childGroupRaw;
const childMatchesSearch = childText.includes(search);
const childMatchesFilter = currentFilter === 'all' || childStatus === currentFilter;
const childMatchesType = currentType === 'all' || childType === currentType;
const childVisible = visible && row.classList.contains('expanded') && childMatchesSearch && childMatchesFilter && childMatchesType;
const childIsClosed = closedStatuses.has(childStatus);
const childMatchesFilter = currentFilter === 'all'
|| (currentFilter === 'åben' && !childIsClosed)
|| (currentFilter === 'lukket' && childIsClosed)
|| childStatus === currentFilter;
const childMatchesType = currentTypes.size === 0 || currentTypes.has(childType);
const childMatchesAssignee = currentAssignees.size === 0 || currentAssignees.has(childAssigneeId);
const childMatchesGroup = currentGroups.size === 0 || currentGroups.has(childGroupId);
const childVisible = visible && row.classList.contains('expanded') && childMatchesSearch && childMatchesFilter && childMatchesType && childMatchesAssignee && childMatchesGroup;
child.style.display = childVisible ? '' : 'none';
});
}
});
}
function renderMiniFilterOptions(selectEl, listEl, selectedSet, labelEl, defaultLabel) {
if (!selectEl || !listEl) return;
const options = Array.from(selectEl.options || []).filter((opt) => String(opt.value || '').trim() !== '');
if (options.length === 0) {
listEl.innerHTML = '<span class="type-filter-empty">Ingen muligheder</span>';
if (labelEl) labelEl.textContent = defaultLabel;
return;
}
listEl.innerHTML = options.map((opt) => {
const value = String(opt.value || '').trim();
const isChecked = selectedSet.has(value);
const safeValue = value.replace(/"/g, '&quot;');
const safeLabel = String(opt.textContent || value).replace(/"/g, '&quot;');
const safeId = `${selectEl.id}-opt-${value.replace(/[^a-zA-Z0-9_-]+/g, '-')}`;
return `
<label class="mini-filter-item" for="${safeId}">
<input class="form-check-input" type="checkbox" id="${safeId}" data-value="${safeValue}" ${isChecked ? 'checked' : ''}>
<span class="mini-filter-label">${safeLabel}</span>
</label>
`;
}).join('');
if (labelEl) {
labelEl.textContent = selectedSet.size === 0 ? defaultLabel : `${selectedSet.size} valgt`;
}
}
function syncMiniSelect(selectEl, selectedSet) {
if (!selectEl) return;
Array.from(selectEl.options || []).forEach((opt) => {
const value = String(opt.value || '').trim();
opt.selected = value !== '' && selectedSet.has(value);
});
}
if (assigneeFilterList && assigneeFilter) {
assigneeFilterList.addEventListener('change', function(event) {
const checkbox = event.target.closest('input[type="checkbox"][data-value]');
if (!checkbox) return;
const value = String(checkbox.dataset.value || '').trim();
if (!value) return;
if (checkbox.checked) currentAssignees.add(value);
else currentAssignees.delete(value);
syncMiniSelect(assigneeFilter, currentAssignees);
renderMiniFilterOptions(assigneeFilter, assigneeFilterList, currentAssignees, assigneeDropdownLabel, 'Ansvarlig');
applyFilters();
});
}
if (groupFilterList && groupFilter) {
groupFilterList.addEventListener('change', function(event) {
const checkbox = event.target.closest('input[type="checkbox"][data-value]');
if (!checkbox) return;
const value = String(checkbox.dataset.value || '').trim();
if (!value) return;
if (checkbox.checked) currentGroups.add(value);
else currentGroups.delete(value);
syncMiniSelect(groupFilter, currentGroups);
renderMiniFilterOptions(groupFilter, groupFilterList, currentGroups, groupDropdownLabel, 'Grupper');
applyFilters();
});
}
if (clearAssigneeFilterBtn) {
clearAssigneeFilterBtn.addEventListener('click', function() {
currentAssignees = new Set();
syncMiniSelect(assigneeFilter, currentAssignees);
renderMiniFilterOptions(assigneeFilter, assigneeFilterList, currentAssignees, assigneeDropdownLabel, 'Ansvarlig');
applyFilters();
});
}
if (clearGroupFilterBtn) {
clearGroupFilterBtn.addEventListener('click', function() {
currentGroups = new Set();
syncMiniSelect(groupFilter, currentGroups);
renderMiniFilterOptions(groupFilter, groupFilterList, currentGroups, groupDropdownLabel, 'Grupper');
applyFilters();
});
}
function initMiniFilterSelections() {
if (assigneeFilter) {
currentAssignees = new Set(
Array.from(assigneeFilter.selectedOptions || [])
.map((opt) => String(opt.value || '').trim())
.filter((value) => value)
);
renderMiniFilterOptions(assigneeFilter, assigneeFilterList, currentAssignees, assigneeDropdownLabel, 'Ansvarlig');
}
if (groupFilter) {
currentGroups = new Set(
Array.from(groupFilter.selectedOptions || [])
.map((opt) => String(opt.value || '').trim())
.filter((value) => value)
);
renderMiniFilterOptions(groupFilter, groupFilterList, currentGroups, groupDropdownLabel, 'Grupper');
}
}
if (searchInput) {
searchInput.addEventListener('input', function(e) {
currentSearch = e.target.value.toLowerCase();
@ -807,9 +1215,83 @@
});
const typeFilter = document.getElementById('typeFilter');
if (typeFilter) {
typeFilter.addEventListener('change', function() {
currentType = this.value || 'all';
const typeFilterCheckboxList = document.getElementById('typeFilterCheckboxList');
const typeFilterDropdownLabel = document.getElementById('typeFilterDropdownLabel');
const typeFilterSelection = document.getElementById('typeFilterSelection');
const clearTypeFilterBtn = document.getElementById('clearTypeFilterBtn');
const saveTypeFilterDefaultBtn = document.getElementById('saveTypeFilterDefaultBtn');
function getSelectedTypesFromUi() {
return Array.from(currentTypes);
}
function renderTypeFilterOptions() {
if (!typeFilterCheckboxList || !typeFilter) return;
const options = Array.from(typeFilter.options || []);
if (options.length === 0) {
typeFilterCheckboxList.innerHTML = '<span class="type-filter-empty">Ingen typer fundet</span>';
if (typeFilterSelection) typeFilterSelection.textContent = 'Ingen typer tilgaengelige';
if (typeFilterDropdownLabel) typeFilterDropdownLabel.textContent = 'Ingen typer';
return;
}
typeFilterCheckboxList.innerHTML = options.map((opt) => {
const value = String(opt.value || '').trim();
const key = value.toLowerCase();
const isActive = currentTypes.has(key);
const safeValue = value.replace(/"/g, '&quot;');
const safeId = `type-filter-opt-${key.replace(/[^a-z0-9_-]+/g, '-')}`;
return `
<label class="type-filter-checkbox-item" for="${safeId}">
<input class="form-check-input" type="checkbox" id="${safeId}" data-type="${safeValue}" ${isActive ? 'checked' : ''}>
<span class="type-filter-checkbox-label">${value}</span>
</label>
`;
}).join('');
if (typeFilterSelection) {
const selectedLabels = options
.filter((opt) => currentTypes.has(String(opt.value || '').trim().toLowerCase()))
.map((opt) => String(opt.value || '').trim())
.filter(Boolean);
if (selectedLabels.length === 0) {
typeFilterSelection.textContent = 'Ingen typer valgt';
if (typeFilterDropdownLabel) typeFilterDropdownLabel.textContent = 'Vælg typer';
} else {
typeFilterSelection.textContent = `Valgt: ${selectedLabels.join(', ')}`;
if (typeFilterDropdownLabel) typeFilterDropdownLabel.textContent = `${selectedLabels.length} valgt`;
}
}
}
function applySelectedTypesToUi() {
if (!typeFilter) return;
Array.from(typeFilter.options || []).forEach((opt) => {
opt.selected = currentTypes.has(String(opt.value || '').trim().toLowerCase());
});
renderTypeFilterOptions();
}
if (typeFilterCheckboxList) {
typeFilterCheckboxList.addEventListener('change', function(event) {
const checkbox = event.target.closest('input[type="checkbox"][data-type]');
if (!checkbox) return;
const typeValue = String(checkbox.dataset.type || '').trim().toLowerCase();
if (!typeValue) return;
if (checkbox.checked) {
currentTypes.add(typeValue);
} else {
currentTypes.delete(typeValue);
}
applySelectedTypesToUi();
applyFilters();
});
}
if (clearTypeFilterBtn) {
clearTypeFilterBtn.addEventListener('click', function() {
currentTypes = new Set();
applySelectedTypesToUi();
applyFilters();
});
}
@ -833,16 +1315,68 @@
configuredTypes.forEach((t) => rowTypes.add(String(t || '').trim()));
const mergedTypes = Array.from(rowTypes).filter(Boolean).sort((a, b) => a.localeCompare(b, 'da'));
if (mergedTypes.length === 0) return;
if (mergedTypes.length === 0) {
typeFilter.innerHTML = '';
renderTypeFilterOptions();
return;
}
typeFilter.innerHTML = `<option value="all">Alle typer</option>` +
mergedTypes.map(type => `<option value="${type}">${type}</option>`).join('');
typeFilter.innerHTML = mergedTypes.map(type => `<option value="${type}">${type}</option>`).join('');
applySelectedTypesToUi();
} catch (err) {
console.error('Failed to load case types', err);
}
}
loadTypeFilters();
async function loadTypeFilterPreferences() {
try {
const res = await fetch('/api/v1/sag/me/list-preferences', { credentials: 'include' });
if (!res.ok) return;
const data = await res.json();
const fromServer = Array.isArray(data?.type_filters) ? data.type_filters : [];
currentTypes = new Set(fromServer.map((v) => String(v || '').trim().toLowerCase()).filter(Boolean));
applySelectedTypesToUi();
applyFilters();
} catch (err) {
console.error('Failed to load type filter preferences', err);
}
}
async function saveTypeFilterPreferences() {
if (!saveTypeFilterDefaultBtn) return;
const selected = getSelectedTypesFromUi();
saveTypeFilterDefaultBtn.disabled = true;
try {
const res = await fetch('/api/v1/sag/me/list-preferences', {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type_filters: selected }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (typeof showNotification === 'function') {
showNotification('Typefilter gemt som standard', 'success');
}
} catch (err) {
console.error('Failed to save type filter preferences', err);
if (typeof showNotification === 'function') {
showNotification('Kunne ikke gemme typefilter', 'error');
}
} finally {
saveTypeFilterDefaultBtn.disabled = false;
}
}
if (saveTypeFilterDefaultBtn) {
saveTypeFilterDefaultBtn.addEventListener('click', saveTypeFilterPreferences);
}
(async () => {
initMiniFilterSelections();
await loadTypeFilters();
await loadTypeFilterPreferences();
applyFilters();
})();
if (topAlertCustomerId) {
loadSagTopAlertsForCustomer(topAlertCustomerId);

View File

@ -331,6 +331,9 @@ async def yealink_established(
"direction": direction,
"contact": kontakt,
"recent_cases": contact_details.get("recent_cases", []),
"contact_cases": contact_details.get("contact_cases", []),
"company_cases": contact_details.get("company_cases", []),
"related_contacts": contact_details.get("related_contacts", []),
"last_call": contact_details.get("last_call"),
}
for user_id in user_ids:

View File

@ -176,18 +176,56 @@ class TelefoniService:
return bool(rows)
@staticmethod
def get_contact_details(contact_id: int) -> dict:
"""
Get extended contact details including:
- Latest 3 open cases
- Last call date
"""
def get_contact_details(contact_id: int, company_id: Optional[int] = None) -> dict:
"""Get extended contact details for telefoni popups and call notifications."""
if not contact_id:
return {"recent_cases": [], "last_call": None}
return {
"recent_cases": [],
"contact_cases": [],
"company_cases": [],
"related_contacts": [],
"last_call": None,
}
# Get the 3 newest open cases for this contact
cases_query = """
SELECT
contact_row = execute_query_single(
"""
SELECT
c.id,
c.first_name,
c.last_name,
c.email,
c.phone,
c.mobile,
c.title,
c.department,
c.is_active,
c.user_company,
(
SELECT cu.id
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) AS company_id,
(
SELECT cu.name
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) AS company
FROM contacts c
WHERE c.id = %s
""",
(contact_id,),
)
effective_company_id = company_id or (contact_row.get("company_id") if contact_row else None)
open_cases_query = """
SELECT
s.id,
s.titel,
s.created_at
@ -200,11 +238,77 @@ class TelefoniService:
ORDER BY s.created_at DESC
LIMIT 3
"""
cases = execute_query(cases_query, (contact_id,)) or []
recent_open_cases = execute_query(open_cases_query, (contact_id,)) or []
contact_cases_query = """
SELECT
s.id,
s.titel,
s.status,
s.customer_id,
cu.name AS customer_name,
s.created_at,
s.updated_at
FROM sag_sager s
INNER JOIN sag_kontakter sk ON s.id = sk.sag_id
LEFT JOIN customers cu ON cu.id = s.customer_id
WHERE sk.contact_id = %s
AND s.deleted_at IS NULL
AND sk.deleted_at IS NULL
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
LIMIT 5
"""
contact_cases = execute_query(contact_cases_query, (contact_id,)) or []
company_cases = []
related_contacts = []
if effective_company_id:
company_cases_query = """
SELECT
s.id,
s.titel,
s.status,
s.customer_id,
cu.name AS customer_name,
s.created_at,
s.updated_at
FROM sag_sager s
LEFT JOIN customers cu ON cu.id = s.customer_id
WHERE s.customer_id = %s
AND s.deleted_at IS NULL
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
LIMIT 5
"""
company_cases = execute_query(company_cases_query, (effective_company_id,)) or []
related_contacts_query = """
SELECT
c.id,
c.first_name,
c.last_name,
c.email,
c.phone,
c.mobile,
c.title,
c.department,
c.is_active,
c.created_at,
c.updated_at,
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) AS company_names
FROM contacts c
INNER JOIN contact_companies cc ON c.id = cc.contact_id
INNER JOIN customers cu ON cc.customer_id = cu.id
WHERE cc.customer_id = %s
AND c.id <> %s
AND c.is_active = TRUE
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile, c.title, c.department, c.is_active, c.created_at, c.updated_at
ORDER BY c.last_name, c.first_name
LIMIT 8
"""
related_contacts = execute_query(related_contacts_query, (effective_company_id, contact_id)) or []
# Get the most recent call for this contact
last_call_query = """
SELECT
SELECT
t.started_at,
t.bruger_id,
t.duration_sec,
@ -218,7 +322,7 @@ class TelefoniService:
LIMIT 1
"""
last_call_row = execute_query_single(last_call_query, (contact_id,))
last_call_data = None
if last_call_row:
last_call_data = {
@ -234,7 +338,46 @@ class TelefoniService:
"titel": case["titel"],
"created_at": case["created_at"],
}
for case in cases
for case in recent_open_cases
],
"contact_cases": [
{
"id": case["id"],
"titel": case["titel"],
"status": case.get("status"),
"customer_id": case.get("customer_id"),
"customer_name": case.get("customer_name"),
"created_at": case.get("created_at"),
"updated_at": case.get("updated_at"),
}
for case in contact_cases
],
"company_cases": [
{
"id": case["id"],
"titel": case["titel"],
"status": case.get("status"),
"customer_id": case.get("customer_id"),
"customer_name": case.get("customer_name"),
"created_at": case.get("created_at"),
"updated_at": case.get("updated_at"),
}
for case in company_cases
],
"related_contacts": [
{
"id": contact["id"],
"first_name": contact.get("first_name"),
"last_name": contact.get("last_name"),
"email": contact.get("email"),
"phone": contact.get("phone"),
"mobile": contact.get("mobile"),
"title": contact.get("title"),
"department": contact.get("department"),
"is_active": contact.get("is_active"),
"company_names": contact.get("company_names") or [],
}
for contact in related_contacts
],
"last_call": last_call_data,
}

View File

@ -1517,6 +1517,18 @@ async def scan_document(file_path: str):
</div>
</div>
<div class="card p-4 mb-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="mb-0 fw-bold">Menu visning (min konto)</h5>
<button class="btn btn-sm btn-primary" type="button" id="saveMenuVisibilityBtn">
<i class="bi bi-save me-2"></i>Gem
</button>
</div>
<p class="text-muted mb-3">Vælg hvilke hovedpunkter og underpunkter du vil se i topmenuen.</p>
<div id="menuVisibilityFeedback" class="small mb-2 text-muted"></div>
<div id="menuVisibilityGrid" class="row g-3"></div>
</div>
<div class="card p-4">
<h5 class="mb-4 fw-bold">System Indstillinger</h5>
<div id="systemSettings">
@ -5317,12 +5329,173 @@ async function deactivateStage(stageId) {
loadPipelineStages();
}
const MENU_VISIBILITY_GROUPS = [
{
title: 'Hovedmenu',
items: [
{ key: 'menu-crm', label: 'CRM' },
{ key: 'menu-sager', label: 'Sager' },
{ key: 'menu-kalender', label: 'Kalender' },
{ key: 'menu-support', label: 'Support' },
{ key: 'menu-salg', label: 'Salg' },
{ key: 'menu-okonomi', label: 'Økonomi' },
{ key: 'menu-datamigration', label: 'Data migration' },
],
},
{
title: 'CRM underpunkter',
items: [
{ key: 'menu-crm-customers', label: 'Kunder' },
{ key: 'menu-crm-contacts', label: 'Kontakter' },
{ key: 'menu-crm-vendors', label: 'Leverandører' },
{ key: 'menu-crm-links', label: 'Links' },
{ key: 'menu-crm-locations', label: 'Lokaliteter' },
{ key: 'menu-crm-opportunities', label: 'Muligheder' },
{ key: 'menu-crm-pipeline', label: 'Pipeline' },
],
},
{
title: 'Support underpunkter',
items: [
{ key: 'menu-support-conversations', label: 'Mine Samtaler' },
{ key: 'menu-support-tickets', label: 'Arkiverede Tickets' },
{ key: 'menu-support-emails', label: 'Email' },
{ key: 'menu-support-telefoni', label: 'Telefoni' },
{ key: 'menu-support-mission', label: 'Mission Control' },
{ key: 'menu-support-anydesk', label: 'AnyDesk Sessions' },
{ key: 'menu-support-hardware', label: 'BMC Assets' },
{ key: 'menu-support-hardware-customers', label: 'Kundehardware' },
{ key: 'menu-support-eset', label: 'ESET Oversigt' },
{ key: 'menu-support-manual', label: 'Manualer' },
],
},
{
title: 'Salg/Økonomi underpunkter',
items: [
{ key: 'menu-salg-orders', label: 'Ordre' },
{ key: 'menu-salg-products', label: 'Produkter' },
{ key: 'menu-salg-webshop', label: 'Webshop Administration' },
{ key: 'menu-okonomi-time-queue', label: 'Time Queue' },
{ key: 'menu-okonomi-supplier-invoices', label: 'Leverandør fakturaer' },
{ key: 'menu-okonomi-prepaid', label: 'Prepaid Cards' },
{ key: 'menu-okonomi-fixed-price', label: 'Fastpris Aftaler' },
{ key: 'menu-okonomi-subscriptions', label: 'Abonnementer' },
],
},
{
title: 'Data migration underpunkter',
items: [
{ key: 'menu-datamigration-dashboard', label: 'Dashboard' },
{ key: 'menu-datamigration-registrations', label: 'Registreringer' },
{ key: 'menu-datamigration-wizard', label: 'Godkend Timer' },
{ key: 'menu-datamigration-employee-log', label: 'Medarbejder Log' },
{ key: 'menu-datamigration-service-contract-wizard', label: 'Servicekontrakt Migration' },
{ key: 'menu-datamigration-service-contract-report', label: 'Servicekontrakt Rapport' },
{ key: 'menu-datamigration-orders', label: 'Ordrer' },
{ key: 'menu-datamigration-customers', label: 'Kunder' },
],
},
];
function setMenuVisibilityFeedback(message, type = 'muted') {
const el = document.getElementById('menuVisibilityFeedback');
if (!el) return;
const cls = type === 'error' ? 'text-danger' : type === 'success' ? 'text-success' : 'text-muted';
el.className = `small mb-2 ${cls}`;
el.textContent = message;
}
function renderMenuVisibilityGrid(hiddenKeys = []) {
const grid = document.getElementById('menuVisibilityGrid');
if (!grid) return;
const hiddenSet = new Set((hiddenKeys || []).map(v => String(v || '').trim().toLowerCase()));
grid.innerHTML = MENU_VISIBILITY_GROUPS.map(group => {
const checkboxes = group.items.map(item => {
const checked = hiddenSet.has(item.key.toLowerCase()) ? '' : 'checked';
return `
<div class="form-check mb-1">
<input class="form-check-input menu-visibility-checkbox" type="checkbox" value="${item.key}" id="menu-toggle-${item.key}" ${checked}>
<label class="form-check-label" for="menu-toggle-${item.key}">${item.label}</label>
</div>
`;
}).join('');
return `
<div class="col-lg-4 col-md-6">
<div class="border rounded p-3 h-100">
<div class="fw-semibold mb-2">${group.title}</div>
${checkboxes}
</div>
</div>
`;
}).join('');
}
async function loadMenuVisibilityPreferences() {
try {
setMenuVisibilityFeedback('Indlæser menuindstillinger...');
const response = await fetch('/api/v1/auth/me/menu-preferences', { credentials: 'include' });
if (!response.ok) {
throw new Error(await getErrorMessage(response, 'Kunne ikke indlæse menuindstillinger'));
}
const data = await response.json();
renderMenuVisibilityGrid(data.hidden_menu_keys || []);
setMenuVisibilityFeedback('');
} catch (error) {
renderMenuVisibilityGrid([]);
setMenuVisibilityFeedback(error.message || 'Kunne ikke indlæse menuindstillinger', 'error');
}
}
async function saveMenuVisibilityPreferences() {
const saveBtn = document.getElementById('saveMenuVisibilityBtn');
const checks = Array.from(document.querySelectorAll('.menu-visibility-checkbox'));
const hiddenKeys = checks
.filter(el => !el.checked)
.map(el => String(el.value || '').trim().toLowerCase())
.filter(Boolean);
if (saveBtn) saveBtn.disabled = true;
setMenuVisibilityFeedback('Gemmer menuindstillinger...');
try {
const response = await fetch('/api/v1/auth/me/menu-preferences', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ hidden_menu_keys: hiddenKeys }),
});
if (!response.ok) {
throw new Error(await getErrorMessage(response, 'Kunne ikke gemme menuindstillinger'));
}
window.dispatchEvent(new CustomEvent('bmc:menu-preferences-updated', {
detail: { hidden_menu_keys: hiddenKeys },
}));
setMenuVisibilityFeedback('Menuindstillinger gemt.', 'success');
showNotification('Menuindstillinger gemt', 'success');
} catch (error) {
setMenuVisibilityFeedback(error.message || 'Kunne ikke gemme menuindstillinger', 'error');
showNotification(error.message || 'Kunne ikke gemme menuindstillinger', 'error');
} finally {
if (saveBtn) saveBtn.disabled = false;
}
}
// Load on page ready
document.addEventListener('DOMContentLoaded', () => {
loadSettings();
loadUsers();
setupTagModalListeners();
loadPipelineStages();
loadMenuVisibilityPreferences();
const saveMenuVisibilityBtn = document.getElementById('saveMenuVisibilityBtn');
if (saveMenuVisibilityBtn) {
saveMenuVisibilityBtn.addEventListener('click', saveMenuVisibilityPreferences);
}
const telefoniTemplate = document.getElementById('telefoniActionTemplate');
const telefoniDefaultExt = document.getElementById('telefoniDefaultExtension');

View File

@ -734,6 +734,21 @@
background-color: var(--accent-light);
color: var(--accent);
}
/* Make section headers in nav dropdowns clearly non-clickable labels */
#navbarNav .dropdown-menu li > .dropdown-header {
margin: 0.2rem 0 0.35rem;
padding: 0.35rem 0.75rem;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
opacity: 0.8;
pointer-events: none;
user-select: none;
cursor: default;
}
.result-item {
padding: 0.75rem 1rem;
@ -778,98 +793,98 @@
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav mx-auto">
<li class="nav-item dropdown">
<li class="nav-item dropdown" data-menu-key="menu-crm">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-people me-2"></i>CRM
</a>
<ul class="dropdown-menu mt-2">
<li><h6 class="dropdown-header">Kunderelationer</h6></li>
<li><a class="dropdown-item py-2" href="/customers">Kunder</a></li>
<li><a class="dropdown-item py-2" href="/contacts">Kontakter</a></li>
<li><a class="dropdown-item py-2" href="/vendors">Leverandører</a></li>
<li data-menu-key="menu-crm-customers"><a class="dropdown-item py-2" href="/customers">Kunder</a></li>
<li data-menu-key="menu-crm-contacts"><a class="dropdown-item py-2" href="/contacts">Kontakter</a></li>
<li data-menu-key="menu-crm-vendors"><a class="dropdown-item py-2" href="/vendors">Leverandører</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Struktur</h6></li>
<li><a class="dropdown-item py-2" href="/links">Links</a></li>
<li><a class="dropdown-item py-2" href="/app/locations">Lokaliteter</a></li>
<li data-menu-key="menu-crm-links"><a class="dropdown-item py-2" href="/links">Links</a></li>
<li data-menu-key="menu-crm-locations"><a class="dropdown-item py-2" href="/app/locations">Lokaliteter</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Pipeline</h6></li>
<li><a class="dropdown-item py-2" href="/opportunities"><i class="bi bi-briefcase me-2"></i>Muligheder</a></li>
<li><a class="dropdown-item py-2" href="/pipeline"><i class="bi bi-diagram-3 me-2"></i>Pipeline</a></li>
<li data-menu-key="menu-crm-opportunities"><a class="dropdown-item py-2" href="/opportunities"><i class="bi bi-briefcase me-2"></i>Muligheder</a></li>
<li data-menu-key="menu-crm-pipeline"><a class="dropdown-item py-2" href="/pipeline"><i class="bi bi-diagram-3 me-2"></i>Pipeline</a></li>
</ul>
</li>
<li class="nav-item">
<li class="nav-item" data-menu-key="menu-sager">
<a class="nav-link" href="/sag">
<i class="bi bi-list-check me-2"></i>Sager
</a>
</li>
<li class="nav-item">
<li class="nav-item" data-menu-key="menu-kalender">
<a class="nav-link" href="/calendar">
<i class="bi bi-calendar3 me-2"></i>Kalender
</a>
</li>
<li class="nav-item dropdown">
<li class="nav-item dropdown" data-menu-key="menu-support">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-headset me-2"></i>Support
</a>
<ul class="dropdown-menu mt-2">
<li><h6 class="dropdown-header">Support</h6></li>
<li><a class="dropdown-item py-2" href="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></li>
<li><a class="dropdown-item py-2" href="/ticket/archived"><i class="bi bi-archive me-2"></i>Arkiverede Tickets</a></li>
<li><a class="dropdown-item py-2" href="/emails"><i class="bi bi-envelope me-2"></i>Email</a></li>
<li><a class="dropdown-item py-2" href="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</a></li>
<li><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li>
<li><a class="dropdown-item py-2" href="/anydesk/sessions"><i class="bi bi-display me-2"></i>AnyDesk Sessions</a></li>
<li data-menu-key="menu-support-conversations"><a class="dropdown-item py-2" href="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></li>
<li data-menu-key="menu-support-tickets"><a class="dropdown-item py-2" href="/ticket/archived"><i class="bi bi-archive me-2"></i>Arkiverede Tickets</a></li>
<li data-menu-key="menu-support-emails"><a class="dropdown-item py-2" href="/emails"><i class="bi bi-envelope me-2"></i>Email</a></li>
<li data-menu-key="menu-support-telefoni"><a class="dropdown-item py-2" href="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</a></li>
<li data-menu-key="menu-support-mission"><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li>
<li data-menu-key="menu-support-anydesk"><a class="dropdown-item py-2" href="/anydesk/sessions"><i class="bi bi-display me-2"></i>AnyDesk Sessions</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Assets & Licenser</h6></li>
<li><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>BMC Assets</a></li>
<li><a class="dropdown-item py-2" href="/hardware/customers"><i class="bi bi-building me-2"></i>Kundehardware</a></li>
<li><a class="dropdown-item py-2" href="/hardware/eset"><i class="bi bi-shield-check me-2"></i>ESET Oversigt</a></li>
<li data-menu-key="menu-support-hardware"><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>BMC Assets</a></li>
<li data-menu-key="menu-support-hardware-customers"><a class="dropdown-item py-2" href="/hardware/customers"><i class="bi bi-building me-2"></i>Kundehardware</a></li>
<li data-menu-key="menu-support-eset"><a class="dropdown-item py-2" href="/hardware/eset"><i class="bi bi-shield-check me-2"></i>ESET Oversigt</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Værktøjer</h6></li>
<li><a class="dropdown-item py-2" href="/manual"><i class="bi bi-journal-richtext me-2"></i>Manualer</a></li>
<li data-menu-key="menu-support-manual"><a class="dropdown-item py-2" href="/manual"><i class="bi bi-journal-richtext me-2"></i>Manualer</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<li class="nav-item dropdown" data-menu-key="menu-salg">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-cart3 me-2"></i>Salg
</a>
<ul class="dropdown-menu mt-2">
<li><h6 class="dropdown-header">Salg</h6></li>
<li><a class="dropdown-item py-2" href="/ordre"><i class="bi bi-receipt me-2"></i>Ordre</a></li>
<li><a class="dropdown-item py-2" href="/products"><i class="bi bi-box-seam me-2"></i>Produkter</a></li>
<li><a class="dropdown-item py-2" href="/webshop"><i class="bi bi-shop me-2"></i>Webshop Administration</a></li>
<li data-menu-key="menu-salg-orders"><a class="dropdown-item py-2" href="/ordre"><i class="bi bi-receipt me-2"></i>Ordre</a></li>
<li data-menu-key="menu-salg-products"><a class="dropdown-item py-2" href="/products"><i class="bi bi-box-seam me-2"></i>Produkter</a></li>
<li data-menu-key="menu-salg-webshop"><a class="dropdown-item py-2" href="/webshop"><i class="bi bi-shop me-2"></i>Webshop Administration</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<li class="nav-item dropdown" data-menu-key="menu-okonomi">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-currency-dollar me-2"></i>Økonomi
</a>
<ul class="dropdown-menu mt-2">
<li><h6 class="dropdown-header">Fakturering</h6></li>
<li><a class="dropdown-item py-2" href="/economy/time-queue"><i class="bi bi-clock-history me-2"></i>Time Queue</a></li>
<li><a class="dropdown-item py-2" href="/billing/supplier-invoices"><i class="bi bi-receipt me-2"></i>Leverandør fakturaer</a></li>
<li data-menu-key="menu-okonomi-time-queue"><a class="dropdown-item py-2" href="/economy/time-queue"><i class="bi bi-clock-history me-2"></i>Time Queue</a></li>
<li data-menu-key="menu-okonomi-supplier-invoices"><a class="dropdown-item py-2" href="/billing/supplier-invoices"><i class="bi bi-receipt me-2"></i>Leverandør fakturaer</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Aftaler</h6></li>
<li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
<li><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
<li><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
<li data-menu-key="menu-okonomi-prepaid"><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
<li data-menu-key="menu-okonomi-fixed-price"><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
<li data-menu-key="menu-okonomi-subscriptions"><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
</ul>
</li>
</ul>
<div class="d-flex align-items-center gap-3">
<div class="dropdown">
<div class="dropdown" data-menu-key="menu-datamigration">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-clock-history me-2"></i>Data migration
</a>
<ul class="dropdown-menu dropdown-menu-end mt-2">
<li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/employee-log"><i class="bi bi-bar-chart-steps me-2"></i>Medarbejder Log</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/service-contract-report"><i class="bi bi-file-earmark-bar-graph me-2"></i>Servicekontrakt Rapport</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
<li data-menu-key="menu-datamigration-dashboard"><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li data-menu-key="menu-datamigration-registrations"><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
<li data-menu-key="menu-datamigration-wizard"><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
<li data-menu-key="menu-datamigration-employee-log"><a class="dropdown-item py-2" href="/timetracking/employee-log"><i class="bi bi-bar-chart-steps me-2"></i>Medarbejder Log</a></li>
<li data-menu-key="menu-datamigration-service-contract-wizard"><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li>
<li data-menu-key="menu-datamigration-service-contract-report"><a class="dropdown-item py-2" href="/timetracking/service-contract-report"><i class="bi bi-file-earmark-bar-graph me-2"></i>Servicekontrakt Rapport</a></li>
<li data-menu-key="menu-datamigration-orders"><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
<li data-menu-key="menu-datamigration-customers"><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
</ul>
</div>
<button class="btn btn-light rounded-circle border-0" id="globalSearchBtn" style="background: var(--accent-light); color: var(--accent);" title="Global søgning (Cmd/Ctrl+K)">
@ -1269,7 +1284,7 @@ window.addEventListener('unhandledrejection', function(event) {
<script src="/static/js/notifications.js?v=1.0"></script>
<script src="/static/js/telefoni.js?v=2.3"></script>
<script src="/static/js/sms.js?v=1.0"></script>
<script src="/static/js/bug-report.js?v=1.0"></script>
<script src="/static/js/bug-report.js?v=1.4"></script>
<script src="/static/js/bottom-bar.js?v=2.23"></script>
<script>
// Dark Mode Toggle Logic
@ -1348,11 +1363,138 @@ window.addEventListener('unhandledrejection', function(event) {
}
});
const MENU_PREFS_CACHE_KEY = 'bmc_menu_hidden_keys';
function normalizeHiddenMenuKeys(keys) {
if (!Array.isArray(keys)) return [];
const seen = new Set();
const normalized = [];
for (const raw of keys) {
const key = String(raw || '').trim().toLowerCase();
if (!key) continue;
if (seen.has(key)) continue;
seen.add(key);
normalized.push(key);
}
return normalized;
}
function isVisibleActionItem(li) {
if (!li) return false;
const anchor = li.querySelector('a.dropdown-item');
if (!anchor) return false;
if (li.style.display === 'none') return false;
if (anchor.style.display === 'none') return false;
return true;
}
function cleanupDropdownSections(menu) {
const items = Array.from(menu.children).filter((node) => node.tagName === 'LI');
// Reset section and divider display first, then recalculate.
items.forEach((li) => {
if (li.querySelector('h6.dropdown-header') || li.querySelector('hr.dropdown-divider')) {
li.style.display = '';
}
});
// Hide headers that no longer have visible action items below them.
items.forEach((li, idx) => {
if (!li.querySelector('h6.dropdown-header')) return;
let hasVisibleItem = false;
for (let j = idx + 1; j < items.length; j += 1) {
if (items[j].querySelector('h6.dropdown-header')) break;
if (isVisibleActionItem(items[j])) {
hasVisibleItem = true;
break;
}
}
li.style.display = hasVisibleItem ? '' : 'none';
});
// Hide separators that don't separate visible action groups.
items.forEach((li, idx) => {
if (!li.querySelector('hr.dropdown-divider')) return;
let hasVisibleBefore = false;
for (let j = idx - 1; j >= 0; j -= 1) {
if (isVisibleActionItem(items[j])) {
hasVisibleBefore = true;
break;
}
}
let hasVisibleAfter = false;
for (let j = idx + 1; j < items.length; j += 1) {
if (isVisibleActionItem(items[j])) {
hasVisibleAfter = true;
break;
}
}
li.style.display = hasVisibleBefore && hasVisibleAfter ? '' : 'none';
});
}
function cleanupNavbarDropdowns(hiddenSet) {
document.querySelectorAll('#navbarNav .dropdown-menu').forEach((menu) => {
cleanupDropdownSections(menu);
});
document.querySelectorAll('#navbarNav .nav-item.dropdown[data-menu-key], #navbarNav .d-flex > .dropdown[data-menu-key]').forEach((dropdownRoot) => {
const key = String(dropdownRoot.getAttribute('data-menu-key') || '').trim().toLowerCase();
if (!key || hiddenSet.has(key)) return;
const menu = dropdownRoot.querySelector(':scope > .dropdown-menu');
if (!menu) return;
const hasVisibleItems = Array.from(menu.children).some((li) => isVisibleActionItem(li));
dropdownRoot.style.display = hasVisibleItems ? '' : 'none';
});
}
function applyMenuVisibility(hiddenKeys) {
const hidden = new Set(normalizeHiddenMenuKeys(hiddenKeys));
document.querySelectorAll('[data-menu-key]').forEach((node) => {
const key = String(node.getAttribute('data-menu-key') || '').trim().toLowerCase();
if (!key) return;
node.style.display = hidden.has(key) ? 'none' : '';
});
cleanupNavbarDropdowns(hidden);
window.__bmcMenuHiddenKeys = Array.from(hidden);
}
async function loadAndApplyMenuVisibility() {
try {
const res = await fetch('/api/v1/auth/me/menu-preferences', { credentials: 'include' });
if (!res.ok) throw new Error(`HTTP_${res.status}`);
const data = await res.json();
const hidden = normalizeHiddenMenuKeys(data.hidden_menu_keys || []);
localStorage.setItem(MENU_PREFS_CACHE_KEY, JSON.stringify(hidden));
applyMenuVisibility(hidden);
} catch (_err) {
try {
const cached = JSON.parse(localStorage.getItem(MENU_PREFS_CACHE_KEY) || '[]');
applyMenuVisibility(cached);
} catch (_cacheErr) {
applyMenuVisibility([]);
}
}
}
window.addEventListener('bmc:menu-preferences-updated', (event) => {
const hidden = normalizeHiddenMenuKeys(event?.detail?.hidden_menu_keys || []);
localStorage.setItem(MENU_PREFS_CACHE_KEY, JSON.stringify(hidden));
applyMenuVisibility(hidden);
});
// Global Search Modal (Cmd+K) - Initialize after DOM is ready
let selectedResultIndex = -1;
let allResults = [];
document.addEventListener('DOMContentLoaded', () => {
loadAndApplyMenuVisibility();
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
const searchBubbleBtn = document.getElementById('globalSearchBtn');
const contextManualBtn = document.getElementById('contextManualBtn');
@ -2043,6 +2185,11 @@ window.addEventListener('unhandledrejection', function(event) {
Reminders
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="profile-menu-tab" data-bs-toggle="tab" data-bs-target="#profile-menu" type="button" role="tab">
Menu
</button>
</li>
</ul>
<div class="tab-content" id="profileTabsContent">
@ -2133,6 +2280,22 @@ window.addEventListener('unhandledrejection', function(event) {
</div>
</div>
</div>
<div class="tab-pane fade" id="profile-menu" role="tabpanel" tabindex="0">
<div class="card border-0">
<div class="card-body px-0">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0 text-primary"><i class="bi bi-layout-text-window-reverse me-2"></i>Menuvisning (min konto)</h6>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary" type="button" id="profMenuShowAllBtn">Vis alle</button>
<button class="btn btn-sm btn-primary" type="button" id="profMenuSaveBtn">Gem</button>
</div>
</div>
<div id="profMenuFeedback" class="small text-muted mb-2"></div>
<div id="profMenuPrefsGrid" class="row g-2"></div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
@ -2143,6 +2306,141 @@ window.addEventListener('unhandledrejection', function(event) {
</div>
<script>
const PROFILE_MENU_PREF_ITEMS = [
{ key: 'menu-crm', label: 'CRM' },
{ key: 'menu-sager', label: 'Sager' },
{ key: 'menu-kalender', label: 'Kalender' },
{ key: 'menu-support', label: 'Support' },
{ key: 'menu-salg', label: 'Salg' },
{ key: 'menu-okonomi', label: 'Økonomi' },
{ key: 'menu-datamigration', label: 'Data migration' },
{ key: 'menu-crm-customers', label: 'CRM: Kunder' },
{ key: 'menu-crm-contacts', label: 'CRM: Kontakter' },
{ key: 'menu-crm-vendors', label: 'CRM: Leverandører' },
{ key: 'menu-crm-links', label: 'CRM: Links' },
{ key: 'menu-crm-locations', label: 'CRM: Lokaliteter' },
{ key: 'menu-crm-opportunities', label: 'CRM: Muligheder' },
{ key: 'menu-crm-pipeline', label: 'CRM: Pipeline' },
{ key: 'menu-support-conversations', label: 'Support: Mine Samtaler' },
{ key: 'menu-support-tickets', label: 'Support: Arkiverede Tickets' },
{ key: 'menu-support-emails', label: 'Support: Email' },
{ key: 'menu-support-telefoni', label: 'Support: Telefoni' },
{ key: 'menu-support-mission', label: 'Support: Mission Control' },
{ key: 'menu-support-anydesk', label: 'Support: AnyDesk Sessions' },
{ key: 'menu-support-hardware', label: 'Support: BMC Assets' },
{ key: 'menu-support-hardware-customers', label: 'Support: Kundehardware' },
{ key: 'menu-support-eset', label: 'Support: ESET Oversigt' },
{ key: 'menu-support-manual', label: 'Support: Manualer' },
{ key: 'menu-salg-orders', label: 'Salg: Ordre' },
{ key: 'menu-salg-products', label: 'Salg: Produkter' },
{ key: 'menu-salg-webshop', label: 'Salg: Webshop Administration' },
{ key: 'menu-okonomi-time-queue', label: 'Økonomi: Time Queue' },
{ key: 'menu-okonomi-supplier-invoices', label: 'Økonomi: Leverandør fakturaer' },
{ key: 'menu-okonomi-prepaid', label: 'Økonomi: Prepaid Cards' },
{ key: 'menu-okonomi-fixed-price', label: 'Økonomi: Fastpris Aftaler' },
{ key: 'menu-okonomi-subscriptions', label: 'Økonomi: Abonnementer' },
{ key: 'menu-datamigration-dashboard', label: 'Data migration: Dashboard' },
{ key: 'menu-datamigration-registrations', label: 'Data migration: Registreringer' },
{ key: 'menu-datamigration-wizard', label: 'Data migration: Godkend Timer' },
{ key: 'menu-datamigration-employee-log', label: 'Data migration: Medarbejder Log' },
{ key: 'menu-datamigration-service-contract-wizard', label: 'Data migration: Servicekontrakt Migration' },
{ key: 'menu-datamigration-service-contract-report', label: 'Data migration: Servicekontrakt Rapport' },
{ key: 'menu-datamigration-orders', label: 'Data migration: Ordrer' },
{ key: 'menu-datamigration-customers', label: 'Data migration: Kunder' },
];
function setProfileMenuFeedback(message, type = 'muted') {
const el = document.getElementById('profMenuFeedback');
if (!el) return;
const cls = type === 'error' ? 'text-danger' : type === 'success' ? 'text-success' : 'text-muted';
el.className = `small ${cls} mb-2`;
el.textContent = message || '';
}
function renderProfileMenuPrefs(hiddenKeys = []) {
const grid = document.getElementById('profMenuPrefsGrid');
if (!grid) return;
const hidden = new Set((hiddenKeys || []).map(v => String(v || '').trim().toLowerCase()));
grid.innerHTML = PROFILE_MENU_PREF_ITEMS.map(item => {
const checked = hidden.has(item.key.toLowerCase()) ? '' : 'checked';
return `
<div class="col-md-6 col-lg-4">
<div class="form-check mb-1">
<input class="form-check-input prof-menu-checkbox" type="checkbox" id="prof-menu-${item.key}" value="${item.key}" ${checked}>
<label class="form-check-label" for="prof-menu-${item.key}">${item.label}</label>
</div>
</div>
`;
}).join('');
}
async function loadProfileMenuPreferences() {
try {
setProfileMenuFeedback('Indlæser menu...');
const res = await fetch('/api/v1/auth/me/menu-preferences', { credentials: 'include' });
if (!res.ok) {
if (res.status === 401) throw new Error('Session udløbet. Log ind igen.');
if (res.status === 404) throw new Error('Menu-endpoint mangler på serveren (genstart API).');
let detail = '';
try {
const err = await res.json();
detail = err?.detail || '';
} catch (_parseErr) {
detail = '';
}
throw new Error(detail || `Kunne ikke indlæse menu (HTTP ${res.status})`);
}
const data = await res.json();
renderProfileMenuPrefs(data.hidden_menu_keys || []);
setProfileMenuFeedback('');
} catch (e) {
renderProfileMenuPrefs(window.__bmcMenuHiddenKeys || []);
setProfileMenuFeedback(e.message || 'Kunne ikke indlæse menu', 'error');
}
}
async function saveProfileMenuPreferences() {
const checks = Array.from(document.querySelectorAll('.prof-menu-checkbox'));
const hiddenKeys = checks
.filter(cb => !cb.checked)
.map(cb => String(cb.value || '').trim().toLowerCase())
.filter(Boolean);
const saveBtn = document.getElementById('profMenuSaveBtn');
if (saveBtn) saveBtn.disabled = true;
setProfileMenuFeedback('Gemmer menu...');
try {
const res = await fetch('/api/v1/auth/me/menu-preferences', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ hidden_menu_keys: hiddenKeys })
});
if (!res.ok) {
if (res.status === 401) throw new Error('Session udløbet. Log ind igen.');
if (res.status === 404) throw new Error('Menu-endpoint mangler på serveren (genstart API).');
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Kunne ikke gemme menu');
}
window.dispatchEvent(new CustomEvent('bmc:menu-preferences-updated', {
detail: { hidden_menu_keys: hiddenKeys }
}));
setProfileMenuFeedback('Menu gemt.', 'success');
} catch (e) {
setProfileMenuFeedback(e.message || 'Kunne ikke gemme menu', 'error');
} finally {
if (saveBtn) saveBtn.disabled = false;
}
}
function showAllProfileMenuItems() {
document.querySelectorAll('.prof-menu-checkbox').forEach((cb) => {
cb.checked = true;
});
}
async function loadReminderPreferences() {
try {
const res = await fetch('/api/v1/users/me/notification-preferences', { credentials: 'include' });
@ -2349,12 +2647,18 @@ window.addEventListener('unhandledrejection', function(event) {
}
document.addEventListener('DOMContentLoaded', () => {
const saveMenuBtn = document.getElementById('profMenuSaveBtn');
if (saveMenuBtn) saveMenuBtn.addEventListener('click', saveProfileMenuPreferences);
const showAllBtn = document.getElementById('profMenuShowAllBtn');
if (showAllBtn) showAllBtn.addEventListener('click', showAllProfileMenuItems);
const profileModalEl = document.getElementById('profileModal');
if (profileModalEl) {
profileModalEl.addEventListener('shown.bs.modal', () => {
loadReminderPreferences();
loadProfileReminders();
loadUserProfile();
loadProfileMenuPreferences();
});
}
});

View File

@ -141,6 +141,7 @@ from app.modules.bottom_bar.backend import router as bottom_bar_api
from app.modules.bottom_bar.backend import public_router as bottom_bar_public_api
from app.modules.rentals.backend import router as rentals_api
from app.modules.task_templates.backend import router as task_templates_api
from app.bug_reports.backend import router as bug_reports_api
# Configure logging
logging.basicConfig(
@ -440,6 +441,7 @@ app.include_router(opportunities_api.router, prefix="/api/v1", tags=["Opportunit
app.include_router(auth_api.router, prefix="/api/v1/auth", tags=["Auth"])
app.include_router(auth_admin_api.router, prefix="/api/v1", tags=["Auth Admin"])
app.include_router(anydesk.router, prefix="/api/v1", tags=["Remote Support"])
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"])

View File

@ -0,0 +1,12 @@
-- Allow dynamic case statuses managed by settings instead of hard-coded DB enum-like check
-- Replaces legacy constraint that only allowed 'åben' and 'lukket'.
ALTER TABLE sag_sager
DROP CONSTRAINT IF EXISTS sag_sager_status_check;
ALTER TABLE sag_sager
DROP CONSTRAINT IF EXISTS sag_sager_status_nonempty_check;
ALTER TABLE sag_sager
ADD CONSTRAINT sag_sager_status_nonempty_check
CHECK (length(trim(status)) > 0);

View File

@ -0,0 +1,10 @@
-- Per-user preferences for sag list filters (e.g. default type selections)
CREATE TABLE IF NOT EXISTS user_sag_list_preferences (
user_id INTEGER PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
type_filters JSONB NOT NULL DEFAULT '[]'::jsonb,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_sag_list_preferences_updated_at
ON user_sag_list_preferences(updated_at DESC);

View File

@ -0,0 +1,40 @@
-- Migration 191: Per-user menu visibility preferences
CREATE TABLE IF NOT EXISTS user_menu_preferences (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
menu_key VARCHAR(120) NOT NULL,
visible BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, menu_key)
);
CREATE INDEX IF NOT EXISTS idx_user_menu_preferences_user_id
ON user_menu_preferences(user_id);
CREATE INDEX IF NOT EXISTS idx_user_menu_preferences_menu_key
ON user_menu_preferences(menu_key);
CREATE OR REPLACE FUNCTION update_user_menu_preferences_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_trigger
WHERE tgname = 'user_menu_preferences_updated_at_trigger'
) THEN
CREATE TRIGGER user_menu_preferences_updated_at_trigger
BEFORE UPDATE ON user_menu_preferences
FOR EACH ROW
EXECUTE FUNCTION update_user_menu_preferences_updated_at();
END IF;
END;
$$;

View File

@ -5,6 +5,7 @@
let screenshotDataUrl = null;
let pendingScreenshotPromise = null;
let isCapturingDisplayMedia = false;
let html2canvasLoaderPromise = null;
const pushLog = (type, args) => {
try {
@ -85,6 +86,20 @@
});
}
async function withTimeout(promise, ms, timeoutMessage) {
let timer = null;
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(timeoutMessage || 'Operation timed out')), ms);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
function isCrossOriginUrl(url) {
try {
if (!url) return false;
@ -116,6 +131,25 @@
return canvas.toDataURL('image/png');
}
async function ensureHtml2Canvas() {
if (window.html2canvas) {
return;
}
if (!html2canvasLoaderPromise) {
html2canvasLoaderPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js';
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Could not load screenshot library'));
document.head.appendChild(script);
});
}
await html2canvasLoaderPromise;
}
async function takeScreenshotViaDisplayMedia() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
throw new Error('Display media API not supported');
@ -158,9 +192,7 @@
}
async function takeScreenshot() {
if (!window.html2canvas) {
throw new Error('Screenshot library not loaded');
}
await ensureHtml2Canvas();
const doc = document.documentElement;
const body = document.body;
@ -183,15 +215,31 @@
const common = {
useCORS: true,
allowTaint: false,
allowTaint: true,
logging: false,
scale: 1,
backgroundColor: '#ffffff',
imageTimeout: 3000,
imageTimeout: 7000,
ignoreElements: shouldIgnoreInScreenshot,
removeContainer: true,
};
// Strategy 0: Viewport capture via foreignObject (works on many CSS-heavy pages)
try {
return await renderScreenshot(document.body || document.documentElement, {
...common,
foreignObjectRendering: true,
width: window.innerWidth,
height: window.innerHeight,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
scrollX: window.scrollX,
scrollY: window.scrollY,
});
} catch (e0) {
console.warn('Bug report screenshot strategy 0 failed', e0);
}
// Strategy 1: Full page (most useful when it works)
try {
return await renderScreenshot(document.documentElement, {
@ -207,9 +255,6 @@
});
} catch (e1) {
console.warn('Bug report screenshot strategy 1 failed', e1);
if (String(e1?.message || '').toLowerCase().includes('unsupported color function "color"')) {
throw new Error('Html2canvas understøtter ikke denne browsers farveprofil (color()).');
}
}
// Strategy 2: Main content only (explicit selectors avoid navbar-only captures)
@ -231,9 +276,6 @@
});
} catch (e2) {
console.warn('Bug report screenshot strategy 2 failed', e2);
if (String(e2?.message || '').toLowerCase().includes('unsupported color function "color"')) {
throw new Error('Html2canvas understøtter ikke denne browsers farveprofil (color()).');
}
}
throw new Error('Automatic screenshot failed');
@ -284,29 +326,62 @@
}
}
async function openBugReportModal() {
async function captureScreenshotBeforeModal() {
try {
screenshotDataUrl = await withTimeout(
takeScreenshotViaDisplayMedia(),
12000,
'Skærmvalg timed out'
);
return { ok: true, viaDisplayMedia: true };
} catch (displayErr) {
console.warn('Bug report display-media first attempt failed', displayErr);
}
const attemptAutoCapture = async () => {
const promise = pendingScreenshotPromise || takeScreenshot();
pendingScreenshotPromise = promise;
return await promise;
};
try {
screenshotDataUrl = await attemptAutoCapture();
return { ok: true };
} catch (firstError) {
console.warn('Bug report screenshot first attempt failed', firstError);
try {
pendingScreenshotPromise = withTimeout(
takeScreenshot(),
8000,
'Automatic screenshot timed out'
);
screenshotDataUrl = await pendingScreenshotPromise;
return { ok: true, recovered: true };
} catch (secondError) {
console.warn('Bug report screenshot retry failed', secondError);
screenshotDataUrl = null;
return { ok: false, error: secondError || firstError };
}
} finally {
pendingScreenshotPromise = null;
}
}
async function openBugReportModal(statusText, statusIsError) {
if (!bugModal) {
const modalEl = document.getElementById('bugReportModal');
if (!modalEl || !window.bootstrap) return;
bugModal = new bootstrap.Modal(modalEl);
}
setStatus('Tager screenshot...');
screenshotDataUrl = null;
setPreview(null);
try {
screenshotDataUrl = pendingScreenshotPromise
? await pendingScreenshotPromise
: await takeScreenshot();
setPreview(screenshotDataUrl);
setStatus('Screenshot klar. Udfyld felterne og send.');
} catch (e) {
console.warn('Bug report screenshot failed', e);
setStatus('Kunne ikke tage screenshot automatisk. Klik "Tag screenshot via skærmdeling" eller indsæt med Cmd+V.', true);
} finally {
pendingScreenshotPromise = null;
}
setPreview(screenshotDataUrl);
setStatus(
statusText || (screenshotDataUrl
? 'Screenshot klar. Udfyld felterne og send.'
: 'Kunne ikke tage screenshot automatisk. Klik "Tag screenshot via skærmdeling" eller indsæt med Cmd+V.'),
Boolean(statusIsError)
);
bugModal.show();
}
@ -438,15 +513,21 @@
const modalEl = document.getElementById('bugReportModal');
if (btn) {
const primeCapture = () => {
prepareScreenshotFromTrigger(true);
};
btn.addEventListener('pointerdown', primeCapture);
btn.addEventListener('click', (e) => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
prepareScreenshotFromTrigger(false);
openBugReportModal();
const capture = await captureScreenshotBeforeModal();
if (capture.ok) {
const msg = capture.viaDisplayMedia
? 'Screenshot taget ved klik via skærmdeling. Udfyld felterne og send.'
: 'Screenshot taget ved klik. Udfyld felterne og send.';
openBugReportModal(msg, false);
} else {
const reason = String(capture?.error?.message || '').trim();
const errorMsg = reason
? `Kunne ikke tage screenshot automatisk (${reason}). Klik \"Tag screenshot via skærmdeling\" eller indsæt med Cmd+V.`
: 'Kunne ikke tage screenshot automatisk. Klik "Tag screenshot via skærmdeling" eller indsæt med Cmd+V.';
openBugReportModal(errorMsg, true);
}
});
}
@ -486,8 +567,20 @@
if (isTyping) return;
if (e.ctrlKey && e.shiftKey && (e.key === 'B' || e.key === 'b')) {
e.preventDefault();
prepareScreenshotFromTrigger(true);
openBugReportModal();
captureScreenshotBeforeModal().then((capture) => {
if (capture.ok) {
const msg = capture.viaDisplayMedia
? 'Screenshot taget ved klik via skærmdeling. Udfyld felterne og send.'
: 'Screenshot taget ved klik. Udfyld felterne og send.';
openBugReportModal(msg, false);
} else {
const reason = String(capture?.error?.message || '').trim();
const errorMsg = reason
? `Kunne ikke tage screenshot automatisk (${reason}). Klik \"Tag screenshot via skærmdeling\" eller indsæt med Cmd+V.`
: 'Kunne ikke tage screenshot automatisk. Klik "Tag screenshot via skærmdeling" eller indsæt med Cmd+V.';
openBugReportModal(errorMsg, true);
}
});
}
});
});

View File

@ -59,15 +59,146 @@
.replaceAll("'", '&#039;');
}
function normalizeNamePart(value) {
return String(value || '')
.trim()
.toLocaleLowerCase('da-DK')
.replace(/\s+/g, ' ');
}
function normalizeDigits(value) {
return String(value || '').replace(/\D+/g, '');
}
function buildToastContact(contactData) {
if (!contactData) return null;
const firstName = String(contactData.first_name || '').trim();
const lastName = String(contactData.last_name || '').trim();
const name = String(contactData.name || `${firstName} ${lastName}` || '').trim();
const primaryCompany = Array.isArray(contactData.companies) && contactData.companies.length > 0
? contactData.companies[0]
: null;
return {
id: contactData.id,
first_name: firstName,
last_name: lastName,
name,
company: primaryCompany?.name || contactData.company || '',
company_id: primaryCompany?.id || contactData.company_id || null,
phone: contactData.phone || null,
mobile: contactData.mobile || null,
};
}
function formatCaseLabel(caseItem) {
if (!caseItem) return '';
const title = String(caseItem.titel || caseItem.title || `Sag #${caseItem.id || ''}`).trim();
const customer = String(caseItem.customer_name || '').trim();
return customer ? `${title} · ${customer}` : title;
}
function formatContactLabel(contactItem) {
if (!contactItem) return '';
const firstName = String(contactItem.first_name || '').trim();
const lastName = String(contactItem.last_name || '').trim();
const name = [firstName, lastName].filter(Boolean).join(' ') || `Kontakt #${contactItem.id || ''}`;
const companyNames = Array.isArray(contactItem.company_names) ? contactItem.company_names.filter(Boolean) : [];
return companyNames.length ? `${name} · ${companyNames[0]}` : name;
}
async function fetchContactByName(firstName, lastName, phoneValue = '') {
const fullName = [String(firstName || '').trim(), String(lastName || '').trim()]
.filter(Boolean)
.join(' ');
const digits = normalizeDigits(phoneValue);
if (!fullName && !digits) return null;
const queries = [];
if (fullName) queries.push(fullName);
if (digits && digits !== fullName) queries.push(phoneValue || digits);
const contactMap = new Map();
for (const query of queries) {
const qs = new URLSearchParams({ search: query, limit: '25' });
const res = await fetch(`/api/v1/contacts?${qs.toString()}`, {
credentials: 'include'
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
const payload = await res.json();
const contacts = Array.isArray(payload?.contacts) ? payload.contacts : [];
contacts.forEach((item) => {
if (item?.id) contactMap.set(Number(item.id), item);
});
}
const contacts = Array.from(contactMap.values());
const wantedFirst = normalizeNamePart(firstName);
const wantedLast = normalizeNamePart(lastName);
const wantedDigits = normalizeDigits(phoneValue);
const exactNameMatch = contacts.find((item) => {
return wantedFirst && normalizeNamePart(item?.first_name) === wantedFirst
&& normalizeNamePart(item?.last_name) === wantedLast;
});
if (exactNameMatch) return exactNameMatch;
if (wantedDigits) {
const exactNumberMatch = contacts.find((item) => {
const numbers = [item?.phone, item?.mobile].map(normalizeDigits).filter(Boolean);
return numbers.some((number) => number === wantedDigits || number.endsWith(wantedDigits) || wantedDigits.endsWith(number));
});
if (exactNumberMatch) return exactNumberMatch;
}
return contacts[0] || null;
}
async function fetchFullContact(contactId) {
const res = await fetch(`/api/v1/contacts/${Number(contactId)}`, {
credentials: 'include'
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
return res.json();
}
async function fetchContactCases(contactId) {
const res = await fetch(`/api/v1/contacts/${Number(contactId)}/cases`, {
credentials: 'include'
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
return res.json();
}
async function searchCases(query) {
const qs = new URLSearchParams({ q: String(query || '').trim() });
const res = await fetch(`/api/v1/search/sag?${qs.toString()}`, {
credentials: 'include'
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
const payload = await res.json();
return Array.isArray(payload) ? payload : [];
}
function showIncomingCallToast(data) {
const container = ensureContainer();
const contact = data.contact || null;
let currentData = { ...data };
let contact = currentData.contact || null;
const number = data.number || '';
const title = contact?.name ? contact.name : 'Ukendt nummer';
const company = contact?.company ? contact.company : '';
const recentCases = data.recent_cases || [];
const lastCall = data.last_call;
const callId = data.call_id;
const toastEl = document.createElement('div');
@ -76,77 +207,387 @@
toastEl.setAttribute('aria-live', 'assertive');
toastEl.setAttribute('aria-atomic', 'true');
const openContactBtn = contact?.id
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-action="open-contact">Åbn kontakt</button>`
: '';
// Build recent cases HTML
let casesHtml = '';
if (recentCases.length > 0) {
casesHtml = '<div class="mt-2 mb-2"><small class="text-muted fw-semibold">Åbne sager:</small>';
recentCases.forEach(c => {
casesHtml += `<div class="small"><a href="/sag/${c.id}" class="text-decoration-none" target="_blank">${escapeHtml(c.titel)}</a></div>`;
});
casesHtml += '</div>';
function closeSmartCaseModal() {
const existing = document.getElementById('telefoni-smart-case-modal');
if (existing) existing.remove();
document.querySelectorAll('[data-telefoni-backdrop]').forEach((el) => el.remove());
document.body.classList.remove('modal-open');
}
// Build last call HTML
let lastCallHtml = '';
if (lastCall) {
// lastCall can be either a date string (legacy) or an object with started_at and bruger_navn
const callDate = lastCall.started_at ? lastCall.started_at : lastCall;
const lastCallDate = new Date(callDate);
const now = new Date();
const diffMs = now - lastCallDate;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
let timeAgo = '';
if (diffDays === 0) {
timeAgo = 'I dag';
} else if (diffDays === 1) {
timeAgo = 'I går';
} else if (diffDays < 7) {
timeAgo = `${diffDays} dage siden`;
function openCaseSmartModal() {
closeSmartCaseModal();
const modal = document.createElement('div');
modal.id = 'telefoni-smart-case-modal';
modal.className = 'modal fade show';
modal.style.display = 'block';
modal.setAttribute('tabindex', '-1');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('role', 'dialog');
modal.innerHTML = `
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header bg-primary text-white">
<div>
<h5 class="modal-title mb-0"><i class="bi bi-link-45deg me-2"></i>Link sag</h5>
<div class="small opacity-75">${escapeHtml(number || 'Ukendt nummer')} · vælg en relevant sag eller søg videre</div>
</div>
<button type="button" class="btn-close btn-close-white" data-action="close-smart-case"></button>
</div>
<div class="modal-body">
<ul class="nav nav-pills gap-2 mb-3" role="tablist">
<li class="nav-item"><button class="nav-link active" type="button" data-tab-target="contact">Kontaktens sager</button></li>
<li class="nav-item"><button class="nav-link" type="button" data-tab-target="company">Firmaets sager</button></li>
<li class="nav-item"><button class="nav-link" type="button" data-tab-target="related">Relaterede kontakter</button></li>
<li class="nav-item"><button class="nav-link" type="button" data-tab-target="search">Søg sager</button></li>
</ul>
<div class="mb-3">
<label class="form-label small text-muted mb-1">Manuel sag-ID</label>
<div class="input-group">
<input type="number" min="1" class="form-control" data-role="manual-case-id" placeholder="Fx 1234">
<button type="button" class="btn btn-outline-secondary" data-action="link-manual-case">Link sag</button>
</div>
</div>
<div class="tab-pane-holder" data-pane="contact">
<div class="text-muted small p-2"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser...</div>
</div>
<div class="tab-pane-holder d-none" data-pane="company">
<div class="text-muted small p-2"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser...</div>
</div>
<div class="tab-pane-holder d-none" data-pane="related">
<div class="text-muted small p-2"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser...</div>
</div>
<div class="tab-pane-holder d-none" data-pane="search">
<div class="mb-3">
<label class="form-label small text-muted mb-1">Søg i alle sager</label>
<input type="text" class="form-control" data-role="case-search-input" placeholder="Titel, kunde, ID, buzzword...">
</div>
<div class="alert alert-light border mb-0" data-role="search-hint">Skriv mindst 2 tegn for at søge.</div>
<div class="list-group mt-3" data-role="search-results"></div>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop fade show';
backdrop.dataset.telefoniBackdrop = '1';
document.body.appendChild(backdrop);
document.body.classList.add('modal-open');
// ── helpers ──────────────────────────────────────────────────────
const showPane = (paneName) => {
modal.querySelectorAll('.tab-pane-holder').forEach((pane) => {
pane.classList.toggle('d-none', pane.dataset.pane !== paneName);
});
modal.querySelectorAll('[data-tab-target]').forEach((tabBtn) => {
tabBtn.classList.toggle('active', tabBtn.dataset.tabTarget === paneName);
});
};
const renderCaseList = (pane, items, emptyText) => {
const paneEl = modal.querySelector(`[data-pane="${pane}"]`);
if (!paneEl) return;
if (!items || items.length === 0) {
paneEl.innerHTML = `<div class="alert alert-light border mb-0">${escapeHtml(emptyText)}</div>`;
return;
}
paneEl.innerHTML = `<div class="list-group">${items.map((item) => {
const caseId = Number(item.id);
const label = formatCaseLabel(item);
const meta = [item.status, item.created_at ? new Date(item.created_at).toLocaleDateString('da-DK') : '']
.filter(Boolean).join(' · ');
return `
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center gap-3" data-case-id="${caseId}">
<div>
<div class="fw-semibold">${escapeHtml(label)}</div>
<div class="small text-muted">${escapeHtml(meta || `ID: ${caseId}`)}</div>
</div>
<i class="bi bi-arrow-right-circle text-primary"></i>
</button>`;
}).join('')}</div>`;
paneEl.querySelectorAll('[data-case-id]').forEach((button) => {
button.addEventListener('click', async () => {
const caseId = Number(button.getAttribute('data-case-id'));
await patchCallCase(caseId);
closeSmartCaseModal();
window.location.href = `/sag/${caseId}/v3`;
});
});
};
const renderRelatedContacts = (relatedContacts) => {
const paneEl = modal.querySelector('[data-pane="related"]');
if (!paneEl) return;
if (!relatedContacts.length) {
paneEl.innerHTML = '<div class="alert alert-light border mb-0">Ingen relaterede kontakter fundet</div>';
return;
}
paneEl.innerHTML = `<div class="list-group">${relatedContacts.map((item) => {
const contactId = Number(item.id);
const label = formatContactLabel(item);
const phone = String(item.phone || item.mobile || '').trim();
return `
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center gap-3" data-contact-id="${contactId}">
<div>
<div class="fw-semibold">${escapeHtml(label)}</div>
<div class="small text-muted">${escapeHtml(phone || 'Ingen telefon')}</div>
</div>
<i class="bi bi-person-lines-fill text-primary"></i>
</button>`;
}).join('')}</div>`;
paneEl.querySelectorAll('[data-contact-id]').forEach((button) => {
button.addEventListener('click', () => {
const cid = Number(button.getAttribute('data-contact-id'));
if (cid > 0) window.location.href = `/contacts/${cid}`;
});
});
};
// ── søg-fane ─────────────────────────────────────────────────────
const searchInput = modal.querySelector('[data-role="case-search-input"]');
const searchResults = modal.querySelector('[data-role="search-results"]');
const searchHint = modal.querySelector('[data-role="search-hint"]');
let searchTimer = null;
let searchToken = 0;
const runSearch = async (query) => {
const token = ++searchToken;
const q = String(query || '').trim();
if (q.length < 2) {
searchHint.textContent = 'Skriv mindst 2 tegn for at søge.';
searchResults.innerHTML = '';
return;
}
searchHint.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Søger...';
searchResults.innerHTML = '';
try {
const results = await searchCases(q);
if (token !== searchToken) return;
if (!results.length) {
searchHint.textContent = 'Ingen sager fundet';
return;
}
searchHint.textContent = `Fandt ${results.length} sager`;
searchResults.innerHTML = results.map((item) => {
const caseId = Number(item.id);
const label = formatCaseLabel(item);
return `
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" data-search-case-id="${caseId}">
<div>
<div class="fw-semibold">${escapeHtml(label)}</div>
<div class="small text-muted">${escapeHtml(item.status || '-')} · ID: ${caseId}</div>
</div>
<span class="badge text-bg-primary">Link</span>
</button>`;
}).join('');
searchResults.querySelectorAll('[data-search-case-id]').forEach((button) => {
button.addEventListener('click', async () => {
const caseId = Number(button.getAttribute('data-search-case-id'));
await patchCallCase(caseId);
closeSmartCaseModal();
window.location.href = `/sag/${caseId}/v3`;
});
});
} catch (error) {
if (token !== searchToken) return;
searchHint.textContent = `Fejl: ${error?.message || 'ukendt fejl'}`;
}
};
searchInput?.addEventListener('input', () => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => runSearch(searchInput.value), 250);
});
searchInput?.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
if (searchTimer) clearTimeout(searchTimer);
runSearch(searchInput.value);
});
modal.querySelector('[data-action="link-manual-case"]')?.addEventListener('click', async () => {
const manualValue = Number(modal.querySelector('[data-role="manual-case-id"]')?.value || 0);
if (!Number.isInteger(manualValue) || manualValue <= 0) {
window.alert('Ugyldigt sag-ID');
return;
}
await patchCallCase(manualValue);
closeSmartCaseModal();
window.location.href = `/sag/${manualValue}/v3`;
});
modal.querySelector('[data-action="close-smart-case"]')?.addEventListener('click', closeSmartCaseModal);
backdrop.addEventListener('click', closeSmartCaseModal);
document.addEventListener('keydown', function onKeydown(event) {
if (event.key === 'Escape') {
closeSmartCaseModal();
document.removeEventListener('keydown', onKeydown);
}
});
modal.querySelectorAll('[data-tab-target]').forEach((tabBtn) => {
tabBtn.addEventListener('click', () => showPane(tabBtn.dataset.tabTarget));
});
// ── hent altid friske data fra API ────────────────────────────────
const contactId = contact?.id ? Number(contact.id) : null;
if (contactId) {
fetchContactCases(contactId).then((ctx) => {
const contactCases = ctx?.contact_cases || [];
const companyCases = ctx?.company_cases || [];
// Gem i currentData så efterfølgende åbninger også har data
currentData.contact_cases = contactCases;
currentData.company_cases = companyCases;
renderCaseList('contact', contactCases, 'Ingen sager tilknyttet denne kontakt');
renderCaseList('company', companyCases, 'Ingen sager på firmaet');
// Relaterede kontakter fra WS-payload (hurtig) eller tom
const relatedContacts = Array.isArray(currentData.related_contacts)
? currentData.related_contacts : [];
renderRelatedContacts(relatedContacts);
const hasSuggestions = contactCases.length > 0 || companyCases.length > 0;
if (!hasSuggestions) {
showPane('search');
const term = String(contact?.name || contact?.company || number || '').trim();
if (term.length >= 2) {
searchInput.value = term;
runSearch(term);
} else {
setTimeout(() => searchInput?.focus(), 50);
}
}
}).catch((err) => {
console.warn('📞 fetchContactCases fejlede:', err?.message || err);
renderCaseList('contact', [], 'Ingen data');
renderCaseList('company', [], 'Ingen data');
renderRelatedContacts([]);
showPane('search');
const term = String(contact?.name || contact?.company || number || '').trim();
if (term.length >= 2) {
searchInput.value = term;
runSearch(term);
} else {
setTimeout(() => searchInput?.focus(), 50);
}
});
} else {
timeAgo = lastCallDate.toLocaleDateString('da-DK');
}
const brugerInfo = lastCall.bruger_navn ? ` (${escapeHtml(lastCall.bruger_navn)})` : '';
// Format duration
let durationInfo = '';
if (lastCall.duration_sec) {
const mins = Math.floor(lastCall.duration_sec / 60);
const secs = lastCall.duration_sec % 60;
if (mins > 0) {
durationInfo = ` - ${mins}m ${secs}s`;
// Ingen kontakt — vis søge-fanen med det samme
renderCaseList('contact', [], 'Ingen kontakt tilknyttet dette opkald');
renderCaseList('company', [], 'Ingen kontakt tilknyttet dette opkald');
renderRelatedContacts([]);
showPane('search');
const term = String(number || '').trim();
if (term.length >= 2) {
searchInput.value = term;
runSearch(term);
} else {
durationInfo = ` - ${secs}s`;
setTimeout(() => searchInput?.focus(), 50);
}
}
lastCallHtml = `<div class="small text-muted mt-2"><i class="bi bi-clock-history me-1"></i>Sidst snakket: ${timeAgo}${brugerInfo}${durationInfo}</div>`;
}
toastEl.innerHTML = `
<div class="toast-header">
<strong class="me-auto"><i class="bi bi-telephone me-2"></i>Opkald</strong>
<small class="text-muted">${escapeHtml(data.direction === 'outbound' ? 'Udgående' : 'Indgående')}</small>
<button type="button" class="btn-close ms-2" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
<div class="fw-bold">${escapeHtml(number)}</div>
<div>${escapeHtml(title)}</div>
${company ? `<div class="text-muted small">${escapeHtml(company)}</div>` : ''}
${lastCallHtml}
${casesHtml}
<div class="d-flex gap-2 mt-3">
${openContactBtn}
<button type="button" class="btn btn-sm btn-primary" data-action="create-case">Opret sag</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-action="link-case">Link sag</button>
function renderToastBody() {
contact = currentData.contact || null;
const title = contact?.name ? contact.name : 'Ukendt nummer';
const company = contact?.company ? contact.company : '';
const recentCases = currentData.recent_cases || [];
const lastCall = currentData.last_call;
const openContactBtn = contact?.id
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-action="open-contact">Åbn kontakt</button>`
: '';
let casesHtml = '';
if (recentCases.length > 0) {
casesHtml = '<div class="mt-2 mb-2"><small class="text-muted fw-semibold">Åbne sager:</small>';
recentCases.forEach(c => {
casesHtml += `<div class="small"><a href="/sag/${c.id}" class="text-decoration-none" target="_blank">${escapeHtml(c.titel)}</a></div>`;
});
casesHtml += '</div>';
}
let lastCallHtml = '';
if (lastCall) {
const callDate = lastCall.started_at ? lastCall.started_at : lastCall;
const lastCallDate = new Date(callDate);
const now = new Date();
const diffMs = now - lastCallDate;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
let timeAgo = '';
if (diffDays === 0) {
timeAgo = 'I dag';
} else if (diffDays === 1) {
timeAgo = 'I går';
} else if (diffDays < 7) {
timeAgo = `${diffDays} dage siden`;
} else {
timeAgo = lastCallDate.toLocaleDateString('da-DK');
}
const brugerInfo = lastCall.bruger_navn ? ` (${escapeHtml(lastCall.bruger_navn)})` : '';
let durationInfo = '';
if (lastCall.duration_sec) {
const mins = Math.floor(lastCall.duration_sec / 60);
const secs = lastCall.duration_sec % 60;
durationInfo = mins > 0 ? ` - ${mins}m ${secs}s` : ` - ${secs}s`;
}
lastCallHtml = `<div class="small text-muted mt-2"><i class="bi bi-clock-history me-1"></i>Sidst snakket: ${timeAgo}${brugerInfo}${durationInfo}</div>`;
}
const quickCreateHtml = !contact?.id ? `
<div class="border rounded p-2 mt-3 bg-light-subtle">
<div class="small fw-semibold mb-2">Hurtig opret kontakt</div>
<div class="row g-2">
<div class="col-6">
<input type="text" class="form-control form-control-sm" data-role="quick-first-name" placeholder="Fornavn">
</div>
<div class="col-6">
<input type="text" class="form-control form-control-sm" data-role="quick-last-name" placeholder="Efternavn">
</div>
<div class="col-12">
<input type="email" class="form-control form-control-sm" data-role="quick-email" placeholder="E-mail (valgfri)">
</div>
<div class="col-12">
<div class="form-text mt-0">Finder først eksisterende kontakt navn og opdaterer telefon, hvis den kun mangler nummer.</div>
</div>
</div>
<div class="d-flex gap-2 mt-2">
<button type="button" class="btn btn-sm btn-success" data-action="quick-create-contact">Opret kontakt</button>
</div>
</div>
</div>
`;
` : '';
toastEl.innerHTML = `
<div class="toast-header">
<strong class="me-auto"><i class="bi bi-telephone me-2"></i>Opkald</strong>
<small class="text-muted">${escapeHtml(currentData.direction === 'outbound' ? 'Udgående' : 'Indgående')}</small>
<button type="button" class="btn-close ms-2" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
<div class="fw-bold">${escapeHtml(number)}</div>
<div>${escapeHtml(title)}</div>
${company ? `<div class="text-muted small">${escapeHtml(company)}</div>` : ''}
${lastCallHtml}
${casesHtml}
${quickCreateHtml}
<div class="d-flex gap-2 mt-3 flex-wrap">
${openContactBtn}
<button type="button" class="btn btn-sm btn-primary" data-action="create-case">Opret sag</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-action="link-case">Link sag</button>
</div>
</div>
`;
}
renderToastBody();
container.appendChild(toastEl);
const toast = new bootstrap.Toast(toastEl, { autohide: false });
@ -177,6 +618,125 @@
return parsedCaseId;
}
async function patchCallContact(contactId) {
if (!Number.isInteger(Number(callId)) || Number(callId) <= 0) {
throw new Error('Mangler call_id');
}
const res = await fetch(`/api/v1/telefoni/calls/${Number(callId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ kontakt_id: Number(contactId) })
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
return res.json();
}
async function createOrUpdateContactFromToast() {
const firstNameInput = toastEl.querySelector('[data-role="quick-first-name"]');
const lastNameInput = toastEl.querySelector('[data-role="quick-last-name"]');
const emailInput = toastEl.querySelector('[data-role="quick-email"]');
const firstName = String(firstNameInput?.value || '').trim();
const lastName = String(lastNameInput?.value || '').trim();
const email = String(emailInput?.value || '').trim();
if (!firstName) {
window.alert('Fornavn er påkrævet');
firstNameInput?.focus();
return;
}
const actionBtn = toastEl.querySelector('[data-action="quick-create-contact"]');
if (!actionBtn) return;
actionBtn.disabled = true;
const originalText = actionBtn.textContent;
actionBtn.textContent = 'Gemmer...';
try {
const phoneValue = String(number || '').trim() || null;
const existing = await fetchContactByName(firstName, lastName, phoneValue || '');
let savedContactId = null;
if (existing?.id) {
const existingHasPhone = String(existing.phone || existing.mobile || '').trim();
const shouldUpdatePhone = !existingHasPhone && phoneValue;
const shouldUpdateEmail = !String(existing.email || '').trim() && email;
if (shouldUpdatePhone || shouldUpdateEmail) {
const updatePayload = {};
if (shouldUpdatePhone) updatePayload.phone = phoneValue;
if (shouldUpdateEmail) updatePayload.email = email;
const updateRes = await fetch(`/api/v1/contacts/${Number(existing.id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(updatePayload)
});
if (!updateRes.ok) {
const text = await updateRes.text();
throw new Error(text || `HTTP ${updateRes.status}`);
}
}
savedContactId = Number(existing.id);
} else {
const createRes = await fetch('/api/v1/contacts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
first_name: firstName,
last_name: lastName,
email: email || null,
phone: phoneValue,
title: null,
})
});
if (!createRes.ok) {
const text = await createRes.text();
throw new Error(text || `HTTP ${createRes.status}`);
}
const created = await createRes.json();
savedContactId = Number(created?.id || 0);
}
if (!Number.isInteger(savedContactId) || savedContactId <= 0) {
throw new Error('Kontakt-ID mangler');
}
await patchCallContact(savedContactId);
const [fullContact, caseContext] = await Promise.all([
fetchFullContact(savedContactId),
fetchContactCases(savedContactId),
]);
currentData = {
...currentData,
contact: buildToastContact(fullContact),
contact_cases: caseContext?.contact_cases || currentData.contact_cases || [],
company_cases: caseContext?.company_cases || currentData.company_cases || [],
related_contacts: caseContext?.related_contacts || currentData.related_contacts || [],
};
renderToastBody();
} catch (err) {
window.alert(`Kunne ikke oprette kontakt: ${err?.message || 'ukendt fejl'}`);
} finally {
const refreshedActionBtn = toastEl.querySelector('[data-action="quick-create-contact"]');
if (refreshedActionBtn) {
refreshedActionBtn.disabled = false;
refreshedActionBtn.textContent = originalText;
}
}
}
toastEl.addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
@ -193,26 +753,10 @@
window.location.href = `/sag/new?${qs.toString()}`;
}
if (action === 'link-case') {
const answer = window.prompt('Indtast eksisterende sag-ID, som opkaldet skal linkes til:');
if (answer === null) return;
const caseId = Number(String(answer).trim());
if (!Number.isInteger(caseId) || caseId <= 0) {
window.alert('Ugyldigt sag-ID');
return;
}
btn.disabled = true;
const originalText = btn.textContent;
btn.textContent = 'Gemmer...';
try {
const linkedCaseId = await patchCallCase(caseId);
window.location.href = `/sag/${linkedCaseId}/v3`;
} catch (err) {
window.alert(`Kunne ikke linke sag: ${err?.message || 'ukendt fejl'}`);
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
openCaseSmartModal();
}
if (action === 'quick-create-contact') {
await createOrUpdateContactFromToast();
}
});
}