diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index 47a50f0..67762d2 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -169,6 +169,43 @@ def _validate_customer_id(customer_id: Optional[int], field_name: str = "custome if not exists: 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 # ============================================================================ @@ -577,6 +614,7 @@ async def list_sager( if not include_deferred: query += " AND (deferred_until IS NULL OR deferred_until <= NOW())" + query += " AND (start_date IS NULL OR start_date <= NOW())" if status: query += " AND s.status = %s" @@ -717,6 +755,8 @@ async def create_sag(data: dict): logger.info("✅ Case created: %s", result[0]["id"]) return result[0] raise HTTPException(status_code=500, detail="Failed to create case") + except HTTPException: + raise except Exception as e: logger.error("❌ Error creating case: %s", e) raise HTTPException(status_code=500, detail="Failed to create case") @@ -957,9 +997,14 @@ async def update_sag(sag_id: int, updates: dict): """Update a case.""" try: # 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: 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" if "type" in updates and "template_key" not in updates: @@ -1018,6 +1063,10 @@ async def update_sag(sag_id: int, updates: dict): result = execute_query(query, tuple(params)) 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) return result[0] 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) 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") async def add_case_customer(sag_id: int, data: dict): """Add a customer to a case.""" diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py index a2f53fc..e713222 100644 --- a/app/modules/sag/frontend/views.py +++ b/app/modules/sag/frontend/views.py @@ -237,6 +237,7 @@ async def sager_liste( 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 += ")" + query += " AND (s.start_date IS NULL OR s.start_date <= NOW())" if status: query += " AND s.status = %s" @@ -289,6 +290,10 @@ async def sager_liste( """ 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: fallback_query += " AND s.status = %s" fallback_params.append(status) @@ -498,9 +503,50 @@ async def sag_detaljer(request: Request, sag_id: int): except Exception as e: logger.error(f"Error building relation tree: {e}") relation_tree = [] - except Exception as e: - logger.error(f"Error building relation tree: {e}") - relation_tree = [] + + # Fallback: if tree builder fails/returns empty but relations exist, render a minimal flat 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 customer = None diff --git a/app/modules/sag/services/relation_service.py b/app/modules/sag/services/relation_service.py index 33a4df8..2eebc93 100644 --- a/app/modules/sag/services/relation_service.py +++ b/app/modules/sag/services/relation_service.py @@ -4,6 +4,11 @@ from app.core.database import execute_query class RelationService: """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 def get_relation_tree(root_id: int) -> List[Dict]: """ @@ -33,7 +38,16 @@ class RelationService: # 2. Fetch details for these cases 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))} # 3. Fetch all edges between these cases @@ -53,7 +67,8 @@ class RelationService: # Helper to normalize relation types # Now that we cleaned DB, we expect standard Danish terms, but good to be safe 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']: return m, k # m is parent of k if rtype_lower in ['årsag til', 'cause of']: @@ -66,7 +81,8 @@ class RelationService: processed_edges = set() 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) edge_key = tuple(sorted((k,m))) + (rtype,) @@ -119,7 +135,7 @@ class RelationService: path_visited.add(cid) # 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 = [] for child_info in children_data: diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html index e8d7c41..ead3d4e 100644 --- a/app/modules/sag/templates/detail.html +++ b/app/modules/sag/templates/detail.html @@ -1463,17 +1463,53 @@ color: var(--accent); } - .relation-type-badge { + .relation-type-pill { display: inline-flex; align-items: center; - gap: 0.25rem; - font-size: 0.75rem; - color: var(--accent); - background: rgba(15, 76, 117, 0.1); - padding: 2px 6px; - border-radius: 4px; - margin-right: 8px; - font-weight: 500; + gap: 0.3rem; + font-size: 0.72rem; + font-weight: 700; + padding: 0.22rem 0.5rem; + border-radius: 999px; + border: 1px solid transparent; + line-height: 1.2; + 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 { @@ -1684,6 +1720,41 @@ 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 { background: rgba(255,255,255,0.78); border-color: rgba(15,76,117,0.3); @@ -1764,6 +1835,11 @@ 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 { grid-template-columns: repeat(8, minmax(145px, 1fr)); } @@ -1923,6 +1999,31 @@ 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 { border-color: var(--accent); color: var(--accent); @@ -1979,6 +2080,17 @@ 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 { position: fixed; inset: 0; @@ -2198,7 +2310,12 @@