feat: Implement bug reporting feature with screenshot support
- Added a new modal for reporting bugs, including fields for describing the issue and attaching files. - Implemented backend API for creating bug reports, including rate limiting and metadata logging. - Introduced a new database table to track bug report submissions for auditing purposes. - Enhanced the frontend to capture screenshots automatically and allow manual file uploads. - Added error handling and user feedback for the bug reporting process. - Updated existing templates and scripts to integrate the new bug reporting functionality.
This commit is contained in:
parent
1a44baba62
commit
71f6372496
0
app/bug_reports/__init__.py
Normal file
0
app/bug_reports/__init__.py
Normal file
0
app/bug_reports/backend/__init__.py
Normal file
0
app/bug_reports/backend/__init__.py
Normal file
20
app/bug_reports/backend/models.py
Normal file
20
app/bug_reports/backend/models.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class BugReportPayload(BaseModel):
|
||||||
|
actual: str = Field(..., min_length=3, max_length=8000)
|
||||||
|
expected: str = Field(..., min_length=3, max_length=8000)
|
||||||
|
screenshot_base64: Optional[str] = Field(default=None, max_length=25_000_000)
|
||||||
|
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
logs: List[Dict[str, Any]] = Field(default_factory=list)
|
||||||
|
extra_file_name: Optional[str] = Field(default=None, max_length=255)
|
||||||
|
extra_file_base64: Optional[str] = Field(default=None, max_length=25_000_000)
|
||||||
|
|
||||||
|
|
||||||
|
class BugReportResult(BaseModel):
|
||||||
|
success: bool
|
||||||
|
sag_id: int
|
||||||
|
case_url: str
|
||||||
|
message: str
|
||||||
212
app/bug_reports/backend/router.py
Normal file
212
app/bug_reports/backend/router.py
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
|
||||||
|
from app.bug_reports.backend.models import BugReportPayload, BugReportResult
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import execute_query, execute_query_single
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
UPLOAD_BASE_PATH = Path(settings.UPLOAD_DIR).resolve()
|
||||||
|
SAG_FILE_SUBDIR = "sag_files"
|
||||||
|
(UPLOAD_BASE_PATH / SAG_FILE_SUBDIR).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(table_name: str) -> bool:
|
||||||
|
row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(table_name,),
|
||||||
|
)
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_data_url(data_url: str) -> tuple[bytes, str]:
|
||||||
|
# Expected format: data:image/png;base64,....
|
||||||
|
match = re.match(r"^data:([\w/+.-]+);base64,(.+)$", data_url or "", flags=re.DOTALL)
|
||||||
|
if not match:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid base64 data URL")
|
||||||
|
|
||||||
|
content_type = match.group(1)
|
||||||
|
b64_data = match.group(2)
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(b64_data, validate=True)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid base64 encoding")
|
||||||
|
|
||||||
|
return raw, content_type
|
||||||
|
|
||||||
|
|
||||||
|
def _store_raw_file(raw: bytes, filename: str) -> tuple[str, int]:
|
||||||
|
safe_name = Path(filename).name
|
||||||
|
stored_name = f"{SAG_FILE_SUBDIR}/{uuid4().hex}_{safe_name}"
|
||||||
|
destination = UPLOAD_BASE_PATH / stored_name
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with destination.open("wb") as f:
|
||||||
|
f.write(raw)
|
||||||
|
|
||||||
|
return stored_name, len(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_sag_file_record(sag_id: int, filename: str, content_type: str, size_bytes: int, stored_name: str) -> None:
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_files (sag_id, filename, content_type, size_bytes, stored_name)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(sag_id, filename, content_type, size_bytes, stored_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_limit(user_id: int) -> None:
|
||||||
|
if not _table_exists("bug_report_submissions"):
|
||||||
|
# If migration is not yet applied, fail-open to avoid blocking support workflows.
|
||||||
|
return
|
||||||
|
|
||||||
|
max_per_hour = max(1, int(settings.BUG_REPORT_MAX_PER_HOUR))
|
||||||
|
row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::int AS count
|
||||||
|
FROM bug_report_submissions
|
||||||
|
WHERE user_id = %s
|
||||||
|
AND created_at >= %s
|
||||||
|
""",
|
||||||
|
(user_id, datetime.utcnow() - timedelta(hours=1)),
|
||||||
|
)
|
||||||
|
count = int((row or {}).get("count") or 0)
|
||||||
|
if count >= max_per_hour:
|
||||||
|
raise HTTPException(status_code=429, detail="Rate limit exceeded for bug reports")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_customer_id() -> int:
|
||||||
|
configured_id = int(settings.BUG_REPORT_DEFAULT_CUSTOMER_ID)
|
||||||
|
configured_row = execute_query_single("SELECT id FROM customers WHERE id = %s", (configured_id,))
|
||||||
|
if configured_row:
|
||||||
|
return int(configured_row["id"])
|
||||||
|
|
||||||
|
named_row = execute_query_single(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM customers
|
||||||
|
WHERE LOWER(name) = LOWER(%s)
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
("BMC Networks",),
|
||||||
|
)
|
||||||
|
if named_row:
|
||||||
|
return int(named_row["id"])
|
||||||
|
|
||||||
|
fallback = execute_query_single("SELECT id FROM customers ORDER BY id ASC LIMIT 1")
|
||||||
|
if fallback:
|
||||||
|
return int(fallback["id"])
|
||||||
|
|
||||||
|
raise HTTPException(status_code=400, detail="No customers available for bug report case creation")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bug-reports", response_model=BugReportResult)
|
||||||
|
async def create_bug_report(payload: BugReportPayload, request: Request):
|
||||||
|
user_id = getattr(request.state, "user_id", None) or 1
|
||||||
|
|
||||||
|
_rate_limit(int(user_id))
|
||||||
|
|
||||||
|
title_seed = (payload.actual or "").strip().splitlines()[0][:80]
|
||||||
|
title = f"Bug: {title_seed or 'Ukendt fejl'}"
|
||||||
|
|
||||||
|
metadata_json = json.dumps(payload.metadata or {}, ensure_ascii=False, indent=2)
|
||||||
|
logs_preview = (payload.logs or [])[:50]
|
||||||
|
logs_json = json.dumps(logs_preview, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
description = (
|
||||||
|
"## Hvad gik galt\n"
|
||||||
|
f"{payload.actual.strip()}\n\n"
|
||||||
|
"## Hvad burde være sket\n"
|
||||||
|
f"{payload.expected.strip()}\n\n"
|
||||||
|
"## Metadata\n"
|
||||||
|
f"```json\n{metadata_json}\n```\n\n"
|
||||||
|
"## Log preview (seneste 50)\n"
|
||||||
|
f"```json\n{logs_json}\n```\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
customer_id = _resolve_customer_id()
|
||||||
|
assigned_user_id: Optional[int] = settings.BUG_REPORT_AUTO_ASSIGN_USER_ID
|
||||||
|
|
||||||
|
created = execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_sager
|
||||||
|
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, created_by_user_id)
|
||||||
|
VALUES
|
||||||
|
(%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
"bug_report",
|
||||||
|
"åben",
|
||||||
|
customer_id,
|
||||||
|
assigned_user_id,
|
||||||
|
user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not created:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create bug case")
|
||||||
|
|
||||||
|
sag_id = int(created[0]["id"])
|
||||||
|
|
||||||
|
# Attach screenshot if provided
|
||||||
|
if payload.screenshot_base64:
|
||||||
|
raw, content_type = _decode_data_url(payload.screenshot_base64)
|
||||||
|
if len(raw) > settings.BUG_REPORT_MAX_SCREENSHOT_BYTES:
|
||||||
|
raise HTTPException(status_code=400, detail="Screenshot too large")
|
||||||
|
|
||||||
|
stored_name, size = _store_raw_file(raw, f"bugreport_{sag_id}.png")
|
||||||
|
_create_sag_file_record(sag_id, "screenshot.png", content_type, size, stored_name)
|
||||||
|
|
||||||
|
# Attach logs as json file
|
||||||
|
logs_raw = json.dumps(payload.logs or [], ensure_ascii=False, indent=2).encode("utf-8")
|
||||||
|
stored_name, size = _store_raw_file(logs_raw, f"bugreport_{sag_id}_logs.json")
|
||||||
|
_create_sag_file_record(sag_id, "logs.json", "application/json", size, stored_name)
|
||||||
|
|
||||||
|
# Optional extra file
|
||||||
|
if payload.extra_file_base64 and payload.extra_file_name:
|
||||||
|
raw, content_type = _decode_data_url(payload.extra_file_base64)
|
||||||
|
if len(raw) > settings.BUG_REPORT_MAX_ATTACHMENT_BYTES:
|
||||||
|
raise HTTPException(status_code=400, detail="Extra file too large")
|
||||||
|
stored_name, size = _store_raw_file(raw, payload.extra_file_name)
|
||||||
|
_create_sag_file_record(sag_id, payload.extra_file_name, content_type, size, stored_name)
|
||||||
|
|
||||||
|
# Track submission for rate-limiting/audit
|
||||||
|
if _table_exists("bug_report_submissions"):
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO bug_report_submissions (sag_id, user_id, screenshot_attached)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
""",
|
||||||
|
(sag_id, user_id, bool(payload.screenshot_base64)),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("✅ Bug report case created: SAG-%s by user_id=%s", sag_id, user_id)
|
||||||
|
|
||||||
|
return BugReportResult(
|
||||||
|
success=True,
|
||||||
|
sag_id=sag_id,
|
||||||
|
case_url=f"/sag/{sag_id}/v3",
|
||||||
|
message="Fejl rapporteret og sag oprettet",
|
||||||
|
)
|
||||||
@ -209,6 +209,15 @@ class Settings(BaseSettings):
|
|||||||
BACKUP_INCLUDE_DATA: bool = True # Include data/ in file backups
|
BACKUP_INCLUDE_DATA: bool = True # Include data/ in file backups
|
||||||
UPLOAD_DIR: str = "uploads" # Upload directory path
|
UPLOAD_DIR: str = "uploads" # Upload directory path
|
||||||
|
|
||||||
|
# Bug report capture
|
||||||
|
BUG_REPORT_ENABLED: bool = True
|
||||||
|
BUG_REPORT_HOTKEY: str = "Ctrl+Shift+B"
|
||||||
|
BUG_REPORT_MAX_PER_HOUR: int = 12
|
||||||
|
BUG_REPORT_DEFAULT_CUSTOMER_ID: int = 1
|
||||||
|
BUG_REPORT_AUTO_ASSIGN_USER_ID: int | None = 1
|
||||||
|
BUG_REPORT_MAX_SCREENSHOT_BYTES: int = 8 * 1024 * 1024
|
||||||
|
BUG_REPORT_MAX_ATTACHMENT_BYTES: int = 20 * 1024 * 1024
|
||||||
|
|
||||||
# Offsite Backup Settings (SFTP)
|
# Offsite Backup Settings (SFTP)
|
||||||
OFFSITE_ENABLED: bool = False
|
OFFSITE_ENABLED: bool = False
|
||||||
OFFSITE_WEEKLY_DAY: str = "sunday"
|
OFFSITE_WEEKLY_DAY: str = "sunday"
|
||||||
|
|||||||
@ -1181,6 +1181,12 @@ async def create_contact(location_id: int, data: ContactCreate):
|
|||||||
- 500: Database error
|
- 500: Database error
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
def _none_if_empty(value: Optional[str]) -> Optional[str]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
stripped = value.strip()
|
||||||
|
return stripped or None
|
||||||
|
|
||||||
# Check location exists
|
# Check location exists
|
||||||
location_query = "SELECT name FROM locations_locations WHERE id = %s AND deleted_at IS NULL"
|
location_query = "SELECT name FROM locations_locations WHERE id = %s AND deleted_at IS NULL"
|
||||||
location_check = execute_query(location_query, (location_id,))
|
location_check = execute_query(location_query, (location_id,))
|
||||||
@ -1194,6 +1200,43 @@ async def create_contact(location_id: int, data: ContactCreate):
|
|||||||
|
|
||||||
location_name = location_check[0]['name']
|
location_name = location_check[0]['name']
|
||||||
|
|
||||||
|
contact_name = (data.contact_name or "").strip()
|
||||||
|
contact_email = _none_if_empty(data.contact_email)
|
||||||
|
contact_phone = _none_if_empty(data.contact_phone)
|
||||||
|
contact_role = _none_if_empty(data.role)
|
||||||
|
|
||||||
|
if data.existing_contact_id:
|
||||||
|
existing_contact_query = """
|
||||||
|
SELECT id, first_name, last_name, email, phone, mobile, title
|
||||||
|
FROM contacts
|
||||||
|
WHERE id = %s
|
||||||
|
"""
|
||||||
|
existing_contact_result = execute_query(existing_contact_query, (data.existing_contact_id,))
|
||||||
|
|
||||||
|
if not existing_contact_result:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Existing contact with id {data.existing_contact_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_contact = existing_contact_result[0]
|
||||||
|
existing_full_name = (
|
||||||
|
f"{(existing_contact.get('first_name') or '').strip()} "
|
||||||
|
f"{(existing_contact.get('last_name') or '').strip()}"
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
if not contact_name:
|
||||||
|
contact_name = existing_full_name
|
||||||
|
if not contact_email:
|
||||||
|
contact_email = _none_if_empty(existing_contact.get('email'))
|
||||||
|
if not contact_phone:
|
||||||
|
contact_phone = _none_if_empty(existing_contact.get('mobile')) or _none_if_empty(existing_contact.get('phone'))
|
||||||
|
if not contact_role:
|
||||||
|
contact_role = _none_if_empty(existing_contact.get('title'))
|
||||||
|
|
||||||
|
if not contact_name:
|
||||||
|
raise HTTPException(status_code=400, detail="contact_name is required")
|
||||||
|
|
||||||
# If is_primary is true, unset primary flag on other contacts
|
# If is_primary is true, unset primary flag on other contacts
|
||||||
if data.is_primary:
|
if data.is_primary:
|
||||||
unset_primary_query = """
|
unset_primary_query = """
|
||||||
@ -1215,10 +1258,10 @@ async def create_contact(location_id: int, data: ContactCreate):
|
|||||||
|
|
||||||
params = (
|
params = (
|
||||||
location_id,
|
location_id,
|
||||||
data.contact_name,
|
contact_name,
|
||||||
data.contact_email,
|
contact_email,
|
||||||
data.contact_phone,
|
contact_phone,
|
||||||
data.role,
|
contact_role,
|
||||||
data.is_primary
|
data.is_primary
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1230,7 +1273,16 @@ async def create_contact(location_id: int, data: ContactCreate):
|
|||||||
|
|
||||||
contact = Contact(**result[0])
|
contact = Contact(**result[0])
|
||||||
|
|
||||||
logger.info(f"✅ Contact added: {data.contact_name} at {location_name} (Location ID: {location_id})")
|
if data.existing_contact_id:
|
||||||
|
logger.info(
|
||||||
|
"✅ Contact added from existing contact %s: %s at %s (Location ID: %s)",
|
||||||
|
data.existing_contact_id,
|
||||||
|
contact_name,
|
||||||
|
location_name,
|
||||||
|
location_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(f"✅ Contact added: {contact_name} at {location_name} (Location ID: {location_id})")
|
||||||
return contact
|
return contact
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|||||||
@ -120,6 +120,11 @@ class ContactBase(BaseModel):
|
|||||||
class ContactCreate(ContactBase):
|
class ContactCreate(ContactBase):
|
||||||
"""Request model for creating contact"""
|
"""Request model for creating contact"""
|
||||||
is_primary: bool = Field(False, description="Set as primary contact for location")
|
is_primary: bool = Field(False, description="Set as primary contact for location")
|
||||||
|
existing_contact_id: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
ge=1,
|
||||||
|
description="Optional ID of an existing global contact to copy data from"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ContactUpdate(BaseModel):
|
class ContactUpdate(BaseModel):
|
||||||
|
|||||||
@ -3098,13 +3098,48 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* V3 readability fix: keep menu structure unchanged, restore normal text sizing */
|
/* V3 readability fix: keep menu structure unchanged, restore normal text sizing */
|
||||||
|
#caseTabs {
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.34);
|
||||||
|
background: rgba(11, 53, 90, 0.36);
|
||||||
|
border-radius: 10px 10px 0 0;
|
||||||
|
padding: 0.2rem 0.3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
#caseTabs .nav-link {
|
#caseTabs .nav-link {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
padding: 0.58rem 0.9rem;
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#caseTabs .nav-link:hover,
|
||||||
|
#caseTabs .nav-link:focus-visible {
|
||||||
|
color: #ffffff;
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
border-color: rgba(255, 255, 255, 0.28);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#caseTabs .nav-link.active {
|
||||||
|
color: #0f4c75;
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: rgba(255, 255, 255, 0.78);
|
||||||
|
border-bottom-color: #ffffff;
|
||||||
|
box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.35) inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
#caseTabs .nav-link i {
|
#caseTabs .nav-link i {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
#caseTabs .nav-link.active i {
|
||||||
|
color: #0f4c75;
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.case-detail-page-shell .header-bg .badge {
|
.case-detail-page-shell .header-bg .badge {
|
||||||
@ -3357,10 +3392,10 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="case-detail-page-shell" style="background-color: #f1f5f9; min-height: 100vh;">
|
<div class="case-detail-page-shell" style="background: linear-gradient(to right, #0f4c75, #1e3c72) 0 0 / 100% 420px no-repeat, #ffffff; min-height: 100vh; margin-top: -1.35rem;">
|
||||||
|
|
||||||
<!-- HEADER BG: Clean Title & Actions WITHOUT Selectors -->
|
<!-- HEADER BG: Clean Title & Actions WITHOUT Selectors -->
|
||||||
<div class="header-bg py-2" style="background: linear-gradient(to right, #0f4c75, #1e3c72); color: white; margin-top: -1rem;">
|
<div class="header-bg py-2" style="background: linear-gradient(to right, #0f4c75, #1e3c72); color: white; margin-top: -0.3rem;">
|
||||||
<div class="container-fluid px-3 px-xl-4 position-relative">
|
<div class="container-fluid px-3 px-xl-4 position-relative">
|
||||||
<div class="d-flex justify-content-between align-items-start mb-2 flex-wrap gap-2 header-top-row">
|
<div class="d-flex justify-content-between align-items-start mb-2 flex-wrap gap-2 header-top-row">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
@ -3391,7 +3426,7 @@
|
|||||||
|
|
||||||
<div class="mb-1">
|
<div class="mb-1">
|
||||||
<div id="sag-titel-view" class="d-flex align-items-center gap-2">
|
<div id="sag-titel-view" class="d-flex align-items-center gap-2">
|
||||||
<h2 id="sag-titel-text" class="fw-bolder mb-1 text-white" style="letter-spacing: -0.5px;">{{ case.titel }}</h2>
|
<h2 id="sag-titel-text" class="fw-bolder mb-1 text-white" style="font-size: 1.55rem; letter-spacing: -0.35px;">{{ case.titel }}</h2>
|
||||||
<button class="btn btn-sm btn-link text-white-50 p-0 mb-1" onclick="startTitelEdit()" title="Rediger overskrift"><i class="bi bi-pencil-square"></i></button>
|
<button class="btn btn-sm btn-link text-white-50 p-0 mb-1" onclick="startTitelEdit()" title="Rediger overskrift"><i class="bi bi-pencil-square"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="sag-titel-editor" class="d-none">
|
<div id="sag-titel-editor" class="d-none">
|
||||||
@ -3415,7 +3450,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- WIDGET DASHBOARD -->
|
<!-- WIDGET DASHBOARD -->
|
||||||
<div class="container-fluid px-3 px-xl-4 position-relative" style="z-index: 10; margin-top: -1.25rem;">
|
<div class="container-fluid px-3 px-xl-4 position-relative" style="z-index: 10; margin-top: -0.95rem;">
|
||||||
<div class="row g-2 mb-3">
|
<div class="row g-2 mb-3">
|
||||||
|
|
||||||
<!-- Widget 1: Klassifikation -->
|
<!-- Widget 1: Klassifikation -->
|
||||||
@ -3426,7 +3461,7 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Status</label>
|
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Status</label>
|
||||||
<select id="topbarStatusSelect" class="form-select form-select-sm bg-light" onchange="saveCaseStatusFromTopbar()" style="width: 62%;">
|
<select id="topbarStatusSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
|
||||||
{% for st in status_options %}
|
{% for st in status_options %}
|
||||||
<option value="{{ st }}" {% if (case.status or '')|lower == st|lower %}selected{% endif %}>{{ st|capitalize }}</option>
|
<option value="{{ st }}" {% if (case.status or '')|lower == st|lower %}selected{% endif %}>{{ st|capitalize }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -3435,7 +3470,7 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Type</label>
|
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Type</label>
|
||||||
<select id="topbarTypeSelect" class="form-select form-select-sm bg-light" onchange="saveCaseTypeFromTopbar()" style="width: 62%;">
|
<select id="topbarTypeSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
|
||||||
{% set topbar_type = (case.template_key or case.type or 'ticket')|lower %}
|
{% set topbar_type = (case.template_key or case.type or 'ticket')|lower %}
|
||||||
<option value="ticket" {% if topbar_type == 'ticket' %}selected{% endif %}>Ticket</option>
|
<option value="ticket" {% if topbar_type == 'ticket' %}selected{% endif %}>Ticket</option>
|
||||||
<option value="pipeline" {% if topbar_type == 'pipeline' %}selected{% endif %}>Pipeline</option>
|
<option value="pipeline" {% if topbar_type == 'pipeline' %}selected{% endif %}>Pipeline</option>
|
||||||
@ -3448,7 +3483,7 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Prioritet</label>
|
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Prioritet</label>
|
||||||
<select id="topbarPrioritySelect" class="form-select form-select-sm bg-light" onchange="saveCasePriorityFromTopbar()" style="width: 62%;">
|
<select id="topbarPrioritySelect" class="form-select form-select-sm bg-light" style="width: 62%;">
|
||||||
{% set topbar_priority = (case.priority or 'normal')|lower %}
|
{% set topbar_priority = (case.priority or 'normal')|lower %}
|
||||||
<option value="low" {% if topbar_priority == 'low' %}selected{% endif %}>Lav</option>
|
<option value="low" {% if topbar_priority == 'low' %}selected{% endif %}>Lav</option>
|
||||||
<option value="normal" {% if topbar_priority == 'normal' %}selected{% endif %}>Normal</option>
|
<option value="normal" {% if topbar_priority == 'normal' %}selected{% endif %}>Normal</option>
|
||||||
@ -3468,7 +3503,7 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Ansvarlig</label>
|
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Ansvarlig</label>
|
||||||
<select id="tabsAssignmentUserSelect" class="form-select form-select-sm bg-light" onchange="saveAssignmentFromTabsBar()" style="width: 62%;">
|
<select id="tabsAssignmentUserSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
|
||||||
<option value="">Ingen bruger</option>
|
<option value="">Ingen bruger</option>
|
||||||
{% for user in assignment_users or [] %}
|
{% for user in assignment_users or [] %}
|
||||||
<option value="{{ user.user_id }}" {% if case.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>
|
<option value="{{ user.user_id }}" {% if case.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>
|
||||||
@ -3478,7 +3513,7 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Gruppe</label>
|
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Gruppe</label>
|
||||||
<select id="tabsAssignmentGroupSelect" class="form-select form-select-sm bg-light" onchange="saveAssignmentFromTabsBar()" style="width: 62%;">
|
<select id="tabsAssignmentGroupSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
|
||||||
<option value="">Ingen gruppe</option>
|
<option value="">Ingen gruppe</option>
|
||||||
{% for group in assignment_groups or [] %}
|
{% for group in assignment_groups or [] %}
|
||||||
<option value="{{ group.id }}" {% if case.assigned_group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
|
<option value="{{ group.id }}" {% if case.assigned_group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
|
||||||
@ -3514,7 +3549,7 @@
|
|||||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Start</label>
|
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Start</label>
|
||||||
<div class="input-group input-group-sm" style="width: 62%;">
|
<div class="input-group input-group-sm" style="width: 62%;">
|
||||||
<input id="topbarStartDateInput" type="date" class="form-control bg-light" value="{{ case.start_date.strftime('%Y-%m-%d') if case.start_date else '' }}" onchange="saveCaseStartDateFromTopbar()">
|
<input id="topbarStartDateInput" type="date" class="form-control bg-light" value="{{ case.start_date.strftime('%Y-%m-%d') if case.start_date else '' }}">
|
||||||
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"></button>
|
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"></button>
|
||||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;">
|
<ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;">
|
||||||
<li><a class="dropdown-item text-danger" href="#" onclick="event.preventDefault(); clearCaseStartDateFromTopbar();"><i class="bi bi-x-circle me-2"></i>Ryd dato</a></li>
|
<li><a class="dropdown-item text-danger" href="#" onclick="event.preventDefault(); clearCaseStartDateFromTopbar();"><i class="bi bi-x-circle me-2"></i>Ryd dato</a></li>
|
||||||
@ -3531,7 +3566,7 @@
|
|||||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
<label class="mb-0 text-secondary" style="font-size:0.8rem;" title="Start senest (Trigger)">Senest/Udsat</label>
|
<label class="mb-0 text-secondary" style="font-size:0.8rem;" title="Start senest (Trigger)">Senest/Udsat</label>
|
||||||
<div class="input-group input-group-sm" style="width: 62%;">
|
<div class="input-group input-group-sm" style="width: 62%;">
|
||||||
<input id="topbarDeferredInput" type="date" class="form-control bg-light" value="{{ case.deferred_until.strftime('%Y-%m-%d') if case.deferred_until else '' }}" onchange="updateDeferredUntil(this.value || null)">
|
<input id="topbarDeferredInput" type="date" class="form-control bg-light" value="{{ case.deferred_until.strftime('%Y-%m-%d') if case.deferred_until else '' }}">
|
||||||
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"></button>
|
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"></button>
|
||||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;">
|
<ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;">
|
||||||
<li><a class="dropdown-item text-danger" href="#" onclick="event.preventDefault(); updateDeferredUntil(null); document.getElementById('topbarDeferredInput').value='';"><i class="bi bi-x-circle me-2"></i>Ryd dato</a></li>
|
<li><a class="dropdown-item text-danger" href="#" onclick="event.preventDefault(); updateDeferredUntil(null); document.getElementById('topbarDeferredInput').value='';"><i class="bi bi-x-circle me-2"></i>Ryd dato</a></li>
|
||||||
@ -3545,7 +3580,7 @@
|
|||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Deadline</label>
|
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Deadline</label>
|
||||||
<div class="input-group input-group-sm" style="width: 62%;">
|
<div class="input-group input-group-sm" style="width: 62%;">
|
||||||
<input id="topbarDeadlineInput" type="date" class="form-control {{ 'text-danger fw-bold' if is_deadline_overdue else '' }} bg-light" value="{{ case.deadline.strftime('%Y-%m-%d') if case.deadline else '' }}" onchange="updateDeadline(this.value || null)">
|
<input id="topbarDeadlineInput" type="date" class="form-control {{ 'text-danger fw-bold' if is_deadline_overdue else '' }} bg-light" value="{{ case.deadline.strftime('%Y-%m-%d') if case.deadline else '' }}">
|
||||||
<button class="btn btn-outline-danger bg-light" type="button" onclick="updateDeadline(null); document.getElementById('topbarDeadlineInput').value=''" title="Fjern deadline"><i class="bi bi-x"></i></button>
|
<button class="btn btn-outline-danger bg-light" type="button" onclick="updateDeadline(null); document.getElementById('topbarDeadlineInput').value=''" title="Fjern deadline"><i class="bi bi-x"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -3568,8 +3603,8 @@
|
|||||||
<div id="caseCustomerTopAlerts" class="mb-2 d-none"></div>
|
<div id="caseCustomerTopAlerts" class="mb-2 d-none"></div>
|
||||||
|
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
<button type="button" id="caseAnyDeskOpenBtn" class="btn btn-sm btn-white bg-white border text-start shadow-sm" onclick="openCaseAnyDeskModal()"><i class="bi bi-display text-primary me-2"></i> AnyDesk Quick Connect</button>
|
<button type="button" id="caseAnyDeskOpenBtn" class="btn btn-sm btn-white bg-white border text-start shadow-sm"><i class="bi bi-display text-primary me-2"></i> AnyDesk Quick Connect</button>
|
||||||
<button type="button" class="btn btn-sm btn-white bg-white border text-start shadow-sm" onclick="openCaseWorkOrderPrint()"><i class="bi bi-printer text-primary me-2"></i> Print Arbejdsseddel</button>
|
<button type="button" id="topbarPrintWorkOrderBtn" class="btn btn-sm btn-white bg-white border text-start shadow-sm"><i class="bi bi-printer text-primary me-2"></i> Print Arbejdsseddel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -3642,6 +3677,93 @@
|
|||||||
window.forceCaseTabActivation(tabId);
|
window.forceCaseTabActivation(tabId);
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
(function bindTopWidgetActions() {
|
||||||
|
const localCaseId = Number({{ case.id }});
|
||||||
|
|
||||||
|
const patchCase = async (payload) => {
|
||||||
|
const response = await fetch(`/api/v1/sag/${localCaseId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindChange = (id, handler) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener('change', async () => {
|
||||||
|
try {
|
||||||
|
await handler(el);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Top widget change failed for ${id}`, error);
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast('Kunne ikke gemme ændringen', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
bindChange('topbarStatusSelect', async (el) => {
|
||||||
|
await patchCase({ status: el.value || 'åben' });
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
bindChange('topbarTypeSelect', async (el) => {
|
||||||
|
await patchCase({ type: String(el.value || 'ticket').toLowerCase() });
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
bindChange('topbarPrioritySelect', async (el) => {
|
||||||
|
await patchCase({ priority: String(el.value || 'normal').toLowerCase() });
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
bindChange('tabsAssignmentUserSelect', async (el) => {
|
||||||
|
const raw = String(el.value || '').trim();
|
||||||
|
await patchCase({ ansvarlig_bruger_id: raw ? Number(raw) : null });
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
bindChange('tabsAssignmentGroupSelect', async (el) => {
|
||||||
|
const raw = String(el.value || '').trim();
|
||||||
|
await patchCase({ assigned_group_id: raw ? Number(raw) : null });
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
bindChange('topbarStartDateInput', async (el) => {
|
||||||
|
await patchCase({ start_date: el.value || null });
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
bindChange('topbarDeferredInput', async (el) => {
|
||||||
|
await patchCase({ deferred_until: el.value || null });
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
bindChange('topbarDeadlineInput', async (el) => {
|
||||||
|
await patchCase({ deadline: el.value || null });
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
const printBtn = document.getElementById('topbarPrintWorkOrderBtn');
|
||||||
|
if (printBtn) {
|
||||||
|
printBtn.addEventListener('click', () => {
|
||||||
|
window.open(`/sag/${localCaseId}/work-orders/print`, '_blank', 'noopener');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const anydeskBtn = document.getElementById('caseAnyDeskOpenBtn');
|
||||||
|
if (anydeskBtn && typeof window.openCaseAnyDeskModal === 'function') {
|
||||||
|
anydeskBtn.addEventListener('click', () => {
|
||||||
|
window.openCaseAnyDeskModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Tabs Navigation -->
|
<!-- Tabs Navigation -->
|
||||||
|
|||||||
@ -349,20 +349,34 @@ function renderLinkContactResults(results) {
|
|||||||
async function searchContacts(query) {
|
async function searchContacts(query) {
|
||||||
const token = ++linkContactState.searchToken;
|
const token = ++linkContactState.searchToken;
|
||||||
const container = document.getElementById('linkContactResults');
|
const container = document.getElementById('linkContactResults');
|
||||||
|
const trimmed = String(query || '').trim();
|
||||||
|
|
||||||
|
if (trimmed.length < 2) {
|
||||||
|
renderLinkContactResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (container) {
|
if (container) {
|
||||||
container.innerHTML = '<div class="alert alert-light border mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Søger...</div>';
|
container.innerHTML = '<div class="alert alert-light border mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Søger...</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const qs = new URLSearchParams({ search: query || '', limit: '30', offset: '0' });
|
const qs = new URLSearchParams({ q: trimmed });
|
||||||
const res = await fetch(`/api/v1/contacts?${qs.toString()}`, { credentials: 'include' });
|
let res = await fetch(`/api/v1/search/contacts?${qs.toString()}`, { credentials: 'include' });
|
||||||
|
|
||||||
|
// Backward compatible fallback in case search router is unavailable.
|
||||||
|
if (!res.ok && (res.status === 404 || res.status === 405)) {
|
||||||
|
const legacyQs = new URLSearchParams({ search: trimmed, limit: '30', offset: '0' });
|
||||||
|
res = await fetch(`/api/v1/contacts?${legacyQs.toString()}`, { credentials: 'include' });
|
||||||
|
}
|
||||||
|
|
||||||
if (token !== linkContactState.searchToken) return;
|
if (token !== linkContactState.searchToken) return;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (container) container.innerHTML = '<div class="alert alert-danger mb-0">Kunne ikke søge kontakter</div>';
|
if (container) container.innerHTML = '<div class="alert alert-danger mb-0">Kunne ikke søge kontakter</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const results = Array.isArray(data?.contacts) ? data.contacts : [];
|
const results = Array.isArray(data) ? data : (Array.isArray(data?.contacts) ? data.contacts : []);
|
||||||
renderLinkContactResults(results);
|
renderLinkContactResults(results);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (token !== linkContactState.searchToken) return;
|
if (token !== linkContactState.searchToken) return;
|
||||||
|
|||||||
@ -882,6 +882,9 @@
|
|||||||
<button class="btn btn-light rounded-circle border-0" id="quickCreateBtn" style="background: var(--accent-light); color: var(--accent);" title="Opret ny sag (+ eller Cmd+Shift+C)">
|
<button class="btn btn-light rounded-circle border-0" id="quickCreateBtn" style="background: var(--accent-light); color: var(--accent);" title="Opret ny sag (+ eller Cmd+Shift+C)">
|
||||||
<i class="bi bi-plus-circle-fill fs-5"></i>
|
<i class="bi bi-plus-circle-fill fs-5"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-light rounded-circle border-0" id="bugReportBtn" style="background: var(--accent-light); color: var(--accent);" title="Rapporter fejl (Ctrl+Shift+B)">
|
||||||
|
<i class="bi bi-bug"></i>
|
||||||
|
</button>
|
||||||
<button class="btn btn-light rounded-circle border-0" id="contextManualBtn" style="background: var(--accent-light); color: var(--accent);" title="Kontekstuel hjælp">
|
<button class="btn btn-light rounded-circle border-0" id="contextManualBtn" style="background: var(--accent-light); color: var(--accent);" title="Kontekstuel hjælp">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -1263,12 +1266,14 @@ window.addEventListener('unhandledrejection', function(event) {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
|
||||||
<script src="/static/js/tag-picker.js?v=2.2"></script>
|
<script src="/static/js/tag-picker.js?v=2.2"></script>
|
||||||
<script src="/static/js/task-template-selector.js?v=1.1"></script>
|
<script src="/static/js/task-template-selector.js?v=1.1"></script>
|
||||||
<script src="/static/js/notifications.js?v=1.0"></script>
|
<script src="/static/js/notifications.js?v=1.0"></script>
|
||||||
<script src="/static/js/telefoni.js?v=2.2"></script>
|
<script src="/static/js/telefoni.js?v=2.2"></script>
|
||||||
<script src="/static/js/sms.js?v=1.0"></script>
|
<script src="/static/js/sms.js?v=1.0"></script>
|
||||||
<script src="/static/js/bottom-bar.js?v=2.23"></script>
|
<script src="/static/js/bottom-bar.js?v=2.23"></script>
|
||||||
|
<script src="/static/js/bug-report.js?v=1.6"></script>
|
||||||
<script>
|
<script>
|
||||||
// Dark Mode Toggle Logic
|
// Dark Mode Toggle Logic
|
||||||
window.BMC_CAN_CLICK_TO_CALL = {{ 'true' if _can_click_to_call else 'false' }};
|
window.BMC_CAN_CLICK_TO_CALL = {{ 'true' if _can_click_to_call else 'false' }};
|
||||||
@ -2018,6 +2023,9 @@ window.addEventListener('unhandledrejection', function(event) {
|
|||||||
<!-- QuickCreate Modal (AI-Powered Case Creation) -->
|
<!-- QuickCreate Modal (AI-Powered Case Creation) -->
|
||||||
{% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %}
|
{% include ["quick_create_modal.html", "shared/frontend/quick_create_modal.html"] ignore missing %}
|
||||||
|
|
||||||
|
<!-- Bug Report Modal -->
|
||||||
|
{% include ["bug_report_modal.html", "shared/frontend/bug_report_modal.html"] ignore missing %}
|
||||||
|
|
||||||
<!-- Manual Help Modal -->
|
<!-- Manual Help Modal -->
|
||||||
{% include ["manual_modal.html", "shared/frontend/manual_modal.html"] ignore missing %}
|
{% include ["manual_modal.html", "shared/frontend/manual_modal.html"] ignore missing %}
|
||||||
|
|
||||||
|
|||||||
47
app/shared/frontend/bug_report_modal.html
Normal file
47
app/shared/frontend/bug_report_modal.html
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<div class="modal fade" id="bugReportModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><i class="bi bi-bug me-2"></i>Rapporter fejl</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="alert alert-info py-2 small mb-3">
|
||||||
|
Screenshot tages automatisk af den aktuelle side.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="bugActualInput" class="form-label">Hvad gik galt?</label>
|
||||||
|
<textarea id="bugActualInput" class="form-control" rows="4" maxlength="8000" placeholder="Beskriv problemet"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="bugExpectedInput" class="form-label">Hvad burde være sket?</label>
|
||||||
|
<textarea id="bugExpectedInput" class="form-control" rows="4" maxlength="8000" placeholder="Forventet adfærd"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="bugExtraFileInput" class="form-label">Ekstra fil (valgfri)</label>
|
||||||
|
<input id="bugExtraFileInput" type="file" class="form-control" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Screenshot preview</label>
|
||||||
|
<div id="bugScreenshotPreviewWrap" class="border rounded p-2 bg-light text-center">
|
||||||
|
<span id="bugScreenshotPreviewPlaceholder" class="text-muted small">Ingen screenshot endnu</span>
|
||||||
|
<img id="bugScreenshotPreview" alt="Screenshot preview" style="display:none;max-width:100%;height:auto;border-radius:6px;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bugReportStatus" class="small text-muted mt-2"></div>
|
||||||
|
<div class="small text-muted mt-2">Screenshot forsøges automatisk ved klik på bug-ikonet. Du kan også indsætte med Cmd+V.</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="bugReportSubmitBtn">
|
||||||
|
<i class="bi bi-send me-1"></i>Send fejlrapport
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
90
fix_telefoni_per_krag_numbers.sql
Normal file
90
fix_telefoni_per_krag_numbers.sql
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
-- Fix malformed caller numbers for Per Krag's Yealink phone.
|
||||||
|
--
|
||||||
|
-- Run via:
|
||||||
|
-- podman exec -i bmc-hub-postgres-prod psql -U bmc_hub -d hubdb_v2 -f /dev/stdin < fix_telefoni_per_krag_numbers.sql
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- STEP 1: Fix any remaining numbers that still have the 1857220892 suffix
|
||||||
|
-- (threshold lowered from >15 to >10 to catch shorter corrupted numbers)
|
||||||
|
UPDATE telefoni_opkald
|
||||||
|
SET ekstern_nummer = CASE
|
||||||
|
-- Too few remaining digits → NULL
|
||||||
|
WHEN (length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10) <= 3
|
||||||
|
THEN NULL
|
||||||
|
-- 8 remaining digits = Danish number → +45 prefix
|
||||||
|
WHEN (length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10) = 8
|
||||||
|
THEN '+45' || left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
|
||||||
|
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
|
||||||
|
-- Other length → keep + prefix
|
||||||
|
ELSE '+' || left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
|
||||||
|
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
|
||||||
|
END
|
||||||
|
WHERE regexp_replace(ekstern_nummer, '[^0-9]', '', 'g') LIKE '%1857220892'
|
||||||
|
AND length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) > 10;
|
||||||
|
|
||||||
|
-- STEP 2: Fix numbers that were previously half-patched: '+' + exactly 8 digits.
|
||||||
|
-- '+[8 digits]' is not valid E.164 (minimum 9 digits needed), so these are
|
||||||
|
-- Danish numbers that lost their +45 prefix during the first patch.
|
||||||
|
UPDATE telefoni_opkald
|
||||||
|
SET ekstern_nummer = '+45' || regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')
|
||||||
|
WHERE ekstern_nummer ~ '^\+[0-9]{8}$';
|
||||||
|
|
||||||
|
-- Verify: show any remaining suspicious numbers for this user
|
||||||
|
SELECT id, started_at, ekstern_nummer
|
||||||
|
FROM telefoni_opkald t
|
||||||
|
JOIN users u ON u.user_id = t.bruger_id
|
||||||
|
WHERE u.full_name ILIKE '%per krag%'
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 20;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
|
||||||
|
-- Preview what will be changed
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
ekstern_nummer AS before,
|
||||||
|
CASE
|
||||||
|
WHEN (length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10) < 4
|
||||||
|
THEN NULL
|
||||||
|
WHEN (length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10) = 8
|
||||||
|
THEN '+45' || left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
|
||||||
|
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
|
||||||
|
WHEN ekstern_nummer LIKE '+%'
|
||||||
|
THEN '+' || left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
|
||||||
|
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
|
||||||
|
ELSE left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
|
||||||
|
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
|
||||||
|
END AS after
|
||||||
|
FROM telefoni_opkald
|
||||||
|
WHERE regexp_replace(ekstern_nummer, '[^0-9]', '', 'g') LIKE '%1857220892'
|
||||||
|
AND length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) > 15
|
||||||
|
ORDER BY started_at DESC;
|
||||||
|
|
||||||
|
-- Apply the fix
|
||||||
|
UPDATE telefoni_opkald
|
||||||
|
SET ekstern_nummer = CASE
|
||||||
|
-- Strip last 10 digits (the local SIP suffix)
|
||||||
|
WHEN (length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10) < 4
|
||||||
|
THEN NULL
|
||||||
|
-- 8 remaining digits = Danish number → prefix +45
|
||||||
|
WHEN (length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10) = 8
|
||||||
|
THEN '+45' || left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
|
||||||
|
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
|
||||||
|
-- Otherwise keep original + prefix
|
||||||
|
WHEN ekstern_nummer LIKE '+%'
|
||||||
|
THEN '+' || left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
|
||||||
|
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
|
||||||
|
ELSE left(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g'),
|
||||||
|
length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) - 10)
|
||||||
|
END
|
||||||
|
WHERE regexp_replace(ekstern_nummer, '[^0-9]', '', 'g') LIKE '%1857220892'
|
||||||
|
AND length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) > 15;
|
||||||
|
|
||||||
|
SELECT concat('Retter ', count(*), ' rækker') AS resultat
|
||||||
|
FROM telefoni_opkald
|
||||||
|
WHERE regexp_replace(ekstern_nummer, '[^0-9]', '', 'g') LIKE '%1857220892'
|
||||||
|
AND length(regexp_replace(ekstern_nummer, '[^0-9]', '', 'g')) > 15;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
44
main.py
44
main.py
@ -141,6 +141,7 @@ from app.modules.rentals.backend import router as rentals_api
|
|||||||
from app.modules.task_templates.backend import router as task_templates_api
|
from app.modules.task_templates.backend import router as task_templates_api
|
||||||
from app.economy.backend import router as economy_api
|
from app.economy.backend import router as economy_api
|
||||||
from app.economy.frontend import views as economy_views
|
from app.economy.frontend import views as economy_views
|
||||||
|
from app.bug_reports.backend import router as bug_reports_api
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -154,6 +155,34 @@ logging.basicConfig(
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class _AccessLogPathFilter(logging.Filter):
|
||||||
|
"""Filter out high-frequency access log noise for specific polling endpoints."""
|
||||||
|
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
try:
|
||||||
|
target = "/api/v1/bottom-bar/state"
|
||||||
|
|
||||||
|
# Match rendered access message (works across uvicorn format variations).
|
||||||
|
message = record.getMessage() or ""
|
||||||
|
if f"GET {target}" in message or f'"GET {target}' in message:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Fallback: match structured args when available.
|
||||||
|
args = record.args if isinstance(record.args, tuple) else ()
|
||||||
|
if len(args) >= 3:
|
||||||
|
method = str(args[1])
|
||||||
|
path = str(args[2])
|
||||||
|
if method == "GET" and path.startswith(target):
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
# Never break logging on filter errors.
|
||||||
|
return True
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
logging.getLogger("uvicorn.access").addFilter(_AccessLogPathFilter())
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Lifecycle management - startup and shutdown"""
|
"""Lifecycle management - startup and shutdown"""
|
||||||
@ -460,6 +489,7 @@ app.include_router(bottom_bar_public_api.router, tags=["Bottom Bar Public"])
|
|||||||
app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Billing"])
|
app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Billing"])
|
||||||
app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"])
|
app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"])
|
||||||
app.include_router(economy_api.router, prefix="/api/v1", tags=["Economy"])
|
app.include_router(economy_api.router, prefix="/api/v1", tags=["Economy"])
|
||||||
|
app.include_router(bug_reports_api.router, prefix="/api/v1", tags=["Bug Reports"])
|
||||||
|
|
||||||
if settings.LINKS_MODULE_ENABLED:
|
if settings.LINKS_MODULE_ENABLED:
|
||||||
from app.modules.links.backend import router as links_api
|
from app.modules.links.backend import router as links_api
|
||||||
@ -516,8 +546,20 @@ async def health_check():
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
from copy import deepcopy
|
||||||
|
from uvicorn.config import LOGGING_CONFIG as UVICORN_LOGGING_CONFIG
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
log_config = deepcopy(UVICORN_LOGGING_CONFIG)
|
||||||
|
log_config.setdefault("filters", {})["suppress_bottom_bar_polling"] = {
|
||||||
|
"()": _AccessLogPathFilter,
|
||||||
|
}
|
||||||
|
access_handler = log_config.get("handlers", {}).get("access", {})
|
||||||
|
existing_filters = list(access_handler.get("filters", []))
|
||||||
|
if "suppress_bottom_bar_polling" not in existing_filters:
|
||||||
|
existing_filters.append("suppress_bottom_bar_polling")
|
||||||
|
access_handler["filters"] = existing_filters
|
||||||
|
|
||||||
# Only enable reload in local development (not in Docker) - check both variables
|
# Only enable reload in local development (not in Docker) - check both variables
|
||||||
enable_reload = (
|
enable_reload = (
|
||||||
os.getenv("ENABLE_RELOAD", "false").lower() == "true" or
|
os.getenv("ENABLE_RELOAD", "false").lower() == "true" or
|
||||||
@ -533,6 +575,7 @@ if __name__ == "__main__":
|
|||||||
reload_includes=["*.py"],
|
reload_includes=["*.py"],
|
||||||
reload_dirs=["app"],
|
reload_dirs=["app"],
|
||||||
reload_excludes=[".git/*", "*.pyc", "__pycache__/*", "logs/*", "uploads/*", "data/*"],
|
reload_excludes=[".git/*", "*.pyc", "__pycache__/*", "logs/*", "uploads/*", "data/*"],
|
||||||
|
log_config=log_config,
|
||||||
log_level="info"
|
log_level="info"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@ -544,5 +587,6 @@ if __name__ == "__main__":
|
|||||||
workers=2,
|
workers=2,
|
||||||
timeout_keep_alive=65,
|
timeout_keep_alive=65,
|
||||||
access_log=True,
|
access_log=True,
|
||||||
|
log_config=log_config,
|
||||||
log_level="info"
|
log_level="info"
|
||||||
)
|
)
|
||||||
|
|||||||
15
migrations/1004_bug_report_submissions.sql
Normal file
15
migrations/1004_bug_report_submissions.sql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
-- Track bug report submissions for rate limiting and auditing.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bug_report_submissions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
screenshot_attached BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bug_report_submissions_user_time
|
||||||
|
ON bug_report_submissions(user_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bug_report_submissions_sag
|
||||||
|
ON bug_report_submissions(sag_id);
|
||||||
452
static/js/bug-report.js
Normal file
452
static/js/bug-report.js
Normal file
@ -0,0 +1,452 @@
|
|||||||
|
(function () {
|
||||||
|
const logs = [];
|
||||||
|
const MAX_LOGS = 200;
|
||||||
|
let bugModal = null;
|
||||||
|
let screenshotDataUrl = null;
|
||||||
|
let pendingScreenshotPromise = null;
|
||||||
|
|
||||||
|
const pushLog = (type, args) => {
|
||||||
|
try {
|
||||||
|
logs.push({
|
||||||
|
type,
|
||||||
|
message: (args || []).map((x) => {
|
||||||
|
if (typeof x === 'string') return x;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(x);
|
||||||
|
} catch (_) {
|
||||||
|
return String(x);
|
||||||
|
}
|
||||||
|
}).join(' '),
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
if (logs.length > MAX_LOGS) {
|
||||||
|
logs.splice(0, logs.length - MAX_LOGS);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
['log', 'warn', 'error'].forEach((type) => {
|
||||||
|
const original = console[type];
|
||||||
|
console[type] = function (...args) {
|
||||||
|
pushLog(type, args);
|
||||||
|
original.apply(console, args);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
logs.push({
|
||||||
|
type: 'error',
|
||||||
|
message: event.message,
|
||||||
|
url: event.filename,
|
||||||
|
line: event.lineno,
|
||||||
|
col: event.colno,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
if (logs.length > MAX_LOGS) {
|
||||||
|
logs.splice(0, logs.length - MAX_LOGS);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function getCurrentUser() {
|
||||||
|
const metaUser = document.querySelector('meta[name="user-id"]');
|
||||||
|
const userFromMeta = metaUser ? metaUser.getAttribute('content') : null;
|
||||||
|
let userFromToken = null;
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
userFromToken = payload.sub || payload.user_id || null;
|
||||||
|
} catch (_) {
|
||||||
|
userFromToken = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return userFromToken || userFromMeta || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetadata() {
|
||||||
|
return {
|
||||||
|
url: window.location.href,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
screenSize: `${window.innerWidth}x${window.innerHeight}`,
|
||||||
|
user: getCurrentUser(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toDataUrl(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result || ''));
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCrossOriginUrl(url) {
|
||||||
|
try {
|
||||||
|
if (!url) return false;
|
||||||
|
const parsed = new URL(url, window.location.href);
|
||||||
|
return parsed.origin !== window.location.origin;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIgnoreInScreenshot(el) {
|
||||||
|
if (!el || !el.tagName) return false;
|
||||||
|
const tag = String(el.tagName).toUpperCase();
|
||||||
|
|
||||||
|
if (tag === 'IFRAME' || tag === 'VIDEO' || tag === 'OBJECT' || tag === 'EMBED') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag === 'IMG') {
|
||||||
|
const src = el.getAttribute('src') || '';
|
||||||
|
return isCrossOriginUrl(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderScreenshot(target, opts) {
|
||||||
|
const canvas = await window.html2canvas(target, opts);
|
||||||
|
return canvas.toDataURL('image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function takeScreenshotViaDisplayMedia() {
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
||||||
|
throw new Error('Display media API not supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||||
|
video: true,
|
||||||
|
audio: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.srcObject = stream;
|
||||||
|
video.muted = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
video.onloadedmetadata = () => resolve();
|
||||||
|
video.onerror = () => reject(new Error('Kunne ikke læse videostream'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await video.play();
|
||||||
|
|
||||||
|
const width = Math.max(1, video.videoWidth || window.innerWidth);
|
||||||
|
const height = Math.max(1, video.videoHeight || window.innerHeight);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('Canvas context unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(video, 0, 0, width, height);
|
||||||
|
return canvas.toDataURL('image/png');
|
||||||
|
} finally {
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function takeScreenshot() {
|
||||||
|
if (!window.html2canvas) {
|
||||||
|
throw new Error('Screenshot library not loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = document.documentElement;
|
||||||
|
const body = document.body;
|
||||||
|
const fullWidth = Math.max(
|
||||||
|
doc ? doc.scrollWidth : 0,
|
||||||
|
doc ? doc.offsetWidth : 0,
|
||||||
|
doc ? doc.clientWidth : 0,
|
||||||
|
body ? body.scrollWidth : 0,
|
||||||
|
body ? body.offsetWidth : 0,
|
||||||
|
window.innerWidth || 0
|
||||||
|
);
|
||||||
|
const fullHeight = Math.max(
|
||||||
|
doc ? doc.scrollHeight : 0,
|
||||||
|
doc ? doc.offsetHeight : 0,
|
||||||
|
doc ? doc.clientHeight : 0,
|
||||||
|
body ? body.scrollHeight : 0,
|
||||||
|
body ? body.offsetHeight : 0,
|
||||||
|
window.innerHeight || 0
|
||||||
|
);
|
||||||
|
|
||||||
|
const common = {
|
||||||
|
useCORS: true,
|
||||||
|
allowTaint: false,
|
||||||
|
logging: false,
|
||||||
|
scale: 1,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
imageTimeout: 3000,
|
||||||
|
ignoreElements: shouldIgnoreInScreenshot,
|
||||||
|
removeContainer: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Strategy 1: Full page (most useful when it works)
|
||||||
|
try {
|
||||||
|
return await renderScreenshot(document.documentElement, {
|
||||||
|
...common,
|
||||||
|
width: fullWidth,
|
||||||
|
height: fullHeight,
|
||||||
|
windowWidth: fullWidth,
|
||||||
|
windowHeight: fullHeight,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scrollX: 0,
|
||||||
|
scrollY: 0,
|
||||||
|
});
|
||||||
|
} catch (e1) {
|
||||||
|
console.warn('Bug report screenshot strategy 1 failed', e1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: Main content only (explicit selectors avoid navbar-only captures)
|
||||||
|
const contentRoot =
|
||||||
|
document.querySelector('.container-fluid.px-4.py-4') ||
|
||||||
|
document.querySelector('[data-bugreport-root]') ||
|
||||||
|
document.querySelector('#main-content') ||
|
||||||
|
document.querySelector('#content') ||
|
||||||
|
document.querySelector('main') ||
|
||||||
|
document.querySelector('.content-wrapper') ||
|
||||||
|
document.documentElement;
|
||||||
|
try {
|
||||||
|
return await renderScreenshot(contentRoot, {
|
||||||
|
...common,
|
||||||
|
width: Math.max(contentRoot.scrollWidth || 0, contentRoot.clientWidth || 0, window.innerWidth || 0),
|
||||||
|
height: Math.max(contentRoot.scrollHeight || 0, contentRoot.clientHeight || 0, window.innerHeight || 0),
|
||||||
|
scrollX: 0,
|
||||||
|
scrollY: 0,
|
||||||
|
});
|
||||||
|
} catch (e2) {
|
||||||
|
console.warn('Bug report screenshot strategy 2 failed', e2);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Automatic screenshot failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text, isError) {
|
||||||
|
const el = document.getElementById('bugReportStatus');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text || '';
|
||||||
|
el.className = isError ? 'small text-danger mt-2' : 'small text-muted mt-2';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPreview(dataUrl) {
|
||||||
|
const img = document.getElementById('bugScreenshotPreview');
|
||||||
|
const placeholder = document.getElementById('bugScreenshotPreviewPlaceholder');
|
||||||
|
if (!img || !placeholder) return;
|
||||||
|
if (dataUrl) {
|
||||||
|
img.src = dataUrl;
|
||||||
|
img.style.display = '';
|
||||||
|
placeholder.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
img.removeAttribute('src');
|
||||||
|
img.style.display = 'none';
|
||||||
|
placeholder.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePasteScreenshot(event) {
|
||||||
|
const modalVisible = document.getElementById('bugReportModal')?.classList.contains('show');
|
||||||
|
if (!modalVisible) return;
|
||||||
|
|
||||||
|
const items = Array.from(event.clipboardData?.items || []);
|
||||||
|
const imageItem = items.find((item) => item.kind === 'file' && item.type.startsWith('image/'));
|
||||||
|
if (!imageItem) return;
|
||||||
|
|
||||||
|
const blob = imageItem.getAsFile();
|
||||||
|
if (!blob) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
const dataUrl = await toDataUrl(blob);
|
||||||
|
screenshotDataUrl = dataUrl;
|
||||||
|
setPreview(dataUrl);
|
||||||
|
setStatus('Screenshot indsat fra clipboard.');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Clipboard screenshot parse failed', e);
|
||||||
|
setStatus('Kunne ikke indsætte screenshot fra clipboard.', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBugReportModal() {
|
||||||
|
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. Vedhæft et billede manuelt eller indsæt med Cmd+V.', true);
|
||||||
|
} finally {
|
||||||
|
pendingScreenshotPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bugModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareScreenshotFromTrigger() {
|
||||||
|
pendingScreenshotPromise = (async () => {
|
||||||
|
try {
|
||||||
|
return await takeScreenshot();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Bug report html2canvas capture failed, trying display media', e);
|
||||||
|
return await takeScreenshotViaDisplayMedia();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return pendingScreenshotPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBugReport() {
|
||||||
|
const actual = (document.getElementById('bugActualInput')?.value || '').trim();
|
||||||
|
const expected = (document.getElementById('bugExpectedInput')?.value || '').trim();
|
||||||
|
const fileInput = document.getElementById('bugExtraFileInput');
|
||||||
|
const submitBtn = document.getElementById('bugReportSubmitBtn');
|
||||||
|
|
||||||
|
if (!actual || !expected) {
|
||||||
|
setStatus('Udfyld både "Hvad gik galt" og "Hvad burde være sket".', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let extraFileName = null;
|
||||||
|
let extraFileBase64 = null;
|
||||||
|
if (fileInput && fileInput.files && fileInput.files[0]) {
|
||||||
|
const f = fileInput.files[0];
|
||||||
|
extraFileName = f.name;
|
||||||
|
try {
|
||||||
|
extraFileBase64 = await toDataUrl(f);
|
||||||
|
} catch (_) {
|
||||||
|
setStatus('Kunne ikke læse ekstra fil.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
actual,
|
||||||
|
expected,
|
||||||
|
screenshot_base64: screenshotDataUrl,
|
||||||
|
metadata: getMetadata(),
|
||||||
|
logs,
|
||||||
|
extra_file_name: extraFileName,
|
||||||
|
extra_file_base64: extraFileBase64,
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevText = submitBtn ? submitBtn.innerHTML : '';
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sender...';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endpoints = ['/api/v1/bug-reports', '/bug-reports'];
|
||||||
|
let res = null;
|
||||||
|
let data = {};
|
||||||
|
|
||||||
|
for (const endpoint of endpoints) {
|
||||||
|
res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
data = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
|
// Try fallback endpoint only when path is missing.
|
||||||
|
if (res.status === 404) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
const detail = (data && (data.detail || data.message)) || 'Kunne ikke sende fejlrapport';
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('Fejl rapporteret.');
|
||||||
|
const target = data.case_url || '/sag';
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = target;
|
||||||
|
}, 500);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e.message || 'Kunne ikke sende fejlrapport', true);
|
||||||
|
} finally {
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = prevText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const btn = document.getElementById('bugReportBtn');
|
||||||
|
const submitBtn = document.getElementById('bugReportSubmitBtn');
|
||||||
|
const modalEl = document.getElementById('bugReportModal');
|
||||||
|
|
||||||
|
if (btn) {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!pendingScreenshotPromise) {
|
||||||
|
prepareScreenshotFromTrigger();
|
||||||
|
}
|
||||||
|
openBugReportModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.addEventListener('click', () => {
|
||||||
|
submitBugReport();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modalEl) {
|
||||||
|
modalEl.addEventListener('hidden.bs.modal', () => {
|
||||||
|
const actual = document.getElementById('bugActualInput');
|
||||||
|
const expected = document.getElementById('bugExpectedInput');
|
||||||
|
const fileInput = document.getElementById('bugExtraFileInput');
|
||||||
|
if (actual) actual.value = '';
|
||||||
|
if (expected) expected.value = '';
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
screenshotDataUrl = null;
|
||||||
|
setPreview(null);
|
||||||
|
setStatus('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('paste', (e) => {
|
||||||
|
handlePasteScreenshot(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
const isTyping = ['INPUT', 'TEXTAREA'].includes((e.target?.tagName || '').toUpperCase());
|
||||||
|
if (isTyping) return;
|
||||||
|
if (e.ctrlKey && e.shiftKey && (e.key === 'B' || e.key === 'b')) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!pendingScreenshotPromise) {
|
||||||
|
prepareScreenshotFromTrigger();
|
||||||
|
}
|
||||||
|
openBugReportModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
10
updateto.sh
10
updateto.sh
@ -28,9 +28,6 @@ fi
|
|||||||
CURRENT_IP=$(hostname -I | awk '{print $1}' 2>/dev/null || echo "unknown")
|
CURRENT_IP=$(hostname -I | awk '{print $1}' 2>/dev/null || echo "unknown")
|
||||||
CURRENT_DIR=$(pwd)
|
CURRENT_DIR=$(pwd)
|
||||||
DEFAULT_STACK_NAME="prod"
|
DEFAULT_STACK_NAME="prod"
|
||||||
if [ "$CURRENT_DIR" = "/srv/podman/bmc_hub_v2" ]; then
|
|
||||||
DEFAULT_STACK_NAME="v2"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$CURRENT_IP" != "172.16.31.183" ]] && [[ "$CURRENT_DIR" != "/srv/podman/bmc_hub_v2" ]]; then
|
if [[ "$CURRENT_IP" != "172.16.31.183" ]] && [[ "$CURRENT_DIR" != "/srv/podman/bmc_hub_v2" ]]; then
|
||||||
echo "⚠️ ADVARSEL: Dette script skal kun køres på PRODUCTION serveren!"
|
echo "⚠️ ADVARSEL: Dette script skal kun køres på PRODUCTION serveren!"
|
||||||
@ -166,9 +163,14 @@ fi
|
|||||||
# Guard against host port conflicts before attempting startup
|
# Guard against host port conflicts before attempting startup
|
||||||
POSTGRES_BIND_ADDR="${POSTGRES_BIND_ADDR:-127.0.0.1}"
|
POSTGRES_BIND_ADDR="${POSTGRES_BIND_ADDR:-127.0.0.1}"
|
||||||
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
|
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
|
||||||
if podman ps --format '{{.Names}} {{.Ports}}' | grep -E "${POSTGRES_BIND_ADDR}:${POSTGRES_PORT}->5432/tcp" | grep -v "$POSTGRES_CONTAINER" >/dev/null 2>&1; then
|
PORT_HOLDERS=$(podman ps --format '{{.Names}} {{.Ports}}' | grep -E "${POSTGRES_BIND_ADDR}:${POSTGRES_PORT}->5432/tcp" | grep -v "$POSTGRES_CONTAINER" || true)
|
||||||
|
if [ -n "$PORT_HOLDERS" ]; then
|
||||||
echo "❌ Fejl: Portkonflikt på ${POSTGRES_BIND_ADDR}:${POSTGRES_PORT} (Postgres host-port)"
|
echo "❌ Fejl: Portkonflikt på ${POSTGRES_BIND_ADDR}:${POSTGRES_PORT} (Postgres host-port)"
|
||||||
echo " Sæt en ledig port i .env, fx POSTGRES_PORT=5433"
|
echo " Sæt en ledig port i .env, fx POSTGRES_PORT=5433"
|
||||||
|
if echo "$PORT_HOLDERS" | grep -q 'bmc-hub-postgres-prod' && [ "$STACK_NAME" != "prod" ]; then
|
||||||
|
echo " Tip: Aktiv prod-stack fundet. Kør med korrekt stack-navn:"
|
||||||
|
echo " STACK_NAME=prod ./updateto.sh $VERSION"
|
||||||
|
fi
|
||||||
podman ps --format 'table {{.Names}}\t{{.Ports}}'
|
podman ps --format 'table {{.Names}}\t{{.Ports}}'
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user