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
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):

View File

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

View File

@ -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"}

View File

@ -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,
})

File diff suppressed because it is too large Load Diff

View File

@ -1,350 +1,469 @@
<!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>
{% extends "shared/frontend/base.html" %}
{% block title %}Sager - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.search-bar {
margin-bottom: 1.5rem;
}
.search-bar input {
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.1);
padding: 0.6rem 1rem;
}
.table-wrapper {
background: var(--bg-card);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.sag-table {
width: 100%;
margin: 0;
}
.sag-table thead {
background: var(--accent);
color: white;
}
.sag-table thead th {
padding: 0.8rem 1rem;
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
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;
}
.sag-id {
font-weight: 700;
color: var(--accent);
font-size: 0.95rem;
}
.sag-titel {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0;
}
.sag-beskrivelse {
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;
}
/* Tree view styles */
.tree-row {
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;
justify-content: center;
width: 20px;
height: 20px;
background: var(--accent-light);
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%);
}
.tree-toggle:hover {
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 {
display: inline-block;
padding: 0.35rem 0.8rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-åben {
background: #fff3cd;
color: #856404;
}
.status-lukket {
background: #d4edda;
color: #155724;
}
.filter-pills {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filter-pill {
padding: 0.5rem 1rem;
border-radius: 20px;
border: 1px solid rgba(0,0,0,0.1);
background: var(--bg-card);
cursor: pointer;
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 {
text-align: center;
padding: 3rem;
color: var(--text-secondary);
}
.empty-state i {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.3;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid" style="max-width: 1400px; padding-top: 2rem;">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 style="margin: 0; color: var(--accent);">
<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>
</div>
<!-- Stats Bar -->
<div class="stats-bar">
<div class="stat-item">
<div class="stat-value">{{ sager|length }}</div>
<div class="stat-label">Total</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[:10] }}</span>
</div>
</a>
{% endfor %}
{% else %}
<div class="empty-state">
<p>Ingen sager fundet</p>
</div>
{% endif %}
</div>
<div class="stat-item">
<div class="stat-value">{{ sager|selectattr('status', 'equalto', 'åben')|list|length }}</div>
<div class="stat-label">Åbne</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>
<!-- Search & Filters -->
<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 %}
<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 %}
{% if sag.id not in child_ids %}
{% set has_relations = sag.id in relations_map and relations_map[sag.id]|length > 0 %}
<tr class="tree-row {% if has_relations %}has-children{% endif %}"
data-sag-id="{{ sag.id }}"
data-status="{{ sag.status }}">
<td>
{% if has_relations %}
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
{% endif %}
<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>
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
{{ sag.created_at.strftime('%d/%m-%Y') if sag.created_at else '-' }}
</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 %}
<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 %}
<div class="empty-state">
<i class="bi bi-inbox"></i>
<p>Ingen sager fundet</p>
</div>
{% endif %}
</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'));
<script>
// Tree toggle functionality
function toggleTreeNode(event, sagId) {
event.stopPropagation();
const toggle = event.currentTarget || event.target;
const row = toggle.closest('tr');
if (!row) {
return;
}
// Load dark mode preference
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
if (row.classList.contains('expanded')) {
row.classList.remove('expanded');
toggle.textContent = '+';
} else {
row.classList.add('expanded');
toggle.textContent = '-';
}
// 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';
});
applyFilters();
}
// Search functionality
const searchInput = document.getElementById('searchInput');
const allRows = document.querySelectorAll('.tree-row');
let currentSearch = '';
let currentFilter = 'all';
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';
});
}
});
</script>
</body>
</html>
}
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>
{% endblock %}

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>
</li>
<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
</a>
</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 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"])

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