- Added migration 025 for the Ticket System, creating tables for tickets, comments, attachments, worklogs, prepaid cards, and audit logs. - Introduced migration 026 to add ticket-related permissions to the auth system and assign them to user groups. - Developed a test suite for the Ticket Module, validating database schema, ticket number generation, prepaid card constraints, service logic, worklog creation, audit logging, and views.
362 lines
11 KiB
HTML
362 lines
11 KiB
HTML
{% extends "shared/frontend/base.html" %}
|
|
|
|
{% block title %}Ticket Dashboard - BMC Hub{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.stat-card {
|
|
text-align: center;
|
|
padding: 2rem 1.5rem;
|
|
cursor: pointer;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.stat-card::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 4px;
|
|
background: var(--accent);
|
|
transform: scaleX(0);
|
|
transition: transform 0.3s;
|
|
}
|
|
|
|
.stat-card:hover::before {
|
|
transform: scaleX(1);
|
|
}
|
|
|
|
.stat-card h3 {
|
|
font-size: 3rem;
|
|
font-weight: 700;
|
|
color: var(--accent);
|
|
margin-bottom: 0.5rem;
|
|
line-height: 1;
|
|
}
|
|
|
|
.stat-card p {
|
|
color: var(--text-secondary);
|
|
font-size: 0.9rem;
|
|
margin: 0;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.stat-card .icon {
|
|
font-size: 2rem;
|
|
opacity: 0.3;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.stat-card.status-open h3 { color: #17a2b8; }
|
|
.stat-card.status-in-progress h3 { color: #ffc107; }
|
|
.stat-card.status-resolved h3 { color: #28a745; }
|
|
.stat-card.status-closed h3 { color: #6c757d; }
|
|
|
|
.ticket-list {
|
|
background: var(--bg-card);
|
|
}
|
|
|
|
.ticket-list th {
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
border-bottom: 2px solid var(--accent-light);
|
|
padding: 1rem 0.75rem;
|
|
font-size: 0.85rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.ticket-list td {
|
|
padding: 1rem 0.75rem;
|
|
vertical-align: middle;
|
|
border-bottom: 1px solid var(--accent-light);
|
|
}
|
|
|
|
.ticket-row {
|
|
transition: background-color 0.2s;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.ticket-row:hover {
|
|
background-color: var(--accent-light);
|
|
}
|
|
|
|
.badge {
|
|
padding: 0.4rem 0.8rem;
|
|
font-weight: 500;
|
|
border-radius: 6px;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.badge-status-open {
|
|
background-color: #d1ecf1;
|
|
color: #0c5460;
|
|
}
|
|
|
|
.badge-status-in_progress {
|
|
background-color: #fff3cd;
|
|
color: #856404;
|
|
}
|
|
|
|
.badge-status-pending_customer {
|
|
background-color: #e2e3e5;
|
|
color: #383d41;
|
|
}
|
|
|
|
.badge-status-resolved {
|
|
background-color: #d4edda;
|
|
color: #155724;
|
|
}
|
|
|
|
.badge-status-closed {
|
|
background-color: #f8d7da;
|
|
color: #721c24;
|
|
}
|
|
|
|
.badge-priority-low {
|
|
background-color: var(--accent-light);
|
|
color: var(--accent);
|
|
}
|
|
|
|
.badge-priority-normal {
|
|
background-color: #e2e3e5;
|
|
color: #383d41;
|
|
}
|
|
|
|
.badge-priority-high {
|
|
background-color: #fff3cd;
|
|
color: #856404;
|
|
}
|
|
|
|
.badge-priority-urgent, .badge-priority-critical {
|
|
background-color: #f8d7da;
|
|
color: #721c24;
|
|
}
|
|
|
|
.ticket-number {
|
|
font-family: 'Monaco', 'Courier New', monospace;
|
|
background: var(--accent-light);
|
|
padding: 0.2rem 0.5rem;
|
|
border-radius: 4px;
|
|
font-size: 0.85rem;
|
|
color: var(--accent);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.worklog-stats {
|
|
display: flex;
|
|
justify-content: space-around;
|
|
padding: 1.5rem;
|
|
background: linear-gradient(135deg, var(--accent-light) 0%, var(--bg-card) 100%);
|
|
border-radius: var(--border-radius);
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.worklog-stat {
|
|
text-align: center;
|
|
}
|
|
|
|
.worklog-stat h4 {
|
|
font-size: 2rem;
|
|
font-weight: 700;
|
|
color: var(--accent);
|
|
margin: 0;
|
|
}
|
|
|
|
.worklog-stat p {
|
|
color: var(--text-secondary);
|
|
margin: 0;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 4rem 2rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.empty-state i {
|
|
font-size: 4rem;
|
|
margin-bottom: 1rem;
|
|
opacity: 0.3;
|
|
}
|
|
|
|
.section-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.section-header h2 {
|
|
font-size: 1.5rem;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
}
|
|
|
|
.quick-actions {
|
|
display: flex;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid px-4">
|
|
<!-- Page Header -->
|
|
<div class="section-header">
|
|
<div>
|
|
<h1 class="mb-2">
|
|
<i class="bi bi-speedometer2"></i> Ticket Dashboard
|
|
</h1>
|
|
<p class="text-muted">Oversigt over alle tickets og worklog aktivitet</p>
|
|
</div>
|
|
<div class="quick-actions">
|
|
<a href="/ticket/tickets/new" class="btn btn-primary">
|
|
<i class="bi bi-plus-circle"></i> Ny Ticket
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Ticket Statistics -->
|
|
<div class="row mb-4">
|
|
<div class="col-md-3">
|
|
<div class="card stat-card status-open" onclick="filterTickets('open')">
|
|
<div class="icon"><i class="bi bi-inbox"></i></div>
|
|
<h3>{{ stats.open_count or 0 }}</h3>
|
|
<p>Nye Tickets</p>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card stat-card status-in-progress" onclick="filterTickets('in_progress')">
|
|
<div class="icon"><i class="bi bi-arrow-repeat"></i></div>
|
|
<h3>{{ stats.in_progress_count or 0 }}</h3>
|
|
<p>I Gang</p>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card stat-card status-resolved" onclick="filterTickets('resolved')">
|
|
<div class="icon"><i class="bi bi-check-circle"></i></div>
|
|
<h3>{{ stats.resolved_count or 0 }}</h3>
|
|
<p>Løst</p>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card stat-card status-closed" onclick="filterTickets('closed')">
|
|
<div class="icon"><i class="bi bi-archive"></i></div>
|
|
<h3>{{ stats.closed_count or 0 }}</h3>
|
|
<p>Lukket</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Worklog Statistics -->
|
|
<div class="worklog-stats">
|
|
<div class="worklog-stat">
|
|
<h4>{{ worklog_stats.draft_count or 0 }}</h4>
|
|
<p>Draft Worklog</p>
|
|
</div>
|
|
<div class="worklog-stat">
|
|
<h4>{{ "%.1f"|format(worklog_stats.draft_hours or 0) }}t</h4>
|
|
<p>Udraft Timer</p>
|
|
</div>
|
|
<div class="worklog-stat">
|
|
<h4>{{ worklog_stats.billable_count or 0 }}</h4>
|
|
<p>Billable Entries</p>
|
|
</div>
|
|
<div class="worklog-stat">
|
|
<h4>{{ "%.1f"|format(worklog_stats.billable_hours or 0) }}t</h4>
|
|
<p>Billable Timer</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Tickets -->
|
|
<div class="section-header">
|
|
<h2>
|
|
<i class="bi bi-clock-history"></i> Seneste Tickets
|
|
</h2>
|
|
<a href="/ticket/tickets" class="btn btn-outline-secondary">
|
|
<i class="bi bi-list-ul"></i> Se Alle
|
|
</a>
|
|
</div>
|
|
|
|
{% if recent_tickets %}
|
|
<div class="card">
|
|
<div class="table-responsive">
|
|
<table class="table ticket-list mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>Ticket</th>
|
|
<th>Kunde</th>
|
|
<th>Status</th>
|
|
<th>Prioritet</th>
|
|
<th>Oprettet</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for ticket in recent_tickets %}
|
|
<tr class="ticket-row" onclick="window.location='/ticket/tickets/{{ ticket.id }}'">
|
|
<td>
|
|
<span class="ticket-number">{{ ticket.ticket_number }}</span>
|
|
<br>
|
|
<strong>{{ ticket.subject }}</strong>
|
|
</td>
|
|
<td>
|
|
{% if ticket.customer_name %}
|
|
{{ ticket.customer_name }}
|
|
{% else %}
|
|
<span class="text-muted">-</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
<span class="badge badge-status-{{ ticket.status }}">
|
|
{{ ticket.status.replace('_', ' ').title() }}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<span class="badge badge-priority-{{ ticket.priority }}">
|
|
{{ ticket.priority.title() }}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
{{ ticket.created_at.strftime('%d-%m-%Y %H:%M') if ticket.created_at else '-' }}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="card">
|
|
<div class="empty-state">
|
|
<i class="bi bi-inbox"></i>
|
|
<h3>Ingen tickets endnu</h3>
|
|
<p>Opret din første ticket for at komme i gang</p>
|
|
<a href="/ticket/tickets/new" class="btn btn-primary mt-3">
|
|
<i class="bi bi-plus-circle"></i> Opret Ticket
|
|
</a>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
// Filter tickets by status
|
|
function filterTickets(status) {
|
|
window.location.href = `/ticket/tickets?status=${status}`;
|
|
}
|
|
|
|
// Auto-refresh every 5 minutes
|
|
setTimeout(() => {
|
|
location.reload();
|
|
}, 300000);
|
|
</script>
|
|
{% endblock %}
|