bmc_hub/app/dashboard/frontend/index.html
Christian 7eda0ce58b 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.
2026-02-12 07:03:18 +01:00

677 lines
24 KiB
HTML

{% extends "shared/frontend/base.html" %}
{% 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 %}
<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 %}