feat: Enhance case listing and detail views with improved filtering and relation handling

- Added filtering for cases based on start date in `sager_liste`.
- Improved fallback relation tree rendering in `sag_detaljer` when tree builder fails.
- Normalized relation types in `RelationService` for consistency.
- Updated relation type display in templates with new styles and improved semantics.
- Enhanced customer handling in detail view with edit functionality.
- Updated various labels for clarity in the UI.
- Added new buttons for deferred status shortcuts in the detail view.
- Improved tag picker resilience by decoupling from optional tag group API.
This commit is contained in:
Christian 2026-04-04 02:46:37 +02:00
parent 1f834160ca
commit 807c68679e
7 changed files with 622 additions and 70 deletions

View File

@ -169,6 +169,43 @@ def _validate_customer_id(customer_id: Optional[int], field_name: str = "custome
if not exists: if not exists:
raise HTTPException(status_code=400, detail=f"Invalid {field_name}") raise HTTPException(status_code=400, detail=f"Invalid {field_name}")
def _activate_waiting_cases_by_status(trigger_case_id: int, status_value: Optional[str]) -> None:
"""Set start_date for waiting cases when their status dependency is met."""
normalized_status = str(status_value or "").strip()
if not normalized_status:
return
try:
updated_rows = execute_query(
"""
UPDATE sag_sager
SET start_date = NOW(), updated_at = NOW()
WHERE deleted_at IS NULL
AND deferred_until_case_id = %s
AND deferred_until_status IS NOT NULL
AND start_date IS NULL
AND LOWER(TRIM(deferred_until_status)) = LOWER(TRIM(%s))
RETURNING id
""",
(trigger_case_id, normalized_status),
) or []
if updated_rows:
logger.info(
"✅ Activated %s waiting case(s) from trigger case %s with status '%s'",
len(updated_rows),
trigger_case_id,
normalized_status,
)
except Exception as exc:
logger.warning(
"⚠️ Could not activate waiting cases for trigger case %s and status '%s': %s",
trigger_case_id,
normalized_status,
exc,
)
# ============================================================================ # ============================================================================
# QUICKCREATE AI ANALYSIS # QUICKCREATE AI ANALYSIS
# ============================================================================ # ============================================================================
@ -577,6 +614,7 @@ async def list_sager(
if not include_deferred: if not include_deferred:
query += " AND (deferred_until IS NULL OR deferred_until <= NOW())" query += " AND (deferred_until IS NULL OR deferred_until <= NOW())"
query += " AND (start_date IS NULL OR start_date <= NOW())"
if status: if status:
query += " AND s.status = %s" query += " AND s.status = %s"
@ -717,6 +755,8 @@ async def create_sag(data: dict):
logger.info("✅ Case created: %s", result[0]["id"]) logger.info("✅ Case created: %s", result[0]["id"])
return result[0] return result[0]
raise HTTPException(status_code=500, detail="Failed to create case") raise HTTPException(status_code=500, detail="Failed to create case")
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error("❌ Error creating case: %s", e) logger.error("❌ Error creating case: %s", e)
raise HTTPException(status_code=500, detail="Failed to create case") raise HTTPException(status_code=500, detail="Failed to create case")
@ -957,10 +997,15 @@ async def update_sag(sag_id: int, updates: dict):
"""Update a case.""" """Update a case."""
try: try:
# Check if case exists # Check if case exists
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) check = execute_query(
"SELECT id, status FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(sag_id,),
)
if not check: if not check:
raise HTTPException(status_code=404, detail="Case not found") raise HTTPException(status_code=404, detail="Case not found")
previous_status = str((check[0] or {}).get("status") or "").strip().lower()
# Backwards compatibility: frontend sends "type", DB stores "template_key" # Backwards compatibility: frontend sends "type", DB stores "template_key"
if "type" in updates and "template_key" not in updates: if "type" in updates and "template_key" not in updates:
updates["template_key"] = updates.get("type") updates["template_key"] = updates.get("type")
@ -1018,6 +1063,10 @@ async def update_sag(sag_id: int, updates: dict):
result = execute_query(query, tuple(params)) result = execute_query(query, tuple(params))
if result: if result:
if "status" in updates:
new_status = str((result[0] or {}).get("status") or "").strip().lower()
if new_status and new_status != previous_status:
_activate_waiting_cases_by_status(sag_id, new_status)
logger.info("✅ Case updated: %s", sag_id) logger.info("✅ Case updated: %s", sag_id)
return result[0] return result[0]
raise HTTPException(status_code=500, detail="Failed to update case") raise HTTPException(status_code=500, detail="Failed to update case")
@ -1387,6 +1436,74 @@ async def list_case_customers(sag_id: int):
logger.error("❌ Error listing case customers: %s", e) logger.error("❌ Error listing case customers: %s", e)
raise HTTPException(status_code=500, detail="Failed to list case customers") raise HTTPException(status_code=500, detail="Failed to list case customers")
@router.post("/sag/{sag_id}/customer/replace")
async def replace_case_customer(sag_id: int, payload: dict):
"""Replace the primary customer on a case and keep relations synchronized."""
try:
existing_case = execute_query_single(
"SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(sag_id,),
)
if not existing_case:
raise HTTPException(status_code=404, detail="Case not found")
customer_id = _coerce_optional_int((payload or {}).get("customer_id"), "customer_id")
if customer_id is None:
raise HTTPException(status_code=400, detail="customer_id is required")
_validate_customer_id(customer_id)
if table_has_column("sag_sager", "customer_id"):
execute_query(
"UPDATE sag_sager SET customer_id = %s WHERE id = %s",
(customer_id, sag_id),
)
if _table_exists("sag_kunder"):
execute_query(
"""
UPDATE sag_kunder
SET deleted_at = NOW()
WHERE sag_id = %s
AND customer_id <> %s
AND deleted_at IS NULL
AND LOWER(COALESCE(role, '')) = 'kunde'
""",
(sag_id, customer_id),
)
existing_link = execute_query_single(
"""
SELECT id
FROM sag_kunder
WHERE sag_id = %s
AND customer_id = %s
AND deleted_at IS NULL
LIMIT 1
""",
(sag_id, customer_id),
)
if existing_link:
execute_query(
"UPDATE sag_kunder SET role = %s WHERE id = %s",
("Kunde", existing_link["id"]),
)
else:
execute_query(
"INSERT INTO sag_kunder (sag_id, customer_id, role) VALUES (%s, %s, %s)",
(sag_id, customer_id, "Kunde"),
)
logger.info("✅ Primary customer replaced for case %s -> customer %s", sag_id, customer_id)
return {"status": "ok", "sag_id": sag_id, "customer_id": customer_id}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error replacing case customer: %s", e)
raise HTTPException(status_code=500, detail="Failed to replace case customer")
@router.post("/sag/{sag_id}/customers") @router.post("/sag/{sag_id}/customers")
async def add_case_customer(sag_id: int, data: dict): async def add_case_customer(sag_id: int, data: dict):
"""Add a customer to a case.""" """Add a customer to a case."""

View File

@ -237,6 +237,7 @@ async def sager_liste(
query += " OR s.deferred_until <= NOW()" query += " OR s.deferred_until <= NOW()"
query += " OR (s.deferred_until_case_id IS NOT NULL AND s.deferred_until_status IS NOT NULL AND ds.status = s.deferred_until_status)" query += " OR (s.deferred_until_case_id IS NOT NULL AND s.deferred_until_status IS NOT NULL AND ds.status = s.deferred_until_status)"
query += ")" query += ")"
query += " AND (s.start_date IS NULL OR s.start_date <= NOW())"
if status: if status:
query += " AND s.status = %s" query += " AND s.status = %s"
@ -289,6 +290,10 @@ async def sager_liste(
""" """
fallback_params = [] fallback_params = []
if not include_deferred:
fallback_query += " AND (s.deferred_until IS NULL OR s.deferred_until <= NOW())"
fallback_query += " AND (s.start_date IS NULL OR s.start_date <= NOW())"
if status: if status:
fallback_query += " AND s.status = %s" fallback_query += " AND s.status = %s"
fallback_params.append(status) fallback_params.append(status)
@ -498,9 +503,50 @@ async def sag_detaljer(request: Request, sag_id: int):
except Exception as e: except Exception as e:
logger.error(f"Error building relation tree: {e}") logger.error(f"Error building relation tree: {e}")
relation_tree = [] relation_tree = []
except Exception as e:
logger.error(f"Error building relation tree: {e}") # Fallback: if tree builder fails/returns empty but relations exist, render a minimal flat tree.
relation_tree = [] if not relation_tree and relationer:
try:
root_node = {
"case": {
"id": sag.get("id"),
"titel": sag.get("titel"),
"status": sag.get("status"),
"type": sag.get("type"),
"template_key": sag.get("template_key"),
},
"relation_type": None,
"relation_id": None,
"is_current": True,
"children": [],
}
seen_related = set()
for rel in relationer or []:
source_id = rel.get("kilde_sag_id")
target_id = rel.get("målsag_id")
related_id = target_id if source_id == sag_id else source_id
if not related_id or related_id in seen_related:
continue
seen_related.add(related_id)
root_node["children"].append({
"case": {
"id": related_id,
"titel": rel.get("mål_titel") if source_id == sag_id else rel.get("kilde_titel"),
"status": None,
"type": None,
"template_key": None,
},
"relation_type": rel.get("relationstype") or "Relateret til",
"relation_id": rel.get("id"),
"is_current": False,
"children": [],
})
relation_tree = [root_node]
except Exception as fallback_err:
logger.warning("⚠️ Could not build fallback relation tree: %s", fallback_err)
# Fetch customer info if customer_id exists # Fetch customer info if customer_id exists
customer = None customer = None

View File

@ -4,6 +4,11 @@ from app.core.database import execute_query
class RelationService: class RelationService:
"""Service for handling case relations (Sager)""" """Service for handling case relations (Sager)"""
@staticmethod
def _normalize_relation_type(value: Optional[str]) -> str:
text = str(value or "").strip()
return text or "Relateret til"
@staticmethod @staticmethod
def get_relation_tree(root_id: int) -> List[Dict]: def get_relation_tree(root_id: int) -> List[Dict]:
""" """
@ -33,7 +38,16 @@ class RelationService:
# 2. Fetch details for these cases # 2. Fetch details for these cases
placeholders = ','.join(['%s'] * len(tree_ids)) placeholders = ','.join(['%s'] * len(tree_ids))
tree_cases_query = f"SELECT id, titel, status, type, template_key FROM sag_sager WHERE id IN ({placeholders})" tree_cases_query = f"""
SELECT
id,
titel,
status,
template_key,
COALESCE(template_key, 'ticket') AS type
FROM sag_sager
WHERE id IN ({placeholders})
"""
tree_cases = {c['id']: c for c in execute_query(tree_cases_query, tuple(tree_ids))} tree_cases = {c['id']: c for c in execute_query(tree_cases_query, tuple(tree_ids))}
# 3. Fetch all edges between these cases # 3. Fetch all edges between these cases
@ -53,7 +67,8 @@ class RelationService:
# Helper to normalize relation types # Helper to normalize relation types
# Now that we cleaned DB, we expect standard Danish terms, but good to be safe # Now that we cleaned DB, we expect standard Danish terms, but good to be safe
def get_direction(k, m, rtype): def get_direction(k, m, rtype):
rtype_lower = rtype.lower() normalized_type = RelationService._normalize_relation_type(rtype)
rtype_lower = normalized_type.lower()
if rtype_lower in ['afledt af', 'derived from']: if rtype_lower in ['afledt af', 'derived from']:
return m, k # m is parent of k return m, k # m is parent of k
if rtype_lower in ['årsag til', 'cause of']: if rtype_lower in ['årsag til', 'cause of']:
@ -66,7 +81,8 @@ class RelationService:
processed_edges = set() processed_edges = set()
for edge in tree_edges: for edge in tree_edges:
k, m, rtype = edge['kilde_sag_id'], edge['målsag_id'], edge['relationstype'] k, m = edge['kilde_sag_id'], edge['målsag_id']
rtype = RelationService._normalize_relation_type(edge.get('relationstype'))
# Dedup edges (bi-directional check) # Dedup edges (bi-directional check)
edge_key = tuple(sorted((k,m))) + (rtype,) edge_key = tuple(sorted((k,m))) + (rtype,)
@ -119,7 +135,7 @@ class RelationService:
path_visited.add(cid) path_visited.add(cid)
# Sort children for consistent display # Sort children for consistent display
children_data = sorted(children_map.get(cid, []), key=lambda x: x['type']) children_data = sorted(children_map.get(cid, []), key=lambda x: str(x.get('type') or '').lower())
children_nodes = [] children_nodes = []
for child_info in children_data: for child_info in children_data:

View File

@ -1463,17 +1463,53 @@
color: var(--accent); color: var(--accent);
} }
.relation-type-badge { .relation-type-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.3rem;
font-size: 0.75rem; font-size: 0.72rem;
color: var(--accent); font-weight: 700;
background: rgba(15, 76, 117, 0.1); padding: 0.22rem 0.5rem;
padding: 2px 6px; border-radius: 999px;
border-radius: 4px; border: 1px solid transparent;
margin-right: 8px; line-height: 1.2;
font-weight: 500; white-space: nowrap;
}
.relation-type-pill.is-root {
background: rgba(15, 76, 117, 0.12);
color: #0f4c75;
border-color: rgba(15, 76, 117, 0.25);
}
.relation-type-pill.is-related {
background: rgba(100, 116, 139, 0.12);
color: #334155;
border-color: rgba(100, 116, 139, 0.25);
}
.relation-type-pill.is-derived {
background: rgba(59, 130, 246, 0.12);
color: #1d4ed8;
border-color: rgba(59, 130, 246, 0.25);
}
.relation-type-pill.is-cause {
background: rgba(16, 185, 129, 0.13);
color: #047857;
border-color: rgba(16, 185, 129, 0.3);
}
.relation-type-pill.is-block {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
border-color: rgba(239, 68, 68, 0.3);
}
.relation-type-subtext {
font-size: 0.68rem;
color: var(--text-secondary, #6b7280);
margin-top: 0.2rem;
} }
.right-modules-grid { .right-modules-grid {
@ -1684,6 +1720,41 @@
color: var(--accent); color: var(--accent);
} }
.topbar-company-row {
display: inline-flex;
align-items: center;
gap: 0.42rem;
max-width: 100%;
}
.topbar-company-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar-company-edit-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border-radius: 999px;
border: 1px solid rgba(15,76,117,0.35);
background: #0d6efd;
color: #ffffff;
box-shadow: 0 1px 4px rgba(13,110,253,0.25);
}
.topbar-company-edit-btn:hover,
.topbar-company-edit-btn:focus-visible {
background: #0b5ed7;
border-color: rgba(11,94,215,0.85);
color: #ffffff;
}
.topbar-primary .case-inline-select { .topbar-primary .case-inline-select {
background: rgba(255,255,255,0.78); background: rgba(255,255,255,0.78);
border-color: rgba(15,76,117,0.3); border-color: rgba(15,76,117,0.3);
@ -1764,6 +1835,11 @@
color: #d9ebf7; color: #d9ebf7;
} }
[data-bs-theme="dark"] .topbar-company-edit-btn {
border-color: rgba(170,205,245,0.5);
box-shadow: 0 1px 6px rgba(75,145,255,0.35);
}
.case-tabs-topbar.topbar-secondary { .case-tabs-topbar.topbar-secondary {
grid-template-columns: repeat(8, minmax(145px, 1fr)); grid-template-columns: repeat(8, minmax(145px, 1fr));
} }
@ -1923,6 +1999,31 @@
text-align: left; text-align: left;
} }
.topbar-deferred-shortcuts {
display: flex;
gap: 0.35rem;
margin-top: 0.45rem;
flex-wrap: wrap;
}
.topbar-mini-trigger {
border: 1px solid rgba(0,0,0,0.14);
background: rgba(255,255,255,0.75);
color: var(--text-primary);
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
padding: 0.35rem 0.55rem;
letter-spacing: 0.01em;
}
.topbar-mini-trigger:hover {
border-color: var(--accent);
color: var(--accent);
background: rgba(255,255,255,0.95);
}
.topbar-secondary-action:hover { .topbar-secondary-action:hover {
border-color: var(--accent); border-color: var(--accent);
color: var(--accent); color: var(--accent);
@ -1979,6 +2080,17 @@
background: rgba(20, 27, 38, 0.78); background: rgba(20, 27, 38, 0.78);
} }
[data-bs-theme="dark"] .topbar-mini-trigger {
background: rgba(20, 27, 38, 0.78);
border-color: rgba(170, 190, 216, 0.4);
color: #dce8f4;
}
[data-bs-theme="dark"] .topbar-mini-trigger:hover {
border-color: #9fc4e8;
color: #9fc4e8;
}
.case-add-side-backdrop { .case-add-side-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@ -2198,7 +2310,12 @@
</div> </div>
<div class="case-tabs-topbar-item"> <div class="case-tabs-topbar-item">
<div class="case-tabs-topbar-label"><i class="bi bi-building"></i>Firma</div> <div class="case-tabs-topbar-label"><i class="bi bi-building"></i>Firma</div>
<div class="case-tabs-topbar-value">{{ customer.name if customer else 'Ingen kunde' }}</div> <div class="case-tabs-topbar-value topbar-company-row">
<span class="topbar-company-name">{{ customer.name if customer else 'Ingen kunde' }}</span>
<button type="button" class="topbar-company-edit-btn" onclick="showCustomerSearch('replace')" title="Skift kunde/firma">
<i class="bi bi-pencil"></i>
</button>
</div>
</div> </div>
<div class="case-tabs-topbar-item"> <div class="case-tabs-topbar-item">
<div class="case-tabs-topbar-label"><i class="bi bi-person"></i>Kontakt</div> <div class="case-tabs-topbar-label"><i class="bi bi-person"></i>Kontakt</div>
@ -2272,7 +2389,7 @@
<div class="case-tabs-topbar-value">{{ case.created_at.strftime('%d/%m/%Y') if case.created_at else '-' }}</div> <div class="case-tabs-topbar-value">{{ case.created_at.strftime('%d/%m/%Y') if case.created_at else '-' }}</div>
</div> </div>
<div class="case-tabs-topbar-item field-start"> <div class="case-tabs-topbar-item field-start">
<div class="case-tabs-topbar-label"><i class="bi bi-play-circle"></i>Start arbejde</div> <div class="case-tabs-topbar-label"><i class="bi bi-play-circle"></i>Arbejdsstart</div>
<div class="topbar-secondary-inline"> <div class="topbar-secondary-inline">
<input <input
id="topbarStartDateInput" id="topbarStartDateInput"
@ -2287,7 +2404,7 @@
</div> </div>
</div> </div>
<div class="case-tabs-topbar-item field-start-before"> <div class="case-tabs-topbar-item field-start-before">
<div class="case-tabs-topbar-label"><i class="bi bi-hourglass-split"></i>Start inden denne dato</div> <div class="case-tabs-topbar-label"><i class="bi bi-hourglass-split"></i>Start senest</div>
<div class="topbar-secondary-inline"> <div class="topbar-secondary-inline">
<input <input
id="topbarDeferredInput" id="topbarDeferredInput"
@ -2298,6 +2415,10 @@
> >
<button type="button" class="topbar-secondary-action is-icon" onclick="updateDeferredUntil(null); document.getElementById('topbarDeferredInput').value=''" title="Fjern dato"><i class="bi bi-x-lg"></i></button> <button type="button" class="topbar-secondary-action is-icon" onclick="updateDeferredUntil(null); document.getElementById('topbarDeferredInput').value=''" title="Fjern dato"><i class="bi bi-x-lg"></i></button>
</div> </div>
<div class="topbar-deferred-shortcuts">
<button type="button" class="topbar-mini-trigger" onclick="openDeferredModalWithPresetStatus('lukket')">Lukket</button>
<button type="button" class="topbar-mini-trigger" onclick="openDeferredModalWithPresetStatus('løst')">Løst</button>
</div>
</div> </div>
<div class="case-tabs-topbar-item field-deadline"> <div class="case-tabs-topbar-item field-deadline">
<div class="case-tabs-topbar-label"><i class="bi bi-clock"></i>Deadline dato</div> <div class="case-tabs-topbar-label"><i class="bi bi-clock"></i>Deadline dato</div>
@ -2357,6 +2478,9 @@
<!-- Right cluster: dates --> <!-- Right cluster: dates -->
<div class="d-flex align-items-center gap-3"> <div class="d-flex align-items-center gap-3">
<button type="button" class="btn btn-sm btn-primary" onclick="showCustomerSearch('replace')" title="Skift kunde eller firma på sagen">
<i class="bi bi-building-gear me-1"></i>Skift kunde/firma
</button>
<span class="case-date-item"> <span class="case-date-item">
<i class="bi bi-plus-circle"></i> <i class="bi bi-plus-circle"></i>
<span>{{ case.created_at.strftime('%d. %b %Y') if case.created_at else '—' }}</span> <span>{{ case.created_at.strftime('%d. %b %Y') if case.created_at else '—' }}</span>
@ -2618,9 +2742,6 @@
<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="mb-2 fw-bolder" style="color: var(--accent); font-size: 1.8rem; letter-spacing: -0.5px;">{{ case.titel }}</h2> <h2 id="sag-titel-text" class="mb-2 fw-bolder" style="color: var(--accent); font-size: 1.8rem; letter-spacing: -0.5px;">{{ case.titel }}</h2>
<button class="btn btn-sm btn-link text-muted 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-muted p-0 mb-1" onclick="startTitelEdit()" title="Rediger overskrift"><i class="bi bi-pencil-square"></i></button>
<button class="btn btn-sm btn-outline-primary mb-1" onclick="openAssignmentQuick()" title="Ændr hvem sagen er tildelt til">
<i class="bi bi-person-check me-1"></i>Tildel sag
</button>
</div> </div>
<!-- Title edit --> <!-- Title edit -->
<div id="sag-titel-editor" class="d-none"> <div id="sag-titel-editor" class="d-none">
@ -2878,7 +2999,22 @@
<span class="badge bg-light text-dark border">{{ node.case.template_key or node.case.type or 'ticket' }}</span> <span class="badge bg-light text-dark border">{{ node.case.template_key or node.case.type or 'ticket' }}</span>
</td> </td>
<td> <td>
<span class="small">{{ node.relation_type or ( 'Rodsag' if node.is_current else '-' ) }}</span> {% set rel_raw = (node.relation_type or '')|trim %}
{% set rel_key = rel_raw|lower %}
{% if node.is_current %}
<span class="relation-type-pill is-root" title="Rodsag i visningen.">Rodsag</span>
{% elif rel_key == 'afledt af' %}
<span class="relation-type-pill is-derived" title="Denne sag kommer fra en anden sag. Funktion: styrer retning i relationstræet.">Kommer fra</span>
<div class="relation-type-subtext">(Afledt af)</div>
{% elif rel_key == 'årsag til' %}
<span class="relation-type-pill is-cause" title="Denne sag skaber en følge-sag. Funktion: styrer retning i relationstræet.">Skaber følge-sag</span>
<div class="relation-type-subtext">(Årsag til)</div>
{% elif rel_key == 'blokkerer' %}
<span class="relation-type-pill is-block" title="Denne sag blokerer den anden, indtil den er løst.">Blokerer</span>
{% else %}
<span class="relation-type-pill is-related" title="Sagerne er fagligt koblet uden direkte afhængighed.">Koblet til</span>
<div class="relation-type-subtext">(Relateret til)</div>
{% endif %}
{% if node.is_repeated %} {% if node.is_repeated %}
<i class="bi bi-arrow-repeat text-muted ms-1" title="Vises flere steder i relationstræet"></i> <i class="bi bi-arrow-repeat text-muted ms-1" title="Vises flere steder i relationstræet"></i>
{% endif %} {% endif %}
@ -2886,14 +3022,14 @@
<td class="text-end"> <td class="text-end">
<div class="btn-group btn-group-sm" role="group"> <div class="btn-group btn-group-sm" role="group">
{% if node.relation_id %} {% if node.relation_id %}
<button onclick="deleteRelation({{ node.relation_id }})" class="btn btn-outline-danger" title="Fjern relation"> <button onclick="deleteRelation({{ node.relation_id }})" class="btn btn-outline-danger btn-rel-action" title="Fjern relation">
<i class="bi bi-x-lg"></i> <i class="bi bi-x-lg"></i>
</button> </button>
{% endif %} {% endif %}
<button class="btn btn-outline-secondary" title="Tags" onclick="openRelTagPopover({{ node.case.id }})"> <button class="btn btn-outline-secondary btn-rel-action" title="Tags" onclick="openRelTagPopover({{ node.case.id }})">
<i class="bi bi-tag"></i> <i class="bi bi-tag"></i>
</button> </button>
<button class="btn btn-outline-primary" title="Quick action" onclick='openRelQaMenu({{ node.case.id }}, {{ (node.case.titel or "")|tojson }}, this)'> <button class="btn btn-outline-primary btn-rel-action" title="Quick action" onclick='openRelQaMenu({{ node.case.id }}, {{ (node.case.titel or "")|tojson }}, this)'>
<i class="bi bi-plus-lg"></i> <i class="bi bi-plus-lg"></i>
</button> </button>
</div> </div>
@ -2916,7 +3052,7 @@
<th>Titel</th> <th>Titel</th>
<th style="width: 120px;">Status</th> <th style="width: 120px;">Status</th>
<th style="width: 130px;">Type</th> <th style="width: 130px;">Type</th>
<th style="width: 140px;">Relation</th> <th style="width: 180px;">Sammenhæng</th>
<th class="text-end" style="width: 130px;">Handling</th> <th class="text-end" style="width: 130px;">Handling</th>
</tr> </tr>
</thead> </thead>
@ -3051,10 +3187,10 @@
<label class="form-label fw-bold">2. Vælg relationstype</label> <label class="form-label fw-bold">2. Vælg relationstype</label>
<select id="relationTypeSelect" class="form-control form-control-lg" onchange="updateAddRelationButton(); updateRelationTypeHint();"> <select id="relationTypeSelect" class="form-control form-control-lg" onchange="updateAddRelationButton(); updateRelationTypeHint();">
<option value="">Vælg hvordan sagerne er relateret...</option> <option value="">Vælg hvordan sagerne er relateret...</option>
<option value="Relateret til">🔗 Relateret til - Faglig kobling uden direkte afhængighed</option> <option value="Relateret til">🔗 Koblet til (Relateret) - Faglig kobling uden direkte afhængighed</option>
<option value="Afledt af">Afledt af - Denne sag er opstået på baggrund af den anden</option> <option value="Afledt af">Kommer fra (Afledt af) - Denne sag er opstået pga. den anden</option>
<option value="Årsag til">Årsag til - Denne sag er årsagen til den anden</option> <option value="Årsag til">Skaber følge-sag (Årsag til) - Denne sag skaber den anden</option>
<option value="Blokkerer">⛔ Blokkerer - Denne sag stopper fremdrift i den anden</option> <option value="Blokkerer">⛔ Blokerer - Den anden kan ikke videre før denne er løst</option>
</select> </select>
</div> </div>
@ -3062,9 +3198,9 @@
<div class="alert alert-light border small mb-3"> <div class="alert alert-light border small mb-3">
<div class="fw-semibold mb-1">Betydning i praksis</div> <div class="fw-semibold mb-1">Betydning i praksis</div>
<div><strong>Relateret til</strong>: Bruges når sager hænger sammen, men ingen af dem afhænger direkte af den anden.</div> <div><strong>Koblet til (Relateret)</strong>: Faglig sammenhæng, men ingen direkte afhængighed.</div>
<div><strong>Afledt af</strong>: Bruges når denne sag er afledt af et tidligere problem/arbejde.</div> <div><strong>Kommer fra (Afledt af)</strong>: Sagen er opstået pga. en anden sag.</div>
<div><strong>Årsag til</strong>: Bruges når denne sag skaber behovet for den anden.</div> <div><strong>Skaber følge-sag (Årsag til)</strong>: Sagen skaber behovet for en anden sag.</div>
<div><strong>Blokkerer</strong>: Bruges når løsning i én sag er nødvendig før den anden kan videre.</div> <div><strong>Blokkerer</strong>: Bruges når løsning i én sag er nødvendig før den anden kan videre.</div>
</div> </div>
@ -3221,7 +3357,7 @@
<button class="btn btn-outline-primary" onclick="shiftDeferredMonths(1)">+1 mnd</button> <button class="btn btn-outline-primary" onclick="shiftDeferredMonths(1)">+1 mnd</button>
</div> </div>
<label class="form-label mt-3">Udsat til sagstatus</label> <label class="form-label mt-3">Start når anden sag har status</label>
<select class="form-select form-select-sm" id="deferredCaseSelect"> <select class="form-select form-select-sm" id="deferredCaseSelect">
<option value="">Vælg relateret sag</option> <option value="">Vælg relateret sag</option>
{% for rc in related_case_options %} {% for rc in related_case_options %}
@ -3238,6 +3374,10 @@
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
<div class="defer-controls mt-2">
<button class="btn btn-outline-primary" type="button" onclick="applyDeferredCloseTrigger()">Når valgt sag lukkes</button>
<button class="btn btn-outline-primary" type="button" onclick="applyDeferredResolvedTrigger()">Når valgt sag er løst</button>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Luk</button> <button type="button" class="btn btn-light" data-bs-dismiss="modal">Luk</button>
@ -3444,7 +3584,7 @@
'reminders': 'Påmindelser', 'reminders': 'Påmindelser',
'calendar': 'Kalender' 'calendar': 'Kalender'
}; };
let caseTypeModuleDefaults = {}; caseTypeModuleDefaults = window.caseTypeModuleDefaults || {};
// Modal instances // Modal instances
let contactSearchModal, customerSearchModal, relationModal, contactInfoModal, createRelatedCaseModalInstance, caseAnyDeskModal; let contactSearchModal, customerSearchModal, relationModal, contactInfoModal, createRelatedCaseModalInstance, caseAnyDeskModal;
@ -3573,9 +3713,22 @@
} }
function showRelationModal() { function showRelationModal() {
const modalEl = document.getElementById('relationModal');
if (!modalEl) {
console.error('relationModal element not found');
return;
}
if (!relationModal) {
relationModal = bootstrap.Modal.getOrCreateInstance(modalEl);
}
relationModal.show(); relationModal.show();
updateRelationTypeHint(); updateRelationTypeHint();
setTimeout(() => document.getElementById('relationCaseSearch').focus(), 300); setTimeout(() => {
const input = document.getElementById('relationCaseSearch');
if (input) input.focus();
}, 300);
} }
async function openAuthenticatedPrintView(url, fallbackTitle) { async function openAuthenticatedPrintView(url, fallbackTitle) {
@ -3836,6 +3989,14 @@
} }
function showCreateRelatedModal() { function showCreateRelatedModal() {
if (!createRelatedCaseModalInstance) {
const modalEl = document.getElementById('createRelatedCaseModal');
if (!modalEl) {
console.error('createRelatedCaseModal element not found');
return;
}
createRelatedCaseModalInstance = bootstrap.Modal.getOrCreateInstance(modalEl);
}
createRelatedCaseModalInstance.show(); createRelatedCaseModalInstance.show();
updateNewCaseRelationTypeHint(); updateNewCaseRelationTypeHint();
} }
@ -3844,15 +4005,15 @@
const map = { const map = {
'Relateret til': { 'Relateret til': {
icon: '🔗', icon: '🔗',
text: 'Sagerne hænger fagligt sammen, men ingen af dem er direkte afhængig af den anden.' text: 'Koblet til: Sagerne hænger fagligt sammen, men ingen af dem er direkte afhængig af den anden.'
}, },
'Afledt af': { 'Afledt af': {
icon: '↪', icon: '↪',
text: 'Denne sag er opstået på baggrund af den anden sag (den anden er ophav/forløber).' text: 'Kommer fra: Denne sag er opstået på baggrund af den anden sag (den anden er ophav/forløber).'
}, },
'Årsag til': { 'Årsag til': {
icon: '➡', icon: '➡',
text: 'Denne sag er årsag til den anden sag (du peger frem mod en konsekvens/opfølgning).' text: 'Skaber følge-sag: Denne sag er årsag til den anden sag (du peger frem mod en konsekvens/opfølgning).'
}, },
'Blokkerer': { 'Blokkerer': {
icon: '⛔', icon: '⛔',
@ -3885,11 +4046,11 @@
const selected = select.value; const selected = select.value;
if (selected === 'Afledt af') { if (selected === 'Afledt af') {
hint.innerHTML = '<strong>↪ Effekt:</strong> Nuværende sag markeres som afledt af den nye sag.'; hint.innerHTML = '<strong>↪ Effekt:</strong> Nuværende sag markeres som kommer fra den nye sag.';
return; return;
} }
if (selected === 'Årsag til') { if (selected === 'Årsag til') {
hint.innerHTML = '<strong>➡ Effekt:</strong> Nuværende sag markeres som årsag til den nye sag.'; hint.innerHTML = '<strong>➡ Effekt:</strong> Nuværende sag markeres som at den skaber den nye følge-sag.';
return; return;
} }
if (selected === 'Blokkerer') { if (selected === 'Blokkerer') {
@ -3897,19 +4058,25 @@
return; return;
} }
hint.innerHTML = '<strong>🔗 Effekt:</strong> Sagerne kobles fagligt uden direkte afhængighed.'; hint.innerHTML = '<strong>🔗 Effekt:</strong> Sagerne kobles fagligt uden direkte afhængighed (Koblet til).';
} }
async function createRelatedCase() { async function createRelatedCase() {
const title = document.getElementById('newCaseTitle').value; const title = document.getElementById('newCaseTitle').value;
const relationType = document.getElementById('newCaseRelationType').value; const relationType = document.getElementById('newCaseRelationType').value;
const description = document.getElementById('newCaseDescription').value; const description = document.getElementById('newCaseDescription').value;
const customerId = (caseCustomerId || wikiCustomerId || null);
if (!title) { if (!title) {
alert('Titel er påkrævet'); alert('Titel er påkrævet');
return; return;
} }
if (!customerId) {
alert('Sagen mangler kunde. Tilknyt kunde først, før du opretter relateret sag.');
return;
}
// 1. Create the new case // 1. Create the new case
try { try {
const caseResponse = await fetch('/api/v1/sag', { const caseResponse = await fetch('/api/v1/sag', {
@ -3918,12 +4085,15 @@
body: JSON.stringify({ body: JSON.stringify({
titel: title, titel: title,
beskrivelse: description, beskrivelse: description,
customer_id: {{ case.customer_id }}, customer_id: customerId,
status: 'åben' status: 'åben'
}) })
}); });
if (!caseResponse.ok) throw new Error('Kunne ikke oprette sag'); if (!caseResponse.ok) {
const payload = await caseResponse.json().catch(() => ({}));
throw new Error(payload.detail || 'Kunne ikke oprette sag');
}
const newCase = await caseResponse.json(); const newCase = await caseResponse.json();
// 2. Create the relation // 2. Create the relation
@ -3936,7 +4106,10 @@
}) })
}); });
if (!relationResponse.ok) throw new Error('Kunne ikke oprette relation'); if (!relationResponse.ok) {
const payload = await relationResponse.json().catch(() => ({}));
throw new Error(payload.detail || 'Kunne ikke oprette relation');
}
// 3. Reload to show new relation // 3. Reload to show new relation
window.location.reload(); window.location.reload();
@ -4044,7 +4217,7 @@
} else { } else {
resultsDiv.innerHTML = customers.map(c => ` resultsDiv.innerHTML = customers.map(c => `
<div class="list-group-item list-group-item-action" style="cursor: pointer;" <div class="list-group-item list-group-item-action" style="cursor: pointer;"
onclick="selectCustomerFromSearch(${caseId}, ${c.id}, '${c.name.replace(/'/g, "\\'")}')"> onclick="selectCustomerFromSearch(${caseId}, ${c.id})">
<strong>${c.name}</strong> <strong>${c.name}</strong>
<div class="small text-muted">${c.email || ''} ${c.cvr_number ? '(CVR: ' + c.cvr_number + ')' : ''}</div> <div class="small text-muted">${c.email || ''} ${c.cvr_number ? '(CVR: ' + c.cvr_number + ')' : ''}</div>
</div> </div>
@ -4057,7 +4230,7 @@
}); });
} }
async function selectCustomerFromSearch(caseId, customerId, customerName) { async function selectCustomerFromSearch(caseId, customerId, customerName = '') {
if (customerSearchMode === 'replace') { if (customerSearchMode === 'replace') {
await replaceCaseCustomer(caseId, customerId, customerName); await replaceCaseCustomer(caseId, customerId, customerName);
return; return;
@ -4067,11 +4240,20 @@
async function replaceCaseCustomer(caseId, customerId, customerName) { async function replaceCaseCustomer(caseId, customerId, customerName) {
try { try {
const response = await fetch(`/api/v1/sag/${caseId}`, { let response = await fetch(`/api/v1/sag/${caseId}/customer/replace`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({customer_id: customerId})
});
// Backwards compatibility with older API nodes.
if (response.status === 404 || response.status === 405) {
response = await fetch(`/api/v1/sag/${caseId}`, {
method: 'PATCH', method: 'PATCH',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({customer_id: customerId}) body: JSON.stringify({customer_id: customerId})
}); });
}
if (response.ok) { if (response.ok) {
customerSearchModal.hide(); customerSearchModal.hide();
@ -8863,7 +9045,7 @@
}; };
try { try {
const res = await fetch(`/api/v1/cases/${sagId}/time`, { const res = await fetch('/api/v1/timetracking/time/manual', {
method: 'POST', method: 'POST',
headers: {'Content-Type':'application/json'}, headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload) body: JSON.stringify(payload)
@ -9213,7 +9395,6 @@
let caseAddActiveAction = null; let caseAddActiveAction = null;
let caseAddOriginalShowRelModal = null; let caseAddOriginalShowRelModal = null;
const CASE_ADD_ACTIONS = [ const CASE_ADD_ACTIONS = [
{ action: 'assign', label: 'Tildel sag', icon: 'bi-person-check', moduleKey: null, relFn: 'openRelAssignModal' },
{ action: 'time', label: 'Tidregistrering', icon: 'bi-clock', moduleKey: 'time', relFn: 'openRelTimeModal' }, { action: 'time', label: 'Tidregistrering', icon: 'bi-clock', moduleKey: 'time', relFn: 'openRelTimeModal' },
{ action: 'note', label: 'Kommentar', icon: 'bi-chat-left-text', moduleKey: 'solution', relFn: 'openRelNoteModal' }, { action: 'note', label: 'Kommentar', icon: 'bi-chat-left-text', moduleKey: 'solution', relFn: 'openRelNoteModal' },
{ action: 'reminder', label: 'Pamindelse', icon: 'bi-bell', moduleKey: 'reminders', relFn: 'openRelReminderModal' }, { action: 'reminder', label: 'Pamindelse', icon: 'bi-bell', moduleKey: 'reminders', relFn: 'openRelReminderModal' },
@ -9734,6 +9915,41 @@
modal.show(); modal.show();
} }
function findDeferredStatusOptionValue(primaryStatus, fallbackContains) {
const statusSelect = document.getElementById('deferredStatusSelect');
if (!statusSelect) return null;
const targetPrimary = String(primaryStatus || '').trim().toLowerCase();
const targetFallback = String(fallbackContains || '').trim().toLowerCase();
const options = Array.from(statusSelect.options || []);
const primary = options.find((opt) => String(opt.value || '').trim().toLowerCase() === targetPrimary);
if (primary && primary.value) return primary.value;
const fallback = options.find((opt) => String(opt.value || '').trim().toLowerCase().includes(targetFallback));
if (fallback && fallback.value) return fallback.value;
return null;
}
function openDeferredModalWithPresetStatus(statusKey) {
const target = String(statusKey || '').trim().toLowerCase();
const fallback = target === 'lukket' ? 'luk' : target;
const value = findDeferredStatusOptionValue(target, fallback);
openDeferredModal();
if (!value) {
showToast(`Status "${statusKey}" findes ikke i listen`, 'warning');
return;
}
const statusSelect = document.getElementById('deferredStatusSelect');
if (statusSelect) {
statusSelect.value = value;
}
}
async function updateDeferredCaseAndStatus(caseId, status) { async function updateDeferredCaseAndStatus(caseId, status) {
try { try {
const res = await fetch(`/api/v1/sag/${caseIds}`, { const res = await fetch(`/api/v1/sag/${caseIds}`, {
@ -9761,6 +9977,37 @@
updateDeferredCaseAndStatus(caseSelect.value || null, statusSelect.value || null); updateDeferredCaseAndStatus(caseSelect.value || null, statusSelect.value || null);
} }
function applyDeferredStatusTrigger(primaryStatus, fallbackContains, missingMessage) {
const caseSelect = document.getElementById('deferredCaseSelect');
const statusSelect = document.getElementById('deferredStatusSelect');
if (!caseSelect || !statusSelect) return;
if (!caseSelect.value) {
showToast('Vælg først en relateret sag', 'warning');
return;
}
const targetPrimary = String(primaryStatus || '').trim().toLowerCase();
const targetFallback = String(fallbackContains || '').trim().toLowerCase();
const matchValue = findDeferredStatusOptionValue(targetPrimary, targetFallback);
if (!matchValue) {
showToast(missingMessage || 'Status findes ikke i listen', 'warning');
return;
}
statusSelect.value = matchValue;
saveDeferredAll();
}
function applyDeferredCloseTrigger() {
applyDeferredStatusTrigger('lukket', 'luk', 'Status "Lukket" findes ikke i listen');
}
function applyDeferredResolvedTrigger() {
applyDeferredStatusTrigger('løst', 'løs', 'Status "Løst" findes ikke i listen');
}
function clearDeferredCase() { function clearDeferredCase() {
const caseSelect = document.getElementById('deferredCaseSelect'); const caseSelect = document.getElementById('deferredCaseSelect');
const statusSelect = document.getElementById('deferredStatusSelect'); const statusSelect = document.getElementById('deferredStatusSelect');
@ -12377,6 +12624,127 @@
}); });
</script> </script>
<script>
// Fallback bindings: keep relation modal buttons working even if an earlier script fails.
(function () {
function ensureAndShowModal(modalId) {
const modalEl = document.getElementById(modalId);
if (!modalEl || !window.bootstrap || !window.bootstrap.Modal) return false;
const instance = window.bootstrap.Modal.getOrCreateInstance(modalEl);
instance.show();
return true;
}
window.showRelationModal = function () {
const opened = ensureAndShowModal('relationModal');
if (opened) {
setTimeout(() => {
const input = document.getElementById('relationCaseSearch');
if (input) input.focus();
}, 250);
}
};
window.showCreateRelatedModal = function () {
const opened = ensureAndShowModal('createRelatedCaseModal');
if (opened && typeof window.updateNewCaseRelationTypeHint === 'function') {
window.updateNewCaseRelationTypeHint();
}
};
if (typeof window.createRelatedCase !== 'function') {
window.createRelatedCase = async function () {
const titleEl = document.getElementById('newCaseTitle');
const relationTypeEl = document.getElementById('newCaseRelationType');
const descriptionEl = document.getElementById('newCaseDescription');
const title = (titleEl?.value || '').trim();
const relationType = (relationTypeEl?.value || '').trim();
const description = (descriptionEl?.value || '').trim();
if (!title) {
alert('Titel er påkrævet');
return;
}
if (!relationType) {
alert('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');
return;
}
let customerId = {{ case.customer_id if case.customer_id else 'null' }};
if (!customerId) {
try {
const rootCaseResponse = await fetch(`/api/v1/sag/${rootCaseId}`, { credentials: 'include' });
if (rootCaseResponse.ok) {
const rootCase = await rootCaseResponse.json();
customerId = rootCase?.customer_id || null;
}
} catch (_) {
// handled by validation below
}
}
if (!customerId) {
alert('Sagen mangler kunde. Tilknyt kunde først, før du opretter relateret sag.');
return;
}
try {
const caseResponse = await fetch('/api/v1/sag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
titel: title,
beskrivelse: description,
status: 'åben',
customer_id: customerId
})
});
if (!caseResponse.ok) {
const payload = await caseResponse.json().catch(() => ({}));
throw new Error(payload.detail || 'Kunne ikke oprette sag');
}
const newCase = await caseResponse.json();
const newCaseId = Number(newCase?.id || 0);
if (!newCaseId) {
throw new Error('Kunne ikke læse nyt sags-id');
}
const relationResponse = await fetch(`/api/v1/sag/${rootCaseId}/relationer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
målsag_id: newCaseId,
relationstype: relationType
})
});
if (!relationResponse.ok) {
const payload = await relationResponse.json().catch(() => ({}));
throw new Error(payload.detail || 'Kunne ikke oprette relation');
}
window.location.reload();
} catch (error) {
alert('Der opstod en fejl: ' + (error?.message || error));
}
};
}
})();
</script>
<!-- ── Relation row: quick-actions + inline tags ──────────────────────── --> <!-- ── Relation row: quick-actions + inline tags ──────────────────────── -->
<script> <script>
(function () { (function () {
@ -12427,7 +12795,6 @@
// ── quick action menu ───────────────────────────────────────────── // ── quick action menu ─────────────────────────────────────────────
const QA_ITEMS = [ const QA_ITEMS = [
{ icon: 'bi-person-check', label: 'Tildel sag', action: 'assign' },
{ icon: 'bi-clock', label: 'Tidregistrering', action: 'time' }, { icon: 'bi-clock', label: 'Tidregistrering', action: 'time' },
{ icon: 'bi-chat-left-text', label: 'Kommentar', action: 'note' }, { icon: 'bi-chat-left-text', label: 'Kommentar', action: 'note' },
{ icon: 'bi-bell', label: 'Påmindelse', action: 'reminder' }, { icon: 'bi-bell', label: 'Påmindelse', action: 'reminder' },

View File

@ -418,8 +418,8 @@
<th style="width: 170px;">Gruppe/Level</th> <th style="width: 170px;">Gruppe/Level</th>
<th style="width: 240px;">Næste todo</th> <th style="width: 240px;">Næste todo</th>
<th style="width: 120px;">Opret.</th> <th style="width: 120px;">Opret.</th>
<th style="width: 120px;">Start arbejde</th> <th style="width: 120px;">Arbejdsstart</th>
<th style="width: 140px;">Start inden</th> <th style="width: 140px;">Start senest</th>
<th style="width: 120px;">Deadline</th> <th style="width: 120px;">Deadline</th>
</tr> </tr>
</thead> </thead>

View File

@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}BMC Hub{% endblock %}</title> <title>{% block title %}BMC Hub{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230f4c75'/%3E%3Ctext x='32' y='42' text-anchor='middle' font-size='30' font-family='Arial, sans-serif' fill='white'%3EB%3C/text%3E%3C/svg%3E">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style> <style>
@ -546,8 +547,21 @@
</div> </div>
{% endblock %} {% endblock %}
<script>
window.addEventListener('unhandledrejection', function(event) {
const reason = event && event.reason;
const msg = String((reason && reason.message) || reason || '');
const stack = String((reason && reason.stack) || '');
const combined = (msg + '\n' + stack).toLowerCase();
// Known Safari/extension autofill overlay crash; ignore noisy external script rejection.
if (combined.includes('bootstrap-autofill-overlay.js') || combined.includes('autocompletetype.includes')) {
event.preventDefault();
}
});
</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="/static/js/tag-picker.js?v=2.0"></script> <script src="/static/js/tag-picker.js?v=2.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>

View File

@ -9,6 +9,7 @@ class TagPicker {
this.searchInput = null; this.searchInput = null;
this.resultsContainer = null; this.resultsContainer = null;
this.allTags = []; this.allTags = [];
this.tagGroups = [];
this.filteredTags = []; this.filteredTags = [];
this.selectedIndex = 0; this.selectedIndex = 0;
this.onSelectCallback = null; this.onSelectCallback = null;
@ -155,7 +156,9 @@ class TagPicker {
const response = await fetch('/api/v1/tags?is_active=true'); const response = await fetch('/api/v1/tags?is_active=true');
if (!response.ok) throw new Error('Failed to load tags'); if (!response.ok) throw new Error('Failed to load tags');
this.allTags = await response.json(); this.allTags = await response.json();
this.tagGroups = await this.loadTagGroups(); // Tag groups are optional metadata. Some hubs do not expose the endpoint,
// so keep picker resilient by not depending on a second API call.
this.tagGroups = [];
console.log('🏷️ Loaded tags:', this.allTags.length); console.log('🏷️ Loaded tags:', this.allTags.length);
this.filteredTags = [...this.allTags]; this.filteredTags = [...this.allTags];
this.renderResults(); this.renderResults();
@ -164,17 +167,6 @@ class TagPicker {
} }
} }
async loadTagGroups() {
try {
const response = await fetch('/api/v1/tags/groups');
if (!response.ok) return [];
return await response.json();
} catch (error) {
console.error('🏷️ Error loading tag groups:', error);
return [];
}
}
filterTags(query) { filterTags(query) {
if (!query.trim()) { if (!query.trim()) {
this.filteredTags = [...this.allTags]; this.filteredTags = [...this.allTags];