+ {% if contact.email %}{% endif %}
+ {% if contact.phone %}{{ contact.phone }}{% endif %}
+
+
+
+
+
+ {% endfor %}
+ {% else %}
+
+ Ingen kontakter tilknyttet
+
+ {% endif %}
+
+
+
@@ -673,6 +728,65 @@
+
+
+
+
+
+
Skift Ejer
+
+
+
+
+
+
+
@@ -744,6 +858,50 @@
+
+
+
+
+
+
Tilføj Kontaktperson
+
+
+
+
+
+
+
{% endblock %}
{% block extra_js %}
@@ -878,6 +1036,101 @@
// Initialize Tags
document.addEventListener('DOMContentLoaded', function() {
+ const ownerCustomerSearch = document.getElementById('ownerCustomerSearch');
+ const ownerCustomerSelect = document.getElementById('ownerCustomerSelect');
+ const ownerContactSelect = document.getElementById('ownerContactSelect');
+ const ownerCustomerHelp = document.getElementById('ownerCustomerHelp');
+ const ownerContactHelp = document.getElementById('ownerContactHelp');
+
+ function filterOwnerCustomers() {
+ if (!ownerCustomerSearch || !ownerCustomerSelect) {
+ return;
+ }
+
+ const filter = ownerCustomerSearch.value.toLowerCase().trim();
+ const options = Array.from(ownerCustomerSelect.options);
+ let visibleCount = 0;
+
+ options.forEach((option, index) => {
+ if (index === 0) {
+ option.hidden = false;
+ return;
+ }
+
+ const label = (option.textContent || '').toLowerCase();
+ const isVisible = !filter || label.includes(filter);
+ option.hidden = !isVisible;
+ if (isVisible) {
+ visibleCount += 1;
+ }
+ });
+
+ const selectedOption = ownerCustomerSelect.selectedOptions[0];
+ if (selectedOption && selectedOption.hidden) {
+ ownerCustomerSelect.value = '';
+ }
+
+ if (ownerCustomerHelp) {
+ if (visibleCount === 0) {
+ ownerCustomerHelp.textContent = 'Ingen virksomheder matcher søgningen.';
+ } else {
+ ownerCustomerHelp.textContent = `Viser ${visibleCount} virksomhed(er).`;
+ }
+ }
+ }
+
+ function filterOwnerContacts() {
+ if (!ownerCustomerSelect || !ownerContactSelect) {
+ return;
+ }
+
+ const selectedCustomerId = ownerCustomerSelect.value;
+ const options = Array.from(ownerContactSelect.options);
+ let visibleCount = 0;
+
+ options.forEach((option, index) => {
+ if (index === 0) {
+ option.hidden = false;
+ return;
+ }
+
+ const optionCustomerId = option.getAttribute('data-customer-id');
+ const isVisible = selectedCustomerId && optionCustomerId === selectedCustomerId;
+ option.hidden = !isVisible;
+ if (isVisible) {
+ visibleCount += 1;
+ }
+ });
+
+ const selectedOption = ownerContactSelect.selectedOptions[0];
+ if (!selectedOption || selectedOption.hidden) {
+ ownerContactSelect.value = '';
+ }
+
+ ownerContactSelect.disabled = !selectedCustomerId || visibleCount === 0;
+ if (ownerContactHelp) {
+ if (!selectedCustomerId) {
+ ownerContactHelp.textContent = 'Vælg først virksomhed.';
+ } else if (visibleCount === 0) {
+ ownerContactHelp.textContent = 'Ingen kontaktpersoner fundet for valgt virksomhed.';
+ } else {
+ ownerContactHelp.textContent = 'Viser kun kontakter for valgt virksomhed.';
+ }
+ }
+ }
+
+ if (ownerCustomerSelect && ownerContactSelect) {
+ ownerCustomerSelect.addEventListener('change', filterOwnerContacts);
+ if (ownerCustomerSearch) {
+ ownerCustomerSearch.addEventListener('input', function() {
+ filterOwnerCustomers();
+ filterOwnerContacts();
+ });
+ }
+ filterOwnerCustomers();
+ filterOwnerContacts();
+ }
+
if (window.renderEntityTags) {
window.renderEntityTags('hardware', {{ hardware.id }}, 'hardware-tags');
}
diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py
index 1837806..cc604b1 100644
--- a/app/modules/sag/backend/router.py
+++ b/app/modules/sag/backend/router.py
@@ -8,6 +8,7 @@ from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request
from fastapi.responses import FileResponse
+from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_query_single
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate
from app.core.config import settings
@@ -397,6 +398,10 @@ async def update_sag(sag_id: int, updates: dict):
if not check:
raise HTTPException(status_code=404, detail="Case not found")
+ # Backwards compatibility: frontend sends "type", DB stores "template_key"
+ if "type" in updates and "template_key" not in updates:
+ updates["template_key"] = updates.get("type")
+
# Build dynamic update query
allowed_fields = ["titel", "beskrivelse", "template_key", "status", "ansvarlig_bruger_id", "deadline", "deferred_until", "deferred_until_case_id", "deferred_until_status"]
set_clauses = []
@@ -411,7 +416,8 @@ async def update_sag(sag_id: int, updates: dict):
raise HTTPException(status_code=400, detail="No valid fields to update")
params.append(sag_id)
- query = f"UPDATE sag_sager SET {", ".join(set_clauses)} WHERE id = %s RETURNING *"
+ set_sql = ", ".join(set_clauses)
+ query = f"UPDATE sag_sager SET {set_sql} WHERE id = %s RETURNING *"
result = execute_query(query, tuple(params))
if result:
@@ -424,6 +430,72 @@ async def update_sag(sag_id: int, updates: dict):
logger.error("❌ Error updating case: %s", e)
raise HTTPException(status_code=500, detail="Failed to update case")
+
+class PipelineUpdate(BaseModel):
+ amount: Optional[float] = None
+ probability: Optional[int] = Field(default=None, ge=0, le=100)
+ stage_id: Optional[int] = None
+ description: Optional[str] = None
+
+
+@router.patch("/sag/{sag_id}/pipeline")
+async def update_sag_pipeline(sag_id: int, pipeline_data: PipelineUpdate):
+ """Update pipeline fields for a case."""
+ try:
+ exists = execute_query(
+ "SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
+ (sag_id,)
+ )
+ if not exists:
+ raise HTTPException(status_code=404, detail="Case not found")
+
+ provided = pipeline_data.model_dump(exclude_unset=True)
+
+ if not provided:
+ raise HTTPException(status_code=400, detail="No pipeline fields provided")
+
+ if "stage_id" in provided and provided["stage_id"] is not None:
+ stage_exists = execute_query(
+ "SELECT id FROM pipeline_stages WHERE id = %s",
+ (provided["stage_id"],)
+ )
+ if not stage_exists:
+ raise HTTPException(status_code=400, detail="Invalid pipeline stage")
+
+ set_clauses = []
+ params = []
+
+ if "amount" in provided:
+ set_clauses.append("pipeline_amount = %s")
+ params.append(provided["amount"])
+
+ if "probability" in provided:
+ set_clauses.append("pipeline_probability = %s")
+ params.append(provided["probability"])
+
+ if "stage_id" in provided:
+ set_clauses.append("pipeline_stage_id = %s")
+ params.append(provided["stage_id"])
+
+ if "description" in provided:
+ set_clauses.append("pipeline_description = %s")
+ params.append(provided["description"])
+
+ params.append(sag_id)
+ query = f"UPDATE sag_sager SET {', '.join(set_clauses)} WHERE id = %s RETURNING *"
+ result = execute_query(query, tuple(params))
+
+ if not result:
+ raise HTTPException(status_code=500, detail="Failed to update pipeline")
+
+ logger.info("✅ Pipeline updated for case: %s", sag_id)
+ return result[0]
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error("❌ Error updating pipeline for case %s: %s", sag_id, e)
+ raise HTTPException(status_code=500, detail="Failed to update pipeline")
+
@router.delete("/sag/{sag_id}")
async def delete_sag(sag_id: int):
"""Soft-delete a case."""
diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py
index e1d9c00..d98bd6d 100644
--- a/app/modules/sag/frontend/views.py
+++ b/app/modules/sag/frontend/views.py
@@ -181,85 +181,11 @@ async def sag_detaljer(request: Request, sag_id: int):
# --- Relation Tree Construction ---
relation_tree = []
try:
- # 1. Get all connected case IDs (Recursive CTE)
- tree_ids_query = """
- WITH RECURSIVE CaseTree AS (
- SELECT id FROM sag_sager WHERE id = %s
- UNION
- SELECT CASE WHEN sr.kilde_sag_id = ct.id THEN sr.målsag_id ELSE sr.kilde_sag_id END
- FROM sag_relationer sr
- JOIN CaseTree ct ON sr.kilde_sag_id = ct.id OR sr.målsag_id = ct.id
- WHERE sr.deleted_at IS NULL
- )
- SELECT id FROM CaseTree LIMIT 50;
- """
- tree_ids_rows = execute_query(tree_ids_query, (sag_id,))
- tree_ids = [r['id'] for r in tree_ids_rows]
-
- if tree_ids:
- # 2. Fetch details
- placeholders = ','.join(['%s'] * len(tree_ids))
- tree_cases_query = f"SELECT id, titel, status 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 edges
- tree_edges_query = f"""
- SELECT id, kilde_sag_id, målsag_id, relationstype
- FROM sag_relationer
- WHERE deleted_at IS NULL
- AND kilde_sag_id IN ({placeholders})
- AND målsag_id IN ({placeholders})
- """
- tree_edges = execute_query(tree_edges_query, tuple(tree_ids) * 2)
-
- # 4. Build Graph
- children_map = {cid: [] for cid in tree_ids}
- parents_map = {cid: [] for cid in tree_ids}
-
- for edge in tree_edges:
- k, m, rtype = edge['kilde_sag_id'], edge['målsag_id'], edge['relationstype'].lower()
- parent, child = k, m # Default (e.g. Relateret til)
-
- if rtype == 'afledt af': # m is parent of k
- parent, child = m, k
- elif rtype == 'årsag til': # k is parent of m
- parent, child = k, m
-
- if parent in children_map:
- children_map[parent].append({
- 'id': child,
- 'type': edge['relationstype'],
- 'rel_id': edge['id']
- })
- if child in parents_map:
- parents_map[child].append(parent)
-
- # 5. Identify Roots and Build
- roots = [cid for cid in tree_ids if not parents_map[cid]]
- if not roots and tree_ids: roots = [min(tree_ids)] # Fallback
-
- def build_tree_node(cid, visited):
- if cid in visited: return None
- visited.add(cid)
- node_case = tree_cases.get(cid)
- if not node_case: return None
-
- children_nodes = []
- for child_info in children_map.get(cid, []):
- c_node = build_tree_node(child_info['id'], visited.copy())
- if c_node:
- c_node['relation_type'] = child_info['type']
- c_node['relation_id'] = child_info['rel_id']
- children_nodes.append(c_node)
-
- return {
- 'case': node_case,
- 'children': children_nodes,
- 'is_current': cid == sag_id
- }
-
- relation_tree = [build_tree_node(r, set()) for r in roots]
- relation_tree = [n for n in relation_tree if n]
+ from app.modules.sag.services.relation_service import RelationService
+ relation_tree = RelationService.get_relation_tree(sag_id)
+ 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 = []
@@ -438,6 +364,16 @@ async def sag_detaljer(request: Request, sag_id: int):
logger.error("❌ Error building related case options: %s", e)
related_case_options = []
+ pipeline_stages = []
+ try:
+ pipeline_stages = execute_query(
+ "SELECT id, name, color, sort_order FROM pipeline_stages ORDER BY sort_order ASC, id ASC",
+ (),
+ )
+ except Exception as e:
+ logger.warning("⚠️ Could not load pipeline stages: %s", e)
+ pipeline_stages = []
+
statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ())
return templates.TemplateResponse("modules/sag/templates/detail.html", {
@@ -460,6 +396,7 @@ async def sag_detaljer(request: Request, sag_id: int):
"is_nextcloud": is_nextcloud,
"nextcloud_instance": nextcloud_instance,
"related_case_options": related_case_options,
+ "pipeline_stages": pipeline_stages,
"status_options": [s["status"] for s in statuses],
})
except HTTPException:
diff --git a/app/modules/sag/services/relation_service.py b/app/modules/sag/services/relation_service.py
new file mode 100644
index 0000000..73b289d
--- /dev/null
+++ b/app/modules/sag/services/relation_service.py
@@ -0,0 +1,197 @@
+from typing import List, Dict, Optional, Set
+from app.core.database import execute_query
+
+class RelationService:
+ """Service for handling case relations (Sager)"""
+
+ @staticmethod
+ def get_relation_tree(root_id: int) -> List[Dict]:
+ """
+ Builds a hierarchical tree of relations for a specific case.
+ Handles cycles and deduplication.
+ """
+ # 1. Fetch all connected cases (Recursive CTE)
+ # We fetch a network around the case, but limit depth/count to avoid explosion
+ tree_ids_query = """
+ WITH RECURSIVE CaseTree AS (
+ SELECT id, 0 as depth FROM sag_sager WHERE id = %s
+ UNION
+ SELECT
+ CASE WHEN sr.kilde_sag_id = ct.id THEN sr.målsag_id ELSE sr.kilde_sag_id END,
+ ct.depth + 1
+ FROM sag_relationer sr
+ JOIN CaseTree ct ON sr.kilde_sag_id = ct.id OR sr.målsag_id = ct.id
+ WHERE sr.deleted_at IS NULL AND ct.depth < 5
+ )
+ SELECT DISTINCT id FROM CaseTree LIMIT 100;
+ """
+ tree_ids_rows = execute_query(tree_ids_query, (root_id,))
+ tree_ids = [r['id'] for r in tree_ids_rows]
+
+ if not tree_ids:
+ return []
+
+ # 2. Fetch details for these cases
+ placeholders = ','.join(['%s'] * len(tree_ids))
+ tree_cases_query = f"SELECT id, titel, status 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
+ tree_edges_query = f"""
+ SELECT id, kilde_sag_id, målsag_id, relationstype
+ FROM sag_relationer
+ WHERE deleted_at IS NULL
+ AND kilde_sag_id IN ({placeholders})
+ AND målsag_id IN ({placeholders})
+ """
+ tree_edges = execute_query(tree_edges_query, tuple(tree_ids) * 2)
+
+ # 4. Build Graph (Adjacency List)
+ children_map: Dict[int, List[Dict]] = {cid: [] for cid in tree_ids}
+ parents_map: Dict[int, List[int]] = {cid: [] for cid in tree_ids}
+
+ # 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()
+ if rtype_lower in ['afledt af', 'derived from']:
+ return m, k # m is parent of k
+ if rtype_lower in ['årsag til', 'cause of']:
+ return k, m # k is parent of m
+ # Default: k is "related" to m, treat as child for visualization if k is current root context
+ # But here we build a directed graph.
+ # If relation is symmetric (Relateret til), we must be careful not to create cycle A->B->A
+ return k, m
+
+ processed_edges = set()
+
+ for edge in tree_edges:
+ k, m, rtype = edge['kilde_sag_id'], edge['målsag_id'], edge['relationstype']
+
+ # Dedup edges (bi-directional check)
+ edge_key = tuple(sorted((k,m))) + (rtype,)
+ # Actually, standardizing direction is key.
+ # If we have (k, m, 'Relateret til'), we add it once.
+
+ parent, child = get_direction(k, m, rtype)
+
+ # Avoid self-loops
+ if parent == child:
+ continue
+
+ # Add to maps
+ children_map[parent].append({
+ 'id': child,
+ 'type': rtype,
+ 'rel_id': edge['id']
+ })
+ parents_map[child].append(parent)
+
+ # 5. Build Tree
+ # We want the `root_id` to be the visual root if possible.
+ # But if `root_id` is a child of something else in this graph, we might want to show that parent?
+ # The current design shows the requested case as root, OR finds the "true" root?
+ # The original code acted a bit vaguely on "roots".
+ # Let's try to center the view on `root_id`.
+
+ # Global visited set to prevent any node from appearing more than once in the entire tree
+ # This prevents the "duplicate entries" issue where a shared child appears under multiple parents
+ # However, it makes it hard to see shared dependencies.
+ # Standard approach: Show duplicate but mark it as "reference" or stop expansion.
+
+ global_visited = set()
+
+ def build_node(cid: int, path_visited: Set[int], current_rel_type: str = None, current_rel_id: int = None):
+ if cid not in tree_cases:
+ return None
+
+ # Cycle detection in current path
+ if cid in path_visited:
+ return {
+ 'case': tree_cases[cid],
+ 'relation_type': current_rel_type,
+ 'relation_id': current_rel_id,
+ 'is_current': cid == root_id,
+ 'is_cycle': True,
+ 'children': []
+ }
+
+ path_visited.add(cid)
+
+ # Sort children for consistent display
+ children_data = sorted(children_map.get(cid, []), key=lambda x: x['type'])
+
+ children_nodes = []
+ for child_info in children_data:
+ child_id = child_info['id']
+
+ # Check if we've seen this node globally to prevent tree duplication explosion
+ # If we want a strict tree where every node appears once:
+ # if child_id in global_visited: continue
+ # But users usually want to see the context.
+ # Let's check if the user wanted "Duplicate entries" removed.
+ # Yes. So let's use global_visited, OR just show a "Link" node.
+
+ # Using path_visited.copy() allows Multi-Parent display (A->C, B->C)
+ # creating visual duplicates.
+ # If we use global_visited, C only appears under A (if A processed first).
+
+ # Compromise: We only expand children if NOT globally visited.
+ # If globally visited, we show the node but no children (Leaf ref).
+
+ is_repeated = child_id in global_visited
+ global_visited.add(child_id)
+
+ child_node = build_node(child_id, path_visited.copy(), child_info['type'], child_info['rel_id'])
+ if child_node:
+ if is_repeated:
+ child_node['children'] = []
+ child_node['is_repeated'] = True
+ children_nodes.append(child_node)
+
+ return {
+ 'case': tree_cases[cid],
+ 'relation_type': current_rel_type,
+ 'relation_id': current_rel_id,
+ 'is_current': cid == root_id,
+ 'children': children_nodes
+ }
+
+ # Determine Roots:
+ # If we just want to show the tree FROM the current case downwards (and upwards?),
+ # the original view mixed everything.
+ # Let's try to find the "top-most" parents of the current case, to show the full context.
+
+ # Traverse up from root_id to find a root
+ curr = root_id
+ while parents_map[curr]:
+ # Pick first parent (naive) - creates a single primary ancestry path
+ curr = parents_map[curr][0]
+ if curr == root_id: break # Cycle
+
+ effective_root = curr
+
+ # Build tree starting from effective root
+ global_visited.add(effective_root)
+ full_tree = build_node(effective_root, set())
+
+ if not full_tree:
+ return []
+
+ return [full_tree]
+
+ @staticmethod
+ def add_relation(source_id: int, target_id: int, type: str):
+ """Creates a relation between two cases."""
+ query = """
+ INSERT INTO sag_relationer (kilde_sag_id, målsag_id, relationstype)
+ VALUES (%s, %s, %s)
+ RETURNING id
+ """
+ return execute_query(query, (source_id, target_id, type))
+
+ @staticmethod
+ def remove_relation(relation_id: int):
+ """Soft deletes a relation."""
+ query = "UPDATE sag_relationer SET deleted_at = NOW() WHERE id = %s"
+ execute_query(query, (relation_id,))
diff --git a/app/modules/sag/templates/create.html b/app/modules/sag/templates/create.html
index 380ab6c..ccef599 100644
--- a/app/modules/sag/templates/create.html
+++ b/app/modules/sag/templates/create.html
@@ -148,25 +148,6 @@