- Added API endpoints for tag management (create, read, update, delete). - Implemented entity tagging functionality to associate tags with various entities. - Created workflow management for tag-triggered actions. - Developed frontend views for tag administration using FastAPI and Jinja2. - Designed HTML template for tag management interface with Bootstrap styling. - Added JavaScript for tag picker component with keyboard shortcuts and dynamic tag filtering. - Created database migration scripts for tags, entity_tags, and tag_workflows tables. - Included default tags for initial setup in the database.
532 lines
20 KiB
HTML
532 lines
20 KiB
HTML
{% extends "shared/frontend/base.html" %}
|
|
|
|
{% block title %}{{ ticket.ticket_number }} - BMC Hub{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.ticket-header {
|
|
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-light) 100%);
|
|
padding: 2rem;
|
|
border-radius: var(--border-radius);
|
|
color: white;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.ticket-number {
|
|
font-family: 'Monaco', 'Courier New', monospace;
|
|
font-size: 1rem;
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.ticket-title {
|
|
font-size: 2rem;
|
|
font-weight: 700;
|
|
margin: 0.5rem 0;
|
|
}
|
|
|
|
.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; }
|
|
|
|
.info-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 1.5rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.info-item {
|
|
background: var(--bg-card);
|
|
padding: 1rem;
|
|
border-radius: var(--border-radius);
|
|
border: 1px solid var(--accent-light);
|
|
}
|
|
|
|
.info-item label {
|
|
display: block;
|
|
font-size: 0.75rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.info-item .value {
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.comment {
|
|
padding: 1.5rem;
|
|
margin-bottom: 1rem;
|
|
background: var(--accent-light);
|
|
border-radius: var(--border-radius);
|
|
border-left: 4px solid var(--accent);
|
|
}
|
|
|
|
.comment.internal {
|
|
background: #fff3cd;
|
|
border-left-color: #ffc107;
|
|
}
|
|
|
|
.comment-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.comment-author {
|
|
font-weight: 600;
|
|
color: var(--accent);
|
|
}
|
|
|
|
.comment-date {
|
|
font-size: 0.85rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.worklog-table th {
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
border-bottom: 2px solid var(--accent-light);
|
|
padding: 0.75rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.worklog-table td {
|
|
padding: 0.75rem;
|
|
border-bottom: 1px solid var(--accent-light);
|
|
}
|
|
|
|
.attachment {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 0.5rem 1rem;
|
|
background: var(--accent-light);
|
|
border-radius: var(--border-radius);
|
|
margin-right: 0.5rem;
|
|
margin-bottom: 0.5rem;
|
|
text-decoration: none;
|
|
color: var(--accent);
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.attachment:hover {
|
|
background: var(--accent);
|
|
color: white;
|
|
}
|
|
|
|
.attachment i {
|
|
margin-right: 0.5rem;
|
|
}
|
|
|
|
.tags-container {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.tag-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 0.75rem;
|
|
border-radius: 8px;
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
color: white;
|
|
transition: all 0.2s;
|
|
cursor: default;
|
|
}
|
|
|
|
.tag-badge:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
|
}
|
|
|
|
.tag-badge .btn-close {
|
|
font-size: 0.6rem;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.tag-badge .btn-close:hover {
|
|
opacity: 1;
|
|
}
|
|
|
|
.add-tag-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 0.75rem;
|
|
border: 2px dashed var(--accent-light);
|
|
border-radius: 8px;
|
|
background: transparent;
|
|
color: var(--accent);
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.add-tag-btn:hover {
|
|
border-color: var(--accent);
|
|
background: var(--accent-light);
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 1.25rem;
|
|
font-weight: 600;
|
|
margin-bottom: 1rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 2rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.empty-state i {
|
|
font-size: 2rem;
|
|
margin-bottom: 0.5rem;
|
|
opacity: 0.3;
|
|
}
|
|
|
|
.action-buttons {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.description-box {
|
|
background: var(--bg-body);
|
|
padding: 1.5rem;
|
|
border-radius: var(--border-radius);
|
|
white-space: pre-wrap;
|
|
line-height: 1.6;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid px-4">
|
|
<!-- Ticket Header -->
|
|
<div class="ticket-header">
|
|
<div class="ticket-number">{{ ticket.ticket_number }}</div>
|
|
<div class="ticket-title">{{ ticket.subject }}</div>
|
|
<div class="mt-3">
|
|
<span class="badge badge-status-{{ ticket.status }}">
|
|
{{ ticket.status.replace('_', ' ').title() }}
|
|
</span>
|
|
<span class="badge badge-priority-{{ ticket.priority }}">
|
|
{{ ticket.priority.title() }} Priority
|
|
</span>
|
|
</div>
|
|
<div class="tags-container" id="ticketTags">
|
|
<!-- Tags loaded via JavaScript -->
|
|
</div>
|
|
<button class="add-tag-btn mt-2" onclick="showTagPicker('ticket', {{ ticket.id }}, reloadTags)">
|
|
<i class="bi bi-plus-circle"></i> Tilføj Tag (⌥⇧T)
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Action Buttons -->
|
|
<div class="action-buttons mb-4">
|
|
<a href="/api/v1/ticket/tickets/{{ ticket.id }}" class="btn btn-outline-primary">
|
|
<i class="bi bi-pencil"></i> Rediger
|
|
</a>
|
|
<button class="btn btn-outline-secondary" onclick="addComment()">
|
|
<i class="bi bi-chat"></i> Tilføj Kommentar
|
|
</button>
|
|
<button class="btn btn-outline-secondary" onclick="addWorklog()">
|
|
<i class="bi bi-clock"></i> Log Tid
|
|
</button>
|
|
<a href="/ticket/tickets" class="btn btn-outline-secondary">
|
|
<i class="bi bi-arrow-left"></i> Tilbage
|
|
</a>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<!-- Main Content -->
|
|
<div class="col-lg-8">
|
|
<!-- Description -->
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="section-title">
|
|
<i class="bi bi-file-text"></i> Beskrivelse
|
|
</div>
|
|
<div class="description-box">
|
|
{{ ticket.description or 'Ingen beskrivelse' }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Comments -->
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="section-title">
|
|
<i class="bi bi-chat-dots"></i> Kommentarer ({{ comments|length }})
|
|
</div>
|
|
{% if comments %}
|
|
{% for comment in comments %}
|
|
<div class="comment {% if comment.internal_note %}internal{% endif %}">
|
|
<div class="comment-header">
|
|
<span class="comment-author">
|
|
<i class="bi bi-person-circle"></i>
|
|
{{ comment.user_name or 'System' }}
|
|
{% if comment.internal_note %}
|
|
<span class="badge bg-warning text-dark ms-2">Internal</span>
|
|
{% endif %}
|
|
</span>
|
|
<span class="comment-date">
|
|
{{ comment.created_at.strftime('%d-%m-%Y %H:%M') if comment.created_at else '-' }}
|
|
</span>
|
|
</div>
|
|
<div class="comment-text">
|
|
{{ comment.comment_text }}
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
{% else %}
|
|
<div class="empty-state">
|
|
<i class="bi bi-chat"></i>
|
|
<p>Ingen kommentarer endnu</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Worklog -->
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="section-title">
|
|
<i class="bi bi-clock-history"></i> Worklog ({{ worklog|length }})
|
|
</div>
|
|
{% if worklog %}
|
|
<div class="table-responsive">
|
|
<table class="table worklog-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Dato</th>
|
|
<th>Timer</th>
|
|
<th>Type</th>
|
|
<th>Beskrivelse</th>
|
|
<th>Status</th>
|
|
<th>Medarbejder</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for entry in worklog %}
|
|
<tr>
|
|
<td>{{ entry.work_date.strftime('%d-%m-%Y') if entry.work_date else '-' }}</td>
|
|
<td><strong>{{ "%.2f"|format(entry.hours) }}t</strong></td>
|
|
<td>{{ entry.work_type }}</td>
|
|
<td>{{ entry.description or '-' }}</td>
|
|
<td>
|
|
<span class="badge {% if entry.status == 'billable' %}bg-success{% elif entry.status == 'draft' %}bg-warning{% else %}bg-secondary{% endif %}">
|
|
{{ entry.status }}
|
|
</span>
|
|
</td>
|
|
<td>{{ entry.user_name or '-' }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<div class="empty-state">
|
|
<i class="bi bi-clock"></i>
|
|
<p>Ingen worklog entries endnu</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Attachments -->
|
|
{% if attachments %}
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="section-title">
|
|
<i class="bi bi-paperclip"></i> Vedhæftninger ({{ attachments|length }})
|
|
</div>
|
|
{% for attachment in attachments %}
|
|
<a href="/api/v1/attachments/{{ attachment.id }}/download" class="attachment">
|
|
<i class="bi bi-file-earmark"></i>
|
|
{{ attachment.filename }}
|
|
<small class="ms-2">({{ (attachment.file_size / 1024)|round(1) }} KB)</small>
|
|
</a>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Sidebar -->
|
|
<div class="col-lg-4">
|
|
<!-- Ticket Info -->
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="section-title">
|
|
<i class="bi bi-info-circle"></i> Ticket Information
|
|
</div>
|
|
<div class="info-item mb-3">
|
|
<label>Kunde</label>
|
|
<div class="value">
|
|
{% if ticket.customer_name %}
|
|
<a href="/customers/{{ ticket.customer_id }}" style="text-decoration: none; color: var(--accent);">
|
|
{{ ticket.customer_name }}
|
|
</a>
|
|
{% else %}
|
|
<span class="text-muted">Ikke angivet</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="info-item mb-3">
|
|
<label>Tildelt til</label>
|
|
<div class="value">
|
|
{{ ticket.assigned_to_name or 'Ikke tildelt' }}
|
|
</div>
|
|
</div>
|
|
<div class="info-item mb-3">
|
|
<label>Oprettet</label>
|
|
<div class="value">
|
|
{{ ticket.created_at.strftime('%d-%m-%Y %H:%M') if ticket.created_at else '-' }}
|
|
</div>
|
|
</div>
|
|
<div class="info-item mb-3">
|
|
<label>Senest opdateret</label>
|
|
<div class="value">
|
|
{{ ticket.updated_at.strftime('%d-%m-%Y %H:%M') if ticket.updated_at else '-' }}
|
|
</div>
|
|
</div>
|
|
{% if ticket.resolved_at %}
|
|
<div class="info-item mb-3">
|
|
<label>Løst</label>
|
|
<div class="value">
|
|
{{ ticket.resolved_at.strftime('%d-%m-%Y %H:%M') }}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
{% if ticket.first_response_at %}
|
|
<div class="info-item mb-3">
|
|
<label>Første svar</label>
|
|
<div class="value">
|
|
{{ ticket.first_response_at.strftime('%d-%m-%Y %H:%M') }}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tags -->
|
|
{% if ticket.tags %}
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="section-title">
|
|
<i class="bi bi-tags"></i> Tags
|
|
</div>
|
|
{% for tag in ticket.tags %}
|
|
<span class="badge bg-secondary me-1 mb-1">#{{ tag }}</span>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
// Add comment (placeholder - integrate with API)
|
|
function addComment() {
|
|
alert('Add comment functionality - integrate with POST /api/v1/ticket/tickets/{{ ticket.id }}/comments');
|
|
}
|
|
|
|
// Add worklog (placeholder - integrate with API)
|
|
function addWorklog() {
|
|
alert('Add worklog functionality - integrate with POST /api/v1/ticket/tickets/{{ ticket.id }}/worklog');
|
|
}
|
|
|
|
// Load and render ticket tags
|
|
async function loadTicketTags() {
|
|
try {
|
|
const response = await fetch('/api/v1/tags/entity/ticket/{{ ticket.id }}');
|
|
if (!response.ok) return;
|
|
|
|
const tags = await response.json();
|
|
const container = document.getElementById('ticketTags');
|
|
|
|
if (tags.length === 0) {
|
|
container.innerHTML = '<small class="text-muted"><i class="bi bi-tags"></i> Ingen tags endnu</small>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = tags.map(tag => `
|
|
<span class="tag-badge" style="background-color: ${tag.color};">
|
|
${tag.icon ? `<i class="bi ${tag.icon}"></i>` : ''}
|
|
${tag.name}
|
|
<button type="button" class="btn-close btn-close-white btn-sm"
|
|
onclick="removeTag(${tag.id}, '${tag.name}')"
|
|
aria-label="Fjern"></button>
|
|
</span>
|
|
`).join('');
|
|
} catch (error) {
|
|
console.error('Error loading tags:', error);
|
|
}
|
|
}
|
|
|
|
async function removeTag(tagId, tagName) {
|
|
if (!confirm(`Fjern tag "${tagName}"?`)) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/tags/entity?entity_type=ticket&entity_id={{ ticket.id }}&tag_id=${tagId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to remove tag');
|
|
await loadTicketTags();
|
|
} catch (error) {
|
|
console.error('Error removing tag:', error);
|
|
alert('Fejl ved fjernelse af tag');
|
|
}
|
|
}
|
|
|
|
function reloadTags() {
|
|
loadTicketTags();
|
|
}
|
|
|
|
// Load tags on page load
|
|
document.addEventListener('DOMContentLoaded', loadTicketTags);
|
|
|
|
// Override global tag picker to auto-reload after adding
|
|
if (window.tagPicker) {
|
|
const originalShow = window.tagPicker.show.bind(window.tagPicker);
|
|
window.showTagPicker = function(entityType, entityId, onSelect) {
|
|
window.tagPicker.show(entityType, entityId, () => {
|
|
loadTicketTags();
|
|
if (onSelect) onSelect();
|
|
});
|
|
};
|
|
}
|
|
</script>
|
|
{% endblock %}
|