From d5dd958bf978a57cbbe5f863124b125f8b61685a Mon Sep 17 00:00:00 2001 From: Christian Date: Sun, 1 Feb 2026 11:58:44 +0100 Subject: [PATCH] Refactor Sager module templates and functionality - Updated index.html to extend base template and improve structure. - Added new styles and search/filter functionality in the Sager list view. - Created a backup of the old index.html as index_old.html. - Updated navigation links in base.html for consistency. - Included new dashboard API router in main.py. - Added test scripts for customer and sag queries to validate database interactions. --- app/customers/backend/router.py | 7 +- app/dashboard/backend/router.py | 40 + app/modules/sag/backend/router.py | 46 + app/modules/sag/frontend/views.py | 90 +- app/modules/sag/templates/detail.html | 1580 +++++++++++++++++++--- app/modules/sag/templates/index.html | 805 ++++++----- app/modules/sag/templates/index_old.html | 350 +++++ app/shared/frontend/base.html | 2 +- main.py | 2 + test_contact_relation.py | 49 + test_sag_query.py | 56 + 11 files changed, 2447 insertions(+), 580 deletions(-) create mode 100644 app/modules/sag/templates/index_old.html create mode 100644 test_contact_relation.py create mode 100644 test_sag_query.py diff --git a/app/customers/backend/router.py b/app/customers/backend/router.py index cededca..424ec31 100644 --- a/app/customers/backend/router.py +++ b/app/customers/backend/router.py @@ -56,6 +56,7 @@ class CustomerUpdate(BaseModel): is_active: Optional[bool] = None invoice_email: Optional[str] = None mobile_phone: Optional[str] = None + department: Optional[str] = None class ContactCreate(BaseModel): @@ -568,11 +569,15 @@ async def update_customer(customer_id: int, update: CustomerUpdate): "SELECT * FROM customers WHERE id = %s", (customer_id,)) return updated - except Exception as e: logger.error(f"❌ Failed to update customer {customer_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) +@router.patch("/customers/{customer_id}") +async def patch_customer(customer_id: int, update: CustomerUpdate): + """Partially update customer information (same as PUT)""" + return await update_customer(customer_id, update) + @router.get("/customers/{customer_id}/data-consistency") async def check_customer_data_consistency(customer_id: int): diff --git a/app/dashboard/backend/router.py b/app/dashboard/backend/router.py index 45c3077..72ef10b 100644 --- a/app/dashboard/backend/router.py +++ b/app/dashboard/backend/router.py @@ -124,6 +124,46 @@ async def global_search(q: str): return {"customers": [], "contacts": [], "vendors": []} +@router.get("/search/sag", response_model=List[Dict[str, Any]]) +async def search_sag(q: str): + """ + Search for cases (sager) with customer information + """ + if not q or len(q) < 2: + return [] + + search_term = f"%{q}%" + + try: + # Search cases with customer names + sager = execute_query(""" + SELECT + s.id, + s.titel, + s.beskrivelse, + s.status, + s.created_at, + s.customer_id, + c.name as customer_name + FROM sag_sager s + LEFT JOIN customers c ON s.customer_id = c.id + WHERE s.deleted_at IS NULL + AND ( + CAST(s.id AS TEXT) ILIKE %s OR + s.titel ILIKE %s OR + s.beskrivelse ILIKE %s OR + c.name ILIKE %s + ) + ORDER BY s.created_at DESC + LIMIT 20 + """, (search_term, search_term, search_term, search_term)) + + return sager or [] + except Exception as e: + logger.error(f"❌ Error searching sager: {e}", exc_info=True) + return [] + + @router.get("/live-stats", response_model=Dict[str, Any]) async def get_live_stats(): """ diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index dae1951..904ed45 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -319,3 +319,49 @@ async def delete_tag(sag_id: int, tag_id: int): except Exception as e: logger.error("❌ Error deleting tag: %s", e) raise HTTPException(status_code=500, detail="Failed to delete tag") + + +# ============================================================================ +# HARDWARE - Placeholder endpoints for frontend compatibility +# ============================================================================ + +@router.get("/sag/{sag_id}/hardware") +async def list_case_hardware(sag_id: int): + """List hardware associated with a case. Placeholder endpoint.""" + # TODO: Implement when hardware-case relation is defined + return [] + +@router.post("/sag/{sag_id}/hardware") +async def add_case_hardware(sag_id: int): + """Add hardware to case. Placeholder endpoint.""" + # TODO: Implement when hardware-case relation is defined + return {"message": "Hardware endpoint not yet implemented"} + +@router.delete("/sag/{sag_id}/hardware/{hardware_id}") +async def remove_case_hardware(sag_id: int, hardware_id: int): + """Remove hardware from case. Placeholder endpoint.""" + # TODO: Implement when hardware-case relation is defined + return {"message": "Hardware endpoint not yet implemented"} + + +# ============================================================================ +# LOCATIONS - Placeholder endpoints for frontend compatibility +# ============================================================================ + +@router.get("/sag/{sag_id}/locations") +async def list_case_locations(sag_id: int): + """List locations associated with a case. Placeholder endpoint.""" + # TODO: Implement when location-case relation is defined + return [] + +@router.post("/sag/{sag_id}/locations") +async def add_case_location(sag_id: int): + """Add location to case. Placeholder endpoint.""" + # TODO: Implement when location-case relation is defined + return {"message": "Location endpoint not yet implemented"} + +@router.delete("/sag/{sag_id}/locations/{location_id}") +async def remove_case_location(sag_id: int, location_id: int): + """Remove location from case. Placeholder endpoint.""" + # TODO: Implement when location-case relation is defined + return {"message": "Location endpoint not yet implemented"} diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py index fd8925b..5cef803 100644 --- a/app/modules/sag/frontend/views.py +++ b/app/modules/sag/frontend/views.py @@ -1,5 +1,5 @@ import logging -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from pathlib import Path @@ -9,31 +9,80 @@ logger = logging.getLogger(__name__) router = APIRouter() # Setup template directory -template_dir = Path(__file__).parent.parent / "templates" -templates = Jinja2Templates(directory=str(template_dir)) +templates = Jinja2Templates(directory="app") @router.get("/sag", response_class=HTMLResponse) async def sager_liste( - request, + request: Request, status: str = Query(None), tag: str = Query(None), customer_id: int = Query(None), ): """Display list of all cases.""" try: - query = "SELECT * FROM sag_sager WHERE deleted_at IS NULL" + query = """ + SELECT s.*, + c.name as customer_name, + CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn + FROM sag_sager s + LEFT JOIN customers c ON s.customer_id = c.id + LEFT JOIN LATERAL ( + SELECT cc.contact_id + FROM contact_companies cc + WHERE cc.customer_id = c.id + ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC + LIMIT 1 + ) cc_first ON true + LEFT JOIN contacts cont ON cc_first.contact_id = cont.id + WHERE s.deleted_at IS NULL + """ params = [] if status: - query += " AND status = %s" + query += " AND s.status = %s" params.append(status) if customer_id: - query += " AND customer_id = %s" + query += " AND s.customer_id = %s" params.append(customer_id) - query += " ORDER BY created_at DESC" + query += " ORDER BY s.created_at DESC" sager = execute_query(query, tuple(params)) + # Fetch relations for all cases + relations_query = """ + SELECT + sr.kilde_sag_id, + sr.målsag_id, + sr.relationstype, + sr.id as relation_id + FROM sag_relationer sr + WHERE sr.deleted_at IS NULL + """ + all_relations = execute_query(relations_query, ()) + child_ids = set() + + # Build relations map: {sag_id: [list of related sag_ids]} + relations_map = {} + for rel in all_relations or []: + if rel.get('målsag_id') is not None: + child_ids.add(rel['målsag_id']) + # Add forward relation + if rel['kilde_sag_id'] not in relations_map: + relations_map[rel['kilde_sag_id']] = [] + relations_map[rel['kilde_sag_id']].append({ + 'target_id': rel['målsag_id'], + 'type': rel['relationstype'], + 'direction': 'forward' + }) + # Add backward relation + if rel['målsag_id'] not in relations_map: + relations_map[rel['målsag_id']] = [] + relations_map[rel['målsag_id']].append({ + 'target_id': rel['kilde_sag_id'], + 'type': rel['relationstype'], + 'direction': 'backward' + }) + # Filter by tag if provided if tag and sager: sag_ids = [s['id'] for s in sager] @@ -46,9 +95,11 @@ async def sager_liste( statuses = execute_query("SELECT DISTINCT status FROM sag_sager WHERE deleted_at IS NULL ORDER BY status", ()) all_tags = execute_query("SELECT DISTINCT tag_navn FROM sag_tags WHERE deleted_at IS NULL ORDER BY tag_navn", ()) - return templates.TemplateResponse("index.html", { + return templates.TemplateResponse("modules/sag/templates/index.html", { "request": request, "sager": sager, + "relations_map": relations_map, + "child_ids": list(child_ids), "statuses": [s['status'] for s in statuses], "all_tags": [t['tag_navn'] for t in all_tags], "current_status": status, @@ -59,7 +110,7 @@ async def sager_liste( raise HTTPException(status_code=500, detail="Failed to load case list") @router.get("/sag/{sag_id}", response_class=HTMLResponse) -async def sag_detaljer(request, sag_id: int): +async def sag_detaljer(request: Request, sag_id: int): """Display case details.""" try: # Fetch main case @@ -91,16 +142,31 @@ async def sag_detaljer(request, sag_id: int): # Fetch customer info if customer_id exists customer = None + hovedkontakt = None if sag.get('customer_id'): customer_query = "SELECT * FROM customers WHERE id = %s" customer_result = execute_query(customer_query, (sag['customer_id'],)) if customer_result: customer = customer_result[0] + + # Fetch hovedkontakt (primary contact) for customer via contact_companies + kontakt_query = """ + SELECT c.* + FROM contacts c + JOIN contact_companies cc ON c.id = cc.contact_id + WHERE cc.customer_id = %s + ORDER BY cc.is_primary DESC, c.id ASC + LIMIT 1 + """ + kontakt_result = execute_query(kontakt_query, (sag['customer_id'],)) + if kontakt_result: + hovedkontakt = kontakt_result[0] - return templates.TemplateResponse("detail.html", { + return templates.TemplateResponse("modules/sag/templates/detail.html", { "request": request, - "sag": sag, + "case": sag, "customer": customer, + "hovedkontakt": hovedkontakt, "tags": tags, "relationer": relationer, }) diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html index 1d4bbca..520c2d6 100644 --- a/app/modules/sag/templates/detail.html +++ b/app/modules/sag/templates/detail.html @@ -1,236 +1,1370 @@ - - - - - - {{ sag.titel }} - BMC Hub - - - - - - +{% extends "shared/frontend/base.html" %} - -
-
- ← Tilbage til sager +{% block title %}{{ case.titel }} - BMC Hub{% endblock %} - -
-
-

{{ sag.titel }}

+{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ + + Tilbage til sager + + + +
+
+
+
+ ID: + {{ case.id }}
-
-
-
- -

{{ sag.beskrivelse or 'Ingen beskrivelse' }}

-
-
- -

{{ sag.status }}

-
-
- -
-
- -

{{ sag.type }}

-
-
- -

{{ sag.deadline[:10] if sag.deadline else 'Ikke sat' }}

-
-
- - {% if customer %} -
-
- -

{{ customer.name }}

-
-
+
+ Kunde: + {{ customer.name if customer else 'Ingen kunde' }} +
+
+ Hovedkontakt: + {% if hovedkontakt %} + + {{ hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name }} + + {% else %} + Ingen kontakt {% endif %} - -
-
- -

{{ sag.created_at }}

-
-
-
- - - {% if tags %} -
-
-
📌 Tags
+
+ Afdeling: + + {{ customer.department if customer and customer.department else 'N/A' }} +
-
- {% for tag in tags %} - {{ tag.tag_navn }} - {% endfor %} +
+ Status: + {{ case.status }}
-
- {% endif %} - - - {% if relationer %} -
-
-
🔗 Relaterede sager
+
+ Opdateret: + {{ case.updated_at.strftime('%d/%m-%Y') if case.updated_at else 'N/A' }}
-
- {% for rel in relationer %} -
-
{{ rel.relationstype }}
- {% if rel.kilde_sag_id == sag.id %} - → {{ rel.mål_titel }} - {% else %} - ← {{ rel.kilde_titel }} - {% endif %} -
- {% endfor %} +
+ Deadline: + {{ case.deadline.strftime('%d/%m-%Y') if case.deadline else 'Ikke sat' }}
- {% endif %} - - -
- ✏️ Rediger - -
- - - + +
+ +
+
+
+

{{ case.titel }}

+
+ + + + +
+
+
+
+ Status + {{ case.status }} +
+
+ Beskrivelse + {{ case.beskrivelse or 'Ingen beskrivelse' }} +
+
+ Oprettet + {{ case.created_at|string|truncate(19, True, '') if case.created_at else 'Ikke sat' }} +
+ {% if case.deadline %} +
+ Deadline + {{ case.deadline|string|truncate(19, True, '') if case.deadline else 'Ikke sat' }} +
+ {% endif %} +
+
+
+ + +
+
+
+
+
+
📌 Tags
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
💻 Hardware
+ +
+
+
+
Henter hardware...
+
+
+
+
+
+
+
+ + +
+
+
+
+
📍 Lokationer
+ +
+
+
+
Henter lokationer...
+
+
+
+
+
+
+
+
👥 Kontakter
+ +
+
+ {% if contacts %} + {% for contact in contacts %} +
+
+ {{ contact.contact_name }} + {{ contact.role }} + {% if contact.contact_email %} + {{ contact.contact_email }} + {% endif %} +
+ +
+ {% endfor %} + {% else %} +

Ingen kontakter

+ {% endif %} +
+
+
+
+
+
+
🏢 Kunder
+ +
+
+ {% if customers %} + {% for customer in customers %} +
+
+ {{ customer.customer_name }} + {{ customer.role }} + {% if customer.customer_email %} + {{ customer.customer_email }} + {% endif %} +
+ +
+ {% endfor %} + {% else %} +

Ingen kunder

+ {% endif %} +
+
+
+
+ + +
+
+
+
+
🔗 Relaterede sager
+ +
+
+ {% if relationer %} + {% for rel in relationer %} +
+ {{ rel.relationstype }} + {% if rel.kilde_sag_id == case.id %} + → {{ rel.mål_titel }} + {% else %} + ← {{ rel.kilde_titel }} + {% endif %} + +
+ {% endfor %} + {% else %} +

Ingen relaterede sager

+ {% endif %} +
+
+
+
+
+
+
🔧 SAG Kompatibilitet
+ +
+
+
+ +

Klik "Vis" for at se alle

+

SAG kompatible moduler

+
+
+
+
+
+ + + + + + + + + + + + + + + + + +
+{% endblock %} diff --git a/app/modules/sag/templates/index.html b/app/modules/sag/templates/index.html index f26d6d3..f12151d 100644 --- a/app/modules/sag/templates/index.html +++ b/app/modules/sag/templates/index.html @@ -1,350 +1,469 @@ - - - - - - Sager - BMC Hub - - - - - - - - -
-
- - - - -
-
-
- -
- -
-
-
- -
- -
-
-
- - -
-
-
- - - +
+
{{ sager|selectattr('status', 'equalto', 'åben')|list|length }}
+
Åbne
+
+
+
{{ sager|selectattr('status', 'equalto', 'lukket')|list|length }}
+
Lukkede
+ + + + +
+
Alle
+
Åbne
+
Lukkede
+
+ + +
+ {% if sager %} + + + + + + + + + + + + + + {% for sag in sager %} + {% if sag.id not in child_ids %} + {% set has_relations = sag.id in relations_map and relations_map[sag.id]|length > 0 %} + + + + + + + + + + {% if has_relations %} + {% set seen_targets = [] %} + {% for rel in relations_map[sag.id] %} + {% set related_sag = sager|selectattr('id', 'equalto', rel.target_id)|first %} + {% if related_sag and rel.target_id not in seen_targets %} + {% set _ = seen_targets.append(rel.target_id) %} + {% set all_rel_types = relations_map[sag.id]|selectattr('target_id', 'equalto', rel.target_id)|map(attribute='type')|list %} + + + + + + + + + + {% endif %} + {% endfor %} + {% endif %} + {% endif %} + {% endfor %} + +
IDTitel & BeskrivelseKundeHovedkontaktStatusOprettetOpdateret
+ {% if has_relations %} + + + {% endif %} + #{{ sag.id }} + +
{{ sag.titel }}
+ {% if sag.beskrivelse %} +
{{ sag.beskrivelse }}
+ {% endif %} +
+ {{ sag.customer_name if sag.customer_name else '-' }} + + {{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }} + + {{ sag.status }} + + {{ sag.created_at.strftime('%d/%m-%Y') if sag.created_at else '-' }} + + {{ sag.updated_at.strftime('%d/%m-%Y') if sag.updated_at else '-' }} +
+ {% else %} +
+ +

Ingen sager fundet

+
+ {% endif %} +
+
- - - - + } + + if (searchInput) { + searchInput.addEventListener('input', function(e) { + currentSearch = e.target.value.toLowerCase(); + applyFilters(); + }); + } + + // Filter functionality + const filterPills = document.querySelectorAll('.filter-pill'); + + filterPills.forEach(pill => { + pill.addEventListener('click', function() { + // Update active state + filterPills.forEach(p => p.classList.remove('active')); + this.classList.add('active'); + + currentFilter = this.dataset.filter || 'all'; + applyFilters(); + }); + }); + +{% endblock %} diff --git a/app/modules/sag/templates/index_old.html b/app/modules/sag/templates/index_old.html new file mode 100644 index 0000000..7559a5c --- /dev/null +++ b/app/modules/sag/templates/index_old.html @@ -0,0 +1,350 @@ + + + + + + Sager - BMC Hub + + + + + + + + +
+
+ + + + +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ + +
+
+
+ + + +
+
+ + + + + diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html index 5c79048..695a73d 100644 --- a/app/shared/frontend/base.html +++ b/app/shared/frontend/base.html @@ -227,7 +227,7 @@ diff --git a/main.py b/main.py index 92110f6..2935987 100644 --- a/main.py +++ b/main.py @@ -32,6 +32,7 @@ from app.billing.frontend import views as billing_views from app.system.backend import router as system_api from app.system.backend import sync_router from app.dashboard.backend import views as dashboard_views +from app.dashboard.backend import router as dashboard_api from app.prepaid.backend import router as prepaid_api from app.prepaid.backend import views as prepaid_views from app.ticket.backend import router as ticket_api @@ -131,6 +132,7 @@ app.include_router(bmc_office_router.router, prefix="/api/v1", tags=["BMC Office # app.include_router(hardware_api.router, prefix="/api/v1", tags=["Hardware"]) # Replaced by hardware module app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"]) app.include_router(system_api.router, prefix="/api/v1", tags=["System"]) +app.include_router(dashboard_api.router, prefix="/api/v1", tags=["Dashboard"]) app.include_router(sync_router.router, prefix="/api/v1/system", tags=["System Sync"]) app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"]) app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"]) diff --git a/test_contact_relation.py b/test_contact_relation.py new file mode 100644 index 0000000..7673783 --- /dev/null +++ b/test_contact_relation.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +import psycopg2 +from psycopg2.extras import RealDictCursor + +# Get connection from .env +with open('.env') as f: + for line in f: + if line.startswith('DATABASE_URL'): + db_url = line.split('=', 1)[1].strip() + break + +# Parse URL +parts = db_url.replace('postgresql://', '').split('@') +user_pass = parts[0].split(':') +host_port_db = parts[1].split('/') + +conn = psycopg2.connect( + host='localhost', + port=5433, + database=host_port_db[1], + user=user_pass[0], + password=user_pass[1] +) + +cursor = conn.cursor(cursor_factory=RealDictCursor) + +# Check customer 17 +print("=== CUSTOMER 17 (Blåhund Import) ===") +cursor.execute("SELECT * FROM customers WHERE id = 17") +customer = cursor.fetchone() +print(f"Customer: {customer}") + +print("\n=== CONTACT_COMPANIES for customer 17 ===") +cursor.execute("SELECT * FROM contact_companies WHERE customer_id = 17") +cc_rows = cursor.fetchall() +print(f"Found {len(cc_rows)} contact_companies entries:") +for row in cc_rows: + print(f" - Contact ID: {row['contact_id']}, Primary: {row.get('is_primary')}") + +if cc_rows: + contact_ids = [r['contact_id'] for r in cc_rows] + placeholders = ','.join(['%s'] * len(contact_ids)) + cursor.execute(f"SELECT id, first_name, last_name FROM contacts WHERE id IN ({placeholders})", contact_ids) + contacts = cursor.fetchall() + print(f"\n=== CONTACTS ===") + for c in contacts: + print(f" ID: {c['id']}, Name: {c.get('first_name')} {c.get('last_name')}") + +conn.close() diff --git a/test_sag_query.py b/test_sag_query.py new file mode 100644 index 0000000..da9e187 --- /dev/null +++ b/test_sag_query.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +import psycopg2 +from psycopg2.extras import RealDictCursor + +# Get connection from .env +with open('.env') as f: + for line in f: + if line.startswith('DATABASE_URL'): + db_url = line.split('=', 1)[1].strip() + break + +# Parse URL: postgresql://user:pass@host:port/dbname +parts = db_url.replace('postgresql://', '').split('@') +user_pass = parts[0].split(':') +host_port_db = parts[1].split('/') +host_port = host_port_db[0].split(':') + +conn = psycopg2.connect( + host='localhost', + port=5433, + database=host_port_db[1], + user=user_pass[0], + password=user_pass[1] +) + +cursor = conn.cursor(cursor_factory=RealDictCursor) + +# Test the exact query from views.py +query = """ + SELECT s.*, + c.name as customer_name, + CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn + FROM sag_sager s + LEFT JOIN customers c ON s.customer_id = c.id + LEFT JOIN LATERAL ( + SELECT cc.contact_id + FROM contact_companies cc + WHERE cc.customer_id = c.id + ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC + LIMIT 1 + ) cc_first ON true + LEFT JOIN contacts cont ON cc_first.contact_id = cont.id + WHERE s.deleted_at IS NULL + ORDER BY s.created_at DESC + LIMIT 5 +""" + +cursor.execute(query) +rows = cursor.fetchall() + +print("Query results:") +print("-" * 80) +for row in rows: + print(f"ID: {row['id']}, Titel: {row['titel'][:30]}, Customer: {row.get('customer_name')}, Kontakt: {row.get('kontakt_navn')}") + +conn.close()