${escapeHtml(connection.sla_subscription_number || '')} · Pris ${formatDKK(connection.sla_price || 0)} · ${priceOk ? 'Pris registreret' : 'Prisen skal kontrolleres'}${statusOk ? '' : ' · SLA-aftalen er ikke aktiv'}
`
+ : `
Ingen SLA-aftale
${customerId ? (options.length ? 'Vælg kundens SLA-aftale.' : 'Kunden har ingen aktiv SLA-aftale, der kan allokeres.') : 'Tildel først forbindelsen til en kunde.'}
@@ -188,10 +188,13 @@
Alle forbindelserDelte hovedforbindelserBMCnet
+ Dedikerede
+ Ikke allokeret
+ Behandlede internetfakturaer
-
+
Forbindelser
@@ -200,7 +203,7 @@
-
Aktive
+
Aktive / afventer
0
@@ -232,7 +235,7 @@
-
+
@@ -296,13 +299,14 @@
-
+
-
+
Behandlede internetfakturaer
GlobalConnect-fakturaer, oprettede/opdaterede forbindelser, IP-ranges og behandlingsfejl.
+
+ GenkontrollérOpdater
@@ -382,6 +388,36 @@
+
+
+
+
+
+
Importér IP Nordic-forbindelser
+
Excel-filen kontrolleres før import. Kunder tilknyttes aldrig automatisk.
+
+
+
+
+
+
+
+
+
+
Handling
Rapporteret firma
Adresse
Start
Kost
Salg
+
+
+
+
+
+
+
+
+
@@ -391,6 +427,10 @@
Vælg en eksisterende forbindelse, opret en separat afventende forbindelse, eller ignorér linjen med en begrundelse. Fakturaen markeres gennemført, når alle kontrollinjer er afklaret.
- `).join('');
+ `}).join('');
+ }
+
+ async function assignSuggestedCustomer(connectionId, customerId) {
+ const response = await fetch(`/api/v1/internet-connections/${connectionId}`, {
+ method: 'PUT', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ customer_id: customerId }),
+ });
+ if (!response.ok) {
+ document.getElementById('pageSummaryText').textContent = await extractErrorMessage(response, 'Kunden kunne ikke tildeles.');
+ return;
+ }
+ await loadInternetPage();
}
async function submitConnectionForm() {
@@ -721,7 +903,7 @@
const saveButton = document.querySelector('#createConnectionBlock button.btn.btn-primary');
const payload = {
name: document.getElementById('connectionNameInput').value.trim(),
- provider: document.getElementById('connectionProviderInput').value.trim() || null,
+ vendor_id: Number(document.getElementById('connectionVendorInput').value || 0) || null,
customer_id: Number(document.getElementById('connectionCustomerIdInput').value || 0) || null,
circuit_number: document.getElementById('connectionCircuitInput').value.trim() || null,
address: document.getElementById('connectionAddressInput').value.trim() || null,
@@ -737,6 +919,8 @@
value_type: document.getElementById('connectionValueTypeInput').value || 'other',
value_label: document.getElementById('connectionValueLabelInput').value.trim() || null,
subscription_id: Number(document.getElementById('connectionSubscriptionIdInput').value || 0) || null,
+ is_manual_shared: document.getElementById('connectionAllocationInput').value === 'shared'
+ && document.getElementById('connectionValueTypeInput').value === 'delefiber',
};
if (!payload.name) {
@@ -769,7 +953,7 @@
feedback.textContent = 'Forbindelse oprettet.';
[
'connectionNameInput',
- 'connectionProviderInput',
+ 'connectionVendorInput',
'connectionCustomerIdInput',
'connectionCircuitInput',
'connectionAddressInput',
@@ -811,7 +995,23 @@
document.getElementById('tabAll').classList.toggle('active', tab === 'all');
document.getElementById('tabShared').classList.toggle('active', tab === 'shared');
document.getElementById('tabBmcnet').classList.toggle('active', tab === 'bmcnet');
- loadInternetPage();
+ document.getElementById('tabDedicated').classList.toggle('active', tab === 'dedicated');
+ document.getElementById('tabUnallocated').classList.toggle('active', tab === 'unallocated');
+ document.getElementById('tabInvoices').classList.toggle('active', tab === 'invoices');
+ const invoiceMode = tab === 'invoices';
+ document.getElementById('connectionsMetrics').classList.toggle('d-none', invoiceMode);
+ document.getElementById('connectionsOverview').classList.toggle('d-none', invoiceMode);
+ document.getElementById('createConnectionBlock').classList.add('collapse');
+ document.getElementById('createConnectionBlock').classList.remove('show');
+ document.getElementById('newConnectionBtn').classList.toggle('d-none', invoiceMode);
+ document.getElementById('invoiceProcessingOverview').classList.toggle('d-none', !invoiceMode);
+ if (invoiceMode) loadInvoiceSyncRuns();
+ else loadInternetPage();
+ }
+
+ function refreshActiveTab() {
+ if (activeTab === 'invoices') return loadInvoiceSyncRuns();
+ return loadInternetPage();
}
function toggleCreateValueFields() {
@@ -845,6 +1045,18 @@
.join('');
}
+ async function loadInternetVendors() {
+ const select = document.getElementById('connectionVendorInput');
+ try {
+ const response = await fetch('/api/v1/vendors?is_active=true&is_internet_provider=true&limit=100');
+ const vendors = response.ok ? await response.json() : [];
+ select.innerHTML = '' + vendors
+ .map((vendor) => ``).join('');
+ } catch (error) {
+ select.innerHTML = '';
+ }
+ }
+
document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('searchInput').addEventListener('keydown', (event) => {
if (event.key === 'Enter') loadInternetPage();
@@ -853,7 +1065,7 @@
document.getElementById('connectionSubscriptionIdInput').value = resolveSubscriptionId(document.getElementById('connectionSubscriptionInput').value) || '';
});
toggleCreateValueFields();
- await loadSubscriptionOptions();
+ await Promise.all([loadSubscriptionOptions(), loadInternetVendors()]);
await Promise.all([loadInternetPage(), loadInvoiceSyncRuns()]);
});
diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py
index c2967bd..8dada3b 100644
--- a/app/modules/sag/backend/router.py
+++ b/app/modules/sag/backend/router.py
@@ -419,7 +419,15 @@ class SagBuzzwordSelectionRequest(BaseModel):
class SagListPreferencesUpdate(BaseModel):
- type_filters: List[str] = Field(default_factory=list)
+ type_filters: Optional[List[str]] = None
+ column_order: Optional[List[str]] = None
+ hidden_columns: Optional[List[str]] = None
+
+
+SAG_LIST_COLUMN_KEYS = (
+ "id", "company", "contact", "description", "type", "priority", "status",
+ "owner", "group", "next_todo", "created", "start", "deferred", "deadline",
+)
def _normalize_email_list(values: List[str], field_name: str) -> List[str]:
@@ -1013,6 +1021,15 @@ async def create_sag(request: Request, data: dict):
case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket"
pipeline = data.get("pipeline") if case_type == "pipeline" else None
order_items = data.get("order_items") if case_type == "ordre" else []
+ raw_contact_ids = data.get("contact_ids") or []
+ if not isinstance(raw_contact_ids, list):
+ raise HTTPException(status_code=400, detail="contact_ids skal være en liste")
+ contact_ids = []
+ for raw_contact_id in raw_contact_ids:
+ contact_id = _coerce_optional_int(raw_contact_id, "contact_id")
+ if contact_id and contact_id not in contact_ids:
+ contact_ids.append(contact_id)
+ telefoni_opkald_id = _coerce_optional_int(data.get("telefoni_opkald_id"), "telefoni_opkald_id")
if pipeline is not None and not isinstance(pipeline, dict):
raise HTTPException(status_code=400, detail="pipeline skal være et objekt")
if not isinstance(order_items, list):
@@ -1098,6 +1115,37 @@ async def create_sag(request: Request, data: dict):
if not result:
raise HTTPException(status_code=500, detail="Failed to create case")
+ if contact_ids:
+ cursor.execute("SELECT id FROM contacts WHERE id = ANY(%s)", (contact_ids,))
+ existing_contact_ids = {int(row["id"]) for row in cursor.fetchall()}
+ missing_contact_ids = [contact_id for contact_id in contact_ids if contact_id not in existing_contact_ids]
+ if missing_contact_ids:
+ raise HTTPException(status_code=400, detail=f"Ugyldig kontakt: {missing_contact_ids[0]}")
+ for index, contact_id in enumerate(contact_ids):
+ cursor.execute(
+ """INSERT INTO sag_kontakter (sag_id, contact_id, role, is_primary)
+ VALUES (%s, %s, 'Kontakt', %s)""",
+ (result["id"], contact_id, index == 0),
+ )
+ cursor.execute(
+ """INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
+ VALUES (%s, %s, FALSE, 'Kontakt')
+ ON CONFLICT (contact_id, customer_id) DO NOTHING""",
+ (contact_id, data.get("customer_id")),
+ )
+
+ if telefoni_opkald_id:
+ cursor.execute(
+ """UPDATE telefoni_opkald
+ SET sag_id = %s,
+ kontakt_id = COALESCE(%s, kontakt_id)
+ WHERE id = %s
+ RETURNING id""",
+ (result["id"], contact_ids[0] if contact_ids else None, telefoni_opkald_id),
+ )
+ if not cursor.fetchone():
+ raise HTTPException(status_code=400, detail="Det valgte opkald findes ikke")
+
has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose")
for item in normalized_items:
if has_purchase_columns:
@@ -1259,16 +1307,14 @@ async def list_recent_sager(request: Request, limit: int = Query(10, ge=1, le=10
async def get_my_sag_list_preferences(request: Request):
user_id = _get_user_id_from_request(request)
try:
+ has_columns = table_has_column("user_sag_list_preferences", "column_order")
+ select_fields = "type_filters, column_order, hidden_columns" if has_columns else "type_filters"
rows = execute_query(
- """
- SELECT type_filters
- FROM user_sag_list_preferences
- WHERE user_id = %s
- """,
+ f"SELECT {select_fields} FROM user_sag_list_preferences WHERE user_id = %s",
(user_id,),
) or []
if not rows:
- return {"type_filters": []}
+ return {"type_filters": [], "column_order": list(SAG_LIST_COLUMN_KEYS), "hidden_columns": []}
raw = rows[0].get("type_filters")
parsed = []
@@ -1291,20 +1337,43 @@ async def get_my_sag_list_preferences(request: Request):
seen.add(item)
normalized.append(item)
- return {"type_filters": normalized}
+ raw_order = rows[0].get("column_order") if has_columns else None
+ raw_hidden = rows[0].get("hidden_columns") if has_columns else None
+ if isinstance(raw_order, str):
+ try: raw_order = json.loads(raw_order)
+ except Exception: raw_order = []
+ if isinstance(raw_hidden, str):
+ try: raw_hidden = json.loads(raw_hidden)
+ except Exception: raw_hidden = []
+ order = [str(key) for key in (raw_order or []) if str(key) in SAG_LIST_COLUMN_KEYS]
+ order.extend(key for key in SAG_LIST_COLUMN_KEYS if key not in order)
+ hidden = [str(key) for key in (raw_hidden or []) if str(key) in SAG_LIST_COLUMN_KEYS]
+ return {"type_filters": normalized, "column_order": order, "hidden_columns": hidden}
except Exception as e:
if "user_sag_list_preferences" in str(e):
- return {"type_filters": []}
+ return {"type_filters": [], "column_order": list(SAG_LIST_COLUMN_KEYS), "hidden_columns": []}
logger.error("❌ Could not load sag list preferences for user %s: %s", user_id, e)
raise HTTPException(status_code=500, detail="Failed to load list preferences")
+
+@router.get("/sag/me/quick-filter-context")
+async def get_my_sag_quick_filter_context(request: Request):
+ """Return the authenticated employee and their groups for list quick filters."""
+ user_id = _get_user_id_from_request(request)
+ rows = execute_query(
+ "SELECT group_id FROM user_groups WHERE user_id = %s ORDER BY group_id",
+ (user_id,),
+ ) or []
+ return {"user_id": user_id, "group_ids": [row["group_id"] for row in rows]}
+
@router.patch("/sag/me/list-preferences")
async def update_my_sag_list_preferences(request: Request, payload: SagListPreferencesUpdate):
user_id = _get_user_id_from_request(request)
try:
normalized = []
seen = set()
- for value in payload.type_filters or []:
+ existing = await get_my_sag_list_preferences(request)
+ for value in (payload.type_filters if payload.type_filters is not None else existing["type_filters"]):
item = str(value or "").strip().lower()
if not item or item in seen:
continue
@@ -1313,18 +1382,31 @@ async def update_my_sag_list_preferences(request: Request, payload: SagListPrefe
seen.add(item)
normalized.append(item)
+ requested_order = payload.column_order if payload.column_order is not None else existing["column_order"]
+ column_order = [str(key) for key in requested_order if str(key) in SAG_LIST_COLUMN_KEYS]
+ column_order.extend(key for key in SAG_LIST_COLUMN_KEYS if key not in column_order)
+ requested_hidden = payload.hidden_columns if payload.hidden_columns is not None else existing["hidden_columns"]
+ hidden_columns = list(dict.fromkeys(str(key) for key in requested_hidden if str(key) in SAG_LIST_COLUMN_KEYS))
+
+ if not table_has_column("user_sag_list_preferences", "column_order"):
+ raise HTTPException(status_code=503, detail="Kolonnepræferencer kræver den nyeste databasemigration")
+
execute_query(
"""
- INSERT INTO user_sag_list_preferences (user_id, type_filters, updated_at)
- VALUES (%s, %s::jsonb, NOW())
+ INSERT INTO user_sag_list_preferences (user_id, type_filters, column_order, hidden_columns, updated_at)
+ VALUES (%s, %s::jsonb, %s::jsonb, %s::jsonb, NOW())
ON CONFLICT (user_id)
DO UPDATE SET
type_filters = EXCLUDED.type_filters,
+ column_order = EXCLUDED.column_order,
+ hidden_columns = EXCLUDED.hidden_columns,
updated_at = NOW()
""",
- (user_id, json.dumps(normalized)),
+ (user_id, json.dumps(normalized), json.dumps(column_order), json.dumps(hidden_columns)),
)
- return {"type_filters": normalized}
+ return {"type_filters": normalized, "column_order": column_order, "hidden_columns": hidden_columns}
+ except HTTPException:
+ raise
except Exception as e:
logger.error("❌ Could not update sag list preferences for user %s: %s", user_id, e)
raise HTTPException(status_code=500, detail="Failed to save list preferences")
@@ -1541,6 +1623,7 @@ async def delete_todo_step(step_id: int):
async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
"""Update a case."""
try:
+ confirm_close_without_time = updates.pop("confirm_close_without_time", False) is True
# Check if case exists
check = execute_query(
"""
@@ -1562,6 +1645,23 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
if "status" in updates:
updates["status"] = _normalize_case_status(updates.get("status"))
+ closing_statuses = {"lukket", "løst", "afsluttet", "closed", "resolved", "done"}
+ new_status = str(updates.get("status") or "").strip().lower()
+ case_type = str(previous_row.get("template_key") or "").strip().lower()
+ is_support_case = case_type in {"", "support", "ticket", "quickcreate", "service"}
+ if new_status in closing_statuses and previous_status not in closing_statuses and is_support_case:
+ has_registered_time = execute_query_single(
+ "SELECT EXISTS(SELECT 1 FROM tmodule_times WHERE sag_id = %s) AS has_time",
+ (sag_id,),
+ ) or {}
+ if not bool(has_registered_time.get("has_time")) and not confirm_close_without_time:
+ raise HTTPException(
+ status_code=409,
+ detail={
+ "code": "close_without_time_confirmation_required",
+ "message": "Der er ikke registreret tid på supportsagen. Bekræft at den skal lukkes uden tid.",
+ },
+ )
if "deadline" in updates:
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
if "start_date" in updates:
diff --git a/app/modules/sag/backend/solutions.py b/app/modules/sag/backend/solutions.py
index f58ce66..6339e7e 100644
--- a/app/modules/sag/backend/solutions.py
+++ b/app/modules/sag/backend/solutions.py
@@ -1,118 +1,231 @@
+import json
import logging
-from fastapi import APIRouter, HTTPException, Request, Depends
from typing import Optional
-from app.core.database import execute_query
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
+
+from app.core.auth_dependencies import get_current_user, require_any_permission
+from app.core.database import execute_query, execute_query_single
from app.models.schemas import Solution, SolutionCreate, SolutionUpdate
-from app.core.auth_dependencies import require_any_permission
logger = logging.getLogger(__name__)
router = APIRouter()
case_edit_access = require_any_permission("cases.edit", "tickets.edit")
+VISIBILITIES = {"internal", "general", "customer"}
+APPROVAL_STATUSES = {"draft", "pending", "approved", "outdated", "rejected"}
+RESULT_ALIASES = {"resolved": "Løst", "partial": "Delvist", "unresolved": "Ej løst", "løst": "Løst", "delvist": "Delvist", "workaround": "Workaround", "ej løst": "Ej løst"}
+TYPE_ALIASES = {"standard": "Support", "permanent": "Support", "external": "Ekstern", "support": "Support", "drift": "Drift", "konsulent": "Konsulent", "infrastruktur": "Infrastruktur", "workaround": "Support"}
+
+
+def _user_id(current_user: dict) -> Optional[int]:
+ value = current_user.get("id") or current_user.get("user_id")
+ return int(value) if value is not None else None
+
+
+def _clean_list(values) -> list[str]:
+ cleaned, seen = [], set()
+ for value in values or []:
+ item = str(value or "").strip()
+ key = item.casefold()
+ if item and key not in seen:
+ seen.add(key)
+ cleaned.append(item[:100])
+ return cleaned[:30]
+
+
+def _normalize_payload(data: dict) -> dict:
+ if "title" in data:
+ data["title"] = str(data.get("title") or "").strip()
+ if not data["title"]:
+ raise HTTPException(status_code=422, detail="Løsningen skal have en titel")
+ if "visibility" in data:
+ data["visibility"] = str(data.get("visibility") or "internal").lower()
+ if data["visibility"] not in VISIBILITIES:
+ raise HTTPException(status_code=422, detail="Ugyldig synlighed")
+ if "approval_status" in data:
+ data["approval_status"] = str(data.get("approval_status") or "draft").lower()
+ if data["approval_status"] not in APPROVAL_STATUSES:
+ raise HTTPException(status_code=422, detail="Ugyldig godkendelsesstatus")
+ if data.get("result") is not None:
+ raw = str(data["result"]).strip()
+ data["result"] = RESULT_ALIASES.get(raw.casefold(), raw)
+ if data.get("solution_type") is not None:
+ raw = str(data["solution_type"]).strip()
+ data["solution_type"] = TYPE_ALIASES.get(raw.casefold(), raw)
+ for field in ("tags", "products"):
+ if field in data:
+ data[field] = _clean_list(data[field])
+ return data
+
+
+def _version_solution(solution: dict, user_id: Optional[int], change_note: Optional[str] = None) -> None:
+ version_row = execute_query_single("SELECT COALESCE(MAX(version_number), 0) + 1 AS next_version FROM sag_solution_versions WHERE solution_id = %s", (solution["id"],)) or {"next_version": 1}
+ snapshot = dict(solution)
+ for key, value in list(snapshot.items()):
+ if hasattr(value, "isoformat"):
+ snapshot[key] = value.isoformat()
+ execute_query(
+ "INSERT INTO sag_solution_versions (solution_id, version_number, snapshot, changed_by_user_id, change_note) VALUES (%s,%s,%s::jsonb,%s,%s)",
+ (solution["id"], version_row["next_version"], json.dumps(snapshot), user_id, change_note),
+ )
+
@router.get("/sag/{sag_id}/solution", response_model=Optional[Solution])
-async def get_solution(sag_id: int):
- """Get the solution associated with a case."""
- try:
- query = "SELECT * FROM sag_solutions WHERE sag_id = %s"
- result = execute_query(query, (sag_id,))
- if not result:
- return None
- return result[0]
- except Exception as e:
- logger.error("❌ Error getting solution for case %s: %s", sag_id, e)
- raise HTTPException(status_code=500, detail="Failed to get solution")
+async def get_solution(sag_id: int, _current_user: dict = Depends(get_current_user)):
+ result = execute_query("SELECT * FROM sag_solutions WHERE sag_id = %s", (sag_id,))
+ return result[0] if result else None
-@router.post(
- "/sag/{sag_id}/solution",
- response_model=Solution,
- dependencies=[Depends(case_edit_access)],
-)
-async def create_solution(sag_id: int, solution: SolutionCreate, request: Request):
- """Create a solution for a case."""
- try:
- # Check if case exists
- case_check = execute_query("SELECT id FROM sag_sager WHERE id = %s", (sag_id,))
- if not case_check:
- raise HTTPException(status_code=404, detail="Case not found")
-
- # Check if solution already exists
- check = execute_query("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,))
- if check:
- raise HTTPException(status_code=400, detail="Solution already exists for this case")
-
- query = """
- INSERT INTO sag_solutions
- (sag_id, title, description, solution_type, result, created_by_user_id)
- VALUES (%s, %s, %s, %s, %s, %s)
- RETURNING *
- """
- params = (
- sag_id,
- solution.title,
- solution.description,
- solution.solution_type,
- solution.result,
- getattr(request.state, "user_id", None)
- )
-
- result = execute_query(query, params)
- if result:
- logger.info("✅ Solution created for case: %s", sag_id)
- return result[0]
- raise HTTPException(status_code=500, detail="Failed to create solution")
- except HTTPException:
- raise
- except Exception as e:
- logger.error("❌ Error creating solution: %s", e)
- raise HTTPException(status_code=500, detail="Failed to create solution")
-@router.patch(
- "/sag/{sag_id}/solution",
- response_model=Solution,
- dependencies=[Depends(case_edit_access)],
-)
-async def update_solution(sag_id: int, updates: SolutionUpdate):
- """Update a solution."""
- try:
- # Check if solution exists
- check = execute_query("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,))
- if not check:
- raise HTTPException(status_code=404, detail="Solution not found")
-
- # Build dynamic update query
- set_clauses = []
- params = []
-
- # Helper to check and add params
- if updates.title is not None:
- set_clauses.append("title = %s")
- params.append(updates.title)
- if updates.description is not None:
- set_clauses.append("description = %s")
- params.append(updates.description)
- if updates.solution_type is not None:
- set_clauses.append("solution_type = %s")
- params.append(updates.solution_type)
- if updates.result is not None:
- set_clauses.append("result = %s")
- params.append(updates.result)
-
- if not set_clauses:
- raise HTTPException(status_code=400, detail="No fields to update")
-
- set_clauses.append("updated_at = NOW()")
-
- params.append(sag_id)
- query = f"UPDATE sag_solutions SET {', '.join(set_clauses)} WHERE sag_id = %s RETURNING *"
-
- result = execute_query(query, tuple(params))
- if result:
- logger.info("✅ Solution updated for case: %s", sag_id)
- return result[0]
- raise HTTPException(status_code=500, detail="Failed to update solution")
- except HTTPException:
- raise
- except Exception as e:
- logger.error("❌ Error updating solution: %s", e)
- raise HTTPException(status_code=500, detail="Failed to update solution")
+@router.get("/sag/{sag_id}/solution/versions")
+async def get_solution_versions(sag_id: int, _current_user: dict = Depends(get_current_user)):
+ solution = execute_query_single("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,))
+ if not solution:
+ return {"items": [], "total": 0}
+ items = execute_query(
+ """SELECT v.id, v.version_number, v.change_note, v.created_at,
+ COALESCE(u.full_name, u.username) AS changed_by
+ FROM sag_solution_versions v LEFT JOIN users u ON u.user_id=v.changed_by_user_id
+ WHERE v.solution_id=%s ORDER BY v.version_number DESC""",
+ (solution["id"],),
+ ) or []
+ return {"items": items, "total": len(items)}
+
+
+@router.post("/sag/{sag_id}/solution", response_model=Solution, dependencies=[Depends(case_edit_access)])
+async def create_solution(sag_id: int, solution: SolutionCreate, current_user: dict = Depends(get_current_user)):
+ if not execute_query_single("SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (sag_id,)):
+ raise HTTPException(status_code=404, detail="Sagen findes ikke")
+ if execute_query_single("SELECT id FROM sag_solutions WHERE sag_id=%s", (sag_id,)):
+ raise HTTPException(status_code=409, detail="Der findes allerede en løsning på sagen")
+ data = _normalize_payload(solution.model_dump(exclude={"sag_id", "created_by_user_id"}))
+ user_id = _user_id(current_user)
+ result = execute_query(
+ """INSERT INTO sag_solutions (
+ sag_id,title,description,solution_type,result,problem,root_cause,investigation,workaround,
+ visibility,approval_status,is_final,tags,products,created_by_user_id,updated_by_user_id
+ ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s,%s) RETURNING *""",
+ (sag_id, data["title"], data.get("description"), data.get("solution_type"), data.get("result"), data.get("problem"), data.get("root_cause"), data.get("investigation"), data.get("workaround"), data.get("visibility", "internal"), data.get("approval_status", "draft"), data.get("is_final", True), json.dumps(data.get("tags", [])), json.dumps(data.get("products", [])), user_id, user_id),
+ )
+ created = result[0]
+ _version_solution(created, user_id, "Løsning oprettet")
+ return created
+
+
+@router.patch("/sag/{sag_id}/solution", response_model=Solution, dependencies=[Depends(case_edit_access)])
+async def update_solution(sag_id: int, updates: SolutionUpdate, current_user: dict = Depends(get_current_user)):
+ if not execute_query_single("SELECT id FROM sag_solutions WHERE sag_id=%s", (sag_id,)):
+ raise HTTPException(status_code=404, detail="Løsningen findes ikke")
+ data = _normalize_payload(updates.model_dump(exclude_unset=True))
+ change_note = data.pop("change_note", None)
+ allowed = {"title", "description", "solution_type", "result", "problem", "root_cause", "investigation", "workaround", "visibility", "approval_status", "is_final", "tags", "products"}
+ fields, params = [], []
+ for key, value in data.items():
+ if key not in allowed:
+ continue
+ fields.append(f"{key} = %s" + ("::jsonb" if key in {"tags", "products"} else ""))
+ params.append(json.dumps(value) if key in {"tags", "products"} else value)
+ if not fields:
+ raise HTTPException(status_code=400, detail="Ingen ændringer at gemme")
+ user_id = _user_id(current_user)
+ fields.extend(["updated_by_user_id = %s", "updated_at = NOW()"])
+ params.extend([user_id, sag_id])
+ updated = execute_query(f"UPDATE sag_solutions SET {', '.join(fields)} WHERE sag_id=%s RETURNING *", tuple(params))[0]
+ _version_solution(updated, user_id, change_note or "Løsning redigeret")
+ return updated
+
+
+@router.post("/sag/{sag_id}/solution/workflow", dependencies=[Depends(case_edit_access)])
+async def solution_workflow(sag_id: int, request: Request, current_user: dict = Depends(get_current_user)):
+ action = str((await request.json()).get("action") or "").lower()
+ solution = execute_query_single("SELECT * FROM sag_solutions WHERE sag_id=%s", (sag_id,))
+ if not solution:
+ raise HTTPException(status_code=404, detail="Løsningen findes ikke")
+ user_id = _user_id(current_user)
+ if action == "submit":
+ status = "pending"
+ elif action == "approve":
+ if not str(solution.get("description") or "").strip():
+ raise HTTPException(status_code=422, detail="Beskriv den endelige løsning før godkendelse")
+ status = "approved"
+ elif action in {"reject", "outdate"}:
+ status = "rejected" if action == "reject" else "outdated"
+ else:
+ raise HTTPException(status_code=422, detail="Ukendt handling")
+ if status == "approved":
+ updated = execute_query_single("UPDATE sag_solutions SET approval_status=%s,approved_by_user_id=%s,approved_at=NOW(),updated_by_user_id=%s,updated_at=NOW() WHERE sag_id=%s RETURNING *", (status, user_id, user_id, sag_id))
+ else:
+ updated = execute_query_single("UPDATE sag_solutions SET approval_status=%s,approved_by_user_id=NULL,approved_at=NULL,updated_by_user_id=%s,updated_at=NOW() WHERE sag_id=%s RETURNING *", (status, user_id, sag_id))
+ _version_solution(updated, user_id, f"Status ændret til {status}")
+ return updated
+
+
+@router.post("/sag/{sag_id}/solution/publish", dependencies=[Depends(case_edit_access)])
+async def publish_solution(sag_id: int, current_user: dict = Depends(get_current_user)):
+ solution = execute_query_single("SELECT * FROM sag_solutions WHERE sag_id=%s", (sag_id,))
+ if not solution:
+ raise HTTPException(status_code=404, detail="Løsningen findes ikke")
+ if solution.get("approval_status") != "approved":
+ raise HTTPException(status_code=409, detail="Løsningen skal godkendes før udgivelse")
+ customer_id = None
+ if solution.get("visibility") == "customer":
+ customer = execute_query_single("SELECT customer_id FROM sag_kunder WHERE sag_id=%s AND deleted_at IS NULL ORDER BY id LIMIT 1", (sag_id,))
+ if not customer:
+ raise HTTPException(status_code=409, detail="Kundespecifik viden kræver en kunde på sagen")
+ customer_id = customer["customer_id"]
+ description = str(solution.get("description") or "").strip()
+ summary = description[:300] + ("…" if len(description) > 300 else "")
+ article = execute_query_single(
+ """INSERT INTO knowledge_articles (
+ solution_id,sag_id,customer_id,title,summary,problem,root_cause,investigation,solution,workaround,
+ visibility,status,tags,products,published_by_user_id,reviewed_at
+ ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'published',%s::jsonb,%s::jsonb,%s,NOW())
+ ON CONFLICT (solution_id) DO UPDATE SET customer_id=EXCLUDED.customer_id,title=EXCLUDED.title,
+ summary=EXCLUDED.summary,problem=EXCLUDED.problem,root_cause=EXCLUDED.root_cause,
+ investigation=EXCLUDED.investigation,solution=EXCLUDED.solution,workaround=EXCLUDED.workaround,
+ visibility=EXCLUDED.visibility,tags=EXCLUDED.tags,products=EXCLUDED.products,status='published',
+ version_number=knowledge_articles.version_number+1,published_by_user_id=EXCLUDED.published_by_user_id,
+ reviewed_at=NOW(),updated_at=NOW() RETURNING *""",
+ (solution["id"], sag_id, customer_id, solution["title"], summary, solution.get("problem"), solution.get("root_cause"), solution.get("investigation"), description, solution.get("workaround"), solution.get("visibility", "internal"), json.dumps(solution.get("tags") or []), json.dumps(solution.get("products") or []), _user_id(current_user)),
+ )
+ return article
+
+
+@router.get("/knowledge/articles")
+async def search_knowledge_articles(q: str = Query("", max_length=200), customer_id: Optional[int] = None, limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0), _current_user: dict = Depends(get_current_user)):
+ term = q.strip()
+ scope = "(ka.visibility IN ('general','internal') OR (ka.visibility='customer' AND ka.customer_id=%s))" if customer_id else "ka.visibility IN ('general','internal')"
+ params: list = [customer_id] if customer_id else []
+ search = ""
+ if term:
+ search = " AND (ka.search_document @@ websearch_to_tsquery('simple', %s) OR ka.title ILIKE %s)"
+ params.extend([term, f"%{term}%"])
+ count = execute_query_single(f"SELECT COUNT(*) AS total FROM knowledge_articles ka WHERE ka.status='published' AND {scope}{search}", tuple(params)) or {"total": 0}
+ item_params = []
+ rank_expr = "ts_rank_cd(ka.search_document, websearch_to_tsquery('simple', %s))" if term else "0"
+ if term:
+ item_params.append(term)
+ item_params.extend(params)
+ item_params.extend([limit, offset])
+ items = execute_query(
+ f"""SELECT ka.id,ka.title,ka.summary,ka.visibility,ka.customer_id,ka.tags,ka.products,
+ ka.version_number,ka.updated_at,ka.sag_id,{rank_expr} AS relevance,c.name AS customer_name
+ FROM knowledge_articles ka LEFT JOIN customers c ON c.id=ka.customer_id
+ WHERE ka.status='published' AND {scope}{search}
+ ORDER BY relevance DESC,ka.updated_at DESC,ka.id DESC LIMIT %s OFFSET %s""",
+ tuple(item_params),
+ ) or []
+ return {"items": items, "total": int(count["total"]), "limit": limit, "offset": offset}
+
+
+@router.get("/knowledge/articles/{article_id}")
+async def get_knowledge_article(article_id: int, _current_user: dict = Depends(get_current_user)):
+ article = execute_query_single(
+ """SELECT ka.*,c.name AS customer_name,COALESCE(u.full_name,u.username) AS published_by
+ FROM knowledge_articles ka LEFT JOIN customers c ON c.id=ka.customer_id
+ LEFT JOIN users u ON u.user_id=ka.published_by_user_id
+ WHERE ka.id=%s AND ka.status='published'""",
+ (article_id,),
+ )
+ if not article:
+ raise HTTPException(status_code=404, detail="Vidensartiklen findes ikke")
+ return article
diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py
index bbc3c44..ae25279 100644
--- a/app/modules/sag/frontend/views.py
+++ b/app/modules/sag/frontend/views.py
@@ -13,6 +13,33 @@ logger = logging.getLogger(__name__)
router = APIRouter()
+@router.get("/knowledge", response_class=HTMLResponse)
+async def knowledge_index(request: Request):
+ """Read-only first release of the case knowledge base."""
+ return templates.TemplateResponse("modules/sag/templates/knowledge_index.html", {"request": request})
+
+
+@router.get("/knowledge/{article_id:int}", response_class=HTMLResponse)
+async def knowledge_detail(request: Request, article_id: int):
+ article = execute_query(
+ """
+ SELECT ka.*, c.name AS customer_name,
+ COALESCE(u.full_name, u.username) AS published_by
+ FROM knowledge_articles ka
+ LEFT JOIN customers c ON c.id = ka.customer_id
+ LEFT JOIN users u ON u.user_id = ka.published_by_user_id
+ WHERE ka.id = %s AND ka.status = 'published'
+ """,
+ (article_id,),
+ )
+ if not article:
+ raise HTTPException(status_code=404, detail="Vidensartiklen findes ikke")
+ return templates.TemplateResponse(
+ "modules/sag/templates/knowledge_detail.html",
+ {"request": request, "article": article[0]},
+ )
+
+
def _render_api_print_bridge(api_path: str, page_title: str) -> str:
safe_api_path = json.dumps(api_path)
safe_title = json.dumps(page_title)
@@ -203,6 +230,7 @@ async def sager_liste(
assigned_group_id: str = Query(None),
unassigned: bool = Query(False),
include_deferred: bool = Query(False),
+ quick: str = Query(None),
):
"""Display list of all cases."""
try:
@@ -215,6 +243,7 @@ async def sager_liste(
query = """
SELECT s.*,
c.name as customer_name,
+ sk_first.contact_id AS kontakt_id,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name,
@@ -278,7 +307,8 @@ async def sager_liste(
query += ")"
query += " AND (s.start_date IS NULL OR s.start_date <= NOW())"
- normalized_status = str(status or "").strip().lower()
+ normalized_quick = str(quick or "").strip().lower()
+ normalized_status = "all" if normalized_quick == "closed" else str(status or "").strip().lower()
normalized_priority = str(priority or "").strip().lower()
if normalized_status == "all":
pass
@@ -316,6 +346,7 @@ async def sager_liste(
fallback_query = """
SELECT s.*,
c.name as customer_name,
+ NULL::integer AS kontakt_id,
'' as kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
NULL::text AS assigned_group_name,
@@ -439,6 +470,7 @@ async def sager_liste(
"relations_map": relations_map,
"child_ids": list(child_ids),
"statuses": status_options,
+ "status_options": status_options,
"all_tags": [t['tag_navn'] for t in all_tags],
"current_status": status,
"current_priority": normalized_priority,
@@ -451,16 +483,19 @@ async def sager_liste(
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int,
"current_unassigned": requested_unassigned,
+ "current_quick_filter": normalized_quick or "all",
"closed_statuses": closed_statuses,
})
except Exception:
logger.exception("❌ Error displaying case list")
+ fallback_status_options = _fetch_case_status_options()
return templates.TemplateResponse("modules/sag/templates/index.html", {
"request": request,
"sager": [],
"relations_map": {},
"child_ids": [],
- "statuses": _fetch_case_status_options(),
+ "statuses": fallback_status_options,
+ "status_options": fallback_status_options,
"all_tags": [],
"current_status": status,
"current_priority": str(priority or "").strip().lower(),
@@ -473,6 +508,7 @@ async def sager_liste(
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int,
"current_unassigned": requested_unassigned,
+ "current_quick_filter": str(quick or "").strip().lower() or "all",
"closed_statuses": _fetch_closed_case_statuses(),
})
diff --git a/app/modules/sag/templates/create.html b/app/modules/sag/templates/create.html
index 01fd169..067a9c8 100644
--- a/app/modules/sag/templates/create.html
+++ b/app/modules/sag/templates/create.html
@@ -475,6 +475,13 @@
}, duration);
}
+ function showCreateError(message) {
+ const errorDiv = document.getElementById('error');
+ errorDiv.classList.remove('d-none');
+ document.getElementById('error-text').textContent = String(message || 'Ukendt fejl');
+ errorDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ }
+
// --- Character Counter ---
const beskrInput = document.getElementById('beskrivelse');
if (beskrInput) {
@@ -494,7 +501,7 @@
if (!source) {
descriptionInput?.focus();
- alert('Skriv en beskrivelse først.');
+ showCreateError('Skriv en beskrivelse først.');
return;
}
@@ -529,7 +536,7 @@
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).show();
} catch (error) {
console.error('Case create rewrite failed:', error);
- alert(`Kunne ikke renskrive beskrivelsen: ${error.message || 'Ukendt fejl'}`);
+ showCreateError(`Kunne ikke renskrive beskrivelsen: ${error.message || 'Ukendt fejl'}`);
} finally {
if (button) {
button.disabled = false;
@@ -999,7 +1006,7 @@
if (!anydeskId) return;
try {
await navigator.clipboard.writeText(anydeskId);
- alert('AnyDesk ID kopieret');
+ showSuccessAlert('AnyDesk ID kopieret');
} catch (err) {
console.error('Copy failed', err);
}
@@ -1012,13 +1019,13 @@
const anydeskLink = anydeskId ? `anydesk://${anydeskId}` : null;
if (!name) {
- alert('Navn er påkrævet');
+ showCreateError('Navn er påkrævet');
return;
}
const customerId = selectedCustomer?.id || getSingleContactCompanyId();
if (!customerId) {
- alert('Vælg et firma før du opretter hardware');
+ showCreateError('Vælg et firma før du opretter hardware');
return;
}
@@ -1044,7 +1051,7 @@
document.getElementById('hardwareAnyDeskIdInput').value = '';
await loadHardwareForContacts();
} catch (err) {
- alert('Fejl: ' + err.message);
+ showCreateError('Fejl: ' + err.message);
}
}
@@ -1335,7 +1342,9 @@
customer_id: selectedCustomer ? selectedCustomer.id : null,
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
assigned_group_id: document.getElementById('assigned_group_id').value ? parseInt(document.getElementById('assigned_group_id').value) : null,
- deadline: document.getElementById('deadline').value || null
+ deadline: document.getElementById('deadline').value || null,
+ contact_ids: Object.keys(selectedContacts).map(id => parseInt(id)).filter(Number.isFinite),
+ telefoni_opkald_id: telefoniPrefill.callId ? parseInt(telefoniPrefill.callId) : null
};
if (data.type === 'pipeline') {
@@ -1361,63 +1370,9 @@
if (response.ok) {
const result = await response.json();
- // Add contacts if any
- const contactPromises = Object.keys(selectedContacts).map(cid =>
- fetch(`/api/v1/sag/${result.id}/contacts`, {
- method: 'POST',
- credentials: 'include',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- contact_id: parseInt(cid),
- role: 'Kontakt',
- is_primary: false
- })
- })
- );
-
- await Promise.all(contactPromises);
-
- // Link telephony call -> case (best-effort)
- if (telefoniPrefill.callId) {
- try {
- await fetch(`/api/v1/telefoni/calls/${encodeURIComponent(telefoniPrefill.callId)}`, {
- method: 'PATCH',
- credentials: 'include',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- sag_id: result.id,
- kontakt_id: telefoniPrefill.contactId || null
- })
- });
- } catch (e) {
- console.warn('Telefoni link failed', e);
- }
- }
-
- // Ensure contact-company link exists
- if (selectedCustomer) {
- const linkPromises = Object.keys(selectedContacts).map(cid =>
- fetch(`/api/v1/contacts/${parseInt(cid)}/companies`, {
- method: 'POST',
- credentials: 'include',
- headers: {'Content-Type': 'application/json'},
- body: JSON.stringify({ customer_id: selectedCustomer.id, is_primary: false })
- })
- );
- const linkResponses = await Promise.all(linkPromises);
- const linkFailed = linkResponses.find(res => !res.ok);
- if (linkFailed) {
- const err = await linkFailed.json();
- throw new Error(err.detail || 'Kunne ikke linke kontakt til firma');
- }
- }
-
successDiv.classList.remove('d-none');
- document.getElementById('success-text').textContent = "Sag oprettet succesfuldt! Omdirigerer...";
-
- setTimeout(() => {
- window.location.href = `/sag/${result.id}/v3`;
- }, 1000);
+ document.getElementById('success-text').textContent = "Sag oprettet";
+ window.location.href = `/sag/${result.id}/v3`;
} else {
const errorText = await response.text();
let errMsg = "Kunne ikke oprette sag";
diff --git a/app/modules/sag/templates/detail_v3.html b/app/modules/sag/templates/detail_v3.html
index 6fc60dd..4034e3d 100644
--- a/app/modules/sag/templates/detail_v3.html
+++ b/app/modules/sag/templates/detail_v3.html
@@ -3915,6 +3915,52 @@
window.caseTypeModuleDefaults = caseTypeModuleDefaults;
window.caseTypeKey = window.caseTypeKey || {{ ((case.template_key or case.type or 'ticket')|lower)|tojson }};
+ window.showCaseFeedback = window.showCaseFeedback || function(message, level = 'danger') {
+ const text = String(message || 'Ukendt besked');
+ if (typeof window.showNotification === 'function') {
+ window.showNotification(text, level === 'danger' ? 'error' : level);
+ return;
+ }
+ if (typeof window.showToast === 'function') {
+ window.showToast(text, level);
+ return;
+ }
+ let host = document.getElementById('caseFeedbackHost');
+ if (!host) {
+ host = document.createElement('div');
+ host.id = 'caseFeedbackHost';
+ host.className = 'position-fixed top-0 end-0 p-3';
+ host.style.zIndex = '2000';
+ document.body.appendChild(host);
+ }
+ const item = document.createElement('div');
+ item.className = `alert alert-${level} shadow-sm mb-2`;
+ item.setAttribute('role', 'status');
+ item.textContent = text;
+ host.appendChild(item);
+ window.setTimeout(() => item.remove(), 4500);
+ };
+
+ window.reloadCasePreservingContext = window.reloadCasePreservingContext || function(delay = 0) {
+ const activeTab = document.querySelector('#caseTabs [data-bs-target].active')?.getAttribute('data-bs-target') || '';
+ const stateKey = `case-ui:${window.location.pathname}`;
+ sessionStorage.setItem(stateKey, JSON.stringify({ activeTab, scrollY: window.scrollY || 0 }));
+ window.setTimeout(() => window.location.assign(window.location.href), Math.max(0, Number(delay) || 0));
+ };
+
+ document.addEventListener('DOMContentLoaded', () => {
+ const stateKey = `case-ui:${window.location.pathname}`;
+ const raw = sessionStorage.getItem(stateKey);
+ if (!raw) return;
+ sessionStorage.removeItem(stateKey);
+ try {
+ const state = JSON.parse(raw);
+ const tabId = String(state.activeTab || '').replace(/^#/, '');
+ if (tabId) window.setTimeout(() => window.forceCaseTabActivation(tabId), 0);
+ window.setTimeout(() => window.scrollTo({ top: Number(state.scrollY) || 0 }), 30);
+ } catch (_) {}
+ }, { once: true });
+
window.syncCaseTabPaneVisibility = window.syncCaseTabPaneVisibility || function(activeTabId) {
if (!activeTabId) return;
const tabContent = document.getElementById('caseTabsContent');
@@ -4003,39 +4049,39 @@
bindChange('topbarTypeSelect', async (el) => {
await patchCase({ type: String(el.value || 'ticket').toLowerCase() });
- location.reload();
+ reloadCasePreservingContext();
});
bindChange('topbarPrioritySelect', async (el) => {
await patchCase({ priority: String(el.value || 'normal').toLowerCase() });
- location.reload();
+ showCaseFeedback('Prioritet gemt', 'success');
});
bindChange('tabsAssignmentUserSelect', async (el) => {
const raw = String(el.value || '').trim();
await patchCase({ ansvarlig_bruger_id: raw ? Number(raw) : null });
- location.reload();
+ showCaseFeedback('Ansvarlig gemt', 'success');
});
bindChange('tabsAssignmentGroupSelect', async (el) => {
const raw = String(el.value || '').trim();
await patchCase({ assigned_group_id: raw ? Number(raw) : null });
- location.reload();
+ showCaseFeedback('Gruppe gemt', 'success');
});
bindChange('topbarStartDateInput', async (el) => {
await patchCase({ start_date: el.value || null });
- location.reload();
+ showCaseFeedback('Startdato gemt', 'success');
});
bindChange('topbarDeferredInput', async (el) => {
await patchCase({ deferred_until: el.value || null });
- location.reload();
+ reloadCasePreservingContext();
});
bindChange('topbarDeadlineInput', async (el) => {
await patchCase({ deadline: el.value || null });
- location.reload();
+ reloadCasePreservingContext();
});
const printBtn = document.getElementById('topbarPrintWorkOrderBtn');
@@ -4398,7 +4444,7 @@
async function ringOutFromCase(number, opts = {}) {
const clean = String(number || '').trim();
if (!clean || clean === '-') {
- if (!opts.silent) alert('Intet gyldigt nummer at ringe til');
+ if (!opts.silent) showCaseFeedback('Intet gyldigt nummer at ringe til');
return false;
}
@@ -4418,7 +4464,7 @@
if (!res.ok) {
const t = await res.text();
- if (!opts.silent) alert('Ring ud fejlede: ' + t);
+ if (!opts.silent) showCaseFeedback('Ring ud fejlede: ' + t);
return false;
}
const result = await res.json().catch(() => ({}));
@@ -4429,10 +4475,10 @@
await logCaseSystemComment(`📞 Opkald startet til ${who}`);
}
- if (!opts.silent) alert('Ringer ud via Yealink...');
+ if (!opts.silent) showCaseFeedback('Ringer ud via Yealink...', 'success');
return true;
} catch (e) {
- if (!opts.silent) alert('Kunne ikke starte opkald');
+ if (!opts.silent) showCaseFeedback('Kunne ikke starte opkald');
return false;
}
}
@@ -5382,7 +5428,7 @@
async function openAuthenticatedPrintView(url, fallbackTitle) {
const popup = window.open('', '_blank', 'noopener');
if (!popup) {
- alert('Browser blokerede popup-vinduet. Tillad popups for at printe.');
+ showCaseFeedback('Browser blokerede popup-vinduet. Tillad popups for at printe.');
return;
}
@@ -5554,7 +5600,7 @@
async function callCurrentContactFromInfo() {
const number = getCurrentContactPreferredNumber();
if (!number) {
- alert('Ingen telefon eller mobil på kontakten');
+ showCaseFeedback('Ingen telefon eller mobil på kontakten');
return;
}
await ringOutFromCase(number, {
@@ -5568,7 +5614,7 @@
if (!currentContactInfo) return;
const number = String(currentContactInfo.mobile || currentContactInfo.phone || '').trim();
if (!number || number === '-') {
- alert('Ingen mobil/telefon på kontakten');
+ showCaseFeedback('Ingen mobil/telefon på kontakten');
return;
}
openSmsPrompt(number, currentContactInfo.name || '', currentContactInfo.id || null);
@@ -5579,7 +5625,7 @@
if (!currentContactInfo) return;
const email = String(currentContactInfo.email || '').trim();
if (!email || email === '-') {
- alert('Ingen e-mail på kontakten');
+ showCaseFeedback('Ingen e-mail på kontakten');
return;
}
openCaseEmailTab();
@@ -5599,7 +5645,7 @@
const name = {{ (((hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name)|trim) if hovedkontakt else '')|tojson }};
const contactId = {{ hovedkontakt.id if hovedkontakt else 'null' }};
if (!number) {
- alert('Ingen telefon eller mobil på hovedkontakt');
+ showCaseFeedback('Ingen telefon eller mobil på hovedkontakt');
return;
}
await ringOutFromCase(number, { logToCase: true, contactName: name, contactId });
@@ -5611,7 +5657,7 @@
const name = {{ ((hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name)|trim)|tojson }};
const contactId = {{ hovedkontakt.id|default('null') }};
if (!number) {
- alert('Ingen mobil/telefon på hovedkontakt');
+ showCaseFeedback('Ingen mobil/telefon på hovedkontakt');
return;
}
openSmsPrompt(number, name, contactId);
@@ -5624,7 +5670,7 @@
const email = {{ (hovedkontakt.email or '')|tojson }};
const name = {{ ((hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name)|trim)|tojson }};
if (!email) {
- alert('Ingen e-mail på hovedkontakt');
+ showCaseFeedback('Ingen e-mail på hovedkontakt');
return;
}
openCaseEmailTab();
@@ -5747,12 +5793,12 @@
const customerId = (caseCustomerId || wikiCustomerId || null);
if (!title) {
- alert('Titel er påkrævet');
+ showCaseFeedback('Titel er påkrævet');
return;
}
if (!customerId) {
- alert('Sagen mangler kunde. Tilknyt kunde først, før du opretter relateret sag.');
+ showCaseFeedback('Sagen mangler kunde. Tilknyt kunde først, før du opretter relateret sag.');
return;
}
@@ -5791,11 +5837,11 @@
}
// 3. Reload to show new relation
- window.location.reload();
+ reloadCasePreservingContext();
} catch (err) {
console.error('Error creating related case:', err);
- alert('Der opstod en fejl: ' + err.message);
+ showCaseFeedback('Der opstod en fejl: ' + err.message);
}
}
@@ -5876,13 +5922,13 @@
if (response.ok) {
contactSearchModal.hide();
- window.location.reload();
+ reloadCasePreservingContext();
} else {
const error = await response.json();
- alert(`Fejl: ${error.detail}`);
+ showCaseFeedback(`Fejl: ${error.detail}`);
}
} catch (err) {
- alert('Fejl ved tilføjelse af kontakt: ' + err.message);
+ showCaseFeedback('Fejl ved tilføjelse af kontakt: ' + err.message);
}
}
@@ -5890,9 +5936,9 @@
if (confirm('Fjern denne kontakt fra sagen?')) {
const response = await fetch(`/api/v1/sag/${caseId}/contacts/${contactId}`, {method: 'DELETE'});
if (response.ok) {
- window.location.reload();
+ reloadCasePreservingContext();
} else {
- alert('Fejl ved fjernelse af kontakt');
+ showCaseFeedback('Fejl ved fjernelse af kontakt');
}
}
}
@@ -5960,13 +6006,13 @@
if (response.ok) {
customerSearchModal.hide();
- window.location.reload();
+ reloadCasePreservingContext();
} else {
const error = await response.json().catch(() => ({}));
- alert(`Fejl ved skift af kunde: ${error.detail || response.statusText || 'Ukendt fejl'}`);
+ showCaseFeedback(`Fejl ved skift af kunde: ${error.detail || response.statusText || 'Ukendt fejl'}`);
}
} catch (err) {
- alert('Fejl ved skift af kunde: ' + err.message);
+ showCaseFeedback('Fejl ved skift af kunde: ' + err.message);
}
}
@@ -5980,13 +6026,13 @@
if (response.ok) {
customerSearchModal.hide();
- window.location.reload();
+ reloadCasePreservingContext();
} else {
const error = await response.json();
- alert(`Fejl: ${error.detail}`);
+ showCaseFeedback(`Fejl: ${error.detail}`);
}
} catch (err) {
- alert('Fejl ved tilføjelse af kunde: ' + err.message);
+ showCaseFeedback('Fejl ved tilføjelse af kunde: ' + err.message);
}
}
@@ -5994,9 +6040,9 @@
if (confirm('Fjern denne kunde fra sagen?')) {
const response = await fetch(`/api/v1/sag/${caseId}/customers/${customerId}`, {method: 'DELETE'});
if (response.ok) {
- window.location.reload();
+ reloadCasePreservingContext();
} else {
- alert('Fejl ved fjernelse af kunde');
+ showCaseFeedback('Fejl ved fjernelse af kunde');
}
}
}
@@ -6266,12 +6312,12 @@
const btn = document.getElementById('addRelationBtn');
if (!selectedRelationCaseId) {
- alert('Vælg en sag først');
+ showCaseFeedback('Vælg en sag først');
return;
}
if (!relationType) {
- alert('Vælg en relationstype');
+ showCaseFeedback('Vælg en relationstype');
return;
}
@@ -6292,15 +6338,15 @@
if (response.ok) {
selectedRelationCaseId = null;
relationModal.hide();
- window.location.reload();
+ reloadCasePreservingContext();
} else {
const error = await response.json();
- alert(`Fejl: ${error.detail}`);
+ showCaseFeedback(`Fejl: ${error.detail}`);
btn.disabled = false;
btn.innerHTML = ' Tilføj relation';
}
} catch (err) {
- alert('Fejl ved tilføjelse af relation: ' + err.message);
+ showCaseFeedback('Fejl ved tilføjelse af relation: ' + err.message);
btn.disabled = false;
btn.innerHTML = ' Tilføj relation';
}
@@ -6310,9 +6356,9 @@
if (confirm('Fjern denne relation?')) {
const response = await fetch(`/api/v1/sag/${caseId}/relationer/${relationId}`, {method: 'DELETE'});
if (response.ok) {
- window.location.reload();
+ reloadCasePreservingContext();
} else {
- alert('Fejl ved fjernelse af relation');
+ showCaseFeedback('Fejl ved fjernelse af relation');
}
}
}
@@ -6391,7 +6437,7 @@
if (!res.ok) throw await res.json();
loadCaseHardware();
} catch (e) {
- alert("Fejl: " + (e.detail || e.message));
+ showCaseFeedback("Fejl: " + (e.detail || e.message));
}
}
@@ -6401,7 +6447,7 @@
await fetch(`/api/v1/sag/{{ case.id }}/hardware/${hwId}`, { method: 'DELETE' });
loadCaseHardware();
} catch (e) {
- alert("Fejl ved sletning");
+ showCaseFeedback("Fejl ved sletning");
}
}
@@ -6668,7 +6714,7 @@
showNotification('Tag tilføjet', 'success');
}
} catch (error) {
- alert('Fejl: ' + error.message);
+ showCaseFeedback('Fejl: ' + error.message);
}
}
@@ -6863,7 +6909,7 @@
if (typeof showNotification === 'function') {
showNotification(error.message || 'Kunne ikke oprette buzzword', 'error');
} else {
- alert(`Fejl: ${error.message || 'Kunne ikke oprette buzzword'}`);
+ showCaseFeedback(`Fejl: ${error.message || 'Kunne ikke oprette buzzword'}`);
}
}
}
@@ -6892,7 +6938,7 @@
if (typeof showNotification === 'function') {
showNotification('Marker tekst først, og tryk derefter Opret markering', 'warning');
} else {
- alert('Marker tekst først, og tryk derefter Opret markering');
+ showCaseFeedback('Marker tekst først, og tryk derefter Opret markering');
}
return;
}
@@ -6906,7 +6952,7 @@
if (typeof showNotification === 'function') {
showNotification(error.message || 'Kunne ikke oprette buzzword', 'error');
} else {
- alert(`Fejl: ${error.message || 'Kunne ikke oprette buzzword'}`);
+ showCaseFeedback(`Fejl: ${error.message || 'Kunne ikke oprette buzzword'}`);
}
}
}
@@ -6933,7 +6979,7 @@
if (typeof showNotification === 'function') {
showNotification(error.message || 'Kunne ikke fjerne buzzword', 'error');
} else {
- alert(`Fejl: ${error.message || 'Kunne ikke fjerne buzzword'}`);
+ showCaseFeedback(`Fejl: ${error.message || 'Kunne ikke fjerne buzzword'}`);
}
}
}
@@ -6947,7 +6993,7 @@
if (typeof showNotification === 'function') {
showNotification('Marker tekst i kommentaren først', 'warning');
} else {
- alert('Marker tekst i kommentaren først');
+ showCaseFeedback('Marker tekst i kommentaren først');
}
return;
}
@@ -7279,7 +7325,7 @@
setNextTodoOverrideId(isNext ? stepId : null);
await loadTodoSteps();
} catch (e) {
- alert('Fejl: ' + e.message);
+ showCaseFeedback('Fejl: ' + e.message);
}
}
@@ -7331,13 +7377,13 @@
const title = titleInput.value.trim();
if (!title) {
- alert('Titel er påkrævet');
+ showCaseFeedback('Titel er påkrævet');
return;
}
const userId = getTodoUserId();
if (!userId) {
- alert('Mangler bruger-id. Log ind igen.');
+ showCaseFeedback('Mangler bruger-id. Log ind igen.');
return;
}
@@ -7363,14 +7409,14 @@
await loadTodoSteps();
toggleTodoStepForm(false);
} catch (e) {
- alert('Fejl: ' + e.message);
+ showCaseFeedback('Fejl: ' + e.message);
}
}
async function toggleTodoStep(stepId, isDone) {
const userId = getTodoUserId();
if (!userId) {
- alert('Mangler bruger-id. Log ind igen.');
+ showCaseFeedback('Mangler bruger-id. Log ind igen.');
return;
}
try {
@@ -7384,7 +7430,7 @@
if (!res.ok) throw new Error('Kunne ikke opdatere step');
await loadTodoSteps();
} catch (e) {
- alert('Fejl: ' + e.message);
+ showCaseFeedback('Fejl: ' + e.message);
}
}
@@ -7395,7 +7441,7 @@
if (!res.ok) throw new Error('Kunne ikke slette step');
await loadTodoSteps();
} catch (e) {
- alert('Fejl: ' + e.message);
+ showCaseFeedback('Fejl: ' + e.message);
}
}
@@ -7413,7 +7459,7 @@
if (!res.ok) throw await res.json();
loadCaseLocations();
} catch (e) {
- alert("Fejl: " + (e.detail || e.message));
+ showCaseFeedback("Fejl: " + (e.detail || e.message));
}
}
@@ -7427,7 +7473,7 @@
}
loadCaseLocations();
} catch (e) {
- alert("Fejl ved sletning: " + (e.message || 'Ukendt fejl'));
+ showCaseFeedback("Fejl ved sletning: " + (e.message || 'Ukendt fejl'));
}
}
@@ -7464,9 +7510,9 @@
if (!response.ok) throw await response.json();
// Reload page to show updated data
- window.location.reload();
+ reloadCasePreservingContext();
} catch (e) {
- alert("Fejl ved opdatering: " + (e.detail || e.message));
+ showCaseFeedback("Fejl ved opdatering: " + (e.detail || e.message));
}
}
@@ -7794,7 +7840,7 @@
if (typeof window.showNotification === 'function') {
window.showNotification(error.message || 'Kunne ikke oprette buzzword', 'error');
} else {
- alert(error.message || 'Kunne ikke oprette buzzword');
+ showCaseFeedback(error.message || 'Kunne ikke oprette buzzword');
}
}
};
@@ -7807,7 +7853,7 @@
if (typeof window.showNotification === 'function') {
window.showNotification('Marker tekst først, og tryk derefter Opret markering', 'warning');
} else {
- alert('Marker tekst først, og tryk derefter Opret markering');
+ showCaseFeedback('Marker tekst først, og tryk derefter Opret markering');
}
return;
}
@@ -7818,7 +7864,7 @@
if (typeof window.showNotification === 'function') {
window.showNotification(error.message || 'Kunne ikke oprette buzzword', 'error');
} else {
- alert(error.message || 'Kunne ikke oprette buzzword');
+ showCaseFeedback(error.message || 'Kunne ikke oprette buzzword');
}
}
};
@@ -8373,12 +8419,19 @@
Løsning
- {% if not solution or request.query_params.get('edit_solution') %}
-
- {% endif %}
+
+ Vidensdatabase
+ {% if solution %}Rediger{% endif %}
+
{% if solution %}
+ {% set approval_labels = {'draft':'Kladde','pending':'Afventer godkendelse','approved':'Godkendt','outdated':'Forældet','rejected':'Afvist'} %}
+ {% set visibility_labels = {'internal':'Kun internt','general':'Generel viden','customer':'Kun denne kunde'} %}
+
+ {{ approval_labels.get(solution.approval_status or 'draft', solution.approval_status or 'Kladde') }}
+ {{ visibility_labels.get(solution.visibility or 'internal', solution.visibility or 'Kun internt') }}
+
{{ solution.title }}
@@ -8394,9 +8447,21 @@
-
-
{{ solution.description }}
+ {% if solution.problem %}
{{ solution.problem }}
{% endif %}
+ {% if solution.root_cause %}
{{ solution.root_cause }}
{% endif %}
+ {% if solution.investigation %}
{{ solution.investigation }}
{% endif %}
+
+
{{ solution.description }}
+ {% if solution.workaround %}
{{ solution.workaround }}
{% endif %}
+
{% for tag in solution.tags or [] %}{{ tag }}{% endfor %}{% for product in solution.products or [] %}{{ product }}{% endfor %}
+
+ {% if solution.approval_status in [None, '', 'draft', 'rejected', 'outdated'] %}Send til godkendelse{% endif %}
+ {% if solution.approval_status == 'pending' %}GodkendAfvis{% endif %}
+ {% if solution.approval_status == 'approved' %}Udgiv i vidensdatabaseMarkér forældet{% endif %}
+ Historik
+
+
{% else %}
@@ -10288,7 +10353,7 @@
const form = event.target;
const content = (form.indhold.value || '').trim();
if (!content) {
- alert('Skriv en kommentar først.');
+ showCaseFeedback('Skriv en kommentar først.');
return;
}
const selectedActions = getSelectedCommentActions();
@@ -10315,15 +10380,15 @@
});
if (response.ok) {
- location.reload();
+ reloadCasePreservingContext();
} else {
- alert('Fejl ved oprettelse af kommentar');
+ showCaseFeedback('Fejl ved oprettelse af kommentar');
btn.innerHTML = originalText;
btn.disabled = false;
}
} catch (error) {
console.error('Error:', error);
- alert('Der skete en fejl. Prøv igen.');
+ showCaseFeedback('Der skete en fejl. Prøv igen.');
btn.innerHTML = originalText;
btn.disabled = false;
}
@@ -10655,13 +10720,13 @@
const textarea = form.querySelector('textarea[name="indhold"]');
const content = (textarea?.value || '').trim();
if (!content) {
- alert('Skriv en kommentar først.');
+ showCaseFeedback('Skriv en kommentar først.');
return;
}
const normalizedActions = Array.from(new Set((actions || []).filter(Boolean)));
if (!normalizedActions.length) {
- alert('Vælg mindst én handling.');
+ showCaseFeedback('Vælg mindst én handling.');
return;
}
@@ -10702,13 +10767,13 @@
textarea.value = '';
}
clearSelectedCommentActions();
- alert('Kommentar sendt og valgte handlinger registreret.');
+ showCaseFeedback('Kommentar sendt og valgte handlinger registreret.', 'success');
if (normalizedActions.includes('close_case') || normalizedActions.includes('await_customer')) {
- location.reload();
+ reloadCasePreservingContext();
}
} catch (error) {
console.error('submitCommentWithActions error:', error);
- alert('Kunne ikke gemme kommentar og handlinger. Prøv igen.');
+ showCaseFeedback('Kunne ikke gemme kommentar og handlinger. Prøv igen.');
} finally {
if (triggerButton) {
triggerButton.disabled = false;
@@ -10724,7 +10789,7 @@
}
const actions = getSelectedCommentActions();
if (!actions.length) {
- alert('Vælg mindst én handling.');
+ showCaseFeedback('Vælg mindst én handling.');
return;
}
await submitCommentWithActions(actions, {
@@ -11109,11 +11174,11 @@
const addSale = document.getElementById('gateway_line_add_sale').checked;
const status = document.getElementById('gateway_line_status').value || 'draft';
if (!quantity || quantity <= 0 || (!addPurchase && !addSale)) {
- alert('Vælg mindst én linjetype og et antal større end 0.');
+ showCaseFeedback('Vælg mindst én linjetype og et antal større end 0.');
return;
}
if ((addPurchase && purchasePrice < 0) || (addSale && salePrice !== null && salePrice < 0)) {
- alert('Priser må ikke være negative.');
+ showCaseFeedback('Priser må ikke være negative.');
return;
}
const description = String(product.product_name || product.name || '').trim();
@@ -11142,7 +11207,7 @@
bootstrap.Modal.getInstance(document.getElementById('gatewayProductLineModal'))?.hide();
await loadVarekobSalg();
} catch (error) {
- alert(error.message || 'Kunne ikke oprette varelinjerne');
+ showCaseFeedback(error.message || 'Kunne ikke oprette varelinjerne');
} finally {
saveButton.disabled = false;
}
@@ -11347,7 +11412,7 @@
}
if (!payload.description || !payload.amount) {
- alert('Beskrivelse og linjesum er påkrævet.');
+ showCaseFeedback('Beskrivelse og linjesum er påkrævet.');
return;
}
@@ -11363,7 +11428,7 @@
});
if (!res.ok) {
- alert('Kunne ikke gemme varelinje');
+ showCaseFeedback('Kunne ikke gemme varelinje');
return;
}
@@ -11375,7 +11440,7 @@
if (!confirm('Vil du slette denne varelinje?')) return;
const res = await fetch(`/api/v1/sag/${salesCaseId}/sale-items/${itemId}`, { method: 'DELETE' });
if (!res.ok) {
- alert('Kunne ikke slette varelinje');
+ showCaseFeedback('Kunne ikke slette varelinje');
return;
}
await loadVarekobSalg();
@@ -11475,11 +11540,11 @@
});
if (!res.ok) {
- alert('Kunne ikke gemme leverandørflow type');
+ showCaseFeedback('Kunne ikke gemme leverandørflow type');
return;
}
- alert('Leverandørflow type gemt');
+ showCaseFeedback('Leverandørflow type gemt', 'success');
toggleSupplierTypePanels();
}
@@ -11532,7 +11597,7 @@
});
if (!res.ok) {
- alert('Kunne ikke gemme linjehandling');
+ showCaseFeedback('Kunne ikke gemme linjehandling');
return;
}
@@ -11565,11 +11630,11 @@
});
if (!res.ok) {
- alert('Kunne ikke registrere ydelseshandling');
+ showCaseFeedback('Kunne ikke registrere ydelseshandling');
return;
}
- alert('Ydelseshandling registreret på sagen');
+ showCaseFeedback('Ydelseshandling registreret på sagen', 'success');
}
async function supplierApproveInvoice(invoiceId) {
@@ -11583,7 +11648,7 @@
});
if (!res.ok) {
const msg = await res.text();
- alert(`Kunne ikke godkende faktura: ${msg}`);
+ showCaseFeedback(`Kunne ikke godkende faktura: ${msg}`);
return;
}
await loadSupplierModule();
@@ -11601,7 +11666,7 @@
});
if (!res.ok) {
const msg = await res.text();
- alert(`Kunne ikke afvise faktura: ${msg}`);
+ showCaseFeedback(`Kunne ikke afvise faktura: ${msg}`);
return;
}
await loadSupplierModule();
@@ -11611,7 +11676,7 @@
const amountRaw = prompt('Betalingsbeløb (tom = restbeløb):', '');
const amount = amountRaw && amountRaw.trim() !== '' ? Number(amountRaw) : null;
if (amountRaw && (!Number.isFinite(amount) || amount <= 0)) {
- alert('Ugyldigt beløb');
+ showCaseFeedback('Ugyldigt beløb');
return;
}
@@ -11632,7 +11697,7 @@
if (!res.ok) {
const msg = await res.text();
- alert(`Kunne ikke registrere betaling: ${msg}`);
+ showCaseFeedback(`Kunne ikke registrere betaling: ${msg}`);
return;
}
@@ -11792,7 +11857,7 @@
}
await loadTimeTrackingTab();
} catch (error) {
- alert(error.message || 'Kunne ikke genoptage timeren');
+ showCaseFeedback(error.message || 'Kunne ikke genoptage timeren');
}
}
@@ -11801,7 +11866,7 @@
window.openBottomBarTimeConversion(Number(timeId));
return;
}
- alert('Konverteringsvinduet kunne ikke åbnes. Genindlæs siden og prøv igen.');
+ showCaseFeedback('Konverteringsvinduet kunne ikke åbnes. Genindlæs siden og prøv igen.');
}
function renderTimeV1Timeline(entries) {
@@ -12240,7 +12305,7 @@
const dateStr = target.dataset.date;
if (!dateStr || !/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
- alert('Kunne ikke bestemme dato for drop-felt');
+ showCaseFeedback('Kunne ikke bestemme dato for drop-felt');
return;
}
@@ -12275,7 +12340,7 @@
await patchTimeV1EntryPlacement(drag.entryId, payload);
await loadTimeTrackingTab();
} catch (error) {
- alert('Kunne ikke flytte tidsregistrering: ' + (error.message || 'ukendt fejl'));
+ showCaseFeedback('Kunne ikke flytte tidsregistrering: ' + (error.message || 'ukendt fejl'));
} finally {
timeV1DragBusy = false;
}
@@ -12312,7 +12377,7 @@
function openTimeV1DetailedEdit(entryId) {
const entry = timeV1EntriesById[Number(entryId)];
if (!entry) {
- alert('Kunne ikke finde tidsregistreringen');
+ showCaseFeedback('Kunne ikke finde tidsregistreringen');
return;
}
@@ -12342,7 +12407,7 @@
async function saveTimeV1DetailedEdit() {
const entryId = Number(document.getElementById('timeV1EditId')?.value || 0);
if (!entryId) {
- alert('Manglende entry-id');
+ showCaseFeedback('Manglende entry-id');
return;
}
@@ -12389,7 +12454,7 @@
}
await loadTimeTrackingTab();
} catch (error) {
- alert('Kunne ikke gemme detalje-redigering: ' + (error.message || 'ukendt fejl'));
+ showCaseFeedback('Kunne ikke gemme detalje-redigering: ' + (error.message || 'ukendt fejl'));
}
}
@@ -12548,7 +12613,7 @@
if (!res.ok) throw new Error(await res.text());
await loadTimeTrackingTab();
} catch (error) {
- alert('Kunne ikke starte timer: ' + (error.message || 'ukendt fejl'));
+ showCaseFeedback('Kunne ikke starte timer: ' + (error.message || 'ukendt fejl'));
}
}
@@ -12562,7 +12627,7 @@
if (!res.ok) throw new Error(await res.text());
await loadTimeTrackingTab();
} catch (error) {
- alert('Kunne ikke stoppe timer: ' + (error.message || 'ukendt fejl'));
+ showCaseFeedback('Kunne ikke stoppe timer: ' + (error.message || 'ukendt fejl'));
}
}
@@ -12572,7 +12637,7 @@
if (!res.ok) throw new Error(await res.text());
await loadTimeTrackingTab();
} catch (error) {
- alert('Kunne ikke pause timer: ' + (error.message || 'ukendt fejl'));
+ showCaseFeedback('Kunne ikke pause timer: ' + (error.message || 'ukendt fejl'));
}
}
@@ -12583,7 +12648,7 @@
if (!res.ok) throw new Error(await res.text());
await loadTimeTrackingTab();
} catch (error) {
- alert('Kunne ikke genoptage timer: ' + (error.message || 'ukendt fejl'));
+ showCaseFeedback('Kunne ikke genoptage timer: ' + (error.message || 'ukendt fejl'));
}
}
@@ -12685,7 +12750,7 @@
const minutes = Number(document.getElementById('timeV1Minutes')?.value || 0);
if (minutes <= 0) {
- alert('Indtast minutter over 0');
+ showCaseFeedback('Indtast minutter over 0');
return;
}
@@ -12695,7 +12760,7 @@
const employeeIds = getTimeV1EmployeeIds();
if (employeeIds.length === 0) {
- alert('Vælg mindst én medarbejder');
+ showCaseFeedback('Vælg mindst én medarbejder');
return;
}
@@ -12749,7 +12814,7 @@
await loadTimeTrackingTab();
} catch (error) {
- alert('Kunne ikke oprette tidsregistrering: ' + (error.message || 'ukendt fejl'));
+ showCaseFeedback('Kunne ikke oprette tidsregistrering: ' + (error.message || 'ukendt fejl'));
}
}
@@ -12766,6 +12831,10 @@
}
});
window.addEventListener('bb:time-converted', () => loadTimeTrackingTab());
+ window.addEventListener('bb:timer-state-changed', () => loadTimeTrackingTab());
+ window.addEventListener('storage', (event) => {
+ if (event.key === 'bmc:timer-state-changed' && event.newValue) loadTimeTrackingTab();
+ });
@@ -15129,10 +15280,10 @@
});
workspace.innerHTML = `
-
-
${title}
-
${bodyHtml}
-
+
+
${title}
+
${bodyHtml}
+
Luk
${footerBtns || ''}
@@ -15143,7 +15294,7 @@
formEl.addEventListener('submit', (evt) => evt.preventDefault());
});
- workspace.querySelectorAll('#relQaModalFooter button').forEach((btnEl) => {
+ workspace.querySelectorAll('#caseAddWorkspaceFooter button').forEach((btnEl) => {
if (!btnEl.getAttribute('type')) {
btnEl.setAttribute('type', 'button');
}
@@ -15356,26 +15507,17 @@
if (!res.ok) {
const err = await res.json();
- alert("Fejl: " + (err.detail || 'Kunne ikke tilføje'));
+ showCaseFeedback("Fejl: " + (err.detail || 'Kunne ikke tilføje'));
return;
}
bootstrap.Modal.getInstance(document.getElementById('entitySearchModal')).hide();
- window.location.reload();
+ reloadCasePreservingContext();
} catch (e) {
- alert("Fejl: " + e.message);
+ showCaseFeedback("Fejl: " + e.message);
}
}
- async function removeContact(caseId, contactId) {
- if(!confirm("Fjern denne kontakt fra sagen?")) return;
- try {
- const res = await fetch(`/api/v1/sag/${caseId}/contacts/${contactId}`, { method: 'DELETE' });
- if (res.ok) window.location.reload();
- else alert("Fejl ved sletning");
- } catch(e) { alert("Fejl: " + e.message); }
- }
-
function openContactRoleModal(contactId, contactName, role, isPrimary) {
document.getElementById('contactRoleContactId').value = contactId;
document.getElementById('contactRoleName').textContent = contactName || '-';
@@ -15404,21 +15546,12 @@
}
bootstrap.Modal.getInstance(document.getElementById('contactRoleModal')).hide();
- window.location.reload();
+ reloadCasePreservingContext();
} catch (e) {
- alert('Fejl: ' + e.message);
+ showCaseFeedback('Fejl: ' + e.message);
}
}
- async function removeCustomer(caseId, customerId) {
- if(!confirm("Fjern denne kunde fra sagen?")) return;
- try {
- const res = await fetch(`/api/v1/sag/${caseId}/customers/${customerId}`, { method: 'DELETE' });
- if (res.ok) window.location.reload();
- else alert("Fejl ved sletning");
- } catch(e) { alert("Fejl: " + e.message); }
- }
-
async function updateDeferredUntil(value) {
try {
const res = await fetch(`/api/v1/sag/${caseIds}`, {
@@ -15431,9 +15564,9 @@
const err = await res.json();
throw new Error(err.detail || 'Kunne ikke opdatere');
}
- window.location.reload();
+ reloadCasePreservingContext();
} catch (e) {
- alert('Fejl: ' + e.message);
+ showCaseFeedback('Fejl: ' + e.message);
}
}
@@ -15449,9 +15582,9 @@
const err = await res.json();
throw new Error(err.detail || 'Kunne ikke opdatere deadline');
}
- window.location.reload();
+ reloadCasePreservingContext();
} catch (e) {
- alert('Fejl: ' + e.message);
+ showCaseFeedback('Fejl: ' + e.message);
}
}
@@ -15597,9 +15730,9 @@
const err = await res.json();
throw new Error(err.detail || 'Kunne ikke opdatere');
}
- window.location.reload();
+ reloadCasePreservingContext();
} catch (e) {
- alert('Fejl: ' + e.message);
+ showCaseFeedback('Fejl: ' + e.message);
}
}
@@ -15766,7 +15899,7 @@
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
// Reload to re-render template vars (color accent on ID chip etc.)
- location.reload();
+ reloadCasePreservingContext();
} catch (e) {
console.error('saveCaseType error', e);
showToast('Kunne ikke gemme sagstype', 'danger');
@@ -15795,7 +15928,7 @@
});
if (!response.ok) throw new Error('HTTP ' + response.status);
- location.reload();
+ reloadCasePreservingContext();
} catch (e) {
console.error('saveCaseStatusFromTopbar error', e);
showToast('Kunne ikke gemme status', 'danger');
@@ -15880,7 +16013,7 @@
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
- location.reload();
+ reloadCasePreservingContext();
} catch (e) {
console.error('saveCasePriorityFromTopbar error', e);
showToast('Kunne ikke gemme prioritet', 'danger');
@@ -15900,7 +16033,7 @@
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
- location.reload();
+ reloadCasePreservingContext();
} catch (e) {
console.error('saveCaseStartDateFromTopbar error', e);
showToast('Kunne ikke gemme startdato', 'danger');
@@ -16050,7 +16183,7 @@
const target = preferred || fallback;
if (!target) {
- alert('Kunne ikke finde tildelingsfelter på siden.');
+ showCaseFeedback('Kunne ikke finde tildelingsfelter på siden.');
return;
}
@@ -16095,9 +16228,9 @@
throw new Error(`${message} (HTTP ${response.status})`);
}
- window.location.reload();
+ reloadCasePreservingContext();
} catch (error) {
- alert(`Fejl: ${error.message}`);
+ showCaseFeedback(`Fejl: ${error.message}`);
}
}
@@ -16453,7 +16586,7 @@
modulePrefs[moduleKey] = isEnabled;
applyViewFromTags();
} catch (e) {
- alert('Fejl: ' + e.message);
+ showCaseFeedback('Fejl: ' + e.message);
}
}
@@ -16586,11 +16719,11 @@
if(res.ok) {
loadSagFiles();
} else {
- alert('Upload fejlede');
+ showCaseFeedback('Upload fejlede');
loadSagFiles(); // Reload to clear loading state
}
} catch(e) {
- alert('Upload fejl: ' + e);
+ showCaseFeedback('Upload fejl: ' + e);
loadSagFiles();
}
}
@@ -16600,8 +16733,8 @@
try {
const res = await fetch(`/api/v1/sag/${caseIds}/files/${fileId}`, { method: 'DELETE' });
if(res.ok) loadSagFiles();
- else alert("Kunne ikke slette fil");
- } catch(e) { alert("Fejl: " + e); }
+ else showCaseFeedback("Kunne ikke slette fil");
+ } catch(e) { showCaseFeedback("Fejl: " + e); }
}
async function renderPdfPreview(fileUrl, fileId, filename, previewContent) {
@@ -16653,7 +16786,7 @@
function previewFileById(fileId) {
const file = sagFilesCache.find((item) => Number(item.id) === Number(fileId));
if (!file) {
- alert('Filen kunne ikke findes.');
+ showCaseFeedback('Filen kunne ikke findes.');
return;
}
previewFile(Number(file.id), String(file.filename || ''), String(file.content_type || ''));
@@ -16931,7 +17064,7 @@
const source = (bodyInput.value || '').trim();
if (!source) {
- alert('Skriv en besked først.');
+ showCaseFeedback('Skriv en besked først.');
return;
}
@@ -16955,7 +17088,7 @@
});
} catch (error) {
console.error(error);
- alert(`Kunne ikke renskrive email: ${error.message || 'Ukendt fejl'}`);
+ showCaseFeedback(`Kunne ikke renskrive email: ${error.message || 'Ukendt fejl'}`);
} finally {
if (btn) {
btn.disabled = false;
@@ -17237,17 +17370,17 @@
.filter((id) => Number.isInteger(id) && id > 0);
if (!to.length) {
- alert('Udfyld mindst én modtager i Til-feltet.');
+ showCaseFeedback('Udfyld mindst én modtager i Til-feltet.');
return;
}
if (!subject) {
- alert('Udfyld emne før afsendelse.');
+ showCaseFeedback('Udfyld emne før afsendelse.');
return;
}
if (!bodyText) {
- alert('Udfyld besked før afsendelse.');
+ showCaseFeedback('Udfyld besked før afsendelse.');
return;
}
@@ -17859,7 +17992,7 @@
if (payload?.detail) detail = payload.detail;
} catch (_) {
}
- alert(detail);
+ showCaseFeedback(detail);
return;
}
@@ -17893,7 +18026,7 @@
}
} catch (error) {
console.error(error);
- alert('Fejl ved opdatering af læsestatus.');
+ showCaseFeedback('Fejl ved opdatering af læsestatus.');
}
}
@@ -17908,7 +18041,7 @@
}
loadLinkedEmails();
}
- } catch(e) { alert(e); }
+ } catch(e) { showCaseFeedback(e); }
}
// Email Search
@@ -17978,8 +18111,8 @@
body: JSON.stringify({email_id: emailId})
});
if(res.ok) loadLinkedEmails();
- else alert("Kunne ikke linke email");
- } catch(e) { alert(e); }
+ else showCaseFeedback("Kunne ikke linke email");
+ } catch(e) { showCaseFeedback(e); }
}
// Email Import Drag & Drop (.msg / .eml)
@@ -17999,7 +18132,7 @@
if (!file) return;
const lowerName = String(file.name || '').toLowerCase();
if (!(lowerName.endsWith('.eml') || lowerName.endsWith('.msg'))) {
- alert('Kun .eml og .msg filer understøttes');
+ showCaseFeedback('Kun .eml og .msg filer understøttes');
return;
}
@@ -18017,9 +18150,9 @@
if(res.ok) {
loadLinkedEmails();
} else {
- alert('Import fejlede');
+ showCaseFeedback('Import fejlede');
}
- } catch(e) { alert(e); }
+ } catch(e) { showCaseFeedback(e); }
finally {
emailDropZone.style.opacity = '1';
}
@@ -18886,7 +19019,7 @@
};
if (!payload.name) {
- alert('Navn er påkrævet');
+ showCaseFeedback('Navn er påkrævet');
return;
}
@@ -18898,7 +19031,7 @@
if (!res.ok) {
const error = await res.json();
- alert(error.detail || 'Kunne ikke oprette produkt');
+ showCaseFeedback(error.detail || 'Kunne ikke oprette produkt');
return;
}
@@ -19251,7 +19384,7 @@
async function saveSubscriptionProvisioningInline() {
if (!currentSubscription?.id || !subscriptionProvisioningData) return;
if (subscriptionProvisioningData?.network_provisioning?.is_provisioned) {
- alert('Denne provisionering er allerede gemt. Opret eller rediger forbindelsen direkte, hvis den skal ændres.');
+ showCaseFeedback('Denne provisionering er allerede gemt. Opret eller rediger forbindelsen direkte, hvis den skal ændres.', 'warning');
return;
}
const headSelect = document.getElementById('subscriptionProvisioningInlineHeadSelect');
@@ -19259,7 +19392,7 @@
const rangeSelects = Array.from(document.querySelectorAll('.subscriptionProvisioningInlineRangeSelect'));
const sharedConnectionId = parseInt(headSelect?.value || '', 10);
if (!Number.isFinite(sharedConnectionId)) {
- alert('Vælg en BMC hovedforbindelse');
+ showCaseFeedback('Vælg en BMC hovedforbindelse');
return;
}
@@ -19268,7 +19401,7 @@
const subscriptionItemId = parseInt(select.dataset.itemId || '', 10);
const parsedRange = parseProvisioningRangeSelection(select.value);
if (!Number.isFinite(subscriptionItemId) || !parsedRange) {
- alert('Vælg et ledigt range til alle IP-produktlinjer');
+ showCaseFeedback('Vælg et ledigt range til alle IP-produktlinjer');
return;
}
ipAllocations.push({
@@ -19294,7 +19427,7 @@
}
await loadSubscriptionForCase();
} catch (e) {
- alert(e.message || e);
+ showCaseFeedback(e.message || e);
}
}
@@ -19388,9 +19521,12 @@
try {
const changeRes = await fetch(`/api/v1/sag-subscriptions/change-requests/by-case/${subscriptionCaseId}`);
if (changeRes.ok) {
- renderSubscriptionChangeCase(await changeRes.json());
- setModuleContentState('subscription', true);
- return;
+ const changePayload = await changeRes.json();
+ if (changePayload.change_request) {
+ renderSubscriptionChangeCase(changePayload);
+ setModuleContentState('subscription', true);
+ return;
+ }
}
const res = await fetch(`/api/v1/sag-subscriptions/agreement-overview/${subscriptionCaseId}`);
if (!res.ok) {
@@ -19587,10 +19723,10 @@
async function saveSelectedSubscriptionChange() {
const cards = [...document.querySelectorAll('.subscription-agreement-card')].filter(card => card.querySelector('.subscription-change-select')?.checked);
- if (!cards.length) return alert('Vælg mindst ét abonnement');
+ if (!cards.length) return showCaseFeedback('Vælg mindst ét abonnement');
const reason = document.getElementById('agreementChangeReason')?.value.trim();
const effectiveDate = document.getElementById('agreementEffectiveDate')?.value;
- if (!reason || !effectiveDate) return alert('Angiv begrundelse og ikrafttrædelsesdato');
+ if (!reason || !effectiveDate) return showCaseFeedback('Angiv begrundelse og ikrafttrædelsesdato');
const proposals = cards.map(card => ({
subscription_id: Number(card.dataset.subscriptionId),
changes: {
@@ -19603,10 +19739,10 @@
}
}));
const invalidProposal = proposals.find(proposal => !proposal.changes.line_items.length || proposal.changes.line_items.some(line => !line.description || line.quantity <= 0 || line.unit_price < 0));
- if (invalidProposal) return alert('Alle valgte abonnementer skal have mindst én gyldig varelinje med beskrivelse, antal og pris.');
+ if (invalidProposal) return showCaseFeedback('Alle valgte abonnementer skal have mindst én gyldig varelinje med beskrivelse, antal og pris.');
const res = await fetch('/api/v1/sag-subscriptions/change-requests', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({main_sag_id: subscriptionCaseId, reason, effective_date: effectiveDate, proposals})});
const result = await res.json();
- if (!res.ok) return alert(result.detail || 'Kunne ikke oprette ændringsundersag');
+ if (!res.ok) return showCaseFeedback(result.detail || 'Kunne ikke oprette ændringsundersag');
window.location.href = result.case_url;
}
@@ -19653,7 +19789,7 @@
async function subscriptionChangeAction(id, action) {
const res = await fetch(`/api/v1/sag-subscriptions/change-requests/${id}/${action}`, {method: 'POST'});
const result = await res.json();
- if (!res.ok) return alert(result.detail || 'Handlingen fejlede');
+ if (!res.ok) return showCaseFeedback(result.detail || 'Handlingen fejlede');
await loadSubscriptionForCase();
}
@@ -19668,11 +19804,11 @@
const lineItems = collectSubscriptionLineItems();
if (!billingInterval || !billingDay || !startDate || !periodStart) {
- alert('Udfyld venligst alle påkrævede felter');
+ showCaseFeedback('Udfyld venligst alle påkrævede felter');
return;
}
if (!lineItems.length) {
- alert('Du skal angive mindst en varelinje');
+ showCaseFeedback('Du skal angive mindst en varelinje');
return;
}
@@ -19727,7 +19863,7 @@
}
await loadSubscriptionForCase();
} catch (e) {
- alert(e.message || e);
+ showCaseFeedback(e.message || e);
}
}
@@ -19752,7 +19888,7 @@
const updated = await res.json();
renderSubscription(updated);
} catch (e) {
- alert(e.message || e);
+ showCaseFeedback(e.message || e);
}
}
@@ -19812,7 +19948,7 @@
const totalHours = hours + (minutes / 60);
if (totalHours === 0) {
- alert('Angiv venligst timer eller minutter');
+ showCaseFeedback('Angiv venligst timer eller minutter');
return;
}
@@ -19877,9 +20013,9 @@
}
// Success - reload page to show new entry
- window.location.reload();
+ reloadCasePreservingContext();
} catch (error) {
- alert('Fejl: ' + error.message);
+ showCaseFeedback('Fejl: ' + error.message);
console.error('Quick add time error:', error);
}
}
@@ -19946,19 +20082,19 @@
const description = (descriptionEl?.value || '').trim();
if (!title) {
- alert('Titel er påkrævet');
+ showCaseFeedback('Titel er påkrævet');
return;
}
if (!relationType) {
- alert('Vælg relationstype');
+ showCaseFeedback('Vælg relationstype');
return;
}
const pathMatch = String(window.location.pathname || '').match(/\/sag\/(\d+)/);
const rootCaseId = pathMatch ? parseInt(pathMatch[1], 10) : null;
if (!rootCaseId) {
- alert('Kunne ikke finde sags-id i URL');
+ showCaseFeedback('Kunne ikke finde sags-id i URL');
return;
}
@@ -19976,7 +20112,7 @@
}
if (!customerId) {
- alert('Sagen mangler kunde. Tilknyt kunde først, før du opretter relateret sag.');
+ showCaseFeedback('Sagen mangler kunde. Tilknyt kunde først, før du opretter relateret sag.');
return;
}
@@ -20019,9 +20155,9 @@
throw new Error(payload.detail || 'Kunne ikke oprette relation');
}
- window.location.reload();
+ reloadCasePreservingContext();
} catch (error) {
- alert('Der opstod en fejl: ' + (error?.message || error));
+ showCaseFeedback('Der opstod en fejl: ' + (error?.message || error));
}
};
}
@@ -20139,7 +20275,7 @@
function getRelQaPrimaryButton() {
const sidePanel = document.getElementById('caseAddSidePanel');
if (sidePanel && sidePanel.classList.contains('open')) {
- return document.querySelector('#caseAddSideWorkspace #relQaModalFooter .btn-primary');
+ return document.querySelector('#caseAddSideWorkspace #caseAddWorkspaceFooter .btn-primary');
}
return document.querySelector('body > #relQaModalEl .btn-primary');
}
@@ -20156,7 +20292,7 @@
// In sidepanel mode, refresh to reflect new persisted data across modules.
if (panelOpen) {
- setTimeout(() => window.location.reload(), 120);
+ reloadCasePreservingContext(120);
}
}
@@ -21217,7 +21353,7 @@
await loadCaseTagsFallback(caseId);
} catch (error) {
console.error('Case fallback tag delete failed:', error);
- alert('Fejl ved sletning af tag: ' + (error && error.message ? error.message : 'ukendt fejl'));
+ showCaseFeedback('Fejl ved sletning af tag: ' + (error && error.message ? error.message : 'ukendt fejl'));
}
};
});
@@ -21402,7 +21538,7 @@
const source = (ta.value || '').trim();
if (!source) {
if (typeof showNotification === 'function') showNotification('Skriv en beskrivelse først', 'warning');
- else alert('Skriv en beskrivelse først');
+ else showCaseFeedback('Skriv en beskrivelse først');
return;
}
@@ -21465,7 +21601,7 @@
} catch (e) {
console.error(e);
if (typeof showNotification === 'function') showNotification('Kunne ikke renskrive beskrivelse', 'error');
- else alert(`Kunne ikke renskrive beskrivelse: ${e.message || 'Ukendt fejl'}`);
+ else showCaseFeedback(`Kunne ikke renskrive beskrivelse: ${e.message || 'Ukendt fejl'}`);
} finally {
if (rewriteBtn) {
rewriteBtn.disabled = false;
@@ -21492,7 +21628,7 @@
const input = document.getElementById('sag-titel-input');
const saveBtn = document.getElementById('sag-titel-save-btn');
const newVal = input.value.trim();
- if (!newVal) { alert('Overskrift må ikke være tom.'); return; }
+ if (!newVal) { showCaseFeedback('Overskrift må ikke være tom.'); return; }
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = 'Gemmer...'; }
try {
const res = await fetch(`/api/v1/sag/${caseIds}`, {
@@ -21511,7 +21647,7 @@
if (typeof showNotification === 'function') showNotification('Overskrift gemt', 'success');
} catch (e) {
if (typeof showNotification === 'function') showNotification('Kunne ikke gemme overskrift: ' + e.message, 'error');
- else alert('Fejl: ' + e.message);
+ else showCaseFeedback('Fejl: ' + e.message);
} finally {
if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = 'Gem'; }
}
@@ -21586,7 +21722,7 @@
if (typeof showNotification === 'function') {
showNotification('Marker tekst i beskrivelsen først', 'warning');
} else {
- alert('Marker tekst i beskrivelsen først');
+ showCaseFeedback('Marker tekst i beskrivelsen først');
}
return;
}
@@ -21603,7 +21739,7 @@
if (typeof showNotification === 'function') {
showNotification(error.message || 'Kunne ikke oprette buzzword', 'error');
} else {
- alert(`Fejl: ${error.message || 'Kunne ikke oprette buzzword'}`);
+ showCaseFeedback(`Fejl: ${error.message || 'Kunne ikke oprette buzzword'}`);
}
}
};
diff --git a/app/modules/sag/templates/edit.html b/app/modules/sag/templates/edit.html
index f7f9181..1de35cf 100644
--- a/app/modules/sag/templates/edit.html
+++ b/app/modules/sag/templates/edit.html
@@ -299,8 +299,6 @@
deadline: document.getElementById('deadline').value || null
};
- console.log('Updating case with data:', data);
-
try {
const response = await fetch(`/api/v1/sag/${caseId}`, {
method: 'PATCH',
@@ -310,17 +308,11 @@
body: JSON.stringify(data)
});
- console.log('Response status:', response.status);
-
if (response.ok) {
- const result = await response.json();
- console.log('Updated case:', result);
-
- document.getElementById('success').textContent = `✅ Sag opdateret! Omdirigerer...`;
+ await response.json();
+ document.getElementById('success').textContent = `✅ Sag opdateret`;
document.getElementById('success').style.display = 'block';
- setTimeout(() => {
- window.location.href = `/sag/${caseId}/v3`;
- }, 1000);
+ window.location.href = `/sag/${caseId}/v3`;
} else {
const errorText = await response.text();
console.error('Error response:', errorText);
diff --git a/app/modules/sag/templates/index.html b/app/modules/sag/templates/index.html
index 8225b07..75e4491 100644
--- a/app/modules/sag/templates/index.html
+++ b/app/modules/sag/templates/index.html
@@ -5,17 +5,54 @@
{% block extra_css %}
+{% endblock %}
+{% block content %}
+