diff --git a/RELEASE_NOTES_v2.2.49.md b/RELEASE_NOTES_v2.2.49.md new file mode 100644 index 0000000..92042dd --- /dev/null +++ b/RELEASE_NOTES_v2.2.49.md @@ -0,0 +1,40 @@ +# Release Notes v2.2.49 + +Dato: 5. marts 2026 + +## Ny funktionalitet + +### Sag – Relationer +- Relation-vinduet vises kun når der faktisk er relerede sager. Enkelt-sag (ingen relationer) viser nu tom-state "Ingen relaterede sager". +- Aktuel sag fremhæves tydeligt i relationstræet: accent-farvet venstre-kant, svag baggrund, udfyldt badge med sags-ID og fed titel. Linket er ikke klikbart (man er allerede der). + +### Sag – Sagstype dropdown +- Sagstype i topbaren er nu et klikbart dropdown i stedet for et link til redigeringssiden. +- Dropdown viser alle 6 typer (Ticket, Pipeline, Opgave, Ordre, Projekt, Service) med farveikoner og markerer den aktive type. +- Valg PATCHer sagen direkte og genindlæser siden. +- Rettet fejl hvor dropdown åbnede bagved siden (`overflow: hidden` fjernet fra `.case-hero`). + +### Sag – Relation quick-actions (+) +- Menuen indeholder nu 12 moduler: Tildel sag, Tidregistrering, Kommentar, Påmindelse, Opgave, Salgspipeline, Filer, Hardware, Løsning, Varekøb & salg, Abonnement, Send email. +- Alle moduler åbner mini-modal med relevante felter direkte fra relationspanelet – ingen sidenavigation nødvendig. +- Salgspipeline skjules fra menuen hvis sagen allerede har pipeline-data (vises som grå "Pipeline (se sagen)"). +- Tags bruger nu det globale TagPicker-system (`window.showTagPicker`). + +### Email service +- Ny `app/services/email_service.py` til centraliseret e-mail-afsendelse. + +### Telefoni +- Opdateringer til telefon-log og router. + +## Ændrede filer +- `app/modules/sag/templates/detail.html` +- `app/modules/sag/backend/router.py` +- `app/dashboard/backend/mission_router.py` +- `app/dashboard/backend/mission_service.py` +- `app/modules/telefoni/backend/router.py` +- `app/modules/telefoni/templates/log.html` +- `app/services/email_service.py` +- `main.py` + +## Drift +- Deploy: `./updateto.sh v2.2.49` diff --git a/app/dashboard/backend/mission_router.py b/app/dashboard/backend/mission_router.py index 375aa89..f0ce6ec 100644 --- a/app/dashboard/backend/mission_router.py +++ b/app/dashboard/backend/mission_router.py @@ -53,6 +53,11 @@ def _parse_query_timestamp(request: Request) -> Optional[datetime]: def _event_from_query(request: Request) -> MissionCallEvent: call_id = _first_query_param(request, "call_id", "callid", "id", "session_id", "uuid") if not call_id: + logger.warning( + "⚠️ Mission webhook invalid query path=%s reason=missing_call_id keys=%s", + request.url.path, + ",".join(sorted(request.query_params.keys())), + ) raise HTTPException(status_code=400, detail="Missing call_id query parameter") return MissionCallEvent( @@ -71,13 +76,28 @@ def _get_webhook_token() -> str: def _validate_mission_webhook_token(request: Request, token: Optional[str] = None) -> None: configured = _get_webhook_token() + path = request.url.path if not configured: - logger.warning("Mission webhook token not configured for path=%s", request.url.path) + logger.warning("❌ Mission webhook rejected path=%s reason=token_not_configured", path) raise HTTPException(status_code=403, detail="Mission webhook token not configured") candidate = token or request.headers.get("x-mission-token") or request.query_params.get("token") if not candidate or candidate.strip() != configured: - logger.warning("Mission webhook forbidden for path=%s", request.url.path) + source = "query_or_arg" + if not token and request.headers.get("x-mission-token"): + source = "header" + + masked = "" + if candidate: + c = candidate.strip() + masked = "***" if len(c) <= 8 else f"{c[:4]}...{c[-4:]}" + + logger.warning( + "❌ Mission webhook forbidden path=%s reason=token_mismatch source=%s token=%s", + path, + source, + masked, + ) raise HTTPException(status_code=403, detail="Forbidden") diff --git a/app/dashboard/backend/mission_service.py b/app/dashboard/backend/mission_service.py index 56dcb38..2941f56 100644 --- a/app/dashboard/backend/mission_service.py +++ b/app/dashboard/backend/mission_service.py @@ -9,6 +9,14 @@ logger = logging.getLogger(__name__) class MissionService: + @staticmethod + def _safe(label: str, func, default): + try: + return func() + except Exception as exc: + logger.error("❌ Mission state component failed: %s (%s)", label, exc) + return default + @staticmethod def _table_exists(table_name: str) -> bool: row = execute_query_single("SELECT to_regclass(%s) AS table_name", (f"public.{table_name}",)) @@ -234,21 +242,49 @@ class MissionService: @staticmethod def get_state() -> Dict[str, Any]: + kpis_default = { + "open_cases": 0, + "new_cases": 0, + "unassigned_cases": 0, + "deadlines_today": 0, + "overdue_deadlines": 0, + } + return { - "kpis": MissionService.get_kpis(), - "active_calls": MissionService.get_active_calls(), - "employee_deadlines": MissionService.get_employee_deadlines(), - "active_alerts": MissionService.get_active_alerts(), - "live_feed": MissionService.get_live_feed(20), + "kpis": MissionService._safe("kpis", MissionService.get_kpis, kpis_default), + "active_calls": MissionService._safe("active_calls", MissionService.get_active_calls, []), + "employee_deadlines": MissionService._safe("employee_deadlines", MissionService.get_employee_deadlines, []), + "active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []), + "live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []), "config": { - "display_queues": MissionService.parse_json_setting("mission_display_queues", []), - "sound_enabled": str(MissionService.get_setting_value("mission_sound_enabled", "true")).lower() == "true", - "sound_volume": int(MissionService.get_setting_value("mission_sound_volume", "70") or 70), - "sound_events": MissionService.parse_json_setting("mission_sound_events", ["incoming_call", "uptime_down", "critical_event"]), - "kpi_visible": MissionService.parse_json_setting( - "mission_kpi_visible", + "display_queues": MissionService._safe("config.display_queues", lambda: MissionService.parse_json_setting("mission_display_queues", []), []), + "sound_enabled": MissionService._safe( + "config.sound_enabled", + lambda: str(MissionService.get_setting_value("mission_sound_enabled", "true")).lower() == "true", + True, + ), + "sound_volume": MissionService._safe( + "config.sound_volume", + lambda: int(MissionService.get_setting_value("mission_sound_volume", "70") or 70), + 70, + ), + "sound_events": MissionService._safe( + "config.sound_events", + lambda: MissionService.parse_json_setting("mission_sound_events", ["incoming_call", "uptime_down", "critical_event"]), + ["incoming_call", "uptime_down", "critical_event"], + ), + "kpi_visible": MissionService._safe( + "config.kpi_visible", + lambda: MissionService.parse_json_setting( + "mission_kpi_visible", + ["open_cases", "new_cases", "unassigned_cases", "deadlines_today", "overdue_deadlines"], + ), ["open_cases", "new_cases", "unassigned_cases", "deadlines_today", "overdue_deadlines"], ), - "customer_filter": MissionService.get_setting_value("mission_customer_filter", "") or "", + "customer_filter": MissionService._safe( + "config.customer_filter", + lambda: MissionService.get_setting_value("mission_customer_filter", "") or "", + "", + ), }, } diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index b296d1f..4c5ec2e 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -114,6 +114,28 @@ class QuickCreateRequest(BaseModel): user_id: int +class SagSendEmailRequest(BaseModel): + to: List[str] + subject: str = Field(..., min_length=1, max_length=998) + body_text: str = Field(..., min_length=1) + cc: List[str] = Field(default_factory=list) + bcc: List[str] = Field(default_factory=list) + body_html: Optional[str] = None + attachment_file_ids: List[int] = Field(default_factory=list) + + +def _normalize_email_list(values: List[str], field_name: str) -> List[str]: + cleaned: List[str] = [] + for value in values or []: + candidate = str(value or "").strip() + if not candidate: + continue + if "@" not in candidate or "." not in candidate.split("@")[-1]: + raise HTTPException(status_code=400, detail=f"Invalid email in {field_name}: {candidate}") + cleaned.append(candidate) + return list(dict.fromkeys(cleaned)) + + @router.post("/sag/analyze-quick-create", response_model=QuickCreateAnalysis) async def analyze_quick_create(request: QuickCreateRequest): """ @@ -577,6 +599,86 @@ async def update_sag(sag_id: int, updates: dict): raise HTTPException(status_code=500, detail="Failed to update case") +# --------------------------------------------------------------------------- +# Beskrivelse inline editing with history +# --------------------------------------------------------------------------- + +class BeskrivelsePatch(BaseModel): + beskrivelse: str + + +@router.patch("/sag/{sag_id}/beskrivelse") +async def update_sag_beskrivelse(sag_id: int, body: BeskrivelsePatch, request: Request): + """Update case description and store a change history entry.""" + try: + row = execute_query_single( + "SELECT id, beskrivelse FROM sag_sager WHERE id = %s AND deleted_at IS NULL", + (sag_id,) + ) + if not row: + raise HTTPException(status_code=404, detail="Case not found") + + old_beskrivelse = row.get("beskrivelse") + new_beskrivelse = body.beskrivelse + + # Resolve acting user (may be None for anonymous) + user_id = _get_user_id_from_request(request) + changed_by_name = None + if user_id: + u = execute_query_single( + "SELECT COALESCE(full_name, username, CONCAT('Bruger #', user_id::text)) AS name FROM users WHERE user_id = %s", + (user_id,) + ) + if u: + changed_by_name = u["name"] + + # Write history entry + execute_query( + """INSERT INTO sag_beskrivelse_history + (sag_id, beskrivelse_before, beskrivelse_after, changed_by_user_id, changed_by_name) + VALUES (%s, %s, %s, %s, %s)""", + (sag_id, old_beskrivelse, new_beskrivelse, user_id, changed_by_name) + ) + + # Update the case + execute_query( + "UPDATE sag_sager SET beskrivelse = %s, updated_at = NOW() WHERE id = %s", + (new_beskrivelse, sag_id) + ) + + logger.info("✅ Beskrivelse updated for sag %s by user %s", sag_id, user_id) + return {"ok": True, "beskrivelse": new_beskrivelse} + + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error updating beskrivelse for sag %s: %s", sag_id, e) + raise HTTPException(status_code=500, detail="Failed to update description") + + +@router.get("/sag/{sag_id}/beskrivelse/history") +async def get_sag_beskrivelse_history(sag_id: int): + """Return the change history for a case's description, newest first.""" + exists = execute_query_single( + "SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", + (sag_id,) + ) + if not exists: + raise HTTPException(status_code=404, detail="Case not found") + + rows = execute_query( + """SELECT id, beskrivelse_before, beskrivelse_after, + changed_by_name, changed_at + FROM sag_beskrivelse_history + WHERE sag_id = %s + ORDER BY changed_at DESC + LIMIT 50""", + (sag_id,) + ) or [] + + return rows + + class PipelineUpdate(BaseModel): amount: Optional[float] = None probability: Optional[int] = Field(default=None, ge=0, le=100) @@ -757,6 +859,15 @@ async def delete_relation(sag_id: int, relation_id: int): # TAGS - Case Tags # ============================================================================ +@router.get("/sag/tags/all") +async def get_all_tags(): + """Return all distinct tag names across all cases (for autocomplete).""" + rows = execute_query( + "SELECT DISTINCT tag_navn FROM sag_tags WHERE deleted_at IS NULL ORDER BY tag_navn ASC LIMIT 200" + ) or [] + return rows + + @router.get("/sag/{sag_id}/tags") async def get_tags(sag_id: int): """Get all tags for a case.""" @@ -2038,6 +2149,154 @@ async def upload_sag_email(sag_id: int, file: UploadFile = File(...)): await add_sag_email_link(sag_id, {"email_id": email_id}) return {"status": "imported", "email_id": email_id} + +@router.post("/sag/{sag_id}/emails/send") +async def send_sag_email(sag_id: int, payload: SagSendEmailRequest): + """Send outbound email directly from case email tab and link it to case.""" + case_exists = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) + if not case_exists: + raise HTTPException(status_code=404, detail="Case not found") + + to_addresses = _normalize_email_list(payload.to, "to") + cc_addresses = _normalize_email_list(payload.cc, "cc") + bcc_addresses = _normalize_email_list(payload.bcc, "bcc") + + if not to_addresses: + raise HTTPException(status_code=400, detail="At least one recipient in 'to' is required") + + subject = (payload.subject or "").strip() + body_text = (payload.body_text or "").strip() + if not subject: + raise HTTPException(status_code=400, detail="subject is required") + if not body_text: + raise HTTPException(status_code=400, detail="body_text is required") + + attachment_rows = [] + attachment_ids = list(dict.fromkeys(payload.attachment_file_ids or [])) + if attachment_ids: + placeholders = ",".join(["%s"] * len(attachment_ids)) + attachment_query = f""" + SELECT id, filename, content_type, size_bytes, stored_name + FROM sag_files + WHERE sag_id = %s AND id IN ({placeholders}) + """ + attachment_rows = execute_query(attachment_query, (sag_id, *attachment_ids)) + + if len(attachment_rows) != len(attachment_ids): + raise HTTPException(status_code=400, detail="One or more selected attachments were not found on this case") + + smtp_attachments = [] + for row in attachment_rows: + path = _resolve_attachment_path(row["stored_name"]) + if not path.exists(): + raise HTTPException(status_code=404, detail=f"Attachment file is missing on server: {row['filename']}") + + smtp_attachments.append({ + "filename": row["filename"], + "content_type": row.get("content_type") or "application/octet-stream", + "content": path.read_bytes(), + "size": row.get("size_bytes") or 0, + "file_path": str(path), + }) + + email_service = EmailService() + success, send_message, generated_message_id = await email_service.send_email_with_attachments( + to_addresses=to_addresses, + subject=subject, + body_text=body_text, + body_html=payload.body_html, + cc=cc_addresses, + bcc=bcc_addresses, + attachments=smtp_attachments, + respect_dry_run=False, + ) + + if not success: + logger.error("❌ Failed to send case email for case %s: %s", sag_id, send_message) + raise HTTPException(status_code=500, detail="Failed to send email") + + sender_name = settings.EMAIL_SMTP_FROM_NAME or "BMC Hub" + sender_email = settings.EMAIL_SMTP_FROM_ADDRESS or "" + + insert_email_query = """ + INSERT INTO email_messages ( + message_id, subject, sender_email, sender_name, + recipient_email, cc, body_text, body_html, + received_date, folder, has_attachments, attachment_count, + status, import_method, linked_case_id + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """ + insert_result = execute_query( + insert_email_query, + ( + generated_message_id, + subject, + sender_email, + sender_name, + ", ".join(to_addresses), + ", ".join(cc_addresses), + body_text, + payload.body_html, + datetime.now(), + "Sent", + bool(smtp_attachments), + len(smtp_attachments), + "sent", + "sag_outbound", + sag_id, + ), + ) + + if not insert_result: + logger.error("❌ Email sent but outbound log insert failed for case %s", sag_id) + raise HTTPException(status_code=500, detail="Email sent but logging failed") + + email_id = insert_result[0]["id"] + + if smtp_attachments: + from psycopg2 import Binary + + for attachment in smtp_attachments: + execute_query( + """ + INSERT INTO email_attachments ( + email_id, filename, content_type, size_bytes, file_path, content_data + ) + VALUES (%s, %s, %s, %s, %s, %s) + """, + ( + email_id, + attachment["filename"], + attachment["content_type"], + attachment.get("size") or len(attachment["content"]), + attachment.get("file_path"), + Binary(attachment["content"]), + ), + ) + + execute_query( + """ + INSERT INTO sag_emails (sag_id, email_id) + VALUES (%s, %s) + ON CONFLICT DO NOTHING + """, + (sag_id, email_id), + ) + + logger.info( + "✅ Outbound case email sent and linked (case=%s, email_id=%s, recipients=%s)", + sag_id, + email_id, + ", ".join(to_addresses), + ) + return { + "status": "sent", + "email_id": email_id, + "message": send_message, + } + # ============================================================================ # SOLUTIONS # ============================================================================ diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html index 16981b7..fe98066 100644 --- a/app/modules/sag/templates/detail.html +++ b/app/modules/sag/templates/detail.html @@ -120,6 +120,341 @@ color: #f8a5ac; } + /* ═══════════════ PREMIUM CASE HERO ═══════════════ */ + .case-hero { + background: var(--bg-card); + border-radius: 16px; + overflow: visible; + box-shadow: + 0 0 0 1px rgba(0,0,0,0.06), + 0 4px 6px -1px rgba(0,0,0,0.05), + 0 16px 32px -8px rgba(15,76,117,0.10); + } + + .case-hero-identity { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.5rem; + background: linear-gradient(135deg, rgba(15,76,117,0.04) 0%, rgba(15,76,117,0.01) 100%); + border-bottom: 1px solid rgba(0,0,0,0.06); + flex-wrap: wrap; + gap: 0.5rem; + border-radius: 16px 16px 0 0; + overflow: hidden; + } + + .case-id-chip { + display: inline-flex; + align-items: center; + font-size: 1.0rem; + font-weight: 900; + letter-spacing: -0.5px; + color: var(--tcolor, #0f4c75); + background: color-mix(in srgb, var(--tcolor, #0f4c75) 10%, transparent); + border: 1.5px solid color-mix(in srgb, var(--tcolor, #0f4c75) 30%, transparent); + border-radius: 8px; + padding: 0.2em 0.65em; + } + + .case-type-chip { + display: inline-flex; + align-items: center; + gap: 0.3em; + font-size: 0.73rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--tcolor, #0f4c75); + background: color-mix(in srgb, var(--tcolor, #0f4c75) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--tcolor, #0f4c75) 25%, transparent); + border-radius: 999px; + padding: 0.3em 0.8em; + transition: all 0.15s ease; + } + + .case-type-chip:hover { + background: color-mix(in srgb, var(--tcolor, #0f4c75) 18%, transparent); + transform: translateY(-1px); + } + + .case-status-chip { + display: inline-flex; + align-items: center; + gap: 0.4em; + font-size: 0.73rem; + font-weight: 700; + letter-spacing: 0.04em; + border-radius: 999px; + padding: 0.3em 0.85em; + border: 1px solid transparent; + } + + .case-status-chip.open { + background: #dcfce7; + color: #15803d; + border-color: #86efac; + } + + .case-status-chip.closed { + background: #f1f5f9; + color: #475569; + border-color: #cbd5e1; + } + + [data-bs-theme="dark"] .case-status-chip.open { + background: rgba(21,128,61,0.15); + color: #4ade80; + border-color: rgba(74,222,128,0.3); + } + + [data-bs-theme="dark"] .case-status-chip.closed { + background: rgba(71,85,105,0.15); + color: #94a3b8; + border-color: rgba(148,163,184,0.2); + } + + .case-status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + opacity: 0.85; + flex-shrink: 0; + animation: none; + } + + .case-status-chip.open .case-status-dot { + background: #16a34a; + box-shadow: 0 0 0 2px #dcfce7, 0 0 6px #16a34a80; + animation: pulse-dot 2s ease-in-out infinite; + } + + @keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + + .case-date-item { + display: inline-flex; + align-items: center; + gap: 0.3em; + font-size: 0.72rem; + color: var(--text-secondary); + opacity: 0.8; + } + + .case-date-item i { + opacity: 0.5; + font-size: 0.75rem; + } + + .case-date-sep { + color: var(--text-secondary); + opacity: 0.3; + font-size: 1rem; + } + + .case-hero-meta { + display: flex; + align-items: stretch; + flex-wrap: wrap; + padding: 0 0.25rem; + min-height: 70px; + } + + .case-meta-cell { + display: flex; + flex-direction: column; + justify-content: center; + padding: 0.85rem 1.25rem; + min-width: 0; + transition: background 0.12s; + border-radius: 4px; + } + + .case-meta-cell:hover { + background: rgba(0,0,0,0.025); + } + + [data-bs-theme="dark"] .case-meta-cell:hover { + background: rgba(255,255,255,0.04); + } + + .case-meta-divider { + width: 1px; + background: rgba(0,0,0,0.07); + margin: 0.6rem 0; + align-self: stretch; + flex-shrink: 0; + } + + [data-bs-theme="dark"] .case-meta-divider { + background: rgba(255,255,255,0.06); + } + + .case-meta-label { + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary); + opacity: 0.55; + display: flex; + align-items: center; + gap: 0.3em; + margin-bottom: 3px; + white-space: nowrap; + } + + .case-meta-label i { + font-size: 0.72rem; + opacity: 0.8; + } + + .case-meta-value { + font-size: 0.88rem; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + display: block; + line-height: 1.3; + } + + .case-meta-link { + cursor: pointer; + transition: color 0.12s; + } + + .case-meta-link:hover { + color: var(--accent) !important; + } + + .case-meta-empty { + font-size: 0.8rem; + color: var(--text-secondary); + opacity: 0.5; + font-style: italic; + } + + .case-inline-select { + border: 1px solid rgba(0,0,0,0.09); + background: var(--bg-body); + color: var(--text-primary); + font-weight: 600; + font-size: 0.82rem; + border-radius: 8px; + padding: 0.3rem 0.6rem; + cursor: pointer; + min-width: 120px; + max-width: 160px; + box-shadow: 0 1px 3px rgba(0,0,0,0.04); + transition: border-color 0.12s, box-shadow 0.12s; + appearance: auto; + } + + .case-inline-select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); + } + + .case-date-badge { + display: inline-flex; + align-items: center; + gap: 0.35em; + font-size: 0.8rem; + font-weight: 700; + padding: 0.25em 0.75em; + border-radius: 8px; + background: rgba(0,0,0,0.04); + border: 1px solid rgba(0,0,0,0.09); + color: var(--text-primary); + white-space: nowrap; + } + + .case-date-badge.overdue { + background: #fef2f2; + border-color: #fca5a5; + color: #b91c1c; + } + + .case-date-badge.deferred { + background: #fffbeb; + border-color: #fcd34d; + color: #92400e; + } + + [data-bs-theme="dark"] .case-date-badge { + background: rgba(255,255,255,0.05); + border-color: rgba(255,255,255,0.1); + } + + [data-bs-theme="dark"] .case-date-badge.overdue { + background: rgba(185,28,28,0.2); + border-color: rgba(252,165,165,0.3); + color: #fca5a5; + } + + [data-bs-theme="dark"] .case-date-badge.deferred { + background: rgba(146,64,14,0.2); + border-color: rgba(252,211,77,0.3); + color: #fcd34d; + } + + .case-edit-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 6px; + border: 1px solid rgba(0,0,0,0.08); + background: transparent; + color: var(--text-secondary); + font-size: 0.7rem; + opacity: 0.5; + transition: all 0.12s; + cursor: pointer; + padding: 0; + flex-shrink: 0; + } + + .case-edit-btn:hover { + opacity: 1; + background: var(--bg-body); + border-color: var(--accent); + color: var(--accent); + transform: scale(1.1); + } + + .hero-meta-label { + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.07em; + font-weight: 700; + color: var(--text-secondary); + opacity: 0.6; + margin-bottom: 3px; + white-space: nowrap; + } + + .hero-meta-value { + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary); + display: block; + white-space: nowrap; + } + + .hero-meta-value:hover { + color: var(--accent); + } + + [data-bs-theme="dark"] .hero-meta-label { + color: rgba(255,255,255,0.45); + } + .case-summary-card { border: 1px solid rgba(0,0,0,0.06); background: var(--bg-card); @@ -474,6 +809,136 @@ background: rgba(15, 76, 117, 0.08); } + /* ── Relation row quick actions ────────────────────────────────────────── */ + .rel-row-actions { + display: flex; + align-items: center; + gap: 0.2rem; + margin-left: auto; + padding-left: 0.35rem; + flex-shrink: 0; + } + .rel-row-actions .btn-rel-action { + padding: 2px 6px; + line-height: 1; + border: 1px solid transparent; + background: transparent; + color: var(--text-secondary, #6c757d); + font-size: 0.8rem; + opacity: 1; + transition: background 0.15s, color 0.15s, border-color 0.15s; + cursor: pointer; + border-radius: 5px; + } + .btn-rel-action:hover, + .btn-rel-action:focus, + .btn-rel-action.active { + background: rgba(15,76,117,0.1); + border-color: rgba(15,76,117,0.2); + color: var(--accent); + outline: none; + } + .btn-rel-action:hover { + opacity: 1 !important; + background: rgba(15,76,117,0.1); + color: var(--accent); + } + + /* tag pills inside relation rows */ + .rel-tag-row { + display: flex; + flex-wrap: wrap; + gap: 3px; + padding: 2px 4px 3px 4px; + min-height: 0; + } + .rel-tag-pill { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 1px 7px; + border-radius: 999px; + background: rgba(15,76,117,0.09); + border: 1px solid rgba(15,76,117,0.18); + color: var(--accent); + font-size: 0.68rem; + font-weight: 600; + line-height: 1.5; + cursor: default; + } + .rel-tag-pill .rel-tag-del { + cursor: pointer; + opacity: 0.5; + font-size: 0.7rem; + transition: opacity .15s; + padding: 0 1px; + } + .rel-tag-pill .rel-tag-del:hover { opacity: 1; color: #dc3545; } + .rel-tag-overflow { + font-size: 0.68rem; + color: var(--text-secondary, #6c757d); + cursor: default; + align-self: center; + } + + /* tag popover */ + .rel-tag-popover { + position: absolute; + z-index: 1080; + background: var(--bg-card, #fff); + border: 1px solid rgba(15,76,117,0.2); + border-radius: 8px; + box-shadow: 0 6px 24px rgba(0,0,0,0.12); + min-width: 220px; + max-width: 280px; + padding: 0.5rem; + } + .rel-tag-popover input { + font-size: 0.82rem; + } + .rel-tag-popover .rel-tag-suggestion { + padding: 4px 8px; + font-size: 0.82rem; + border-radius: 5px; + cursor: pointer; + transition: background .12s; + } + .rel-tag-popover .rel-tag-suggestion:hover { + background: rgba(15,76,117,0.09); + color: var(--accent); + } + .rel-tag-popover .rel-tag-suggestion.new-tag { + color: #198754; + font-style: italic; + } + + /* quick-action dropdown */ + .rel-qa-menu { + position: absolute; + z-index: 1080; + background: var(--bg-card, #fff); + border: 1px solid rgba(15,76,117,0.2); + border-radius: 8px; + box-shadow: 0 6px 24px rgba(0,0,0,0.12); + min-width: 185px; + padding: 0.35rem 0; + } + .rel-qa-menu .qa-item { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.42rem 0.9rem; + font-size: 0.84rem; + cursor: pointer; + transition: background .12s; + border-radius: 5px; + margin: 0 0.2rem; + } + .rel-qa-menu .qa-item:hover { + background: rgba(15,76,117,0.09); + color: var(--accent); + } + .relation-type-badge { display: inline-flex; align-items: center; @@ -636,7 +1101,7 @@ {% endblock %} {% block content %} -
+
@@ -663,122 +1128,170 @@
- -
-
-
-
- ID: - {{ case.id }} -
- -
- Kunde: - {% if customer %} - - {{ customer.name }} - - {% else %} - Ingen kunde - {% endif %} -
+ {% set tkey = (case.template_key or case.type or 'ticket')|lower %} + {% set type_icons = {'ticket': 'bi-ticket-perforated', 'pipeline': 'bi-graph-up-arrow', 'opgave': 'bi-puzzle', 'ordre': 'bi-receipt', 'projekt': 'bi-folder2-open', 'service': 'bi-tools'} %} + {% set type_labels = {'ticket': 'Ticket', 'pipeline': 'Pipeline', 'opgave': 'Opgave', 'ordre': 'Ordre', 'projekt': 'Projekt', 'service': 'Service'} %} + {% set type_colors = {'ticket': '#6366f1', 'pipeline': '#0ea5e9', 'opgave': '#f59e0b', 'ordre': '#10b981', 'projekt': '#8b5cf6', 'service': '#ef4444'} %} + {% set tcolor = type_colors.get(tkey, '#0f4c75') %} + {% set ticon = type_icons.get(tkey, 'bi-card-text') %} + {% set tlabel = type_labels.get(tkey, tkey|capitalize) %} -
- Kontakt: - {% if hovedkontakt %} - - {{ hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name }} - - {% else %} - Ingen - {% endif %} -
+ +
-
- Afdeling: - - {{ customer.department if customer and customer.department else 'N/A' }} - -
+ +
-
- Status: - {{ case.status }} -
+ +
+ #{{ case.id }} - -
- Datoer: - Opr: {{ case.created_at.strftime('%d/%m-%y') if case.created_at else '-' }} - | - Opd: {{ case.updated_at.strftime('%d/%m-%y') if case.updated_at else '-' }} -
+ + {{ case.status|capitalize }} + +
-
- Deadline: - {% if case.deadline %} - - {{ case.deadline.strftime('%d/%m-%y') }} - - {% else %} - Ingen - {% endif %} - -
- - -
- Udsat: - {% if case.deferred_until %} - - {{ case.deferred_until.strftime('%d/%m-%y') }} - - {% else %} - Nej - {% endif %} - -
+ +
+ + + {{ case.created_at.strftime('%d. %b %Y') if case.created_at else '—' }} + + · + + + {{ case.updated_at.strftime('%d. %b %Y') if case.updated_at else '—' }} +
-
- -
-
-
-
- - + {% for user in assignment_users or [] %} {% endfor %} -
-
- - + {% for group in assignment_groups or [] %} {% endfor %}
-
- +
+ +
+ + +
+
Deadline
+
+ {% if case.deadline %} + + + {{ case.deadline.strftime('%d/%m/%Y') }} + + {% else %} + Ingen deadline + {% endif %} +
-
+ +
+ + +
+
Udsat til
+
+ {% if case.deferred_until %} + + + {{ case.deferred_until.strftime('%d/%m/%Y') }} + + {% else %} + Ikke udsat + {% endif %} + +
+
+
+