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:
parent
489f81a1e3
commit
7eda0ce58b
@ -1,5 +1,5 @@
|
||||
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
|
||||
import logging
|
||||
|
||||
@ -15,49 +15,95 @@ async def get_dashboard_stats():
|
||||
try:
|
||||
logger.info("📊 Fetching dashboard stats...")
|
||||
|
||||
# 1. Customer Counts
|
||||
# 1. Customer Counts & Trends
|
||||
logger.info("Fetching customer count...")
|
||||
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
|
||||
|
||||
# 2. Contact Counts
|
||||
logger.info("Fetching contact count...")
|
||||
contact_res = execute_query_single("SELECT COUNT(*) as count FROM contacts")
|
||||
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
|
||||
# New customers this month
|
||||
new_customers_res = execute_query_single("""
|
||||
SELECT COUNT(*) as count
|
||||
FROM customers
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5
|
||||
AND created_at >= DATE_TRUNC('month', CURRENT_DATE)
|
||||
""")
|
||||
new_customers_this_month = new_customers_res['count'] if new_customers_res else 0
|
||||
|
||||
# 5. Vendor Categories Distribution
|
||||
logger.info("Fetching vendor distribution...")
|
||||
vendor_categories = execute_query("""
|
||||
SELECT category, COUNT(*) as count
|
||||
FROM vendors
|
||||
GROUP BY category
|
||||
# Previous month's new customers for trend calculation
|
||||
prev_month_customers_res = execute_query_single("""
|
||||
SELECT COUNT(*) as count
|
||||
FROM customers
|
||||
WHERE deleted_at IS NULL
|
||||
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")
|
||||
return {
|
||||
"counts": {
|
||||
"customers": customer_count,
|
||||
"contacts": contact_count,
|
||||
"vendors": vendor_count
|
||||
"customers": {
|
||||
"total": customer_count,
|
||||
"new_this_month": new_customers_this_month,
|
||||
"growth_pct": customer_growth_pct
|
||||
},
|
||||
"recent_activity": recent_customers or [],
|
||||
"vendor_distribution": vendor_categories or [],
|
||||
"system_status": "online"
|
||||
"tickets": {
|
||||
"open_count": ticket_count,
|
||||
"urgent_count": urgent_ticket_count
|
||||
},
|
||||
"hardware": {
|
||||
"total": hardware_count
|
||||
},
|
||||
"revenue": {
|
||||
"current_month": current_revenue,
|
||||
"growth_pct": revenue_growth_pct
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
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]])
|
||||
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:
|
||||
activities = []
|
||||
@ -227,37 +304,38 @@ async def get_recent_activity():
|
||||
FROM customers
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 3
|
||||
LIMIT 5
|
||||
""")
|
||||
|
||||
# Recent contacts
|
||||
recent_contacts = execute_query("""
|
||||
SELECT id, first_name || ' ' || last_name as name, created_at, 'contact' as activity_type, 'bi-person' as icon, 'success' as color
|
||||
FROM contacts
|
||||
# Recent tickets
|
||||
recent_tickets = execute_query("""
|
||||
SELECT id, subject as name, created_at, 'ticket' as activity_type, 'bi-ticket' as icon, 'warning' as color
|
||||
FROM tticket_tickets
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 3
|
||||
LIMIT 5
|
||||
""")
|
||||
|
||||
# Recent vendors
|
||||
recent_vendors = execute_query("""
|
||||
SELECT id, name, created_at, 'vendor' as activity_type, 'bi-shop' as icon, 'info' as color
|
||||
FROM vendors
|
||||
# Recent cases (sager)
|
||||
recent_cases = execute_query("""
|
||||
SELECT id, titel as name, created_at, 'case' as activity_type, 'bi-folder' as icon, 'info' as color
|
||||
FROM sag_sager
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 2
|
||||
LIMIT 5
|
||||
""")
|
||||
|
||||
# Combine all activities
|
||||
if recent_customers:
|
||||
activities.extend(recent_customers)
|
||||
if recent_contacts:
|
||||
activities.extend(recent_contacts)
|
||||
if recent_vendors:
|
||||
activities.extend(recent_vendors)
|
||||
if recent_tickets:
|
||||
activities.extend(recent_tickets)
|
||||
if recent_cases:
|
||||
activities.extend(recent_cases)
|
||||
|
||||
# Sort by created_at and limit
|
||||
activities.sort(key=lambda x: x.get('created_at', ''), reverse=True)
|
||||
|
||||
return activities[:10]
|
||||
return activities[:15]
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error fetching recent activity: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
@ -2,164 +2,676 @@
|
||||
|
||||
{% block title %}Dashboard - BMC Hub{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
||||
<div>
|
||||
<h2 class="fw-bold mb-1">Dashboard</h2>
|
||||
<p class="text-muted mb-0">Velkommen tilbage, Christian</p>
|
||||
</div>
|
||||
<div class="d-flex gap-3">
|
||||
<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>
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Modern Dashboard Styling */
|
||||
body {
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
|
||||
}
|
||||
|
||||
<!-- Alerts -->
|
||||
{% if bankruptcy_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;">
|
||||
<i class="bi bi-shield-exclamation flex-shrink-0 me-3 fs-2 animate__animated animate__pulse animate__infinite"></i>
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="alert-heading mb-1 fw-bold">⚠️ KONKURS ALARM</h5>
|
||||
<div>Systemet har registreret <strong>{{ bankruptcy_alerts|length }}</strong> potentiel(le) konkurssag(er).</div>
|
||||
<ul class="mb-0 mt-2 small list-unstyled">
|
||||
{% for alert in bankruptcy_alerts %}
|
||||
<li class="mb-1">
|
||||
<span class="badge bg-danger me-2">ALARM</span>
|
||||
<strong>{{ alert.display_name }}:</strong>
|
||||
<a href="/emails?id={{ alert.id }}" class="alert-link text-decoration-underline">{{ alert.subject }}</a>
|
||||
</li>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
.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);
|
||||
}
|
||||
|
||||
{% if unknown_worklog_count > 0 %}
|
||||
<div class="alert alert-warning d-flex align-items-center mb-5" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 me-3 fs-4"></i>
|
||||
<div>
|
||||
<h5 class="alert-heading mb-1">Tidsregistreringer kræver handling</h5>
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
.dashboard-header h2 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
<div class="row g-4 mb-5">
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card p-4 h-100">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<p>Aktive Kunder</p>
|
||||
<i class="bi bi-people text-primary" style="color: var(--accent) !important;"></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>
|
||||
.dashboard-header p {
|
||||
opacity: 0.9;
|
||||
margin: 0.5rem 0 0 0;
|
||||
}
|
||||
|
||||
<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>
|
||||
/* 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;
|
||||
}
|
||||
|
||||
<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 class="progress" style="height: 8px; background-color: var(--accent-light);">
|
||||
<div class="progress-bar" style="width: 24%; background-color: var(--accent);"></div>
|
||||
</div>
|
||||
</div>
|
||||
.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;
|
||||
}
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="small fw-bold text-muted">MEMORY</span>
|
||||
<span class="small fw-bold">56%</span>
|
||||
</div>
|
||||
<div class="progress" style="height: 8px; background-color: var(--accent-light);">
|
||||
<div class="progress-bar" style="width: 56%; background-color: var(--accent);"></div>
|
||||
</div>
|
||||
</div>
|
||||
.stat-card:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 12px 40px rgba(15, 76, 117, 0.15);
|
||||
}
|
||||
|
||||
<div class="mt-auto p-3 rounded" style="background-color: var(--accent-light);">
|
||||
<div class="d-flex">
|
||||
<i class="bi bi-check-circle-fill text-success me-2"></i>
|
||||
<small class="fw-bold" style="color: var(--accent)">Alle systemer kører optimalt.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
.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 %}
|
||||
<div class="container-fluid">
|
||||
<!-- Modern Header -->
|
||||
<div class="dashboard-header">
|
||||
<div class="container-fluid" style="max-width: 1400px;">
|
||||
<h2>📊 Dashboard</h2>
|
||||
<p>Oversigt over BMC Hub - alt på ét sted</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container-fluid" style="max-width: 1400px;">
|
||||
<!-- Alerts -->
|
||||
{% if bankruptcy_alerts %}
|
||||
<div class="alert alert-danger d-flex align-items-center mb-4" role="alert">
|
||||
<i class="bi bi-shield-exclamation flex-shrink-0 me-3 fs-2"></i>
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="alert-heading mb-1 fw-bold">⚠️ KONKURS ALARM</h5>
|
||||
<div>Systemet har registreret <strong>{{ bankruptcy_alerts|length }}</strong> potentiel(le) konkurssag(er).</div>
|
||||
<ul class="mb-0 mt-2 small list-unstyled">
|
||||
{% for alert in bankruptcy_alerts %}
|
||||
<li class="mb-1">
|
||||
<span class="badge bg-danger me-2">ALARM</span>
|
||||
<strong>{{ alert.display_name }}:</strong>
|
||||
<a href="/emails?id={{ alert.id }}" class="alert-link text-decoration-underline">{{ alert.subject }}</a>
|
||||
</li>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
|
||||
{% if unknown_worklog_count > 0 %}
|
||||
<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>
|
||||
<div>
|
||||
<h5 class="alert-heading mb-1">Tidsregistreringer kræver handling</h5>
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stat Cards -->
|
||||
<div class="row g-4 mb-5" id="statCards">
|
||||
<div class="col-md-6 col-xl-3">
|
||||
<a href="/customers" class="stat-card p-4 h-100">
|
||||
<div class="stat-card-icon primary">
|
||||
<i class="bi bi-people-fill"></i>
|
||||
</div>
|
||||
<div class="stat-card-label">Aktive Kunder</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>
|
||||
</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>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
<!-- Quick Actions -->
|
||||
<div class="content-card mb-4">
|
||||
<div class="card-header-modern">
|
||||
<h5>⚡ Hurtige handlinger</h5>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<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>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="content-card">
|
||||
<div class="card-header-modern">
|
||||
<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>
|
||||
{% 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 %}
|
||||
@ -147,16 +147,60 @@ async def list_hardware_by_customer(customer_id: int):
|
||||
|
||||
@router.get("/hardware/by-contact/{contact_id}", response_model=List[dict])
|
||||
async def list_hardware_by_contact(contact_id: int):
|
||||
"""List hardware assets linked directly to a contact."""
|
||||
query = """
|
||||
SELECT DISTINCT h.*
|
||||
"""
|
||||
List hardware assets linked directly to a contact.
|
||||
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
|
||||
JOIN hardware_contacts hc ON hc.hardware_id = h.id
|
||||
WHERE hc.contact_id = %s AND h.deleted_at IS NULL
|
||||
ORDER BY h.created_at DESC
|
||||
"""
|
||||
result = execute_query(query, (contact_id,))
|
||||
return result or []
|
||||
result_new = execute_query(query_new, (contact_id,))
|
||||
|
||||
# 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)
|
||||
|
||||
@ -95,6 +95,14 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.show-cards .devices-table {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.show-cards .devices-cards {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
border-radius: 12px;
|
||||
@ -157,10 +165,13 @@
|
||||
</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="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 class="table-responsive devices-table">
|
||||
<table class="table table-hover align-middle">
|
||||
@ -230,12 +241,25 @@
|
||||
const devicesTable = document.getElementById('devicesTable');
|
||||
const deviceStatus = document.getElementById('deviceStatus');
|
||||
const devicesCards = document.getElementById('devicesCards');
|
||||
const devicesSection = document.getElementById('devicesSection');
|
||||
const tabletToggle = document.getElementById('tabletToggle');
|
||||
const importModal = new bootstrap.Modal(document.getElementById('esetImportModal'));
|
||||
let selectedContactId = null;
|
||||
let contactResults = [];
|
||||
let contactSearchTimer = null;
|
||||
const inlineSelections = {};
|
||||
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) {
|
||||
if (Array.isArray(payload)) return payload;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user