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:
parent
1f834160ca
commit
807c68679e
@ -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."""
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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 sag‑status</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' },
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user