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:
parent
464c27808c
commit
d5dd958bf9
@ -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):
|
||||
|
||||
@ -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():
|
||||
"""
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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]
|
||||
|
||||
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,
|
||||
"sag": sag,
|
||||
"case": sag,
|
||||
"customer": customer,
|
||||
"hovedkontakt": hovedkontakt,
|
||||
"tags": tags,
|
||||
"relationer": relationer,
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
}
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-light);
|
||||
color: var(--text-light);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
{% block title %}Sager - BMC Hub{% endblock %}
|
||||
|
||||
body.dark-mode {
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-dark);
|
||||
}
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.search-bar {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.search-bar input {
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
padding: 0.6rem 1rem;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: 600;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.table-wrapper {
|
||||
background: var(--bg-card);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 2rem 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.sag-table {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
.sag-table thead {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
margin: 0;
|
||||
}
|
||||
.sag-table thead th {
|
||||
padding: 0.8rem 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
body.dark-mode .page-header h1 {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.sag-table tbody tr {
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.sag-table tbody tr:hover {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.btn-new:hover {
|
||||
background-color: var(--secondary-color);
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,168,232,0.3);
|
||||
}
|
||||
.sag-table tbody td {
|
||||
padding: 0.6rem 1rem;
|
||||
vertical-align: middle;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
.sag-id {
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
body.dark-mode .filter-section {
|
||||
background-color: #2a2a3e;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
.sag-titel {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-section label {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
body.dark-mode .filter-section label {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
/* Tree view styles */
|
||||
.tree-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.filter-section select,
|
||||
.filter-section input {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.tree-row.has-children {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
body.dark-mode .filter-section select,
|
||||
body.dark-mode .filter-section input {
|
||||
background-color: #3a3a4e;
|
||||
color: var(--text-dark);
|
||||
border-color: #555;
|
||||
}
|
||||
.tree-row.has-children:before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.tree-row.has-children td:first-child {
|
||||
position: relative;
|
||||
padding-left: 2.5rem !important;
|
||||
}
|
||||
|
||||
body.dark-mode .sag-card {
|
||||
background-color: #2a2a3e;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
.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%);
|
||||
}
|
||||
|
||||
.sag-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
transform: translateY(-2px);
|
||||
border-left-color: var(--accent-color);
|
||||
}
|
||||
.tree-toggle:hover {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
body.dark-mode .sag-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0,168,232,0.2);
|
||||
}
|
||||
.tree-child {
|
||||
background: rgba(0,0,0,0.02);
|
||||
}
|
||||
|
||||
.sag-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.tree-child td {
|
||||
padding: 0.5rem 1rem !important;
|
||||
border-top: none !important;
|
||||
}
|
||||
|
||||
body.dark-mode .sag-title {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.tree-child td:first-child {
|
||||
position: relative;
|
||||
padding-left: 2.5rem !important;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
body.dark-mode .sag-meta {
|
||||
color: #aaa;
|
||||
}
|
||||
.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.3rem 0.7rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.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-color: #ffeaa7;
|
||||
color: #d63031;
|
||||
}
|
||||
.status-åben {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.status-i_gang {
|
||||
background-color: #a29bfe;
|
||||
color: #2d3436;
|
||||
}
|
||||
.status-lukket {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-afsluttet {
|
||||
background-color: #55efc4;
|
||||
color: #00b894;
|
||||
}
|
||||
.filter-pills {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-on_hold {
|
||||
background-color: #fab1a0;
|
||||
color: #e17055;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.filter-pill:hover {
|
||||
background: var(--accent-light);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
body.dark-mode .tag {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
.filter-pill.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.dark-mode-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #999;
|
||||
}
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
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>
|
||||
.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>
|
||||
|
||||
<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'));
|
||||
<!-- 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>
|
||||
// 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 %}
|
||||
|
||||
350
app/modules/sag/templates/index_old.html
Normal file
350
app/modules/sag/templates/index_old.html
Normal 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>
|
||||
@ -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>
|
||||
|
||||
2
main.py
2
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"])
|
||||
|
||||
49
test_contact_relation.py
Normal file
49
test_contact_relation.py
Normal 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
56
test_sag_query.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user