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
|
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):
|
||||||
|
|||||||
@ -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():
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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"}
|
||||||
|
|||||||
@ -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
@ -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>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
.search-bar {
|
||||||
--primary-color: #0f4c75;
|
margin-bottom: 1.5rem;
|
||||||
--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>
|
||||||
function toggleDarkMode() {
|
// Tree toggle functionality
|
||||||
document.body.classList.toggle('dark-mode');
|
function toggleTreeNode(event, sagId) {
|
||||||
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
event.stopPropagation();
|
||||||
|
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>
|
|
||||||
|
|||||||
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>
|
</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>
|
||||||
|
|||||||
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 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
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