From 891180f3f037845a8fa03a39cda471b3318bcdc5 Mon Sep 17 00:00:00 2001 From: Christian Date: Sun, 15 Feb 2026 11:12:58 +0100 Subject: [PATCH] Refactor opportunities and settings management - Removed opportunity detail page route from views.py. - Deleted opportunity_service.py as it is no longer needed. - Updated router.py to seed new setting for case_type_module_defaults. - Enhanced settings.html to include standard modules per case type with UI for selection. - Implemented JavaScript functions to manage case type module defaults. - Added RelationService for handling case relations with a tree structure. - Created migration scripts (128 and 129) for new pipeline fields and descriptions. - Added script to fix relation types in the database. --- app/modules/calendar/templates/index.html | 62 +- app/modules/hardware/frontend/views.py | 164 ++ app/modules/hardware/templates/detail.html | 255 ++- app/modules/sag/backend/router.py | 74 +- app/modules/sag/frontend/views.py | 95 +- app/modules/sag/services/relation_service.py | 197 ++ app/modules/sag/templates/create.html | 44 +- app/modules/sag/templates/detail.html | 792 ++++++-- app/modules/sag/templates/edit.html | 20 +- app/opportunities/backend/router.py | 1345 ++------------ app/opportunities/frontend/opportunities.html | 495 ++--- .../frontend/opportunity_detail.html | 1618 ----------------- app/opportunities/frontend/views.py | 8 - app/services/opportunity_service.py | 22 - app/settings/backend/router.py | 38 +- app/settings/frontend/settings.html | 182 +- apply_migration_128.py | 41 + apply_migration_129.py | 41 + migrations/128_sag_pipeline_fields.sql | 38 + migrations/129_sag_pipeline_description.sql | 2 + scripts/fix_relations.py | 53 + 21 files changed, 2193 insertions(+), 3393 deletions(-) create mode 100644 app/modules/sag/services/relation_service.py delete mode 100644 app/opportunities/frontend/opportunity_detail.html delete mode 100644 app/services/opportunity_service.py create mode 100644 apply_migration_128.py create mode 100644 apply_migration_129.py create mode 100644 migrations/128_sag_pipeline_fields.sql create mode 100644 migrations/129_sag_pipeline_description.sql create mode 100644 scripts/fix_relations.py diff --git a/app/modules/calendar/templates/index.html b/app/modules/calendar/templates/index.html index 3eb4a41..71391e5 100644 --- a/app/modules/calendar/templates/index.html +++ b/app/modules/calendar/templates/index.html @@ -174,17 +174,37 @@ .type-tag { display: inline-flex; align-items: center; - gap: 0.4rem; - background: var(--calendar-bg); - border-radius: 999px; - padding: 0.4rem 0.75rem; + gap: 0.5rem; + background: #ffffff; + border: 1px solid var(--calendar-border); + border-radius: 8px; + padding: 0.35rem 0.6rem; font-size: 0.8rem; - color: var(--calendar-subtle); + color: var(--calendar-ink); + font-weight: 500; cursor: pointer; + transition: all 0.2s ease; + user-select: none; + } + + .type-tag:hover { + background: var(--calendar-bg); + border-color: rgba(15, 76, 117, 0.2); } .type-tag input { accent-color: var(--calendar-sea); + width: 1rem; + height: 1rem; + cursor: pointer; + margin: 0; + } + + .type-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; } .action-row { @@ -431,28 +451,34 @@
-
@@ -479,8 +505,8 @@
Deadline
Deferred
-
Moede
-
Teknikerbesoeg
+
Møde
+
Teknikerbesøg
OBS
Reminder
@@ -505,8 +531,8 @@
@@ -517,7 +543,7 @@
- +
diff --git a/app/modules/hardware/frontend/views.py b/app/modules/hardware/frontend/views.py index 8a08c3c..4a6160f 100644 --- a/app/modules/hardware/frontend/views.py +++ b/app/modules/hardware/frontend/views.py @@ -429,6 +429,56 @@ async def hardware_detail(request: Request, hardware_id: int): ORDER BY created_at DESC """ tags = execute_query(tag_query, (hardware_id,)) + + # Get linked contacts + contacts_query = """ + SELECT hc.*, c.first_name, c.last_name, c.email, c.phone, c.mobile + FROM hardware_contacts hc + JOIN contacts c ON c.id = hc.contact_id + WHERE hc.hardware_id = %s + ORDER BY hc.role = 'primary' DESC, c.first_name ASC + """ + contacts = execute_query(contacts_query, (hardware_id,)) + + # Get available contacts for linking (from current owner) + available_contacts = [] + if hardware.get('current_owner_customer_id'): + avail_query = """ + SELECT c.id, c.first_name, c.last_name, c.email + FROM contacts c + JOIN contact_companies cc ON c.id = cc.contact_id + WHERE cc.customer_id = %s + AND c.is_active = TRUE + AND c.id NOT IN ( + SELECT contact_id FROM hardware_contacts WHERE hardware_id = %s + ) + ORDER BY cc.is_primary DESC, c.first_name ASC + """ + available_contacts = execute_query(avail_query, (hardware['current_owner_customer_id'], hardware_id)) + + # Get customers for ownership selector + owner_customers_query = """ + SELECT id, name AS navn + FROM customers + ORDER BY name + """ + owner_customers = execute_query(owner_customers_query) + + owner_contacts_query = """ + SELECT + cc.customer_id, + c.id, + c.first_name, + c.last_name, + c.email, + c.phone, + cc.is_primary + FROM contacts c + JOIN contact_companies cc ON cc.contact_id = c.id + WHERE c.is_active = TRUE + ORDER BY cc.customer_id, cc.is_primary DESC, c.first_name ASC, c.last_name ASC + """ + owner_contacts = execute_query(owner_contacts_query) # Get all active locations for the tree (including parent_id for structure) all_locations_query = """ @@ -448,6 +498,10 @@ async def hardware_detail(request: Request, hardware_id: int): "attachments": attachments or [], "cases": cases or [], "tags": tags or [], + "contacts": contacts or [], + "available_contacts": available_contacts or [], + "owner_customers": owner_customers or [], + "owner_contacts": owner_contacts or [], "location_tree": location_tree or [], "eset_specs": extract_eset_specs_summary(hardware) }) @@ -518,3 +572,113 @@ async def update_hardware_location( execute_query(update_asset_query, (location_id, hardware_id)) return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303) + + +@router.post("/hardware/{hardware_id}/owner") +async def update_hardware_owner( + request: Request, + hardware_id: int, + owner_customer_id: int = Form(...), + owner_contact_id: Optional[int] = Form(None), + notes: Optional[str] = Form(None) +): + """Update hardware ownership.""" + # Verify hardware exists + check_query = "SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL" + if not execute_query(check_query, (hardware_id,)): + raise HTTPException(status_code=404, detail="Hardware not found") + + # Verify customer exists + customer_check = "SELECT id FROM customers WHERE id = %s AND deleted_at IS NULL" + if not execute_query(customer_check, (owner_customer_id,)): + raise HTTPException(status_code=404, detail="Customer not found") + + # Verify owner contact belongs to selected customer + if owner_contact_id: + contact_check_query = """ + SELECT 1 + FROM contact_companies + WHERE customer_id = %s AND contact_id = %s + LIMIT 1 + """ + if not execute_query(contact_check_query, (owner_customer_id, owner_contact_id)): + raise HTTPException(status_code=400, detail="Selected contact does not belong to customer") + + # Close active ownership history + close_history_query = """ + UPDATE hardware_ownership_history + SET end_date = %s + WHERE hardware_id = %s AND end_date IS NULL AND deleted_at IS NULL + """ + execute_query(close_history_query, (date.today(), hardware_id)) + + # Insert new ownership history + add_history_query = """ + INSERT INTO hardware_ownership_history ( + hardware_id, owner_type, owner_customer_id, start_date, notes + ) + VALUES (%s, %s, %s, %s, %s) + """ + execute_query(add_history_query, (hardware_id, "customer", owner_customer_id, date.today(), notes)) + + # Update current owner on hardware + update_asset_query = """ + UPDATE hardware_assets + SET current_owner_type = %s, current_owner_customer_id = %s, updated_at = NOW() + WHERE id = %s + """ + execute_query(update_asset_query, ("customer", owner_customer_id, hardware_id)) + + # Optionally set owner contact as primary for this hardware + if owner_contact_id: + demote_query = """ + UPDATE hardware_contacts + SET role = 'user' + WHERE hardware_id = %s AND role = 'primary' + """ + execute_query(demote_query, (hardware_id,)) + + owner_contact_query = """ + INSERT INTO hardware_contacts (hardware_id, contact_id, role, source) + VALUES (%s, %s, 'primary', 'manual') + ON CONFLICT (hardware_id, contact_id) + DO UPDATE SET role = EXCLUDED.role, source = EXCLUDED.source + """ + execute_query(owner_contact_query, (hardware_id, owner_contact_id)) + + return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303) + + +@router.post("/hardware/{hardware_id}/contacts/add") +async def add_hardware_contact( + request: Request, + hardware_id: int, + contact_id: int = Form(...) +): + """Link a contact to hardware.""" + # Check if exists + exists_query = "SELECT id FROM hardware_contacts WHERE hardware_id = %s AND contact_id = %s" + if execute_query(exists_query, (hardware_id, contact_id)): + # Already exists, just redirect + pass + else: + insert_query = """ + INSERT INTO hardware_contacts (hardware_id, contact_id, role, source) + VALUES (%s, %s, 'user', 'manual') + """ + execute_query(insert_query, (hardware_id, contact_id)) + + return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303) + + +@router.post("/hardware/{hardware_id}/contacts/{contact_id}/delete") +async def remove_hardware_contact( + request: Request, + hardware_id: int, + contact_id: int +): + """Unlink a contact from hardware.""" + delete_query = "DELETE FROM hardware_contacts WHERE hardware_id = %s AND contact_id = %s" + execute_query(delete_query, (hardware_id, contact_id)) + + return RedirectResponse(url=f"/hardware/{hardware_id}", status_code=303) diff --git a/app/modules/hardware/templates/detail.html b/app/modules/hardware/templates/detail.html index ac24109..1268217 100644 --- a/app/modules/hardware/templates/detail.html +++ b/app/modules/hardware/templates/detail.html @@ -157,6 +157,14 @@ {% set current_owner = ownership[0] if ownership else None %} + {% set owner_contact_ns = namespace(contact=None) %} + {% if contacts %} + {% for c in contacts %} + {% if c.role == 'primary' and owner_contact_ns.contact is none %} + {% set owner_contact_ns.contact = c %} + {% endif %} + {% endfor %} + {% endif %} {% if current_owner and not current_owner.end_date %}
Ejer: @@ -361,19 +369,26 @@
-
+
Ejer
+
{% if current_owner and not current_owner.end_date %}
{{ current_owner.customer_name or current_owner.owner_type|title }}
+ {% if owner_contact_ns.contact %} +

+ {{ owner_contact_ns.contact.first_name }} {{ owner_contact_ns.contact.last_name }} +

+ {% endif %}

Siden: {{ current_owner.start_date }}

{% else %}

Ingen aktiv ejer

+
{% endif %}
@@ -412,6 +427,46 @@
+ +
+
+
Kontaktpersoner
+ +
+
+ {% if contacts %} + {% for contact in contacts %} +
+
+
+
+ {{ contact.first_name }} {{ contact.last_name }} + {% if contact.role == 'primary' %}Primær{% endif %} + {% if contact.source == 'eset' %}ESET{% endif %} +
+
+ {% if contact.email %}{% endif %} + {% if contact.phone %}{{ contact.phone }}{% endif %} +
+
+
+ +
+
+
+ {% endfor %} + {% else %} +
+ Ingen kontakter tilknyttet +
+ {% endif %} +
+
+
@@ -673,6 +728,65 @@
+ + + + + + {% 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 @@
- -
-
- -
- - -
-
- -
- - -
0 tegn
-
-
- -
-
Relationer
@@ -183,13 +164,13 @@
- +
- +
- +
@@ -200,6 +181,25 @@
+ +
+
+ +
+ + +
+
+ +
+ + +
0 tegn
+
+
+ +
+
Hardware (AnyDesk)
diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html index a3adcbc..e35c50e 100644 --- a/app/modules/sag/templates/detail.html +++ b/app/modules/sag/templates/detail.html @@ -258,58 +258,234 @@ color: var(--text-secondary); } + .card[data-module].module-empty-compact { + height: auto !important; + min-height: 0; + --module-compact-min-height: 48px; + } + + .card[data-module].module-empty-compact .card-body { + display: none; + } + + .card[data-module].module-empty-compact .card-header, + .card[data-module].module-empty-compact .module-header { + margin-bottom: 0; + padding-top: 0.45rem; + padding-bottom: 0.45rem; + min-height: var(--module-compact-min-height); + } + + .card[data-module].module-empty-compact .card-title, + .card[data-module].module-empty-compact h5, + .card[data-module].module-empty-compact h6 { + margin-bottom: 0; + font-size: 0.95rem; + } + + .card[data-module].module-empty-compact .btn { + --bs-btn-padding-y: 0.2rem; + --bs-btn-padding-x: 0.45rem; + } + + .todo-section-header { + padding: 0.28rem 0.55rem; + font-size: 0.66rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + background: rgba(15, 76, 117, 0.06); + border-top: 1px solid rgba(15, 76, 117, 0.08); + border-bottom: 1px solid rgba(15, 76, 117, 0.08); + } + + .todo-step-item { + padding: 0.38rem 0.5rem; + } + + .todo-step-header { + display: flex; + align-items: flex-start; + gap: 0.5rem; + } + + .todo-step-left { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + } + + .todo-step-right { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; + justify-content: flex-end; + text-align: right; + } + + .todo-step-title { + font-weight: 600; + margin-bottom: 0; + font-size: 0.82rem; + line-height: 1.2; + } + + .todo-step-meta { + display: flex; + flex-wrap: wrap; + gap: 0.2rem; + margin-top: 0.2rem; + } + + .todo-step-meta .meta-pill { + display: inline-flex; + align-items: center; + padding: 0.08rem 0.34rem; + border-radius: 999px; + background: rgba(0,0,0,0.05); + color: var(--text-secondary); + font-size: 0.64rem; + } + + .todo-step-actions { + display: flex; + gap: 0.25rem; + margin-top: 0.3rem; + } + + .todo-step-right .todo-step-actions { + margin-top: 0; + } + + .todo-info-btn { + width: 18px; + height: 18px; + padding: 0; + border-radius: 999px; + font-size: 0.65rem; + line-height: 1; + } + + .todo-step-actions .btn { + --bs-btn-padding-y: 0.1rem; + --bs-btn-padding-x: 0.28rem; + --bs-btn-font-size: 0.68rem; + line-height: 1; + } + .relation-tree { list-style: none; margin: 0; padding-left: 0; } - .relation-node { - position: relative; - padding-left: 1.2rem; + /* Sub-trees need indentation */ + .relation-children .relation-tree { + margin-left: 8px; /* Indent children */ } + .relation-node { + position: relative; + padding-left: 24px; /* Space for the connector */ + } + + /* Vertical Line (Spine) */ + .relation-children { + /* This container wraps the child
    */ + position: relative; + margin-left: 8px; /* Align with parent connector start */ + border-left: 1px solid rgba(15, 76, 117, 0.2); + } + + /* Horizontal Line (Connector) */ .relation-node:before { content: ""; position: absolute; - left: 0.45rem; - top: 0.2rem; - bottom: 0.2rem; - width: 1px; - background: rgba(15, 76, 117, 0.25); - } - - .relation-node:after { - content: ""; - position: absolute; - left: 0.45rem; - top: 1.05rem; - width: 0.6rem; + left: 0; + top: 1.1rem; /* Mid-height of the top row (approx 32px/2 + padding) */ + width: 20px; height: 1px; - background: rgba(15, 76, 117, 0.35); + background: rgba(15, 76, 117, 0.2); } - .relation-node:last-child:before { - bottom: 1.05rem; + /* Fix: Last child should stop drawing the vertical line if we used ul border, + but here we use .relation-children border which covers all. + To get the "L" shape for the last child, we need the vertical line to come from the ITEM, not the LIST. + */ + + /* Reset simpler approach: Stifinder style */ + .relation-tree, .relation-children { border: none !important; margin: 0; padding: 0; } + + .relation-node { + position: relative; + padding-left: 24px; + } + + /* Vertical line up to this node */ + .relation-node::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + border-left: 1px solid rgba(15, 76, 117, 0.25); + } + + /* Horizontal link */ + .relation-node::after { + content: ''; + position: absolute; + top: 18px; /* Half of row height approx */ + left: 0; + width: 20px; + height: 1px; + border-top: 1px solid rgba(15, 76, 117, 0.25); + } + + /* Remove vertical line for the last item, but keep the top half to form "L" */ + .relation-node:last-child::before { + height: 18px; /* connectors height */ + bottom: auto; + } + + /* Root node shouldn't have lines if it's top level? + Our macro recursively renders, so top level nodes are also .relation-node. + We might need a wrapper. */ + .relation-tree > .relation-node:first-child::before, + .relation-tree > .relation-node:first-child::after { + /* If it's the absolute root, maybe no lines? depends on if we show multiple roots */ } .relation-children { - margin-left: 0.55rem; - padding-left: 0.65rem; - border-left: 1px dashed rgba(15, 76, 117, 0.25); + margin-left: 24px; /* Indent for next level */ } + .relation-node-card { background: rgba(15, 76, 117, 0.03); border: 1px solid rgba(15, 76, 117, 0.12); + transition: background 0.2s; + } + + .relation-node-card:hover { + background: rgba(15, 76, 117, 0.08); } .relation-type-badge { display: inline-flex; align-items: center; gap: 0.25rem; - font-size: 0.65rem; - padding: 0.15rem 0.45rem; + 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; + } border-radius: 999px; background: rgba(15, 76, 117, 0.12); color: var(--accent); @@ -487,49 +663,79 @@
- +
-
+
ID: {{ case.id }}
-
+ +
Kunde: - {{ customer.name if customer else 'Ingen kunde' }} + {% if customer %} + + {{ customer.name }} + + {% else %} + Ingen kunde + {% endif %}
-
- Hovedkontakt: + +
+ Kontakt: {% if hovedkontakt %} + title="Se kontaktinfo"> {{ hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name }} {% else %} - Ingen kontakt + Ingen {% endif %}
-
+ +
Afdeling: + title="Ændre afdeling"> {{ customer.department if customer and customer.department else 'N/A' }}
-
+ +
Status: {{ case.status }}
-
- Opdateret: - {{ case.updated_at.strftime('%d/%m-%Y') if case.updated_at else 'N/A' }} + + +
+ Datoer: + Opr: {{ case.created_at.strftime('%d/%m-%y') if case.created_at else '-' }} + | + Opd: {{ case.updated_at.strftime('%d/%m-%y') if case.updated_at else '-' }} + | + Deadline: + + {{ case.deadline.strftime('%d/%m-%y') if case.deadline else 'Ingen' }} +
-
- Deadline: - {{ case.deadline.strftime('%d/%m-%Y') if case.deadline else 'Ikke sat' }} + + +
+ Udsat: + {% if case.deferred_until %} + + {{ case.deferred_until.strftime('%d/%m-%y') }} + + {% else %} + Nej + {% endif %} +
@@ -562,7 +768,7 @@ @@ -586,7 +792,7 @@
#{{ case.id }} {{ case.status }} - {{ case.type or 'ticket' }} + {{ case.template_key or case.type or 'ticket' }}
@@ -599,82 +805,11 @@
-
-
-
Kunde
-
- {% if customer %} - - {{ customer.name }} - - {% else %} - Ingen kunde - {% endif %} -
-
-
-
Hovedkontakt
-
- {% if hovedkontakt %} - - {{ hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name }} - - {% else %} - - - {% endif %} -
-
-
-
Afdeling
-
- - {{ customer.department if customer and customer.department else 'N/A' }} - -
-
-
-
Oprettet
-
{{ case.created_at|string|truncate(19, True, '') if case.created_at else 'Ikke sat' }}
-
-
-
Opdateret
-
{{ case.updated_at.strftime('%d/%m-%Y') if case.updated_at else 'N/A' }}
-
-
-
Deadline
-
{{ case.deadline.strftime('%d/%m-%Y') if case.deadline else 'Ikke sat' }}
-
-
-
Udsat start
-
-
- - {% if case.deferred_until %} - {{ case.deferred_until.strftime('%d/%m-%Y') }} - {% else %} - Ikke sat - {% endif %} - - - {% if case.deferred_until_case_id %} - Sag #{{ case.deferred_until_case_id }} - {% else %} - Ingen sag - {% endif %} - - - {{ case.deferred_until_status or 'Ingen status' }} - - -
-
-
-
-
-
Beskrivelse
-
{{ case.beskrivelse or 'Ingen beskrivelse' }}
+ + +
+
Beskrivelse
+
{{ case.beskrivelse or 'Ingen beskrivelse' }}
@@ -682,6 +817,98 @@
+ +
+
+
+
+
📈 Salgspipeline
+ +
+
+
+
+
+
+
Stage
+
+ {% set ns = namespace(selected_stage=None) %} + {% for stage in pipeline_stages or [] %} + {% if case.pipeline_stage_id == stage.id %} + {% set ns.selected_stage = stage %} + {% endif %} + {% endfor %} + {% if ns.selected_stage %} + {{ ns.selected_stage.name }} + {% else %} + Ikke sat + {% endif %} +
+
+
+
+
+
Sandsynlighed
+
{{ case.pipeline_probability if case.pipeline_probability is not none else 0 }}%
+
+
+
+
+
Beløb
+
+ {% if case.pipeline_amount is not none %} + {{ "{:,.2f}".format(case.pipeline_amount|float).replace(',', 'X').replace('.', ',').replace('X', '.') }} kr. + {% else %} + Ikke sat + {% endif %} +
+
+
+
+
+
+
Beskrivelse
+
{{ case.pipeline_description or 'Ingen beskrivelse' }}
+
+
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+
+
@@ -703,19 +930,48 @@ {% for node in nodes %}
  • -
    +
    + + {% if node.relation_type %} - {{ node.relation_type }} + {% set rel_icon = 'bi-link-45deg' %} + {% set rel_color = 'text-muted' %} + + {% if node.relation_type == 'Afledt af' %} + {% set rel_icon = 'bi-arrow-return-right' %} + {% set rel_color = 'text-info' %} + {% elif node.relation_type == 'Blokkerer' %} + {% set rel_icon = 'bi-slash-circle' %} + {% set rel_color = 'text-danger' %} + {% endif %} + + + + {{ node.relation_type }} + {% endif %} - - #{{ node.case.id }} - {{ node.case.titel }} + + + + #{{ node.case.id }} + {{ node.case.titel }} + + + + + {% if node.is_repeated %} + + {% endif %} + + {% if node.relation_id %} - +
    + +
    {% endif %}
    @@ -746,7 +1002,7 @@
    -
    📞 Call historik
    +
    📞 Opkaldshistorik
    @@ -877,19 +1133,19 @@
    -
    📧 Linkede Emails
    +
    📧 Linkede e-mails
    - +
    Træk .msg/.eml filer hertil for at importere
    -
    Ingen emails linket...
    +
    Ingen e-mails linket...
    @@ -1038,7 +1294,7 @@
    -
    -
    Email
    +
    E-mail
    -
    @@ -1170,6 +1426,27 @@ let relationSearchTimeout; let wikiSearchTimeout; let selectedRelationCaseId = null; + const caseTypeKey = "{{ (case.template_key or case.type or 'ticket')|lower }}"; + window.moduleDisplayNames = { + 'relations': 'Relationer', + 'call-history': 'Opkaldshistorik', + 'files': 'Filer', + 'emails': 'E-mails', + 'pipeline': 'Salgspipeline', + 'hardware': 'Hardware', + 'locations': 'Lokationer', + 'contacts': 'Kontakter', + 'customers': 'Kunder', + 'wiki': 'Wiki', + 'todo-steps': 'Todo-opgaver', + 'time': 'Tid', + 'solution': 'Løsning', + 'sales': 'Varekøb & salg', + 'subscription': 'Abonnement', + 'reminders': 'Påmindelser', + 'calendar': 'Kalender' + }; + let caseTypeModuleDefaults = {}; // Modal instances let contactSearchModal, customerSearchModal, relationModal, contactInfoModal, createRelatedCaseModalInstance; @@ -1194,7 +1471,7 @@ window.renderEntityTags('case', {{ case.id }}, 'case-tags'); } - loadModulePrefs().then(() => applyViewFromTags()); + Promise.all([loadModulePrefs(), loadCaseTypeModuleDefaultsSetting()]).then(() => applyViewFromTags()); // Set default context for keyboard shortcuts (Option+Shift+T) if (window.setTagPickerContext) { @@ -1866,7 +2143,7 @@ return; } - container.innerHTML = '
    Henter Wiki...
    '; + container.innerHTML = '
    Henter wiki...
    '; const params = new URLSearchParams(); const trimmed = (searchValue || '').trim(); @@ -1954,52 +2231,101 @@ const list = document.getElementById('todo-steps-list'); if (!list) return; + const escapeAttr = (value) => String(value ?? '') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + if (!steps || steps.length === 0) { - list.innerHTML = '
    Ingen steps endnu
    '; + list.innerHTML = '
    Ingen opgaver endnu
    '; setModuleContentState('todo-steps', false); return; } - list.innerHTML = steps.map(step => { + const openSteps = steps.filter(step => !step.is_done); + const doneSteps = steps.filter(step => step.is_done); + + const renderStep = (step) => { const createdBy = step.created_by_name || 'Ukendt'; const completedBy = step.completed_by_name || 'Ukendt'; const dueLabel = step.due_date ? formatTodoDate(step.due_date) : '-'; const createdLabel = formatTodoDateTime(step.created_at); const completedLabel = step.completed_at ? formatTodoDateTime(step.completed_at) : null; const statusBadge = step.is_done - ? 'Ferdig' - : 'Aaben'; - const toggleLabel = step.is_done ? 'Genaabn' : 'Ferdig'; + ? 'Færdig' + : 'Åben'; + const toggleLabel = step.is_done ? 'Genåbn' : 'Færdig'; const toggleClass = step.is_done ? 'btn-outline-secondary' : 'btn-outline-success'; + const tooltipText = [ + `Oprettet af: ${createdBy}`, + `Oprettet: ${createdLabel}`, + `Forfald: ${dueLabel}`, + step.is_done && completedLabel ? `Færdiggjort af: ${completedBy}` : null, + step.is_done && completedLabel ? `Færdiggjort: ${completedLabel}` : null + ].filter(Boolean).join('
    '); return ` -
    -
    -
    -
    ${step.title}
    - ${step.description ? `
    ${step.description}
    ` : ''} -
    Forfald: ${dueLabel}
    -
    Oprettet af ${createdBy} · ${createdLabel}
    - ${step.is_done && completedLabel ? `
    Ferdiggjort af ${completedBy} · ${completedLabel}
    ` : ''} +
    +
    +
    + ${step.title} +
    -
    +
    ${statusBadge} -
    - - +
    + +
    + ${step.description ? `
    ${step.description}
    ` : ''} +
    + Forfald: ${dueLabel} +
    `; - }).join(''); + }; + + const sections = []; + if (openSteps.length) { + sections.push(` +
    Åbne (${openSteps.length})
    + ${openSteps.map(renderStep).join('')} + `); + } + if (doneSteps.length) { + sections.push(` +
    Færdige (${doneSteps.length})
    + ${doneSteps.map(renderStep).join('')} + `); + } + + list.innerHTML = sections.join(''); + if (window.bootstrap) { + list.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((el) => { + bootstrap.Tooltip.getOrCreateInstance(el, { + trigger: 'hover focus', + placement: 'left', + container: 'body', + html: true + }); + }); + } setModuleContentState('todo-steps', true); } async function loadTodoSteps() { const list = document.getElementById('todo-steps-list'); if (!list) return; - list.innerHTML = '
    Henter steps...
    '; + list.innerHTML = '
    Henter opgaver...
    '; try { const res = await fetch(`/api/v1/sag/${caseId}/todo-steps`); @@ -2013,6 +2339,28 @@ } } + function toggleTodoStepForm(forceOpen = null) { + const form = document.getElementById('todoStepForm'); + const moduleCard = document.querySelector('[data-module="todo-steps"]'); + if (!form) return; + + const shouldOpen = forceOpen === null ? form.classList.contains('d-none') : Boolean(forceOpen); + + if (shouldOpen) { + form.classList.remove('d-none'); + if (moduleCard) { + moduleCard.classList.remove('module-empty-compact'); + } + const titleInput = document.getElementById('todoStepTitle'); + if (titleInput) { + titleInput.focus(); + } + } else { + form.classList.add('d-none'); + applyViewLayout(currentCaseView); + } + } + async function createTodoStep(event) { event.preventDefault(); const titleInput = document.getElementById('todoStepTitle'); @@ -2052,6 +2400,7 @@ descInput.value = ''; dueInput.value = ''; await loadTodoSteps(); + toggleTodoStepForm(false); } catch (e) { alert('Fejl: ' + e.message); } @@ -2199,7 +2548,7 @@ {% if contacts %}
    Navn - Title + Titel Kunde Slet
    @@ -2249,7 +2598,7 @@
    Navn Rolle - Email + E-mail Slet
    {% for customer in customers %} @@ -2272,33 +2621,36 @@
    -
    Kunde WIKI
    +
    Kunde-wiki
    -
    Henter Wiki...
    +
    Henter wiki...
    -
    ✅ Todo steps
    +
    ✅ Todo-opgaver
    +
    - - + +
    - +
    -
    Ingen steps endnu
    +
    Ingen opgaver endnu
    @@ -2947,7 +3299,7 @@
    - +
    @@ -4053,7 +4405,7 @@
    {{ hovedkontakt.first_name }} {{ hovedkontakt.last_name }}
    - +
    {% if hovedkontakt.email %} @@ -4149,7 +4501,7 @@
    @@ -1878,20 +1906,123 @@ function getCaseTypesSetting() { return allSettings.find(setting => setting.key === 'case_types'); } +const CASE_MODULE_OPTIONS = [ + 'relations', 'call-history', 'files', 'emails', 'hardware', 'locations', + 'contacts', 'customers', 'wiki', 'todo-steps', 'time', 'solution', + 'sales', 'subscription', 'reminders', 'calendar' +]; + +const CASE_MODULE_LABELS = { + 'relations': 'Relationer', + 'call-history': 'Opkaldshistorik', + 'files': 'Filer', + 'emails': 'E-mails', + 'hardware': 'Hardware', + 'locations': 'Lokationer', + 'contacts': 'Kontakter', + 'customers': 'Kunder', + 'wiki': 'Wiki', + 'todo-steps': 'Todo-opgaver', + 'time': 'Tid', + 'solution': 'Løsning', + 'sales': 'Varekøb & salg', + 'subscription': 'Abonnement', + 'reminders': 'Påmindelser', + 'calendar': 'Kalender' +}; + +let caseTypeModuleDefaultsCache = {}; + +function normalizeCaseTypeModuleDefaults(raw, caseTypes) { + const normalized = {}; + const rawObj = raw && typeof raw === 'object' ? raw : {}; + const validTypes = Array.isArray(caseTypes) ? caseTypes : []; + + validTypes.forEach(type => { + const existing = rawObj[type]; + const asList = Array.isArray(existing) ? existing : CASE_MODULE_OPTIONS; + normalized[type] = asList.filter(m => CASE_MODULE_OPTIONS.includes(m)); + }); + + return normalized; +} + +async function loadCaseTypeModuleDefaultsSetting(caseTypes) { + try { + const response = await fetch('/api/v1/settings/case_type_module_defaults'); + if (!response.ok) { + caseTypeModuleDefaultsCache = normalizeCaseTypeModuleDefaults({}, caseTypes); + } else { + const setting = await response.json(); + const parsed = JSON.parse(setting.value || '{}'); + caseTypeModuleDefaultsCache = normalizeCaseTypeModuleDefaults(parsed, caseTypes); + } + } catch (error) { + console.error('Error loading case type module defaults:', error); + caseTypeModuleDefaultsCache = normalizeCaseTypeModuleDefaults({}, caseTypes); + } + + renderCaseTypeModuleTypeOptions(caseTypes); + renderCaseTypeModuleChecklist(); +} + +function renderCaseTypeModuleTypeOptions(caseTypes) { + const select = document.getElementById('caseTypeModulesTypeSelect'); + if (!select) return; + + const previous = select.value; + select.innerHTML = '' + + (caseTypes || []).map(type => ``).join(''); + + if (previous && (caseTypes || []).includes(previous)) { + select.value = previous; + } else if ((caseTypes || []).length > 0) { + select.value = caseTypes[0]; + } +} + +function renderCaseTypeModuleChecklist() { + const container = document.getElementById('caseTypeModuleChecklist'); + const select = document.getElementById('caseTypeModulesTypeSelect'); + if (!container || !select) return; + + const type = select.value; + if (!type) { + container.innerHTML = '
    Vælg en sagstype for at redigere standardmoduler.
    '; + return; + } + + const enabledModules = new Set(caseTypeModuleDefaultsCache[type] || CASE_MODULE_OPTIONS); + container.innerHTML = CASE_MODULE_OPTIONS.map(moduleKey => ` +
    +
    + + +
    +
    + `).join(''); +} + async function loadCaseTypesSetting() { try { const response = await fetch('/api/v1/settings/case_types'); if (!response.ok) { renderCaseTypes([]); + await loadCaseTypeModuleDefaultsSetting([]); return; } const setting = await response.json(); const rawValue = setting.value || '[]'; const parsed = JSON.parse(rawValue); - renderCaseTypes(Array.isArray(parsed) ? parsed : []); + const types = Array.isArray(parsed) ? parsed : []; + renderCaseTypes(types); + await loadCaseTypeModuleDefaultsSetting(types); } catch (error) { console.error('Error loading case types:', error); renderCaseTypes([]); + await loadCaseTypeModuleDefaultsSetting([]); } } @@ -1916,12 +2047,17 @@ function renderCaseTypes(types) { async function saveCaseTypes(types) { await updateSetting('case_types', JSON.stringify(types)); renderCaseTypes(types); + + caseTypeModuleDefaultsCache = normalizeCaseTypeModuleDefaults(caseTypeModuleDefaultsCache, types); + renderCaseTypeModuleTypeOptions(types); + renderCaseTypeModuleChecklist(); + await updateSetting('case_type_module_defaults', JSON.stringify(caseTypeModuleDefaultsCache)); } async function addCaseType() { const input = document.getElementById('caseTypeInput'); if (!input) return; - const value = input.value.trim(); + const value = input.value.trim().toLowerCase(); if (!value) return; const response = await fetch('/api/v1/settings/case_types'); @@ -1947,6 +2083,48 @@ async function removeCaseType(type) { await saveCaseTypes(filtered); } +async function saveCaseTypeModuleDefaults() { + const select = document.getElementById('caseTypeModulesTypeSelect'); + const type = select ? select.value : ''; + if (!type) { + alert('Vælg en sagstype først'); + return; + } + + const enabled = CASE_MODULE_OPTIONS.filter(moduleKey => { + const checkbox = document.getElementById(`ctmod_${moduleKey}`); + return checkbox ? checkbox.checked : false; + }); + + caseTypeModuleDefaultsCache[type] = enabled; + await updateSetting('case_type_module_defaults', JSON.stringify(caseTypeModuleDefaultsCache)); + if (typeof showNotification === 'function') { + showNotification('Standardmoduler gemt', 'success'); + } +} + +async function resetCaseTypeModuleDefaults() { + const select = document.getElementById('caseTypeModulesTypeSelect'); + const type = select ? select.value : ''; + if (!type) { + alert('Vælg en sagstype først'); + return; + } + + caseTypeModuleDefaultsCache[type] = [...CASE_MODULE_OPTIONS]; + renderCaseTypeModuleChecklist(); + try { + await updateSetting('case_type_module_defaults', JSON.stringify(caseTypeModuleDefaultsCache)); + if (typeof showNotification === 'function') { + showNotification('Standardmoduler nulstillet', 'success'); + } + } catch (error) { + if (typeof showNotification === 'function') { + showNotification('Kunne ikke nulstille standardmoduler', 'error'); + } + } +} + let usersCache = []; let groupsCache = []; let permissionsCache = []; diff --git a/apply_migration_128.py b/apply_migration_128.py new file mode 100644 index 0000000..9a29d35 --- /dev/null +++ b/apply_migration_128.py @@ -0,0 +1,41 @@ +import logging +import os +import sys + +sys.path.append(os.getcwd()) + +from app.core.database import execute_query, init_db +from app.core.config import settings + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def run_migration(): + migration_file = "migrations/128_sag_pipeline_fields.sql" + logger.info("Applying migration: %s", migration_file) + + try: + if "@postgres" in settings.DATABASE_URL: + logger.info("Patching DATABASE_URL for local run") + settings.DATABASE_URL = settings.DATABASE_URL.replace("@postgres", "@localhost").replace(":5432", ":5433") + + init_db() + + with open(migration_file, "r", encoding="utf-8") as migration: + sql = migration.read() + + commands = [cmd.strip() for cmd in sql.split(";") if cmd.strip()] + + for command in commands: + logger.info("Executing migration statement...") + execute_query(command, ()) + + logger.info("✅ Migration 128 applied successfully") + except Exception as exc: + logger.error("❌ Migration 128 failed: %s", exc) + sys.exit(1) + + +if __name__ == "__main__": + run_migration() diff --git a/apply_migration_129.py b/apply_migration_129.py new file mode 100644 index 0000000..f5021af --- /dev/null +++ b/apply_migration_129.py @@ -0,0 +1,41 @@ +import logging +import os +import sys + +sys.path.append(os.getcwd()) + +from app.core.database import execute_query, init_db +from app.core.config import settings + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def run_migration(): + migration_file = "migrations/129_sag_pipeline_description.sql" + logger.info("Applying migration: %s", migration_file) + + try: + if "@postgres" in settings.DATABASE_URL: + logger.info("Patching DATABASE_URL for local run") + settings.DATABASE_URL = settings.DATABASE_URL.replace("@postgres", "@localhost").replace(":5432", ":5433") + + init_db() + + with open(migration_file, "r", encoding="utf-8") as migration: + sql = migration.read() + + commands = [cmd.strip() for cmd in sql.split(";") if cmd.strip()] + + for command in commands: + logger.info("Executing migration statement...") + execute_query(command, ()) + + logger.info("✅ Migration 129 applied successfully") + except Exception as exc: + logger.error("❌ Migration 129 failed: %s", exc) + sys.exit(1) + + +if __name__ == "__main__": + run_migration() diff --git a/migrations/128_sag_pipeline_fields.sql b/migrations/128_sag_pipeline_fields.sql new file mode 100644 index 0000000..47f1080 --- /dev/null +++ b/migrations/128_sag_pipeline_fields.sql @@ -0,0 +1,38 @@ +CREATE TABLE IF NOT EXISTS pipeline_stages ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE, + color VARCHAR(20) DEFAULT '#0f4c75', + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO pipeline_stages (name, color, sort_order) +SELECT 'Lead', '#6c757d', 10 +WHERE NOT EXISTS (SELECT 1 FROM pipeline_stages WHERE LOWER(name) = 'lead'); + +INSERT INTO pipeline_stages (name, color, sort_order) +SELECT 'Kontakt', '#17a2b8', 20 +WHERE NOT EXISTS (SELECT 1 FROM pipeline_stages WHERE LOWER(name) = 'kontakt'); + +INSERT INTO pipeline_stages (name, color, sort_order) +SELECT 'Tilbud', '#ffc107', 30 +WHERE NOT EXISTS (SELECT 1 FROM pipeline_stages WHERE LOWER(name) = 'tilbud'); + +INSERT INTO pipeline_stages (name, color, sort_order) +SELECT 'Forhandling', '#fd7e14', 40 +WHERE NOT EXISTS (SELECT 1 FROM pipeline_stages WHERE LOWER(name) = 'forhandling'); + +INSERT INTO pipeline_stages (name, color, sort_order) +SELECT 'Vundet', '#28a745', 50 +WHERE NOT EXISTS (SELECT 1 FROM pipeline_stages WHERE LOWER(name) = 'vundet'); + +INSERT INTO pipeline_stages (name, color, sort_order) +SELECT 'Tabt', '#dc3545', 60 +WHERE NOT EXISTS (SELECT 1 FROM pipeline_stages WHERE LOWER(name) = 'tabt'); + +ALTER TABLE sag_sager + ADD COLUMN IF NOT EXISTS pipeline_amount DECIMAL(15,2), + ADD COLUMN IF NOT EXISTS pipeline_probability INTEGER DEFAULT 0, + ADD COLUMN IF NOT EXISTS pipeline_stage_id INTEGER REFERENCES pipeline_stages(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_sag_sager_pipeline_stage_id ON sag_sager(pipeline_stage_id); diff --git a/migrations/129_sag_pipeline_description.sql b/migrations/129_sag_pipeline_description.sql new file mode 100644 index 0000000..2d6bb8b --- /dev/null +++ b/migrations/129_sag_pipeline_description.sql @@ -0,0 +1,2 @@ +ALTER TABLE sag_sager + ADD COLUMN IF NOT EXISTS pipeline_description TEXT; diff --git a/scripts/fix_relations.py b/scripts/fix_relations.py new file mode 100644 index 0000000..43a0052 --- /dev/null +++ b/scripts/fix_relations.py @@ -0,0 +1,53 @@ +import sys +import os + +# Add project root to python path to allow importing app modules +sys.path.append(os.getcwd()) + +from app.core.database import execute_query, init_db +from app.core.config import settings + +def fix_relations(): + # Patch database URL for local execution + if "@postgres" in settings.DATABASE_URL: + print(f"🔧 Patcher DATABASE_URL fra '{settings.DATABASE_URL}'...") + settings.DATABASE_URL = settings.DATABASE_URL.replace("@postgres", "@localhost").replace(":5432", ":5433") + print(f" ...til '{settings.DATABASE_URL}'") + + # Initialize database connection + init_db() + + print("🚀 Standardiserer relationstyper i databasen...") + + # Mapping: Højre side (liste) konverteres til Venstre side (key) + mappings = { + "Afledt af": ["DERIVED", "derived", "AFLEDT AF", "afledt af"], + "Blokkerer": ["BLOCKS", "blocks", "BLOKKERER", "blokkerer"], + "Relateret til": ["RELATED", "related", "RELATERET TIL", "relateret til", "relateret"] + } + + total_updated = 0 + + for new_val, old_vals in mappings.items(): + for old_val in old_vals: + if old_val == new_val: + continue + + # Hent antal der skal opdateres + try: + check_query = "SELECT COUNT(*) as count FROM sag_relationer WHERE relationstype = %s AND deleted_at IS NULL" + result = execute_query(check_query, (old_val,)) + count = result[0]['count'] if result else 0 + + if count > 0: + print(f" 🔄 Konverterer {count} rækker fra '{old_val}' -> '{new_val}'") + update_query = "UPDATE sag_relationer SET relationstype = %s WHERE relationstype = %s" + execute_query(update_query, (new_val, old_val)) + total_updated += count + except Exception as e: + print(f"Fejl ved behandling af '{old_val}': {e}") + + print(f"✅ Færdig! Opdaterede i alt {total_updated} relationer.") + +if __name__ == "__main__": + fix_relations()