feat(dashboard): enhance dashboard stats and add upcoming reminders feature

- Updated dashboard stats to include new customer counts and trends, ticket counts, hardware counts, and revenue growth percentages.
- Added a new endpoint for fetching upcoming reminders for the dashboard calendar widget.
- Improved recent activity fetching to include recent tickets and cases.
- Enhanced frontend with modern styling for dashboard components, including stat cards and activity feed.
- Implemented loading states and error handling for stats, activity, and reminders in the frontend.
- Refactored HTML structure for better organization and responsiveness.

feat(hardware): support for new hardware_assets table in contact hardware listing

- Modified the endpoint to list hardware by contact to support both new hardware_assets and legacy hardware tables.
- Merged results from both tables, prioritizing the new hardware_assets table for better data accuracy.

style(eset_import): improve device display options in ESET import template

- Added toggle functionality for switching between tablet view and table view for device listings.
- Enhanced the layout and visibility of device cards and tables for better user experience.
This commit is contained in:
Christian 2026-02-12 07:03:18 +01:00
parent 489f81a1e3
commit 7eda0ce58b
4 changed files with 854 additions and 196 deletions

View File

@ -1,5 +1,5 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from app.core.database import execute_query from app.core.database import execute_query, execute_query_single
from typing import Dict, Any, List from typing import Dict, Any, List
import logging import logging
@ -15,49 +15,95 @@ async def get_dashboard_stats():
try: try:
logger.info("📊 Fetching dashboard stats...") logger.info("📊 Fetching dashboard stats...")
# 1. Customer Counts # 1. Customer Counts & Trends
logger.info("Fetching customer count...") logger.info("Fetching customer count...")
customer_res = execute_query_single("SELECT COUNT(*) as count FROM customers WHERE deleted_at IS NULL") customer_res = execute_query_single("SELECT COUNT(*) as count FROM customers WHERE deleted_at IS NULL")
customer_count = customer_res['count'] if customer_res else 0 customer_count = customer_res['count'] if customer_res else 0
# 2. Contact Counts # New customers this month
logger.info("Fetching contact count...") new_customers_res = execute_query_single("""
contact_res = execute_query_single("SELECT COUNT(*) as count FROM contacts") SELECT COUNT(*) as count
contact_count = contact_res['count'] if contact_res else 0
# 3. Vendor Counts
logger.info("Fetching vendor count...")
vendor_res = execute_query_single("SELECT COUNT(*) as count FROM vendors")
vendor_count = vendor_res['count'] if vendor_res else 0
# 4. Recent Customers (Real "Activity")
logger.info("Fetching recent customers...")
recent_customers = execute_query_single("""
SELECT id, name, created_at, 'customer' as type
FROM customers FROM customers
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
ORDER BY created_at DESC AND created_at >= DATE_TRUNC('month', CURRENT_DATE)
LIMIT 5
""") """)
new_customers_this_month = new_customers_res['count'] if new_customers_res else 0
# 5. Vendor Categories Distribution # Previous month's new customers for trend calculation
logger.info("Fetching vendor distribution...") prev_month_customers_res = execute_query_single("""
vendor_categories = execute_query(""" SELECT COUNT(*) as count
SELECT category, COUNT(*) as count FROM customers
FROM vendors WHERE deleted_at IS NULL
GROUP BY category AND created_at >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '1 month')
AND created_at < DATE_TRUNC('month', CURRENT_DATE)
""") """)
prev_month_customers = prev_month_customers_res['count'] if prev_month_customers_res else 0
customer_growth_pct = 0
if prev_month_customers > 0:
customer_growth_pct = round(((new_customers_this_month - prev_month_customers) / prev_month_customers) * 100, 1)
elif new_customers_this_month > 0:
customer_growth_pct = 100
# 2. Ticket Counts
logger.info("Fetching ticket stats...")
ticket_res = execute_query_single("""
SELECT COUNT(*) as total_count,
COUNT(*) FILTER (WHERE status IN ('open', 'in_progress')) as open_count,
COUNT(*) FILTER (WHERE priority = 'high' AND status IN ('open', 'in_progress')) as urgent_count
FROM tticket_tickets
""")
ticket_count = ticket_res['open_count'] if ticket_res else 0
urgent_ticket_count = ticket_res['urgent_count'] if ticket_res else 0
# 3. Hardware Count
logger.info("Fetching hardware count...")
hardware_res = execute_query_single("SELECT COUNT(*) as count FROM hardware")
hardware_count = hardware_res['count'] if hardware_res else 0
# 4. Revenue (from fixed price billing periods - current month)
logger.info("Fetching revenue stats...")
revenue_res = execute_query_single("""
SELECT COALESCE(SUM(base_amount + COALESCE(overtime_amount, 0)), 0) as total
FROM fixed_price_billing_periods
WHERE period_start >= DATE_TRUNC('month', CURRENT_DATE)
AND period_start < DATE_TRUNC('month', CURRENT_DATE + INTERVAL '1 month')
""")
current_revenue = float(revenue_res['total']) if revenue_res and revenue_res['total'] else 0
# Previous month revenue for trend
prev_revenue_res = execute_query_single("""
SELECT COALESCE(SUM(base_amount + COALESCE(overtime_amount, 0)), 0) as total
FROM fixed_price_billing_periods
WHERE period_start >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '1 month')
AND period_start < DATE_TRUNC('month', CURRENT_DATE)
""")
prev_revenue = float(prev_revenue_res['total']) if prev_revenue_res and prev_revenue_res['total'] else 0
revenue_growth_pct = 0
if prev_revenue > 0:
revenue_growth_pct = round(((current_revenue - prev_revenue) / prev_revenue) * 100, 1)
elif current_revenue > 0:
revenue_growth_pct = 100
logger.info("✅ Dashboard stats fetched successfully") logger.info("✅ Dashboard stats fetched successfully")
return { return {
"counts": { "customers": {
"customers": customer_count, "total": customer_count,
"contacts": contact_count, "new_this_month": new_customers_this_month,
"vendors": vendor_count "growth_pct": customer_growth_pct
}, },
"recent_activity": recent_customers or [], "tickets": {
"vendor_distribution": vendor_categories or [], "open_count": ticket_count,
"system_status": "online" "urgent_count": urgent_ticket_count
},
"hardware": {
"total": hardware_count
},
"revenue": {
"current_month": current_revenue,
"growth_pct": revenue_growth_pct
}
} }
except Exception as e: except Exception as e:
logger.error(f"❌ Error fetching dashboard stats: {e}", exc_info=True) logger.error(f"❌ Error fetching dashboard stats: {e}", exc_info=True)
@ -213,10 +259,41 @@ async def get_live_stats():
} }
@router.get("/reminders/upcoming", response_model=List[Dict[str, Any]])
async def get_upcoming_reminders():
"""
Get upcoming reminders for the dashboard calendar widget
"""
try:
# Get active reminders with next check date within 7 days
reminders = execute_query("""
SELECT
r.id,
r.sag_id,
r.title,
r.next_check_at as due_date,
r.priority,
s.titel as case_title
FROM sag_reminders r
LEFT JOIN sag_sager s ON r.sag_id = s.id
WHERE r.is_active = true
AND r.deleted_at IS NULL
AND r.next_check_at IS NOT NULL
AND r.next_check_at <= CURRENT_DATE + INTERVAL '7 days'
ORDER BY r.next_check_at ASC
LIMIT 10
""")
return reminders or []
except Exception as e:
logger.error(f"❌ Error fetching upcoming reminders: {e}", exc_info=True)
return []
@router.get("/recent-activity", response_model=List[Dict[str, Any]]) @router.get("/recent-activity", response_model=List[Dict[str, Any]])
async def get_recent_activity(): async def get_recent_activity():
""" """
Get recent activity across the system for the sidebar Get recent activity across the system for the dashboard feed
""" """
try: try:
activities = [] activities = []
@ -227,37 +304,38 @@ async def get_recent_activity():
FROM customers FROM customers
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 3 LIMIT 5
""") """)
# Recent contacts # Recent tickets
recent_contacts = execute_query(""" recent_tickets = execute_query("""
SELECT id, first_name || ' ' || last_name as name, created_at, 'contact' as activity_type, 'bi-person' as icon, 'success' as color SELECT id, subject as name, created_at, 'ticket' as activity_type, 'bi-ticket' as icon, 'warning' as color
FROM contacts FROM tticket_tickets
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 3 LIMIT 5
""") """)
# Recent vendors # Recent cases (sager)
recent_vendors = execute_query(""" recent_cases = execute_query("""
SELECT id, name, created_at, 'vendor' as activity_type, 'bi-shop' as icon, 'info' as color SELECT id, titel as name, created_at, 'case' as activity_type, 'bi-folder' as icon, 'info' as color
FROM vendors FROM sag_sager
WHERE deleted_at IS NULL
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 2 LIMIT 5
""") """)
# Combine all activities # Combine all activities
if recent_customers: if recent_customers:
activities.extend(recent_customers) activities.extend(recent_customers)
if recent_contacts: if recent_tickets:
activities.extend(recent_contacts) activities.extend(recent_tickets)
if recent_vendors: if recent_cases:
activities.extend(recent_vendors) activities.extend(recent_cases)
# Sort by created_at and limit # Sort by created_at and limit
activities.sort(key=lambda x: x.get('created_at', ''), reverse=True) activities.sort(key=lambda x: x.get('created_at', ''), reverse=True)
return activities[:10] return activities[:15]
except Exception as e: except Exception as e:
logger.error(f"❌ Error fetching recent activity: {e}", exc_info=True) logger.error(f"❌ Error fetching recent activity: {e}", exc_info=True)
return [] return []

View File

@ -2,164 +2,676 @@
{% block title %}Dashboard - BMC Hub{% endblock %} {% block title %}Dashboard - BMC Hub{% endblock %}
{% block extra_css %}
<style>
/* Modern Dashboard Styling */
body {
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
}
.dashboard-header {
background: linear-gradient(135deg, var(--accent) 0%, #1e5a8e 100%);
color: white;
padding: 2.5rem 0;
margin: -2rem -15px 2rem -15px;
border-radius: 0 0 24px 24px;
box-shadow: 0 10px 30px rgba(15, 76, 117, 0.15);
}
.dashboard-header h2 {
font-size: 2rem;
font-weight: 700;
margin: 0;
}
.dashboard-header p {
opacity: 0.9;
margin: 0.5rem 0 0 0;
}
/* Stat Cards - Modern Gradient Design */
.stat-card {
position: relative;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
text-decoration: none;
display: block;
background: white;
border: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, var(--accent), #3b82f6);
opacity: 0;
transition: opacity 0.3s ease;
}
.stat-card:hover {
transform: translateY(-8px);
box-shadow: 0 12px 40px rgba(15, 76, 117, 0.15);
}
.stat-card:hover::before {
opacity: 1;
}
.stat-card-icon {
width: 56px;
height: 56px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
margin-bottom: 1.25rem;
}
.stat-card-icon.primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.stat-card-icon.success {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
color: white;
}
.stat-card-icon.warning {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.stat-card-icon.info {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
.stat-card-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
margin-bottom: 0.75rem;
}
.stat-card-value {
font-size: 2.5rem;
font-weight: 800;
color: #1e293b;
margin: 0.5rem 0 0.75rem 0;
line-height: 1;
}
.trend-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.75rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
}
.trend-badge.positive {
background: linear-gradient(135deg, #d4fc79 0%, #96e6a1 100%);
color: #166534;
}
.trend-badge.negative {
background: linear-gradient(135deg, #ffeaa7 0%, #fab1a0 100%);
color: #991b1b;
}
.trend-badge.neutral {
background: #e2e8f0;
color: #64748b;
}
/* Content Cards */
.content-card {
background: white;
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
overflow: hidden;
border: 1px solid rgba(226, 232, 240, 0.8);
}
.card-header-modern {
padding: 1.5rem;
border-bottom: 1px solid #f1f5f9;
}
.card-header-modern h5 {
font-size: 1.125rem;
font-weight: 700;
color: #1e293b;
margin: 0;
}
/* Activity Items */
.activity-item {
padding: 1rem 1.5rem;
border-bottom: 1px solid #f1f5f9;
transition: all 0.2s ease;
cursor: pointer;
display: flex;
align-items: center;
gap: 1rem;
}
.activity-item:hover {
background: linear-gradient(90deg, rgba(99, 102, 241, 0.02) 0%, rgba(59, 130, 246, 0.05) 100%);
padding-left: 1.75rem;
}
.activity-item:last-child {
border-bottom: none;
}
.activity-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
flex-shrink: 0;
}
.activity-icon.primary {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%);
color: #6366f1;
}
.activity-icon.warning {
background: linear-gradient(135deg, rgba(251, 146, 60, 0.1) 0%, rgba(245, 87, 108, 0.1) 100%);
color: #f59e0b;
}
.activity-icon.info {
background: linear-gradient(135deg, rgba(14, 165, 233, 0.1) 0%, rgba(6, 182, 212, 0.1) 100%);
color: #0ea5e9;
}
.activity-content {
flex: 1;
}
.activity-name {
font-weight: 600;
color: #1e293b;
margin-bottom: 0.25rem;
}
.activity-time {
font-size: 0.8rem;
color: #94a3b8;
}
/* Reminders */
.reminder-item {
padding: 1rem;
border-left: 4px solid;
border-radius: 12px;
margin-bottom: 0.75rem;
transition: all 0.2s ease;
}
.reminder-item:hover {
transform: translateX(4px);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
.reminder-item.priority-high {
border-left-color: #ef4444;
background: linear-gradient(90deg, rgba(239, 68, 68, 0.05) 0%, rgba(239, 68, 68, 0.02) 100%);
}
.reminder-item.priority-medium {
border-left-color: #f59e0b;
background: linear-gradient(90deg, rgba(245, 158, 11, 0.05) 0%, rgba(245, 158, 11, 0.02) 100%);
}
.reminder-item.priority-low {
border-left-color: #10b981;
background: linear-gradient(90deg, rgba(16, 185, 129, 0.05) 0%, rgba(16, 185, 129, 0.02) 100%);
}
/* Quick Actions */
.quick-action-btn {
border-radius: 14px;
padding: 1rem 1.5rem;
font-weight: 600;
border: 2px solid transparent;
transition: all 0.3s ease;
background: linear-gradient(135deg, var(--accent) 0%, #1e5a8e 100%);
color: white;
}
.quick-action-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(15, 76, 117, 0.25);
border-color: var(--accent);
color: white;
}
.quick-action-btn i {
font-size: 1.1rem;
}
/* Activity Feed Scrollbar */
.activity-feed {
max-height: 550px;
overflow-y: auto;
}
.activity-feed::-webkit-scrollbar {
width: 6px;
}
.activity-feed::-webkit-scrollbar-track {
background: #f1f5f9;
}
.activity-feed::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.activity-feed::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Skeleton Loading */
.skeleton {
background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%);
background-size: 200% 100%;
animation: loading 1.5s ease-in-out infinite;
border-radius: 8px;
}
.skeleton-text {
height: 1rem;
margin-bottom: 0.5rem;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Alerts Modern Style */
.alert {
border-radius: 16px;
border: none;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem 1.5rem;
color: #94a3b8;
}
.empty-state i {
font-size: 3rem;
opacity: 0.3;
margin-bottom: 1rem;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-5"> <div class="container-fluid">
<div> <!-- Modern Header -->
<h2 class="fw-bold mb-1">Dashboard</h2> <div class="dashboard-header">
<p class="text-muted mb-0">Velkommen tilbage, Christian</p> <div class="container-fluid" style="max-width: 1400px;">
</div> <h2>📊 Dashboard</h2>
<div class="d-flex gap-3"> <p>Oversigt over BMC Hub - alt på ét sted</p>
<input type="text" class="header-search" placeholder="Søg...">
<button class="btn btn-primary"><i class="bi bi-plus-lg me-2"></i>Ny Opgave</button>
</div> </div>
</div> </div>
<!-- Alerts --> <div class="container-fluid" style="max-width: 1400px;">
{% if bankruptcy_alerts %} <!-- Alerts -->
<div class="alert alert-danger d-flex align-items-center mb-4 border-0 shadow-sm" role="alert" style="background-color: #ffeaea; color: #842029;"> {% if bankruptcy_alerts %}
<i class="bi bi-shield-exclamation flex-shrink-0 me-3 fs-2 animate__animated animate__pulse animate__infinite"></i> <div class="alert alert-danger d-flex align-items-center mb-4" role="alert">
<div class="flex-grow-1"> <i class="bi bi-shield-exclamation flex-shrink-0 me-3 fs-2"></i>
<h5 class="alert-heading mb-1 fw-bold">⚠️ KONKURS ALARM</h5> <div class="flex-grow-1">
<div>Systemet har registreret <strong>{{ bankruptcy_alerts|length }}</strong> potentiel(le) konkurssag(er).</div> <h5 class="alert-heading mb-1 fw-bold">⚠️ KONKURS ALARM</h5>
<ul class="mb-0 mt-2 small list-unstyled"> <div>Systemet har registreret <strong>{{ bankruptcy_alerts|length }}</strong> potentiel(le) konkurssag(er).</div>
{% for alert in bankruptcy_alerts %} <ul class="mb-0 mt-2 small list-unstyled">
<li class="mb-1"> {% for alert in bankruptcy_alerts %}
<span class="badge bg-danger me-2">ALARM</span> <li class="mb-1">
<strong>{{ alert.display_name }}:</strong> <span class="badge bg-danger me-2">ALARM</span>
<a href="/emails?id={{ alert.id }}" class="alert-link text-decoration-underline">{{ alert.subject }}</a> <strong>{{ alert.display_name }}:</strong>
</li> <a href="/emails?id={{ alert.id }}" class="alert-link text-decoration-underline">{{ alert.subject }}</a>
{% endfor %} </li>
</ul> {% endfor %}
</ul>
</div>
<div>
<a href="/emails?filter=bankruptcy" class="btn btn-sm btn-danger px-3">Håndter Nu <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div> </div>
<div> {% endif %}
<a href="/emails?filter=bankruptcy" class="btn btn-sm btn-danger px-3">Håndter Nu <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
{% endif %}
{% if unknown_worklog_count > 0 %} {% if unknown_worklog_count > 0 %}
<div class="alert alert-warning d-flex align-items-center mb-5" role="alert"> <div class="alert alert-warning d-flex align-items-center mb-4" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 me-3 fs-4"></i> <i class="bi bi-exclamation-triangle-fill flex-shrink-0 me-3 fs-4"></i>
<div> <div>
<h5 class="alert-heading mb-1">Tidsregistreringer kræver handling</h5> <h5 class="alert-heading mb-1">Tidsregistreringer kræver handling</h5>
Der er <strong>{{ unknown_worklog_count }}</strong> tidsregistrering(er) med typen "Ved ikke". Der er <strong>{{ unknown_worklog_count }}</strong> tidsregistrering(er) med typen "Ved ikke".
<a href="/ticket/worklog/review" class="alert-link">Gå til godkendelse</a> for at afklare dem. <a href="/ticket/worklog/review" class="alert-link">Gå til godkendelse</a> for at afklare dem.
</div>
</div> </div>
</div> {% endif %}
{% endif %}
<div class="row g-4 mb-5"> <!-- Stat Cards -->
<div class="col-md-3"> <div class="row g-4 mb-5" id="statCards">
<div class="card stat-card p-4 h-100"> <div class="col-md-6 col-xl-3">
<div class="d-flex justify-content-between mb-2"> <a href="/customers" class="stat-card p-4 h-100">
<p>Aktive Kunder</p> <div class="stat-card-icon primary">
<i class="bi bi-people text-primary" style="color: var(--accent) !important;"></i> <i class="bi bi-people-fill"></i>
</div>
<h3>124</h3>
<small class="text-success"><i class="bi bi-arrow-up-short"></i> 12% denne måned</small>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card p-4 h-100">
<div class="d-flex justify-content-between mb-2">
<p>Hardware</p>
<i class="bi bi-hdd text-primary" style="color: var(--accent) !important;"></i>
</div>
<h3>856</h3>
<small class="text-muted">Enheder online</small>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card p-4 h-100">
<div class="d-flex justify-content-between mb-2">
<p>Support</p>
<i class="bi bi-ticket text-primary" style="color: var(--accent) !important;"></i>
</div>
<h3>12</h3>
<small class="text-warning">3 kræver handling</small>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card p-4 h-100">
<div class="d-flex justify-content-between mb-2">
<p>Omsætning</p>
<i class="bi bi-currency-dollar text-primary" style="color: var(--accent) !important;"></i>
</div>
<h3>450k</h3>
<small class="text-success">Over budget</small>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-lg-8">
<div class="card p-4">
<h5 class="fw-bold mb-4">Seneste Aktiviteter</h5>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Kunde</th>
<th>Handling</th>
<th>Status</th>
<th class="text-end">Tid</th>
</tr>
</thead>
<tbody>
<tr>
<td class="fw-bold">Advokatgruppen A/S</td>
<td>Firewall konfiguration</td>
<td><span class="badge bg-success bg-opacity-10 text-success">Fuldført</span></td>
<td class="text-end text-muted">10:23</td>
</tr>
<tr>
<td class="fw-bold">Byg & Bo ApS</td>
<td>Licens fornyelse</td>
<td><span class="badge bg-warning bg-opacity-10 text-warning">Afventer</span></td>
<td class="text-end text-muted">I går</td>
</tr>
<tr>
<td class="fw-bold">Cafe Møller</td>
<td>Netværksnedbrud</td>
<td><span class="badge bg-danger bg-opacity-10 text-danger">Kritisk</span></td>
<td class="text-end text-muted">I går</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card p-4 h-100">
<h5 class="fw-bold mb-4">System Status</h5>
<div class="mb-4">
<div class="d-flex justify-content-between mb-2">
<span class="small fw-bold text-muted">CPU LOAD</span>
<span class="small fw-bold">24%</span>
</div> </div>
<div class="progress" style="height: 8px; background-color: var(--accent-light);"> <div class="stat-card-label">Aktive Kunder</div>
<div class="progress-bar" style="width: 24%; background-color: var(--accent);"></div> <div class="stat-card-value skeleton skeleton-text" style="width: 80px; height: 2.5rem;" id="customerCount">-</div>
<div id="customerTrend">
<span class="trend-badge neutral skeleton skeleton-text" style="width: 120px; height: 24px;"></span>
</div> </div>
</div> </a>
</div>
<div class="col-md-6 col-xl-3">
<a href="/ticket/tickets?status=open" class="stat-card p-4 h-100">
<div class="stat-card-icon warning">
<i class="bi bi-ticket-perforated-fill"></i>
</div>
<div class="stat-card-label">Support Tickets</div>
<div class="stat-card-value skeleton skeleton-text" style="width: 60px; height: 2.5rem;" id="ticketCount">-</div>
<div id="ticketUrgent">
<span class="skeleton skeleton-text" style="width: 120px; height: 20px;"></span>
</div>
</a>
</div>
<div class="col-md-6 col-xl-3">
<a href="/billing" class="stat-card p-4 h-100">
<div class="stat-card-icon success">
<i class="bi bi-graph-up-arrow"></i>
</div>
<div class="stat-card-label">Omsætning</div>
<div class="stat-card-value skeleton skeleton-text" style="width: 100px; height: 2.5rem;" id="revenueCount">-</div>
<div id="revenueTrend">
<span class="trend-badge neutral skeleton skeleton-text" style="width: 110px; height: 24px;"></span>
</div>
</a>
</div>
<div class="col-md-6 col-xl-3">
<a href="/hardware" class="stat-card p-4 h-100">
<div class="stat-card-icon info">
<i class="bi bi-hdd-rack-fill"></i>
</div>
<div class="stat-card-label">Hardware</div>
<div class="stat-card-value skeleton skeleton-text" style="width: 80px; height: 2.5rem;" id="hardwareCount">-</div>
<small class="text-muted">Enheder registreret</small>
</a>
</div>
</div>
<div class="mb-4"> <!-- Main Content -->
<div class="d-flex justify-content-between mb-2"> <div class="row g-4">
<span class="small fw-bold text-muted">MEMORY</span> <div class="col-lg-8">
<span class="small fw-bold">56%</span> <!-- Quick Actions -->
<div class="content-card mb-4">
<div class="card-header-modern">
<h5>⚡ Hurtige handlinger</h5>
</div> </div>
<div class="progress" style="height: 8px; background-color: var(--accent-light);"> <div class="p-4">
<div class="progress-bar" style="width: 56%; background-color: var(--accent);"></div> <div class="row g-3">
<div class="col-md-4">
<a href="/customers/new" class="btn quick-action-btn w-100">
<i class="bi bi-person-plus-fill me-2"></i>Ny kunde
</a>
</div>
<div class="col-md-4">
<a href="/ticket/tickets/new" class="btn quick-action-btn w-100">
<i class="bi bi-ticket-fill me-2"></i>Opret ticket
</a>
</div>
<div class="col-md-4">
<a href="/sag/new" class="btn quick-action-btn w-100">
<i class="bi bi-folder-plus me-2"></i>Ny sag
</a>
</div>
</div>
</div> </div>
</div> </div>
<div class="mt-auto p-3 rounded" style="background-color: var(--accent-light);"> <!-- Recent Activity -->
<div class="d-flex"> <div class="content-card">
<i class="bi bi-check-circle-fill text-success me-2"></i> <div class="card-header-modern">
<small class="fw-bold" style="color: var(--accent)">Alle systemer kører optimalt.</small> <h5>🕐 Seneste aktivitet</h5>
</div>
<div class="activity-feed" id="activityFeed">
<div class="empty-state">
<i class="bi bi-arrow-clockwise"></i>
<div>Henter aktivitet...</div>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<!-- Calendar / Reminders -->
<div class="content-card">
<div class="card-header-modern">
<h5>📅 Kommende påmindelser</h5>
</div>
<div class="p-4" id="remindersWidget">
<div class="empty-state">
<i class="bi bi-arrow-clockwise"></i>
<div>Henter påmindelser...</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Format currency in Danish format
function formatCurrency(amount) {
return new Intl.NumberFormat('da-DK', {
style: 'decimal',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount) + ' kr';
}
// Format relative time
function timeAgo(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'Lige nu';
if (diffMins < 60) return `${diffMins} min siden`;
if (diffHours < 24) return `${diffHours} timer siden`;
if (diffDays === 1) return 'I går';
if (diffDays < 7) return `${diffDays} dage siden`;
return date.toLocaleDateString('da-DK', { day: 'numeric', month: 'short' });
}
// Get icon for activity type
function getActivityIcon(type, color) {
const icons = {
'customer': 'bi-building-fill',
'ticket': 'bi-ticket-perforated-fill',
'case': 'bi-folder-fill'
};
return `<div class="activity-icon ${color}"><i class="bi ${icons[type] || 'bi-circle-fill'}"></i></div>`;
}
// Get link for activity type
function getActivityLink(type, id) {
const links = {
'customer': '/customers',
'ticket': '/ticket/tickets',
'case': '/sag'
};
return `${links[type] || '#'}/${id}`;
}
// Load dashboard stats
async function loadStats() {
try {
const response = await fetch('/api/v1/stats');
if (!response.ok) throw new Error('Failed to fetch stats');
const data = await response.json();
// Update customer stats
const customerEl = document.getElementById('customerCount');
customerEl.textContent = data.customers.total.toLocaleString('da-DK');
customerEl.classList.remove('skeleton', 'skeleton-text');
const customerTrendEl = document.getElementById('customerTrend');
const customerGrowth = data.customers.growth_pct;
const trendClass = customerGrowth > 0 ? 'positive' : customerGrowth < 0 ? 'negative' : 'neutral';
const trendIcon = customerGrowth > 0 ? '▲' : customerGrowth < 0 ? '' : '';
customerTrendEl.innerHTML = `<span class="trend-badge ${trendClass}">${trendIcon} ${Math.abs(customerGrowth)}% denne måned</span>`;
// Update ticket stats
const ticketEl = document.getElementById('ticketCount');
ticketEl.textContent = data.tickets.open_count.toLocaleString('da-DK');
ticketEl.classList.remove('skeleton', 'skeleton-text');
const ticketUrgentEl = document.getElementById('ticketUrgent');
const urgentCount = data.tickets.urgent_count;
if (urgentCount > 0) {
ticketUrgentEl.innerHTML = `<span class="badge bg-danger">${urgentCount} kræver handling</span>`;
} else {
ticketUrgentEl.textContent = 'Ingen hastesager';
}
ticketUrgentEl.classList.remove('skeleton', 'skeleton-text');
// Update revenue stats
const revenueEl = document.getElementById('revenueCount');
revenueEl.textContent = formatCurrency(data.revenue.current_month);
revenueEl.classList.remove('skeleton', 'skeleton-text');
const revenueTrendEl = document.getElementById('revenueTrend');
const revenueGrowth = data.revenue.growth_pct;
const revTrendClass = revenueGrowth > 0 ? 'positive' : revenueGrowth < 0 ? 'negative' : 'neutral';
const revTrendIcon = revenueGrowth > 0 ? '▲' : revenueGrowth < 0 ? '' : '';
revenueTrendEl.innerHTML = `<span class="trend-badge ${revTrendClass}">${revTrendIcon} ${Math.abs(revenueGrowth)}% denne måned</span>`;
// Update hardware stats
const hardwareEl = document.getElementById('hardwareCount');
hardwareEl.textContent = data.hardware.total.toLocaleString('da-DK');
hardwareEl.classList.remove('skeleton', 'skeleton-text');
} catch (error) {
console.error('Error loading stats:', error);
// Remove skeletons even on error
document.querySelectorAll('.skeleton').forEach(el => {
el.classList.remove('skeleton', 'skeleton-text');
el.textContent = 'Fejl';
});
}
}
// Load recent activity
async function loadActivity() {
try {
const response = await fetch('/api/v1/recent-activity');
if (!response.ok) throw new Error('Failed to fetch activity');
const activities = await response.json();
const feedEl = document.getElementById('activityFeed');
if (!activities || activities.length === 0) {
feedEl.innerHTML = '<div class="empty-state"><i class="bi bi-inbox"></i><div>Ingen aktivitet at vise</div></div>';
return;
}
feedEl.innerHTML = activities.map(activity => `
<div class="activity-item" onclick="window.location.href='${getActivityLink(activity.activity_type, activity.id)}'">
${getActivityIcon(activity.activity_type, activity.color)}
<div class="activity-content">
<div class="activity-name">${activity.name || 'Unavngivet'}</div>
<div class="activity-time">${timeAgo(activity.created_at)}</div>
</div>
<i class="bi bi-chevron-right text-muted"></i>
</div>
`).join('');
} catch (error) {
console.error('Error loading activity:', error);
document.getElementById('activityFeed').innerHTML = '<div class="empty-state"><i class="bi bi-exclamation-triangle"></i><div class="text-danger">Fejl ved indlæsning af aktivitet</div></div>';
}
}
// Load reminders
async function loadReminders() {
try {
const response = await fetch('/api/v1/reminders/upcoming');
if (!response.ok) throw new Error('Failed to fetch reminders');
const reminders = await response.json();
const widgetEl = document.getElementById('remindersWidget');
if (!reminders || reminders.length === 0) {
widgetEl.innerHTML = '<div class="empty-state"><i class="bi bi-calendar-check"></i><div>Ingen kommende påmindelser</div></div>';
return;
}
widgetEl.innerHTML = reminders.map(reminder => {
const dueDate = new Date(reminder.due_date);
const priorityClass = reminder.priority === 'high' ? 'priority-high' : reminder.priority === 'medium' ? 'priority-medium' : 'priority-low';
return `
<div class="reminder-item ${priorityClass}">
<div class="d-flex justify-content-between align-items-start mb-1">
<div class="fw-semibold small">${reminder.title || reminder.case_title || 'Påmindelse'}</div>
<small class="text-muted ms-2">${dueDate.toLocaleDateString('da-DK', { day: 'numeric', month: 'short' })}</small>
</div>
${reminder.case_title ? `<div class="small text-muted">Sag: ${reminder.case_title}</div>` : ''}
</div>
`;
}).join('');
// Add link to view all
widgetEl.innerHTML += '<div class="text-center mt-3"><a href="/sag" class="text-decoration-none small fw-semibold">Se alle påmindelser →</a></div>';
} catch (error) {
console.error('Error loading reminders:', error);
document.getElementById('remindersWidget').innerHTML = '<div class="empty-state"><i class="bi bi-exclamation-triangle"></i><div class="text-danger">Fejl ved indlæsning</div></div>';
}
}
// Load all data on page load
document.addEventListener('DOMContentLoaded', function() {
loadStats();
loadActivity();
loadReminders();
});
</script>
{% endblock %} {% endblock %}

View File

@ -147,16 +147,60 @@ async def list_hardware_by_customer(customer_id: int):
@router.get("/hardware/by-contact/{contact_id}", response_model=List[dict]) @router.get("/hardware/by-contact/{contact_id}", response_model=List[dict])
async def list_hardware_by_contact(contact_id: int): async def list_hardware_by_contact(contact_id: int):
"""List hardware assets linked directly to a contact.""" """
query = """ List hardware assets linked directly to a contact.
SELECT DISTINCT h.* Supports both hardware_assets (new) and hardware (legacy) tables.
"""
# Try new hardware_assets table via hardware_contacts
query_new = """
SELECT DISTINCT
h.id,
h.asset_type,
h.brand,
h.model,
h.serial_number,
h.anydesk_id,
h.anydesk_link,
h.status,
h.notes,
h.created_at,
'hardware_assets' as source_table
FROM hardware_assets h FROM hardware_assets h
JOIN hardware_contacts hc ON hc.hardware_id = h.id JOIN hardware_contacts hc ON hc.hardware_id = h.id
WHERE hc.contact_id = %s AND h.deleted_at IS NULL WHERE hc.contact_id = %s AND h.deleted_at IS NULL
ORDER BY h.created_at DESC ORDER BY h.created_at DESC
""" """
result = execute_query(query, (contact_id,)) result_new = execute_query(query_new, (contact_id,))
return result or []
# Also check legacy hardware table via customer_id (if contact has companies)
query_legacy = """
SELECT DISTINCT
h.id,
NULL as asset_type,
NULL as brand,
h.model,
h.serial_number,
NULL as anydesk_id,
NULL as anydesk_link,
'active' as status,
NULL as notes,
h.created_at,
'hardware' as source_table
FROM hardware h
WHERE h.customer_id IN (
SELECT customer_id
FROM contact_companies
WHERE contact_id = %s
)
AND h.deleted_at IS NULL
ORDER BY h.created_at DESC
"""
result_legacy = execute_query(query_legacy, (contact_id,))
# Merge results, prioritizing new table
all_results = (result_new or []) + (result_legacy or [])
return all_results
@router.post("/hardware", response_model=dict) @router.post("/hardware", response_model=dict)

View File

@ -95,6 +95,14 @@
display: none; display: none;
} }
.show-cards .devices-table {
display: none;
}
.show-cards .devices-cards {
display: block;
}
.device-card { .device-card {
border: 1px solid rgba(0,0,0,0.1); border: 1px solid rgba(0,0,0,0.1);
border-radius: 12px; border-radius: 12px;
@ -157,10 +165,13 @@
</div> </div>
</div> </div>
<div class="section-card"> <div class="section-card" id="devicesSection">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3"> <div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div class="status-pill" id="deviceStatus">Ingen data indlaest</div> <div class="status-pill" id="deviceStatus">Ingen data indlaest</div>
<button class="btn btn-primary" onclick="loadDevices()">Hent devices</button> <div class="d-flex gap-2">
<button class="btn btn-outline-secondary" id="tabletToggle" onclick="toggleTabletView()">Tablet visning</button>
<button class="btn btn-primary" onclick="loadDevices()">Hent devices</button>
</div>
</div> </div>
<div class="table-responsive devices-table"> <div class="table-responsive devices-table">
<table class="table table-hover align-middle"> <table class="table table-hover align-middle">
@ -230,12 +241,25 @@
const devicesTable = document.getElementById('devicesTable'); const devicesTable = document.getElementById('devicesTable');
const deviceStatus = document.getElementById('deviceStatus'); const deviceStatus = document.getElementById('deviceStatus');
const devicesCards = document.getElementById('devicesCards'); const devicesCards = document.getElementById('devicesCards');
const devicesSection = document.getElementById('devicesSection');
const tabletToggle = document.getElementById('tabletToggle');
const importModal = new bootstrap.Modal(document.getElementById('esetImportModal')); const importModal = new bootstrap.Modal(document.getElementById('esetImportModal'));
let selectedContactId = null; let selectedContactId = null;
let contactResults = []; let contactResults = [];
let contactSearchTimer = null; let contactSearchTimer = null;
const inlineSelections = {}; const inlineSelections = {};
const inlineTimers = {}; const inlineTimers = {};
let isTabletView = false;
function toggleTabletView() {
isTabletView = !isTabletView;
if (devicesSection) {
devicesSection.classList.toggle('show-cards', isTabletView);
}
if (tabletToggle) {
tabletToggle.textContent = isTabletView ? 'Tabel visning' : 'Tablet visning';
}
}
function parseDevices(payload) { function parseDevices(payload) {
if (Array.isArray(payload)) return payload; if (Array.isArray(payload)) return payload;