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.
This commit is contained in:
Christian 2026-02-01 11:58:44 +01:00
parent 464c27808c
commit d5dd958bf9
11 changed files with 2447 additions and 580 deletions

View File

@ -56,6 +56,7 @@ class CustomerUpdate(BaseModel):
is_active: Optional[bool] = None is_active: Optional[bool] = None
invoice_email: Optional[str] = None invoice_email: Optional[str] = None
mobile_phone: Optional[str] = None mobile_phone: Optional[str] = None
department: Optional[str] = None
class ContactCreate(BaseModel): class ContactCreate(BaseModel):
@ -568,11 +569,15 @@ async def update_customer(customer_id: int, update: CustomerUpdate):
"SELECT * FROM customers WHERE id = %s", "SELECT * FROM customers WHERE id = %s",
(customer_id,)) (customer_id,))
return updated return updated
except Exception as e: except Exception as e:
logger.error(f"❌ Failed to update customer {customer_id}: {e}") logger.error(f"❌ Failed to update customer {customer_id}: {e}")
raise HTTPException(status_code=500, detail=str(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") @router.get("/customers/{customer_id}/data-consistency")
async def check_customer_data_consistency(customer_id: int): async def check_customer_data_consistency(customer_id: int):

View File

@ -124,6 +124,46 @@ async def global_search(q: str):
return {"customers": [], "contacts": [], "vendors": []} 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]) @router.get("/live-stats", response_model=Dict[str, Any])
async def get_live_stats(): async def get_live_stats():
""" """

View File

@ -319,3 +319,49 @@ async def delete_tag(sag_id: int, tag_id: int):
except Exception as e: except Exception as e:
logger.error("❌ Error deleting tag: %s", e) logger.error("❌ Error deleting tag: %s", e)
raise HTTPException(status_code=500, detail="Failed to delete tag") 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"}

View File

@ -1,5 +1,5 @@
import logging import logging
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pathlib import Path from pathlib import Path
@ -9,31 +9,80 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# Setup template directory # Setup template directory
template_dir = Path(__file__).parent.parent / "templates" templates = Jinja2Templates(directory="app")
templates = Jinja2Templates(directory=str(template_dir))
@router.get("/sag", response_class=HTMLResponse) @router.get("/sag", response_class=HTMLResponse)
async def sager_liste( async def sager_liste(
request, request: Request,
status: str = Query(None), status: str = Query(None),
tag: str = Query(None), tag: str = Query(None),
customer_id: int = Query(None), customer_id: int = Query(None),
): ):
"""Display list of all cases.""" """Display list of all cases."""
try: 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 = [] params = []
if status: if status:
query += " AND status = %s" query += " AND s.status = %s"
params.append(status) params.append(status)
if customer_id: if customer_id:
query += " AND customer_id = %s" query += " AND s.customer_id = %s"
params.append(customer_id) params.append(customer_id)
query += " ORDER BY created_at DESC" query += " ORDER BY s.created_at DESC"
sager = execute_query(query, tuple(params)) 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 # Filter by tag if provided
if tag and sager: if tag and sager:
sag_ids = [s['id'] for s in 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", ()) 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", ()) 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, "request": request,
"sager": sager, "sager": sager,
"relations_map": relations_map,
"child_ids": list(child_ids),
"statuses": [s['status'] for s in statuses], "statuses": [s['status'] for s in statuses],
"all_tags": [t['tag_navn'] for t in all_tags], "all_tags": [t['tag_navn'] for t in all_tags],
"current_status": status, "current_status": status,
@ -59,7 +110,7 @@ async def sager_liste(
raise HTTPException(status_code=500, detail="Failed to load case list") raise HTTPException(status_code=500, detail="Failed to load case list")
@router.get("/sag/{sag_id}", response_class=HTMLResponse) @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.""" """Display case details."""
try: try:
# Fetch main case # Fetch main case
@ -91,16 +142,31 @@ async def sag_detaljer(request, sag_id: int):
# Fetch customer info if customer_id exists # Fetch customer info if customer_id exists
customer = None customer = None
hovedkontakt = None
if sag.get('customer_id'): if sag.get('customer_id'):
customer_query = "SELECT * FROM customers WHERE id = %s" customer_query = "SELECT * FROM customers WHERE id = %s"
customer_result = execute_query(customer_query, (sag['customer_id'],)) customer_result = execute_query(customer_query, (sag['customer_id'],))
if customer_result: if customer_result:
customer = customer_result[0] customer = customer_result[0]
return templates.TemplateResponse("detail.html", { # 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("modules/sag/templates/detail.html", {
"request": request, "request": request,
"sag": sag, "case": sag,
"customer": customer, "customer": customer,
"hovedkontakt": hovedkontakt,
"tags": tags, "tags": tags,
"relationer": relationer, "relationer": relationer,
}) })

File diff suppressed because it is too large Load Diff

View File

@ -1,350 +1,469 @@
<!DOCTYPE html> {% extends "shared/frontend/base.html" %}
<html lang="da">
<head> {% block title %}Sager - BMC Hub{% endblock %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> {% block extra_css %}
<title>Sager - BMC Hub</title> <style>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> .search-bar {
<style> margin-bottom: 1.5rem;
:root {
--primary-color: #0f4c75;
--secondary-color: #3282b8;
--accent-color: #00a8e8;
--bg-light: #f7f9fc;
--bg-dark: #1a1a2e;
--text-light: #333;
--text-dark: #f0f0f0;
--border-color: #ddd;
} }
body { .search-bar input {
background-color: var(--bg-light); border-radius: 8px;
color: var(--text-light); border: 1px solid rgba(0,0,0,0.1);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 0.6rem 1rem;
} }
body.dark-mode { .table-wrapper {
background-color: var(--bg-dark); background: var(--bg-card);
color: var(--text-dark); border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
} }
.navbar { .sag-table {
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); width: 100%;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.navbar-brand {
font-weight: 600;
font-size: 1.4rem;
}
.content-wrapper {
padding: 2rem 0;
min-height: 100vh;
}
.page-header {
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.page-header h1 {
font-size: 2rem;
font-weight: 700;
color: var(--primary-color);
margin: 0; margin: 0;
} }
body.dark-mode .page-header h1 { .sag-table thead {
color: var(--accent-color); background: var(--accent);
}
.btn-new {
background-color: var(--accent-color);
color: white; color: white;
border: none;
padding: 0.6rem 1.5rem;
border-radius: 6px;
font-weight: 500;
transition: all 0.3s ease;
} }
.btn-new:hover { .sag-table thead th {
background-color: var(--secondary-color); padding: 0.8rem 1rem;
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,168,232,0.3);
}
.filter-section {
background: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
body.dark-mode .filter-section {
background-color: #2a2a3e;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.filter-section label {
font-weight: 600; font-weight: 600;
color: var(--primary-color); font-size: 0.85rem;
margin-bottom: 0.5rem; text-transform: uppercase;
display: block; letter-spacing: 0.5px;
border: none;
}
.sag-table tbody tr {
border-bottom: 1px solid rgba(0,0,0,0.05);
transition: all 0.2s;
cursor: pointer;
}
.sag-table tbody tr:hover {
background: var(--accent-light);
}
.sag-table tbody td {
padding: 0.6rem 1rem;
vertical-align: middle;
font-size: 0.9rem; font-size: 0.9rem;
} }
body.dark-mode .filter-section label { .sag-id {
color: var(--accent-color); font-weight: 700;
} color: var(--accent);
.filter-section select,
.filter-section input {
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.5rem;
font-size: 0.95rem; font-size: 0.95rem;
} }
body.dark-mode .filter-section select, .sag-titel {
body.dark-mode .filter-section input {
background-color: #3a3a4e;
color: var(--text-dark);
border-color: #555;
}
.sag-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
border-left: 4px solid var(--primary-color);
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
text-decoration: none;
color: inherit;
display: block;
}
body.dark-mode .sag-card {
background-color: #2a2a3e;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.sag-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
transform: translateY(-2px);
border-left-color: var(--accent-color);
}
body.dark-mode .sag-card:hover {
box-shadow: 0 4px 12px rgba(0,168,232,0.2);
}
.sag-title {
font-size: 1.1rem;
font-weight: 600; font-weight: 600;
color: var(--primary-color); color: var(--text-primary);
margin-bottom: 0.5rem; margin-bottom: 0;
} }
body.dark-mode .sag-title { .sag-beskrivelse {
color: var(--accent-color); color: var(--text-secondary);
font-size: 0.8rem;
margin-top: 0.2rem;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
} }
.sag-meta { /* Tree view styles */
display: flex; .tree-row {
justify-content: space-between; position: relative;
}
.tree-row.has-children {
cursor: pointer;
}
.tree-row.has-children:before {
content: none;
}
.tree-row.has-children td:first-child {
position: relative;
padding-left: 2.5rem !important;
}
.tree-toggle {
display: inline-flex;
align-items: center; align-items: center;
flex-wrap: wrap; justify-content: center;
gap: 1rem; width: 20px;
font-size: 0.9rem; height: 20px;
color: #666; background: var(--accent-light);
margin-top: 1rem; border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-size: 0.75rem;
color: var(--accent);
font-weight: bold;
position: absolute;
left: 0.5rem;
top: 50%;
transform: translateY(-50%);
} }
body.dark-mode .sag-meta { .tree-toggle:hover {
color: #aaa; background: var(--accent);
color: white;
}
.tree-child {
background: rgba(0,0,0,0.02);
}
.tree-child td {
padding: 0.5rem 1rem !important;
border-top: none !important;
}
.tree-child td:first-child {
position: relative;
padding-left: 2.5rem !important;
}
.tree-child td:first-child:before {
content: '└';
position: absolute;
left: 0.5rem;
top: 50%;
transform: translateY(-50%);
color: rgba(0,0,0,0.3);
font-size: 1.2rem;
}
.relation-badge {
display: inline-block;
font-size: 0.65rem;
padding: 0.25rem 0.6rem;
background: var(--accent);
color: white;
border-radius: 12px;
margin-right: 0.5rem;
font-weight: 600;
vertical-align: middle;
} }
.status-badge { .status-badge {
display: inline-block; display: inline-block;
padding: 0.3rem 0.7rem; padding: 0.35rem 0.8rem;
border-radius: 20px; border-radius: 20px;
font-size: 0.8rem; font-size: 0.75rem;
font-weight: 500; font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
} }
.status-åben { .status-åben {
background-color: #ffeaa7; background: #fff3cd;
color: #d63031; color: #856404;
} }
.status-i_gang { .status-lukket {
background-color: #a29bfe; background: #d4edda;
color: #2d3436; color: #155724;
} }
.status-afsluttet { .filter-pills {
background-color: #55efc4; display: flex;
color: #00b894; gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
} }
.status-on_hold { .filter-pill {
background-color: #fab1a0; padding: 0.5rem 1rem;
color: #e17055; border-radius: 20px;
} border: 1px solid rgba(0,0,0,0.1);
background: var(--bg-card);
.tag {
display: inline-block;
background-color: var(--primary-color);
color: white;
padding: 0.3rem 0.6rem;
border-radius: 4px;
font-size: 0.8rem;
margin-right: 0.5rem;
margin-top: 0.5rem;
}
body.dark-mode .tag {
background-color: var(--secondary-color);
}
.dark-mode-toggle {
background: none;
border: none;
color: white;
font-size: 1.2rem;
cursor: pointer; cursor: pointer;
padding: 0.5rem; transition: all 0.2s;
font-size: 0.85rem;
}
.filter-pill:hover {
background: var(--accent-light);
border-color: var(--accent);
}
.filter-pill.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.stats-bar {
display: flex;
gap: 2rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--bg-card);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--accent);
}
.stat-label {
font-size: 0.8rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
} }
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 3rem 1rem; padding: 3rem;
color: #999; color: var(--text-secondary);
} }
body.dark-mode .empty-state { .empty-state i {
color: #666; font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.3;
} }
</style> </style>
</head> {% endblock %}
<body>
<!-- Navigation --> {% block content %}
<nav class="navbar navbar-expand-lg navbar-dark"> <div class="container-fluid" style="max-width: 1400px; padding-top: 2rem;">
<div class="container"> <!-- Header -->
<a class="navbar-brand" href="/">🛠️ BMC Hub</a> <div class="d-flex justify-content-between align-items-center mb-4">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"> <h1 style="margin: 0; color: var(--accent);">
<span class="navbar-toggler-icon"></span> <i class="bi bi-list-check me-2"></i>Sager
</h1>
<button class="btn btn-primary" style="background: var(--accent); border: none;" onclick="window.location.href='/sag/new'">
<i class="bi bi-plus-lg me-2"></i>Ny Sag
</button> </button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link active" href="/sag">Sager</a>
</li>
<li class="nav-item">
<button class="dark-mode-toggle" onclick="toggleDarkMode()">🌙</button>
</li>
</ul>
</div>
</div>
</nav>
<!-- Main Content -->
<div class="content-wrapper">
<div class="container">
<!-- Page Header -->
<div class="page-header">
<h1>📋 Sager</h1>
<a href="/sag/new" class="btn-new">+ Ny sag</a>
</div> </div>
<!-- Filters --> <!-- Stats Bar -->
<div class="filter-section"> <div class="stats-bar">
<div class="row g-3"> <div class="stat-item">
<div class="col-md-4"> <div class="stat-value">{{ sager|length }}</div>
<label>Status</label> <div class="stat-label">Total</div>
<form method="get" style="display: flex; gap: 0.5rem;">
<select name="status" onchange="this.form.submit()" style="flex: 1;">
<option value="">Alle statuser</option>
{% for s in statuses %}
<option value="{{ s }}" {% if s == current_status %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</form>
</div> </div>
<div class="col-md-4"> <div class="stat-item">
<label>Tag</label> <div class="stat-value">{{ sager|selectattr('status', 'equalto', 'åben')|list|length }}</div>
<form method="get" style="display: flex; gap: 0.5rem;"> <div class="stat-label">Åbne</div>
<select name="tag" onchange="this.form.submit()" style="flex: 1;">
<option value="">Alle tags</option>
{% for t in all_tags %}
<option value="{{ t }}" {% if t == current_tag %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</form>
</div>
<div class="col-md-4">
<label>Søg</label>
<input type="text" placeholder="Søg efter sager..." class="form-control" id="searchInput">
</div> </div>
<div class="stat-item">
<div class="stat-value">{{ sager|selectattr('status', 'equalto', 'lukket')|list|length }}</div>
<div class="stat-label">Lukkede</div>
</div> </div>
</div> </div>
<!-- Cases List --> <!-- Search & Filters -->
<div id="casesList"> <div class="search-bar">
<input type="text"
class="form-control"
id="searchInput"
placeholder="🔍 Søg efter sag ID, titel, beskrivelse..."
autocomplete="off">
</div>
<div class="filter-pills">
<div class="filter-pill active" data-filter="all">Alle</div>
<div class="filter-pill" data-filter="åben">Åbne</div>
<div class="filter-pill" data-filter="lukket">Lukkede</div>
</div>
<!-- Table -->
<div class="table-wrapper">
{% if sager %} {% if sager %}
<table class="sag-table">
<thead>
<tr>
<th style="width: 90px;">ID</th>
<th>Titel & Beskrivelse</th>
<th style="width: 180px;">Kunde</th>
<th style="width: 150px;">Hovedkontakt</th>
<th style="width: 100px;">Status</th>
<th style="width: 120px;">Oprettet</th>
<th style="width: 120px;">Opdateret</th>
</tr>
</thead>
<tbody id="sagTableBody">
{% for sag in sager %} {% for sag in sager %}
<a href="/sag/{{ sag.id }}" class="sag-card"> {% if sag.id not in child_ids %}
<div class="sag-title">{{ sag.titel }}</div> {% set has_relations = sag.id in relations_map and relations_map[sag.id]|length > 0 %}
{% if sag.beskrivelse %} <tr class="tree-row {% if has_relations %}has-children{% endif %}"
<div style="color: #666; font-size: 0.9rem; margin-bottom: 0.5rem;">{{ sag.beskrivelse[:100] }}{% if sag.beskrivelse|length > 100 %}...{% endif %}</div> data-sag-id="{{ sag.id }}"
data-status="{{ sag.status }}">
<td>
{% if has_relations %}
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
{% endif %} {% endif %}
<div class="sag-meta"> <span class="sag-id">#{{ sag.id }}</span>
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'">
<div class="sag-titel">{{ sag.titel }}</div>
{% if sag.beskrivelse %}
<div class="sag-beskrivelse">{{ sag.beskrivelse }}</div>
{% endif %}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.customer_name if sag.customer_name else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'">
<span class="status-badge status-{{ sag.status }}">{{ sag.status }}</span> <span class="status-badge status-{{ sag.status }}">{{ sag.status }}</span>
<span>{{ sag.type }}</span> </td>
<span style="color: #999;">{{ sag.created_at[:10] }}</span> <td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
</div> {{ sag.created_at.strftime('%d/%m-%Y') if sag.created_at else '-' }}
</a> </td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
{{ sag.updated_at.strftime('%d/%m-%Y') if sag.updated_at else '-' }}
</td>
</tr>
{% 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 %}
<tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" style="display: none;">
<td>
<span class="sag-id">#{{ related_sag.id }}</span>
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
{% for rt in all_rel_types %}
<span class="relation-badge">{{ rt }}</span>
{% endfor %} {% endfor %}
<div class="sag-titel" style="display: inline;">{{ related_sag.titel }}</div>
{% if related_sag.beskrivelse %}
<div class="sag-beskrivelse">{{ related_sag.beskrivelse }}</div>
{% endif %}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
<span class="status-badge status-{{ related_sag.status }}">{{ related_sag.status }}</span>
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
{{ related_sag.created_at.strftime('%d/%m-%Y') if related_sag.created_at else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
{{ related_sag.updated_at.strftime('%d/%m-%Y') if related_sag.updated_at else '-' }}
</td>
</tr>
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% endfor %}
</tbody>
</table>
{% else %} {% else %}
<div class="empty-state"> <div class="empty-state">
<i class="bi bi-inbox"></i>
<p>Ingen sager fundet</p> <p>Ingen sager fundet</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script>
<script> // Tree toggle functionality
function toggleDarkMode() { function toggleTreeNode(event, sagId) {
document.body.classList.toggle('dark-mode'); event.stopPropagation();
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode')); const toggle = event.currentTarget || event.target;
const row = toggle.closest('tr');
if (!row) {
return;
} }
// Load dark mode preference if (row.classList.contains('expanded')) {
if (localStorage.getItem('darkMode') === 'true') { row.classList.remove('expanded');
document.body.classList.add('dark-mode'); toggle.textContent = '+';
} else {
row.classList.add('expanded');
toggle.textContent = '-';
}
applyFilters();
} }
// Search functionality // Search functionality
document.getElementById('searchInput').addEventListener('keyup', function(e) { const searchInput = document.getElementById('searchInput');
const search = e.target.value.toLowerCase(); const allRows = document.querySelectorAll('.tree-row');
document.querySelectorAll('.sag-card').forEach(card => { let currentSearch = '';
const text = card.textContent.toLowerCase(); let currentFilter = 'all';
card.style.display = text.includes(search) ? 'block' : 'none';
function applyFilters() {
const search = currentSearch;
allRows.forEach(row => {
const text = row.textContent.toLowerCase();
const status = row.dataset.status;
const matchesSearch = text.includes(search);
const matchesFilter = currentFilter === 'all' || status === currentFilter;
const visible = matchesSearch && matchesFilter;
row.style.display = visible ? '' : 'none';
const sagId = row.dataset.sagId;
if (sagId) {
const children = document.querySelectorAll(`tr[data-parent="${sagId}"]`);
children.forEach(child => {
const childText = child.textContent.toLowerCase();
const childStatus = child.dataset.status;
const childMatchesSearch = childText.includes(search);
const childMatchesFilter = currentFilter === 'all' || childStatus === currentFilter;
const childVisible = visible && row.classList.contains('expanded') && childMatchesSearch && childMatchesFilter;
child.style.display = childVisible ? '' : 'none';
});
}
});
}
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();
}); });
}); });
</script> </script>
</body> {% endblock %}
</html>

View File

@ -0,0 +1,350 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sager - BMC Hub</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
:root {
--primary-color: #0f4c75;
--secondary-color: #3282b8;
--accent-color: #00a8e8;
--bg-light: #f7f9fc;
--bg-dark: #1a1a2e;
--text-light: #333;
--text-dark: #f0f0f0;
--border-color: #ddd;
}
body {
background-color: var(--bg-light);
color: var(--text-light);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
body.dark-mode {
background-color: var(--bg-dark);
color: var(--text-dark);
}
.navbar {
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.navbar-brand {
font-weight: 600;
font-size: 1.4rem;
}
.content-wrapper {
padding: 2rem 0;
min-height: 100vh;
}
.page-header {
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.page-header h1 {
font-size: 2rem;
font-weight: 700;
color: var(--primary-color);
margin: 0;
}
body.dark-mode .page-header h1 {
color: var(--accent-color);
}
.btn-new {
background-color: var(--accent-color);
color: white;
border: none;
padding: 0.6rem 1.5rem;
border-radius: 6px;
font-weight: 500;
transition: all 0.3s ease;
}
.btn-new:hover {
background-color: var(--secondary-color);
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,168,232,0.3);
}
.filter-section {
background: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
body.dark-mode .filter-section {
background-color: #2a2a3e;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.filter-section label {
font-weight: 600;
color: var(--primary-color);
margin-bottom: 0.5rem;
display: block;
font-size: 0.9rem;
}
body.dark-mode .filter-section label {
color: var(--accent-color);
}
.filter-section select,
.filter-section input {
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.5rem;
font-size: 0.95rem;
}
body.dark-mode .filter-section select,
body.dark-mode .filter-section input {
background-color: #3a3a4e;
color: var(--text-dark);
border-color: #555;
}
.sag-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
border-left: 4px solid var(--primary-color);
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
text-decoration: none;
color: inherit;
display: block;
}
body.dark-mode .sag-card {
background-color: #2a2a3e;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.sag-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
transform: translateY(-2px);
border-left-color: var(--accent-color);
}
body.dark-mode .sag-card:hover {
box-shadow: 0 4px 12px rgba(0,168,232,0.2);
}
.sag-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--primary-color);
margin-bottom: 0.5rem;
}
body.dark-mode .sag-title {
color: var(--accent-color);
}
.sag-meta {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.9rem;
color: #666;
margin-top: 1rem;
}
body.dark-mode .sag-meta {
color: #aaa;
}
.status-badge {
display: inline-block;
padding: 0.3rem 0.7rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
}
.status-åben {
background-color: #ffeaa7;
color: #d63031;
}
.status-i_gang {
background-color: #a29bfe;
color: #2d3436;
}
.status-afsluttet {
background-color: #55efc4;
color: #00b894;
}
.status-on_hold {
background-color: #fab1a0;
color: #e17055;
}
.tag {
display: inline-block;
background-color: var(--primary-color);
color: white;
padding: 0.3rem 0.6rem;
border-radius: 4px;
font-size: 0.8rem;
margin-right: 0.5rem;
margin-top: 0.5rem;
}
body.dark-mode .tag {
background-color: var(--secondary-color);
}
.dark-mode-toggle {
background: none;
border: none;
color: white;
font-size: 1.2rem;
cursor: pointer;
padding: 0.5rem;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #999;
}
body.dark-mode .empty-state {
color: #666;
}
</style>
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark">
<div class="container">
<a class="navbar-brand" href="/">🛠️ BMC Hub</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link active" href="/sag">Sager</a>
</li>
<li class="nav-item">
<button class="dark-mode-toggle" onclick="toggleDarkMode()">🌙</button>
</li>
</ul>
</div>
</div>
</nav>
<!-- Main Content -->
<div class="content-wrapper">
<div class="container">
<!-- Page Header -->
<div class="page-header">
<h1>📋 Sager</h1>
<a href="/sag/new" class="btn-new">+ Ny sag</a>
</div>
<!-- Filters -->
<div class="filter-section">
<div class="row g-3">
<div class="col-md-4">
<label>Status</label>
<form method="get" style="display: flex; gap: 0.5rem;">
<select name="status" onchange="this.form.submit()" style="flex: 1;">
<option value="">Alle statuser</option>
{% for s in statuses %}
<option value="{{ s }}" {% if s == current_status %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</form>
</div>
<div class="col-md-4">
<label>Tag</label>
<form method="get" style="display: flex; gap: 0.5rem;">
<select name="tag" onchange="this.form.submit()" style="flex: 1;">
<option value="">Alle tags</option>
{% for t in all_tags %}
<option value="{{ t }}" {% if t == current_tag %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</form>
</div>
<div class="col-md-4">
<label>Søg</label>
<input type="text" placeholder="Søg efter sager..." class="form-control" id="searchInput">
</div>
</div>
</div>
<!-- Cases List -->
<div id="casesList">
{% if sager %}
{% for sag in sager %}
<a href="/sag/{{ sag.id }}" class="sag-card">
<div class="sag-title">{{ sag.titel }}</div>
{% if sag.beskrivelse %}
<div style="color: #666; font-size: 0.9rem; margin-bottom: 0.5rem;">{{ sag.beskrivelse[:100] }}{% if sag.beskrivelse|length > 100 %}...{% endif %}</div>
{% endif %}
<div class="sag-meta">
<span class="status-badge status-{{ sag.status }}">{{ sag.status }}</span>
<span>{{ sag.type }}</span>
<span style="color: #999;">{{ sag.created_at.strftime('%Y-%m-%d') if sag.created_at else '' }}</span>
</div>
</a>
{% endfor %}
{% else %}
<div class="empty-state">
<p>Ingen sager fundet</p>
</div>
{% endif %}
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
function toggleDarkMode() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
// Load dark mode preference
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
// Search functionality
document.getElementById('searchInput').addEventListener('keyup', function(e) {
const search = e.target.value.toLowerCase();
document.querySelectorAll('.sag-card').forEach(card => {
const text = card.textContent.toLowerCase();
card.style.display = text.includes(search) ? 'block' : 'none';
});
});
</script>
</body>
</html>

View File

@ -227,7 +227,7 @@
</ul> </ul>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/cases"> <a class="nav-link" href="/sag">
<i class="bi bi-list-check me-2"></i>Sager <i class="bi bi-list-check me-2"></i>Sager
</a> </a>
</li> </li>

View File

@ -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 router as system_api
from app.system.backend import sync_router from app.system.backend import sync_router
from app.dashboard.backend import views as dashboard_views 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 router as prepaid_api
from app.prepaid.backend import views as prepaid_views from app.prepaid.backend import views as prepaid_views
from app.ticket.backend import router as ticket_api 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(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(billing_api.router, prefix="/api/v1", tags=["Billing"])
app.include_router(system_api.router, prefix="/api/v1", tags=["System"]) 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(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(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"])
app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"]) app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"])

49
test_contact_relation.py Normal file
View File

@ -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()

56
test_sag_query.py Normal file
View File

@ -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()