- Added `transcription_service.py` to handle audio transcription via Whisper API. - Integrated logging for transcription processes and error handling. - Supported audio format checks based on configuration settings. docs: Create Ordre System Implementation Plan - Drafted comprehensive implementation plan for e-conomic order integration. - Outlined business requirements, database changes, backend and frontend implementation details. - Included testing plan and deployment steps for the new order system. feat: Add AI prompts and regex action capabilities - Created `ai_prompts` table for storing custom AI prompts. - Added regex extraction and linking action to email workflow actions. feat: Introduce conversations module for transcribed audio - Created `conversations` table to store transcribed conversations with relevant metadata. - Added indexing for customer, ticket, and user linkage. - Implemented full-text search capabilities for Danish language. fix: Add category column to conversations for classification - Added `category` column to `conversations` table for better conversation classification.
1400 lines
65 KiB
HTML
1400 lines
65 KiB
HTML
{% extends "shared/frontend/base.html" %}
|
|
|
|
{% block title %}{{ ticket.ticket_number }} - BMC Hub{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.ticket-header {
|
|
background: white;
|
|
padding: 2rem;
|
|
border-radius: var(--border-radius);
|
|
margin-bottom: 2rem;
|
|
border-left: 6px solid var(--accent);
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
|
}
|
|
|
|
.ticket-header.priority-urgent { border-left-color: #dc3545; }
|
|
.ticket-header.priority-high { border-left-color: #fd7e14; }
|
|
|
|
.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;
|
|
}
|
|
|
|
.contact-item {
|
|
padding: 0.75rem;
|
|
margin-bottom: 0.5rem;
|
|
background: var(--accent-light);
|
|
border-radius: var(--border-radius);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.contact-item:hover {
|
|
transform: translateX(3px);
|
|
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.contact-info {
|
|
flex: 1;
|
|
}
|
|
|
|
.contact-name {
|
|
font-weight: 600;
|
|
color: var(--accent);
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.contact-details {
|
|
font-size: 0.85rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.contact-role-badge {
|
|
padding: 0.25rem 0.6rem;
|
|
border-radius: 4px;
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
margin-right: 0.5rem;
|
|
}
|
|
|
|
/* Standard roller */
|
|
.role-primary { background: #28a745; color: white; }
|
|
.role-requester { background: #17a2b8; color: white; }
|
|
.role-assignee { background: #ffc107; color: #000; }
|
|
.role-cc { background: #6c757d; color: white; }
|
|
.role-observer { background: #e9ecef; color: #495057; }
|
|
|
|
/* Almindelige roller */
|
|
.role-ekstern_it { background: #6f42c1; color: white; }
|
|
.role-third_party { background: #fd7e14; color: white; }
|
|
.role-electrician { background: #20c997; color: white; }
|
|
.role-consultant { background: #0dcaf0; color: #000; }
|
|
.role-vendor { background: #dc3545; color: white; }
|
|
|
|
/* Default for custom roller */
|
|
.contact-role-badge:not([class*="role-primary"]):not([class*="role-requester"]):not([class*="role-assignee"]):not([class*="role-cc"]):not([class*="role-observer"]):not([class*="role-ekstern"]):not([class*="role-third"]):not([class*="role-electrician"]):not([class*="role-consultant"]):not([class*="role-vendor"]) {
|
|
background: var(--accent);
|
|
color: white;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid px-4">
|
|
<!-- Ticket Header -->
|
|
<div class="ticket-header priority-{{ ticket.priority }}">
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div>
|
|
<div class="d-flex align-items-center gap-2 mb-2 text-muted small">
|
|
<span class="ticket-number font-monospace">{{ ticket.ticket_number }}</span>
|
|
<span>•</span>
|
|
<span class="fw-bold text-uppercase" style="letter-spacing: 0.5px;">{{ ticket.ticket_type|default('Incident') }}</span>
|
|
<!-- SLA Timer Mockup -->
|
|
<span class="badge bg-light text-danger border border-danger ms-2">
|
|
<i class="bi bi-hourglass-split"></i> Deadline: 14:00
|
|
</span>
|
|
</div>
|
|
|
|
<div class="d-flex flex-wrap align-items-baseline gap-3">
|
|
<h1 class="ticket-title mt-0 text-dark mb-0">{{ ticket.subject }}</h1>
|
|
<h3 class="h4 text-muted fw-normal mb-0">
|
|
<a href="/customers/{{ ticket.customer_id }}" class="text-decoration-none text-muted hover-primary">
|
|
@ {{ ticket.customer_name }}
|
|
</a>
|
|
</h3>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quick Status -->
|
|
<div class="d-flex align-items-center gap-2">
|
|
<select class="form-select" style="width: auto; font-weight: 500;"
|
|
onchange="updateStatus(this.value)" id="quickStatus">
|
|
<option value="open" {% if ticket.status == 'open' %}selected{% endif %}>Åben</option>
|
|
<option value="in_progress" {% if ticket.status == 'in_progress' %}selected{% endif %}>Igangværende</option>
|
|
<option value="waiting_customer" {% if ticket.status == 'waiting_customer' %}selected{% endif %}>Afventer Kunde</option>
|
|
<option value="waiting_internal" {% if ticket.status == 'waiting_internal' %}selected{% endif %}>Afventer Internt</option>
|
|
<option value="resolved" {% if ticket.status == 'resolved' %}selected{% endif %}>Løst</option>
|
|
<option value="closed" {% if ticket.status == 'closed' %}selected{% endif %}>Lukket</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-3 d-flex gap-2 align-items-center flex-wrap">
|
|
<!-- Priority Badge -->
|
|
<span class="badge badge-priority-{{ ticket.priority }}">
|
|
{{ ticket.priority.title() }} Priority
|
|
</span>
|
|
|
|
<!-- Tags -->
|
|
<div class="tags-container d-inline-flex m-0" id="ticketTags"></div>
|
|
<button class="btn btn-sm btn-light text-muted" onclick="showTagPicker('ticket', {{ ticket.id }}, reloadTags)">
|
|
<i class="bi bi-plus-circle"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Internal Note Alert -->
|
|
{% if ticket.internal_note %}
|
|
<div class="alert alert-warning mt-3 mb-0 d-flex align-items-start border-warning" style="background-color: #fff3cd;">
|
|
<i class="bi bi-shield-lock-fill me-2 fs-5 text-warning"></i>
|
|
<div>
|
|
<strong><i class="bi bi-eye-slash"></i> Internt Notat:</strong>
|
|
<span style="white-space: pre-wrap;">{{ ticket.internal_note }}</span>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Action Buttons Removed (Moved to specific sections) -->
|
|
<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>
|
|
|
|
<!-- Quick Comment Input -->
|
|
<div class="mb-4 p-3 bg-light rounded-3 border">
|
|
<textarea id="quickCommentText" class="form-control border-0 bg-white mb-2 shadow-sm" rows="2" placeholder="Skriv en kommentar... (Ctrl+Enter for at sende)"></textarea>
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" id="quickCommentInternal">
|
|
<label class="form-check-label small text-muted fw-bold" for="quickCommentInternal">
|
|
<i class="bi bi-shield-lock-fill text-warning"></i> Internt Notat
|
|
</label>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm px-4 rounded-pill" onclick="submitQuickComment()">
|
|
Send <i class="bi bi-send-fill ms-1"></i>
|
|
</button>
|
|
</div>
|
|
</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="d-flex justify-content-between align-items-center mb-3">
|
|
<div class="section-title mb-0">
|
|
<i class="bi bi-clock-history"></i> Worklog
|
|
<span class="badge bg-light text-dark border ms-2" id="totalHoursBadge">...</span>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm rounded-pill" onclick="showWorklogModal()">
|
|
<i class="bi bi-plus-lg"></i> Log Tid
|
|
</button>
|
|
</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">
|
|
<!-- Metadata Card (Consolidated) -->
|
|
<div class="card mb-3">
|
|
<div class="card-header bg-white py-3 border-bottom">
|
|
<h6 class="mb-0 fw-bold text-dark"><i class="bi bi-info-circle me-2"></i>Detaljer</h6>
|
|
</div>
|
|
<div class="list-group list-group-flush small">
|
|
<div class="list-group-item d-flex justify-content-between align-items-center px-3 py-3">
|
|
<span class="text-muted">Ansvarlig</span>
|
|
<span class="badge bg-light text-dark border">{{ ticket.assigned_to_name or 'Ubesat' }}</span>
|
|
</div>
|
|
<div class="list-group-item d-flex justify-content-between align-items-center px-3 py-3">
|
|
<span class="text-muted">Oprettet</span>
|
|
<span class="font-monospace">{{ ticket.created_at.strftime('%d-%m-%Y %H:%M') if ticket.created_at else '-' }}</span>
|
|
</div>
|
|
<div class="list-group-item d-flex justify-content-between align-items-center px-3 py-3">
|
|
<span class="text-muted">Opdateret</span>
|
|
<span class="font-monospace">{{ ticket.updated_at.strftime('%d-%m-%Y %H:%M') if ticket.updated_at else '-' }}</span>
|
|
</div>
|
|
{% if ticket.resolved_at %}
|
|
<div class="list-group-item d-flex justify-content-between align-items-center px-3 py-3 bg-light">
|
|
<span class="text-success fw-bold">Løst</span>
|
|
<span class="font-monospace">{{ ticket.resolved_at.strftime('%d-%m-%Y %H:%M') }}</span>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Contacts -->
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="section-title">
|
|
<i class="bi bi-people"></i> Kontakter
|
|
<button class="btn btn-sm btn-outline-primary ms-auto" onclick="showAddContactModal()">
|
|
<i class="bi bi-plus-circle"></i> Tilføj
|
|
</button>
|
|
</div>
|
|
<div id="contactsList">
|
|
<div class="text-center text-muted py-2">
|
|
<i class="bi bi-hourglass-split"></i> Indlæser...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
// ============================================
|
|
// QUICK COMMENT & STATUS
|
|
// ============================================
|
|
|
|
async function updateStatus(newStatus) {
|
|
try {
|
|
// Determine API endpoint and method
|
|
// Using generic update for now, ideally use specific status endpoint if workflow requires
|
|
const response = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ status: newStatus })
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to update status');
|
|
|
|
// Show success feedback
|
|
const select = document.getElementById('quickStatus');
|
|
|
|
// Optional: Flash success or reload.
|
|
// Reload is safer to update all timestamps and UI states
|
|
window.location.reload();
|
|
|
|
} catch (error) {
|
|
console.error('Error updating status:', error);
|
|
alert('Fejl ved opdatering af status');
|
|
// Revert select if possible
|
|
window.location.reload();
|
|
}
|
|
}
|
|
|
|
async function submitQuickComment() {
|
|
const textarea = document.getElementById('quickCommentText');
|
|
const internalCheck = document.getElementById('quickCommentInternal');
|
|
const text = textarea.value.trim();
|
|
const isInternal = internalCheck.checked;
|
|
|
|
if (!text) return;
|
|
|
|
try {
|
|
const response = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}/comments', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
comment_text: text,
|
|
is_internal: isInternal,
|
|
ticket_id: {{ ticket.id }}
|
|
})
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to post comment');
|
|
|
|
// Clear input
|
|
textarea.value = '';
|
|
|
|
// Reload page to show new comment (simpler than DOM manipulation for complex layouts)
|
|
window.location.reload();
|
|
|
|
} catch (error) {
|
|
console.error('Error posting comment:', error);
|
|
alert('Kunne ikke sende kommentar');
|
|
}
|
|
}
|
|
|
|
// Handle Ctrl+Enter in comment box
|
|
document.getElementById('quickCommentText')?.addEventListener('keydown', function(e) {
|
|
if (e.ctrlKey && e.key === 'Enter') {
|
|
submitQuickComment();
|
|
}
|
|
});
|
|
|
|
// ============================================
|
|
// WORKLOG MANAGEMENT
|
|
// ============================================
|
|
|
|
async function showWorklogModal() {
|
|
const today = new Date().toISOString().split('T')[0];
|
|
|
|
// Fetch Prepaid Cards for this customer
|
|
let prepaidOptions = '';
|
|
let activePrepaidCards = [];
|
|
try {
|
|
const response = await fetch('/api/v1/prepaid-cards?status=active&customer_id={{ ticket.customer_id }}');
|
|
if (response.ok) {
|
|
const cards = await response.json();
|
|
activePrepaidCards = cards || [];
|
|
if (activePrepaidCards.length > 0) {
|
|
const cardOpts = activePrepaidCards.map(c => {
|
|
const remaining = parseFloat(c.remaining_hours).toFixed(2);
|
|
const expiryText = c.expires_at ? ` • Udløber ${new Date(c.expires_at).toLocaleDateString('da-DK')}` : '';
|
|
return `<option value="card_${c.id}">💳 Klippekort #${c.id} (${remaining}t tilbage${expiryText})</option>`;
|
|
}).join('');
|
|
|
|
prepaidOptions = `<optgroup label="Klippekort">${cardOpts}</optgroup>`;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to load prepaid cards", e);
|
|
}
|
|
|
|
// Store for use in submitWorklog
|
|
window._activePrepaidCards = activePrepaidCards;
|
|
|
|
const modalHtml = `
|
|
<div class="modal fade" id="worklogModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-clock-history"></i> Registrer Tid</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="row g-3">
|
|
<div class="col-6">
|
|
<label class="form-label">Dato *</label>
|
|
<input type="date" class="form-control" id="worklogDate" value="${today}" required>
|
|
</div>
|
|
<div class="col-6">
|
|
<label class="form-label">Tid brugt *</label>
|
|
<div class="input-group">
|
|
<input type="number" class="form-control" id="worklogHours" min="0" placeholder="tt" step="1">
|
|
<span class="input-group-text">:</span>
|
|
<input type="number" class="form-control" id="worklogMinutes" min="0" placeholder="mm" step="1">
|
|
</div>
|
|
<div class="form-text text-end" id="worklogTotalCalc" style="font-size: 0.8rem;">Total: 0.00 timer</div>
|
|
</div>
|
|
<div class="col-6">
|
|
<label class="form-label">Type</label>
|
|
<select class="form-select" id="worklogType">
|
|
<option value="support" selected>Support</option>
|
|
<option value="troubleshooting">Fejlsøgning</option>
|
|
<option value="development">Udvikling</option>
|
|
<option value="on_site">Kørsel / On-site</option>
|
|
<option value="meeting">Møde</option>
|
|
<option value="other">Andet</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-6">
|
|
<label class="form-label">Afregning</label>
|
|
<select class="form-select" id="worklogBilling">
|
|
<option value="invoice" selected>Faktura</option>
|
|
${prepaidOptions}
|
|
<option value="internal">Internt / Ingen faktura</option>
|
|
<option value="warranty">Garanti / Reklamation</option>
|
|
<option value="unknown">❓ Ved ikke (Send til godkendelse)</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-12">
|
|
<label class="form-label">Beskrivelse</label>
|
|
<textarea class="form-control" id="worklogDesc" rows="3" placeholder="Hvad er der brugt tid på?"></textarea>
|
|
</div>
|
|
<div class="col-12">
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" id="worklogInternal">
|
|
<label class="form-check-label text-muted" for="worklogInternal">
|
|
Skjul for kunde (Intern registrering)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
|
<button type="button" class="btn btn-primary" onclick="submitWorklog()">
|
|
<i class="bi bi-save"></i> Gem Tid
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Clean up old
|
|
const oldModal = document.getElementById('worklogModal');
|
|
if(oldModal) oldModal.remove();
|
|
|
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
|
const modal = new bootstrap.Modal(document.getElementById('worklogModal'));
|
|
modal.show();
|
|
|
|
// Setup listeners for live calculation
|
|
const calcTotal = () => {
|
|
const h = parseInt(document.getElementById('worklogHours').value) || 0;
|
|
const m = parseInt(document.getElementById('worklogMinutes').value) || 0;
|
|
const total = h + (m / 60);
|
|
document.getElementById('worklogTotalCalc').innerText = `Total: ${total.toFixed(2)} timer`;
|
|
};
|
|
document.getElementById('worklogHours').addEventListener('input', calcTotal);
|
|
document.getElementById('worklogMinutes').addEventListener('input', calcTotal);
|
|
|
|
// Focus hours (skipping date usually)
|
|
setTimeout(() => document.getElementById('worklogHours').focus(), 500);
|
|
}
|
|
|
|
async function submitWorklog() {
|
|
const date = document.getElementById('worklogDate').value;
|
|
// Calculate hours from split fields
|
|
const h = parseInt(document.getElementById('worklogHours').value) || 0;
|
|
const m = parseInt(document.getElementById('worklogMinutes').value) || 0;
|
|
const hours = h + (m / 60);
|
|
|
|
const type = document.getElementById('worklogType').value;
|
|
let billing = document.getElementById('worklogBilling').value;
|
|
const desc = document.getElementById('worklogDesc').value;
|
|
const isInternal = document.getElementById('worklogInternal').checked;
|
|
|
|
let prepaidCardId = null;
|
|
|
|
if(!date || hours <= 0) {
|
|
alert("Udfyld venligst dato og tid (timer/minutter)");
|
|
return;
|
|
}
|
|
|
|
// Handle prepaid card selection
|
|
if(billing.startsWith('card_')) {
|
|
prepaidCardId = parseInt(billing.replace('card_', ''));
|
|
billing = 'prepaid_card'; // Reset to enum value
|
|
} else if(billing === 'prepaid_card') {
|
|
// User selected generic "Klippekort" (shouldn't happen with new UI, but handle it)
|
|
// Backend will auto-select if only 1 active, or error if >1
|
|
prepaidCardId = null;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}/worklog', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
ticket_id: {{ ticket.id }},
|
|
work_date: date,
|
|
hours: hours,
|
|
work_type: type,
|
|
billing_method: billing,
|
|
description: desc,
|
|
is_internal: isInternal,
|
|
prepaid_card_id: prepaidCardId
|
|
})
|
|
});
|
|
|
|
if(!response.ok) {
|
|
const err = await response.json();
|
|
throw new Error(err.detail || 'Fejl ved oprettelse');
|
|
}
|
|
|
|
// Reload to show
|
|
window.location.reload();
|
|
|
|
} catch (e) {
|
|
console.error(e);
|
|
alert("Kunne ikke gemme tidsregistrering: " + e.message);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// TAGS MANAGEMENT
|
|
// ============================================
|
|
|
|
// 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 (!container) return; // Guard clause
|
|
|
|
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();
|
|
}
|
|
|
|
// ============================================
|
|
// CONTACTS MANAGEMENT (SEARCHABLE)
|
|
// ============================================
|
|
|
|
let allContactsCache = [];
|
|
let customersCache = [];
|
|
let selectedContactId = null;
|
|
|
|
async function loadContacts() {
|
|
try {
|
|
const response = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}/contacts');
|
|
if (!response.ok) throw new Error('Failed to load contacts');
|
|
|
|
const data = await response.json();
|
|
const container = document.getElementById('contactsList');
|
|
if (!container) return;
|
|
|
|
if (!data.contacts || data.contacts.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="empty-state py-2">
|
|
<i class="bi bi-person-x"></i>
|
|
<p class="mb-0 small">Ingen kontakter tilføjet endnu</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = data.contacts.map(contact => `
|
|
<div class="contact-item">
|
|
<div class="contact-info">
|
|
<div class="contact-name">
|
|
<span class="contact-role-badge role-${contact.role}">
|
|
${getRoleLabel(contact.role)}
|
|
</span>
|
|
${contact.first_name} ${contact.last_name}
|
|
</div>
|
|
<div class="contact-details">
|
|
${contact.email ? `<i class="bi bi-envelope"></i> ${contact.email}` : ''}
|
|
${contact.phone ? `<i class="bi bi-telephone ms-2"></i> ${contact.phone}` : ''}
|
|
</div>
|
|
${contact.notes ? `<div class="contact-details mt-1"><i class="bi bi-sticky"></i> ${contact.notes}</div>` : ''}
|
|
</div>
|
|
<div class="btn-group btn-group-sm">
|
|
<button class="btn btn-outline-primary" onclick="editContactRole(${contact.contact_id}, '${contact.role}', '${contact.notes || ''}')">
|
|
<i class="bi bi-pencil"></i>
|
|
</button>
|
|
<button class="btn btn-outline-danger" onclick="removeContact(${contact.contact_id}, '${contact.first_name} ${contact.last_name}')">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
} catch (error) {
|
|
console.error('Error loading contacts:', error);
|
|
document.getElementById('contactsList').innerHTML = `
|
|
<div class="alert alert-warning mb-0">
|
|
<i class="bi bi-exclamation-triangle"></i> Kunne ikke indlæse kontakter
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
function getRoleLabel(role) {
|
|
const labels = {
|
|
'primary': '⭐ Primær',
|
|
'requester': '📝 Anmoder',
|
|
'assignee': '👤 Ansvarlig',
|
|
'cc': '📧 CC',
|
|
'observer': '👁 Observer',
|
|
'ekstern_it': '💻 Ekstern IT',
|
|
'third_party': '🤝 3. part',
|
|
'electrician': '⚡ Elektriker',
|
|
'consultant': '🎓 Konsulent',
|
|
'vendor': '🏢 Leverandør'
|
|
};
|
|
return labels[role] || ('📌 ' + role.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()));
|
|
}
|
|
|
|
async function showAddContactModal() {
|
|
// Load contacts if not cached
|
|
if (allContactsCache.length === 0) {
|
|
try {
|
|
const response = await fetch('/api/v1/contacts?limit=1000');
|
|
const data = await response.json();
|
|
allContactsCache = data.contacts || [];
|
|
} catch (e) {
|
|
console.error("Failed to load contacts for modal", e);
|
|
alert("Kunne ikke hente kontaktliste");
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Check existing contacts
|
|
const ticketContactsResp = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}/contacts');
|
|
const ticketContacts = await ticketContactsResp.json();
|
|
const isFirstContact = !ticketContacts.contacts || ticketContacts.contacts.length === 0;
|
|
|
|
// Define Modal HTML
|
|
const modalHtml = `
|
|
<div class="modal fade" id="addContactModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title"><i class="bi bi-person-plus"></i> Tilføj Kontakt</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
|
|
<!-- Search Stage -->
|
|
<div id="contactSearchStage">
|
|
<label class="form-label">Find kontakt</label>
|
|
<input type="text" class="form-control mb-2" id="contactSearchInput"
|
|
placeholder="Søg navn eller email..." autocomplete="off">
|
|
|
|
<div class="list-group" id="contactSearchResults" style="max-height: 250px; overflow-y: auto;">
|
|
<!-- Results will appear here -->
|
|
<div class="text-center text-muted py-3 small">Begynd at skrive for at søge...</div>
|
|
</div>
|
|
<div class="mt-2 text-end">
|
|
<small class="text-muted">Finder du ikke kontakten? <a href="#" onclick="showCreateStage(); return false;">Smart Opret</a></small>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create Stage (Hidden) -->
|
|
<div id="contactCreateStage" style="display:none;" class="animate__animated animate__fadeIn">
|
|
<h6 class="border-bottom pb-2 mb-3 text-primary"><i class="bi bi-person-plus"></i> Hurtig oprettelse</h6>
|
|
<div class="row g-2 mb-2">
|
|
<div class="col-6">
|
|
<label class="form-label small">Fornavn *</label>
|
|
<input type="text" class="form-control" id="newContactFirstName" required>
|
|
</div>
|
|
<div class="col-6">
|
|
<label class="form-label small">Efternavn</label>
|
|
<input type="text" class="form-control" id="newContactLastName">
|
|
</div>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label small">Email</label>
|
|
<input type="email" class="form-control" id="newContactEmail">
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label small">Telefon</label>
|
|
<input type="tel" class="form-control" id="newContactPhone">
|
|
</div>
|
|
<div class="mb-2 position-relative">
|
|
<label class="form-label small">Firma</label>
|
|
<div class="input-group input-group-sm">
|
|
<input type="text" class="form-control" id="newContactCompanySearch" placeholder="Søg firma..." autocomplete="off">
|
|
<input type="hidden" id="newContactCompanyId" value="{{ ticket.customer_id }}">
|
|
<button class="btn btn-outline-secondary" type="button" onclick="clearCompanySelection()">
|
|
<i class="bi bi-x-lg"></i>
|
|
</button>
|
|
</div>
|
|
<div id="companySearchResults" class="list-group position-absolute w-100 shadow-sm" style="display:none; z-index: 1050; max-height: 200px; overflow-y: auto;"></div>
|
|
<div class="form-text small" id="selectedCompanyName">Valgt: {{ ticket.customer_name }}</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label small">Titel</label>
|
|
<input type="text" class="form-control" id="newContactTitle">
|
|
</div>
|
|
|
|
<div class="d-flex justify-content-between pt-2 border-top">
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="cancelCreate()">Annuller</button>
|
|
<button type="button" class="btn btn-sm btn-success text-white" onclick="createContactSmart()">
|
|
<i class="bi bi-check-lg"></i> Opret & Vælg
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Selected Stage (Hidden initially) -->
|
|
<div id="contactSelectedStage" style="display:none;" class="animate__animated animate__fadeIn">
|
|
<input type="hidden" id="selectedContactId">
|
|
|
|
<div class="alert alert-primary d-flex justify-content-between align-items-center mb-3">
|
|
<div>
|
|
<i class="bi bi-person-check-fill me-2"></i>
|
|
<strong id="selectedContactName">Name</strong>
|
|
</div>
|
|
<button class="btn btn-sm btn-outline-primary bg-white" onclick="resetContactSelection()">Skift</button>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label">Rolle *</label>
|
|
<select class="form-select" id="roleSelect" onchange="toggleCustomRole()" required>
|
|
<optgroup label="Standard roller">
|
|
<option value="primary">⭐ Primær kontakt</option>
|
|
<option value="requester">📝 Anmoder</option>
|
|
<option value="assignee">👤 Ansvarlig</option>
|
|
<option value="cc">📧 CC (Carbon Copy)</option>
|
|
<option value="observer" selected>👁 Observer</option>
|
|
</optgroup>
|
|
<optgroup label="Almindelige roller">
|
|
<option value="ekstern_it">💻 Ekstern IT</option>
|
|
<option value="third_party">🤝 3. part leverandør</option>
|
|
<option value="electrician">⚡ Elektriker</option>
|
|
<option value="consultant">🎓 Konsulent</option>
|
|
<option value="vendor">🏢 Leverandør</option>
|
|
</optgroup>
|
|
<optgroup label="Custom">
|
|
<option value="_custom">✏️ Indtast custom rolle...</option>
|
|
</optgroup>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="mb-3" id="customRoleDiv" style="display: none;">
|
|
<label class="form-label">Custom Rolle</label>
|
|
<input type="text" class="form-control" id="customRoleInput" placeholder="f.eks. projektleder">
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label">Noter (valgfri)</label>
|
|
<textarea class="form-control" id="contactNotes" rows="2" placeholder="Noter om rollen..."></textarea>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
|
<button type="button" class="btn btn-primary" id="btnAddContactConfirm" onclick="addContact()" disabled>
|
|
<i class="bi bi-plus-circle"></i> Tilføj
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Clean up old
|
|
const oldModal = document.getElementById('addContactModal');
|
|
if(oldModal) oldModal.remove();
|
|
|
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
|
const modalEl = document.getElementById('addContactModal');
|
|
const modal = new bootstrap.Modal(modalEl);
|
|
|
|
// Setup Search Listener
|
|
const input = document.getElementById('contactSearchInput');
|
|
input.addEventListener('input', (e) => filterContacts(e.target.value));
|
|
|
|
// Setup First Contact Logic
|
|
if (isFirstContact) {
|
|
document.getElementById('roleSelect').value = 'primary';
|
|
// Note: User still needs to select a contact first
|
|
}
|
|
|
|
modal.show();
|
|
// Focus input
|
|
setTimeout(() => input.focus(), 500);
|
|
}
|
|
|
|
async function loadCustomers() {
|
|
if(customersCache.length > 0) return;
|
|
try {
|
|
const response = await fetch('/api/v1/customers?limit=100');
|
|
const data = await response.json();
|
|
customersCache = data.customers || data;
|
|
} catch(e) { console.error("Failed to load customers", e); }
|
|
}
|
|
|
|
function showCreateStage() {
|
|
document.getElementById('contactSearchStage').style.display = 'none';
|
|
document.getElementById('contactCreateStage').style.display = 'block';
|
|
document.getElementById('newContactFirstName').focus();
|
|
loadCustomers();
|
|
|
|
// Setup company search
|
|
const input = document.getElementById('newContactCompanySearch');
|
|
input.addEventListener('input', (e) => filterCustomers(e.target.value));
|
|
input.addEventListener('focus', () => {
|
|
if(input.value.length === 0) filterCustomers('');
|
|
});
|
|
|
|
// Hide results on blur with delay to allow clicking
|
|
input.addEventListener('blur', () => {
|
|
setTimeout(() => document.getElementById('companySearchResults').style.display = 'none', 200);
|
|
});
|
|
}
|
|
|
|
function filterCustomers(query) {
|
|
const resultsDiv = document.getElementById('companySearchResults');
|
|
const term = query.toLowerCase();
|
|
|
|
const matches = customersCache.filter(c =>
|
|
c.name.toLowerCase().includes(term) ||
|
|
(c.cvr_number && c.cvr_number.includes(term))
|
|
).slice(0, 10);
|
|
|
|
if(matches.length === 0) {
|
|
resultsDiv.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
resultsDiv.innerHTML = matches.map(c => `
|
|
<a href="#" class="list-group-item list-group-item-action small py-1" onclick="selectCompany(${c.id}, '${c.name}'); return false;">
|
|
${c.name} <span class="text-muted ms-1">(${c.cvr_number || '-'})</span>
|
|
</a>
|
|
`).join('');
|
|
|
|
resultsDiv.style.display = 'block';
|
|
}
|
|
|
|
function selectCompany(id, name) {
|
|
document.getElementById('newContactCompanyId').value = id;
|
|
document.getElementById('selectedCompanyName').innerText = 'Valgt: ' + name;
|
|
document.getElementById('newContactCompanySearch').value = '';
|
|
document.getElementById('companySearchResults').style.display = 'none';
|
|
}
|
|
|
|
function clearCompanySelection() {
|
|
document.getElementById('newContactCompanyId').value = '';
|
|
document.getElementById('selectedCompanyName').innerText = 'Valgt: (Ingen / Privat)';
|
|
document.getElementById('newContactCompanySearch').value = '';
|
|
}
|
|
|
|
function cancelCreate() {
|
|
document.getElementById('contactCreateStage').style.display = 'none';
|
|
document.getElementById('contactSearchStage').style.display = 'block';
|
|
}
|
|
|
|
async function createContactSmart() {
|
|
const first = document.getElementById('newContactFirstName').value.trim();
|
|
const last = document.getElementById('newContactLastName').value.trim();
|
|
const email = document.getElementById('newContactEmail').value.trim();
|
|
const phone = document.getElementById('newContactPhone').value.trim();
|
|
const title = document.getElementById('newContactTitle').value.trim();
|
|
const companyId = document.getElementById('newContactCompanyId').value;
|
|
|
|
if(!first) {
|
|
alert("Fornavn er påkrævet");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const payload = {
|
|
first_name: first,
|
|
last_name: last,
|
|
email: email || null,
|
|
phone: phone || null,
|
|
title: title || null
|
|
};
|
|
|
|
if(companyId) {
|
|
payload.company_id = parseInt(companyId);
|
|
}
|
|
|
|
const response = await fetch('/api/v1/contacts', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if(!response.ok) {
|
|
const err = await response.json();
|
|
throw new Error(err.detail || 'Fejl ved oprettelse');
|
|
}
|
|
|
|
const newContact = await response.json();
|
|
|
|
// Add to cache
|
|
allContactsCache.push(newContact);
|
|
|
|
// Hide create stage
|
|
document.getElementById('contactCreateStage').style.display = 'none';
|
|
|
|
// Select the new contact
|
|
selectContact(newContact.id, `${newContact.first_name} ${newContact.last_name}`, newContact.email);
|
|
|
|
} catch (e) {
|
|
console.error(e);
|
|
alert("Kunne ikke oprette kontakt: " + e.message);
|
|
}
|
|
}
|
|
|
|
function filterContacts(query) {
|
|
const resultsDiv = document.getElementById('contactSearchResults');
|
|
if(!query || query.length < 1) {
|
|
resultsDiv.innerHTML = '<div class="text-center text-muted py-3 small">Indtast navn, email eller firma...</div>';
|
|
return;
|
|
}
|
|
|
|
const term = query.toLowerCase();
|
|
const matches = allContactsCache.filter(c =>
|
|
(c.first_name + ' ' + c.last_name).toLowerCase().includes(term) ||
|
|
(c.email || '').toLowerCase().includes(term) ||
|
|
(c.company_names && c.company_names.some(comp => comp.toLowerCase().includes(term)))
|
|
).slice(0, 10); // Limit results
|
|
|
|
if(matches.length === 0) {
|
|
resultsDiv.innerHTML = '<div class="text-center text-muted py-3 small">Ingen kontakter fundet</div>';
|
|
return;
|
|
}
|
|
|
|
resultsDiv.innerHTML = matches.map(c => {
|
|
const companies = (c.company_names && c.company_names.length > 0)
|
|
? `<div class="small text-muted mt-1"><i class="bi bi-building"></i> ${c.company_names.join(', ')}</div>`
|
|
: '';
|
|
|
|
return `
|
|
<a href="#" class="list-group-item list-group-item-action contact-result-item"
|
|
onclick="selectContact(${c.id}, '${c.first_name} ${c.last_name}', '${c.email||''}')">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<strong>${c.first_name} ${c.last_name}</strong>
|
|
<div class="small text-muted">${c.email || ''}</div>
|
|
${companies}
|
|
</div>
|
|
<i class="bi bi-chevron-right text-muted"></i>
|
|
</div>
|
|
</a>
|
|
`}).join('');
|
|
}
|
|
|
|
function selectContact(id, name, email) {
|
|
selectedContactId = id;
|
|
document.getElementById('selectedContactId').value = id;
|
|
document.getElementById('selectedContactName').innerText = name;
|
|
|
|
// Switch stages
|
|
document.getElementById('contactSearchStage').style.display = 'none';
|
|
document.getElementById('contactSelectedStage').style.display = 'block';
|
|
|
|
// Enable save
|
|
document.getElementById('btnAddContactConfirm').disabled = false;
|
|
}
|
|
|
|
function resetContactSelection() {
|
|
selectedContactId = null;
|
|
document.getElementById('contactSearchStage').style.display = 'block';
|
|
document.getElementById('contactSelectedStage').style.display = 'none';
|
|
document.getElementById('btnAddContactConfirm').disabled = true;
|
|
document.getElementById('contactSearchInput').focus();
|
|
}
|
|
|
|
function toggleCustomRole() {
|
|
const roleSelect = document.getElementById('roleSelect');
|
|
const customDiv = document.getElementById('customRoleDiv');
|
|
const customInput = document.getElementById('customRoleInput');
|
|
|
|
if (roleSelect.value === '_custom') {
|
|
customDiv.style.display = 'block';
|
|
customInput.required = true;
|
|
} else {
|
|
customDiv.style.display = 'none';
|
|
customInput.required = false;
|
|
}
|
|
}
|
|
|
|
async function addContact() {
|
|
if (!selectedContactId) {
|
|
alert('Vælg venligst en kontakt');
|
|
return;
|
|
}
|
|
|
|
let role = document.getElementById('roleSelect').value;
|
|
const notes = document.getElementById('contactNotes').value;
|
|
|
|
// Custom role logic
|
|
if (role === '_custom') {
|
|
const customRole = document.getElementById('customRoleInput').value.trim();
|
|
if (!customRole) {
|
|
alert('Indtast venligst en custom rolle');
|
|
return;
|
|
}
|
|
role = customRole.toLowerCase().replace(/\s+/g, '_').replace(/-/g, '_');
|
|
}
|
|
|
|
try {
|
|
const url = `/api/v1/ticket/tickets/{{ ticket.id }}/contacts?contact_id=${selectedContactId}&role=${role}${notes ? '¬es=' + encodeURIComponent(notes) : ''}`;
|
|
const response = await fetch(url, { method: 'POST' });
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.detail || 'Failed to add contact');
|
|
}
|
|
|
|
// Close modal and reload
|
|
bootstrap.Modal.getInstance(document.getElementById('addContactModal')).hide();
|
|
await loadContacts();
|
|
} catch (error) {
|
|
console.error('Error adding contact:', error);
|
|
alert('Fejl: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function editContactRole(contactId, currentRole, currentNotes) {
|
|
const newRole = prompt(`Ændr rolle (primary, requester, assignee, cc, observer):`, currentRole);
|
|
if (!newRole || newRole === currentRole) return;
|
|
|
|
const notes = prompt(`Noter (valgfri):`, currentNotes);
|
|
|
|
try {
|
|
const url = `/api/v1/ticket/tickets/{{ ticket.id }}/contacts/${contactId}?role=${newRole}${notes ? '¬es=' + encodeURIComponent(notes) : ''}`;
|
|
const response = await fetch(url, { method: 'PUT' });
|
|
|
|
if (!response.ok) throw new Error('Failed to update contact');
|
|
await loadContacts();
|
|
} catch (error) {
|
|
console.error('Error updating contact:', error);
|
|
alert('Fejl ved opdatering af kontakt');
|
|
}
|
|
}
|
|
|
|
async function removeContact(contactId, contactName) {
|
|
if (!confirm(`Fjern ${contactName} fra ticket?`)) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/v1/ticket/tickets/{{ ticket.id }}/contacts/${contactId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to remove contact');
|
|
await loadContacts();
|
|
} catch (error) {
|
|
console.error('Error removing contact:', error);
|
|
alert('Fejl ved fjernelse af kontakt');
|
|
}
|
|
}
|
|
|
|
// Load tags and contacts on page load
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadTicketTags();
|
|
loadContacts();
|
|
|
|
// Initialize tooltips/popovers if any
|
|
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
|
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
|
return new bootstrap.Tooltip(tooltipTriggerEl)
|
|
})
|
|
});
|
|
|
|
// Global Tag Picker Override
|
|
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 %}
|