- 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.
561 lines
21 KiB
HTML
561 lines
21 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="da">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Worklog Godkendelse - BMC Hub</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
<style>
|
|
:root {
|
|
--bg-body: #f8f9fa;
|
|
--bg-card: #ffffff;
|
|
--text-primary: #2c3e50;
|
|
--text-secondary: #6c757d;
|
|
--accent: #0f4c75;
|
|
--accent-light: #eef2f5;
|
|
--border-radius: 12px;
|
|
--success: #28a745;
|
|
--danger: #dc3545;
|
|
--warning: #ffc107;
|
|
}
|
|
|
|
[data-theme="dark"] {
|
|
--bg-body: #1a1d23;
|
|
--bg-card: #252a31;
|
|
--text-primary: #e4e6eb;
|
|
--text-secondary: #b0b3b8;
|
|
--accent: #4a9eff;
|
|
--accent-light: #2d3748;
|
|
--success: #48bb78;
|
|
--danger: #f56565;
|
|
--warning: #ed8936;
|
|
}
|
|
|
|
body {
|
|
background-color: var(--bg-body);
|
|
color: var(--text-primary);
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
padding-top: 80px;
|
|
transition: background-color 0.3s, color 0.3s;
|
|
}
|
|
|
|
.navbar {
|
|
background: var(--bg-card);
|
|
box-shadow: 0 2px 15px rgba(0,0,0,0.08);
|
|
padding: 1rem 0;
|
|
border-bottom: 1px solid rgba(0,0,0,0.05);
|
|
transition: background-color 0.3s;
|
|
}
|
|
|
|
.navbar-brand {
|
|
font-weight: 700;
|
|
color: var(--accent);
|
|
font-size: 1.25rem;
|
|
}
|
|
|
|
.nav-link {
|
|
color: var(--text-secondary);
|
|
padding: 0.6rem 1.2rem !important;
|
|
border-radius: var(--border-radius);
|
|
transition: all 0.2s;
|
|
font-weight: 500;
|
|
margin: 0 0.2rem;
|
|
}
|
|
|
|
.nav-link:hover, .nav-link.active {
|
|
background-color: var(--accent-light);
|
|
color: var(--accent);
|
|
}
|
|
|
|
.card {
|
|
border: none;
|
|
border-radius: var(--border-radius);
|
|
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
|
|
background: var(--bg-card);
|
|
margin-bottom: 1.5rem;
|
|
transition: background-color 0.3s;
|
|
}
|
|
|
|
.stats-row {
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.stat-card {
|
|
text-align: center;
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.stat-card h3 {
|
|
font-size: 2.5rem;
|
|
font-weight: 700;
|
|
color: var(--accent);
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.stat-card p {
|
|
color: var(--text-secondary);
|
|
font-size: 0.9rem;
|
|
margin: 0;
|
|
}
|
|
|
|
.worklog-table {
|
|
background: var(--bg-card);
|
|
}
|
|
|
|
.worklog-table 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;
|
|
}
|
|
|
|
.worklog-table td {
|
|
padding: 1rem 0.75rem;
|
|
vertical-align: middle;
|
|
border-bottom: 1px solid var(--accent-light);
|
|
}
|
|
|
|
.worklog-row {
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.worklog-row:hover {
|
|
background-color: var(--accent-light);
|
|
}
|
|
|
|
.badge {
|
|
padding: 0.4rem 0.8rem;
|
|
font-weight: 500;
|
|
border-radius: 6px;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.badge-invoice {
|
|
background-color: var(--accent-light);
|
|
color: var(--accent);
|
|
}
|
|
|
|
.badge-prepaid {
|
|
background-color: #d4edda;
|
|
color: #155724;
|
|
}
|
|
|
|
.badge-support {
|
|
background-color: #cce5ff;
|
|
color: #004085;
|
|
}
|
|
|
|
.badge-development {
|
|
background-color: #f8d7da;
|
|
color: #721c24;
|
|
}
|
|
|
|
.btn-approve {
|
|
background-color: var(--success);
|
|
color: white;
|
|
border: none;
|
|
padding: 0.4rem 1rem;
|
|
border-radius: 6px;
|
|
font-size: 0.85rem;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.btn-approve:hover {
|
|
background-color: #218838;
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.btn-reject {
|
|
background-color: var(--danger);
|
|
color: white;
|
|
border: none;
|
|
padding: 0.4rem 1rem;
|
|
border-radius: 6px;
|
|
font-size: 0.85rem;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.btn-reject:hover {
|
|
background-color: #c82333;
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.theme-toggle {
|
|
cursor: pointer;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: var(--border-radius);
|
|
background: var(--accent-light);
|
|
color: var(--accent);
|
|
transition: all 0.2s;
|
|
border: none;
|
|
font-size: 1.2rem;
|
|
}
|
|
|
|
.theme-toggle:hover {
|
|
background: var(--accent);
|
|
color: white;
|
|
}
|
|
|
|
.filter-bar {
|
|
background: var(--bg-card);
|
|
padding: 1.5rem;
|
|
border-radius: var(--border-radius);
|
|
margin-bottom: 1.5rem;
|
|
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
|
|
}
|
|
|
|
.hours-display {
|
|
font-weight: 700;
|
|
color: var(--accent);
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 4rem 2rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.empty-state i {
|
|
font-size: 4rem;
|
|
margin-bottom: 1rem;
|
|
opacity: 0.3;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
.customer-name {
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.work-description {
|
|
color: var(--text-secondary);
|
|
font-size: 0.9rem;
|
|
max-width: 300px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.btn-group-actions {
|
|
white-space: nowrap;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!-- Navigation -->
|
|
<nav class="navbar navbar-expand-lg fixed-top">
|
|
<div class="container-fluid">
|
|
<a class="navbar-brand" href="/">
|
|
<i class="bi bi-boxes"></i> BMC Hub
|
|
</a>
|
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
|
<span class="navbar-toggler-icon"></span>
|
|
</button>
|
|
<div class="collapse navbar-collapse" id="navbarNav">
|
|
<ul class="navbar-nav me-auto">
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/ticket/dashboard">
|
|
<i class="bi bi-speedometer2"></i> Dashboard
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/api/v1/tickets">
|
|
<i class="bi bi-ticket-detailed"></i> Tickets
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link active" href="/ticket/worklog/review">
|
|
<i class="bi bi-clock-history"></i> Worklog Godkendelse
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="/api/v1/prepaid-cards">
|
|
<i class="bi bi-credit-card"></i> Klippekort
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle Dark Mode">
|
|
<i class="bi bi-moon-stars-fill"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="container-fluid px-4">
|
|
<!-- Page Header -->
|
|
<div class="row mb-4">
|
|
<div class="col">
|
|
<h1 class="mb-2">
|
|
<i class="bi bi-clock-history"></i> Worklog Godkendelse
|
|
</h1>
|
|
<p class="text-muted">Godkend eller afvis enkelt-entries fra draft worklog</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Statistics Row -->
|
|
<div class="row stats-row">
|
|
<div class="col-md-4">
|
|
<div class="card stat-card">
|
|
<h3>{{ total_entries }}</h3>
|
|
<p>Entries til godkendelse</p>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<div class="card stat-card">
|
|
<h3>{{ "%.2f"|format(total_hours) }}t</h3>
|
|
<p>Total timer</p>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<div class="card stat-card">
|
|
<h3>{{ "%.2f"|format(total_billable_hours) }}t</h3>
|
|
<p>Fakturerbare timer</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filter Bar -->
|
|
<div class="filter-bar">
|
|
<form method="get" action="/ticket/worklog/review" class="row g-3">
|
|
<div class="col-md-4">
|
|
<label for="customer_id" class="form-label">Filtrer efter kunde:</label>
|
|
<select name="customer_id" id="customer_id" class="form-select" onchange="this.form.submit()">
|
|
<option value="">Alle kunder</option>
|
|
{% for customer in customers %}
|
|
<option value="{{ customer.id }}" {% if customer.id == selected_customer_id %}selected{% endif %}>
|
|
{{ customer.name }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label for="status" class="form-label">Status:</label>
|
|
<select name="status" id="status" class="form-select" onchange="this.form.submit()">
|
|
<option value="draft" {% if selected_status == 'draft' %}selected{% endif %}>Draft</option>
|
|
<option value="billable" {% if selected_status == 'billable' %}selected{% endif %}>Billable</option>
|
|
<option value="rejected" {% if selected_status == 'rejected' %}selected{% endif %}>Rejected</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-4 d-flex align-items-end">
|
|
<button type="submit" class="btn btn-primary">
|
|
<i class="bi bi-funnel"></i> Filtrer
|
|
</button>
|
|
<a href="/ticket/worklog/review" class="btn btn-outline-secondary ms-2">
|
|
<i class="bi bi-x-circle"></i> Ryd filtre
|
|
</a>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Worklog Table -->
|
|
{% if worklogs %}
|
|
<div class="card">
|
|
<div class="table-responsive">
|
|
<table class="table worklog-table mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>Ticket</th>
|
|
<th>Kunde</th>
|
|
<th>Dato</th>
|
|
<th>Timer</th>
|
|
<th>Type</th>
|
|
<th>Fakturering</th>
|
|
<th>Beskrivelse</th>
|
|
<th>Medarbejder</th>
|
|
<th style="text-align: right;">Handlinger</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for worklog in worklogs %}
|
|
<tr class="worklog-row">
|
|
<td>
|
|
<span class="ticket-number">{{ worklog.ticket_number }}</span>
|
|
<br>
|
|
<small class="text-muted">{{ worklog.ticket_subject[:30] }}...</small>
|
|
</td>
|
|
<td>
|
|
{% if worklog.customer_name %}
|
|
<span class="customer-name">{{ worklog.customer_name }}</span>
|
|
{% else %}
|
|
<span class="text-muted">-</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
{{ worklog.work_date.strftime('%d-%m-%Y') if worklog.work_date else '-' }}
|
|
</td>
|
|
<td>
|
|
<span class="hours-display">{{ "%.2f"|format(worklog.hours) }}t</span>
|
|
</td>
|
|
<td>
|
|
{% if worklog.work_type == 'support' %}
|
|
<span class="badge badge-support">Support</span>
|
|
{% elif worklog.work_type == 'development' %}
|
|
<span class="badge badge-development">Udvikling</span>
|
|
{% else %}
|
|
<span class="badge">{{ worklog.work_type }}</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
{% if worklog.billing_method == 'invoice' %}
|
|
<span class="badge badge-invoice">
|
|
<i class="bi bi-file-earmark-text"></i> Faktura
|
|
</span>
|
|
{% elif worklog.billing_method == 'prepaid' %}
|
|
<span class="badge badge-prepaid">
|
|
<i class="bi bi-credit-card"></i> Klippekort
|
|
</span>
|
|
{% if worklog.card_number %}
|
|
<br><small class="text-muted">{{ worklog.card_number }}</small>
|
|
{% endif %}
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
<div class="work-description" title="{{ worklog.description or '-' }}">
|
|
{{ worklog.description or '-' }}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
{{ worklog.user_name or 'N/A' }}
|
|
</td>
|
|
<td>
|
|
{% if worklog.status == 'draft' %}
|
|
<div class="btn-group-actions">
|
|
<form method="post" action="/ticket/worklog/{{ worklog.id }}/approve" style="display: inline;">
|
|
<input type="hidden" name="redirect_to" value="{{ request.url }}">
|
|
<button type="submit" class="btn btn-approve btn-sm">
|
|
<i class="bi bi-check-circle"></i> Godkend
|
|
</button>
|
|
</form>
|
|
<button
|
|
type="button"
|
|
class="btn btn-reject btn-sm ms-1"
|
|
onclick="rejectWorklog({{ worklog.id }}, '{{ request.url }}')">
|
|
<i class="bi bi-x-circle"></i> Afvis
|
|
</button>
|
|
</div>
|
|
{% else %}
|
|
<span class="badge">{{ worklog.status }}</span>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="card">
|
|
<div class="empty-state">
|
|
<i class="bi bi-inbox"></i>
|
|
<h3>Ingen worklog entries</h3>
|
|
<p>Der er ingen entries med status "{{ selected_status }}" {% if selected_customer_id %}for denne kunde{% endif %}.</p>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Reject Modal -->
|
|
<div class="modal fade" id="rejectModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content" style="background: var(--bg-card); color: var(--text-primary);">
|
|
<div class="modal-header" style="border-bottom: 1px solid var(--accent-light);">
|
|
<h5 class="modal-title">Afvis Worklog Entry</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<form id="rejectForm" method="post">
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label for="rejectReason" class="form-label">Årsag til afvisning (valgfrit):</label>
|
|
<textarea
|
|
class="form-control"
|
|
id="rejectReason"
|
|
name="reason"
|
|
rows="3"
|
|
placeholder="Forklar hvorfor denne entry afvises..."
|
|
style="background: var(--bg-body); color: var(--text-primary); border-color: var(--accent-light);"></textarea>
|
|
</div>
|
|
<input type="hidden" name="redirect_to" id="rejectRedirectTo">
|
|
</div>
|
|
<div class="modal-footer" style="border-top: 1px solid var(--accent-light);">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
|
<button type="submit" class="btn btn-reject">
|
|
<i class="bi bi-x-circle"></i> Afvis Entry
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
|
<script>
|
|
// Theme Toggle
|
|
function toggleTheme() {
|
|
const html = document.documentElement;
|
|
const currentTheme = html.getAttribute('data-theme');
|
|
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
|
html.setAttribute('data-theme', newTheme);
|
|
localStorage.setItem('theme', newTheme);
|
|
|
|
// Update icon
|
|
const icon = document.querySelector('.theme-toggle i');
|
|
if (newTheme === 'dark') {
|
|
icon.className = 'bi bi-sun-fill';
|
|
} else {
|
|
icon.className = 'bi bi-moon-stars-fill';
|
|
}
|
|
}
|
|
|
|
// Load saved theme
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const savedTheme = localStorage.getItem('theme') || 'light';
|
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
|
|
|
const icon = document.querySelector('.theme-toggle i');
|
|
if (savedTheme === 'dark') {
|
|
icon.className = 'bi bi-sun-fill';
|
|
}
|
|
});
|
|
|
|
// Reject worklog with modal
|
|
function rejectWorklog(worklogId, redirectUrl) {
|
|
const form = document.getElementById('rejectForm');
|
|
form.action = `/ticket/worklog/${worklogId}/reject`;
|
|
document.getElementById('rejectRedirectTo').value = redirectUrl;
|
|
|
|
const modal = new bootstrap.Modal(document.getElementById('rejectModal'));
|
|
modal.show();
|
|
}
|
|
|
|
// Auto-refresh indicator (optional)
|
|
let lastRefresh = Date.now();
|
|
setInterval(() => {
|
|
const elapsed = Math.floor((Date.now() - lastRefresh) / 1000);
|
|
if (elapsed > 300) { // 5 minutes
|
|
const badge = document.createElement('span');
|
|
badge.className = 'badge bg-warning position-fixed';
|
|
badge.style.bottom = '20px';
|
|
badge.style.right = '20px';
|
|
badge.style.cursor = 'pointer';
|
|
badge.innerHTML = '<i class="bi bi-arrow-clockwise"></i> Opdater siden';
|
|
badge.onclick = () => location.reload();
|
|
document.body.appendChild(badge);
|
|
}
|
|
}, 60000);
|
|
</script>
|
|
</body>
|
|
</html>
|