From ce75f12f56445f1be75d5ba08c9c2303ecd7eccc Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 18 Jun 2026 22:20:39 +0200 Subject: [PATCH] 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. --- app/auth/backend/router.py | 74 ++ app/contacts/backend/router_simple.py | 212 ++++++ app/customers/frontend/customer_detail.html | 173 +++-- app/modules/sag/backend/router.py | 94 ++- app/modules/sag/frontend/views.py | 52 +- app/modules/sag/templates/index.html | 714 ++++++++++++++--- app/modules/telefoni/backend/router.py | 3 + app/modules/telefoni/backend/service.py | 173 ++++- app/settings/frontend/settings.html | 173 +++++ app/shared/frontend/base.html | 386 +++++++++- main.py | 2 + migrations/1005_sag_status_dynamic_values.sql | 12 + migrations/1006_user_sag_list_preferences.sql | 10 + migrations/191_user_menu_preferences.sql | 40 + static/js/bug-report.js | 169 +++- static/js/telefoni.js | 720 +++++++++++++++--- 16 files changed, 2678 insertions(+), 329 deletions(-) create mode 100644 migrations/1005_sag_status_dynamic_values.sql create mode 100644 migrations/1006_user_sag_list_preferences.sql create mode 100644 migrations/191_user_menu_preferences.sql diff --git a/app/auth/backend/router.py b/app/auth/backend/router.py index 5071f7a..cfba002 100644 --- a/app/auth/backend/router.py +++ b/app/auth/backend/router.py @@ -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 diff --git a/app/contacts/backend/router_simple.py b/app/contacts/backend/router_simple.py index 5bcbb90..c9afe6e 100644 --- a/app/contacts/backend/router_simple.py +++ b/app/contacts/backend/router_simple.py @@ -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) diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html index c46f440..f420252 100644 --- a/app/customers/frontend/customer_detail.html +++ b/app/customers/frontend/customer_detail.html @@ -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; + } + } {% endblock %} @@ -550,31 +614,36 @@
-
Kontaktpersoner
+
+
Kontaktpersoner
+ Direkte kontaktoplysninger for denne kunde +
-
- - - - - - - - - - - - - - - - -
NavnTitelEmailTelefonMobilPrimær
-
-
+
+
+ + + + + + + + + + + + + + + + +
NavnTitelEmailTelefonMobilPrimær
+
+
+
@@ -2588,9 +2657,10 @@ function displayUtilityCompany(payload) { async function loadContacts() { const container = document.getElementById('contactsContainer'); - container.innerHTML = ` - - + + const renderContactsTable = (bodyHtml) => ` +
+ @@ -2601,14 +2671,20 @@ async function loadContacts() { - - - + ${bodyHtml}
Navn Titel
-
-
`; + + container.innerHTML = ` + ${renderContactsTable(` + + +
+ + + `)} + `; 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 ? `${escapeHtml(contact.email)}` : '—'; - const phone = contact.phone ? `${escapeHtml(contact.phone)}` : '—'; - const mobile = contact.mobile - ? `` + const phone = phoneValue ? `${escapeHtml(phoneValue)}` : '—'; + const mobile = mobileValue + ? `` : '—'; - const title = contact.title ? escapeHtml(contact.title) : '—'; - const primaryBadge = contact.is_primary ? 'Primær' : '—'; + const title = titleValue ? escapeHtml(titleValue) : '—'; + const primaryBadge = contact.is_primary ? 'Primær' : '—'; return ` - ${escapeHtml(contact.name || '-') } + ${escapeHtml(displayName)} ${title} - ${email} - ${phone} + ${email} + ${phone} ${mobile} ${primaryBadge} @@ -2641,21 +2724,7 @@ async function loadContacts() { }).join(''); container.innerHTML = ` - - - - - - - - - - - - - ${rows} - -
NavnTitelEmailTelefonMobilPrimær
+ ${renderContactsTable(rows)} `; } catch (error) { console.error('Failed to load contacts:', error); diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index 8b2fc02..c2845b0 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -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): diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py index 45ad0a6..80f9123 100644 --- a/app/modules/sag/frontend/views.py +++ b/app/modules/sag/frontend/views.py @@ -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) diff --git a/app/modules/sag/templates/index.html b/app/modules/sag/templates/index.html index 8f4f906..041abc2 100644 --- a/app/modules/sag/templates/index.html +++ b/app/modules/sag/templates/index.html @@ -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 %} + {{ initials }} + {% else %} + - + {% endif %} +{%- endmacro %} +
@@ -428,39 +683,66 @@
-
+
Alle
Åbne
Lukkede
-
- +
+ +
-
-
- {% for user in assignment_users or [] %} {% endfor %} - + +
+
+ -
- {% for group in assignment_groups or [] %} {% endfor %} - -
- {% if include_deferred %} - - {% endif %} - + +
{% if include_deferred %}Skjul udsatte{% else %}Vis udsatte{% endif %} @@ -472,12 +754,14 @@ + + @@ -494,12 +778,16 @@ - + + + + +
SagsID Virksom. Kontakt Beskr. Type PrioritetStatus Ansvarl. Gruppe/Level Næste todo
+ 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 '' }}"> + {% if has_relations %} + {% endif %} - #{{ sag.id }} + + #{{ sag.id }} {% if (sag.unread_email_count or 0) > 0 %} {% set unread_level = sag.unread_email_level or 'fresh' %} @@ -522,22 +810,20 @@ {{ sag.priority if sag.priority else 'normal' }} + {% set status_raw = sag.status if sag.status else 'åben' %} + {% set status_class = status_raw|lower|replace(' ', '-') %} + {{ status_raw }} + - {% if sag.ansvarlig_navn %} - {% set owner_name = sag.ansvarlig_navn.strip() %} - {% set owner_parts = owner_name.split() %}
- - {{ ((owner_parts[0][0] if owner_parts|length > 0 else owner_name[0]) ~ (owner_parts[1][0] if owner_parts|length > 1 else ''))|upper }} - - {{ owner_name }} + {{ initials_bubble(sag.ansvarlig_navn) }}
- {% else %} - - - {% endif %}
- {{ sag.assigned_group_name if sag.assigned_group_name else '-' }} +
+ {{ initials_bubble(sag.assigned_group_name, true) }} +
{% 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 %} -