feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
{% extends "shared/frontend/base.html" %}
{% block title %}{{ ticket.ticket_number }} - BMC Hub{% endblock %}
{% block extra_css %}
< style >
.ticket-header {
2026-01-10 21:09:29 +01:00
background: white;
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
padding: 2rem;
border-radius: var(--border-radius);
margin-bottom: 2rem;
2026-01-10 21:09:29 +01:00
border-left: 6px solid var(--accent);
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
}
2026-01-10 21:09:29 +01:00
.ticket-header.priority-urgent { border-left-color: #dc3545; }
.ticket-header.priority-high { border-left-color: #fd7e14; }
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
.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;
}
2025-12-17 07:56:33 +01:00
.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);
}
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
.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;
}
2025-12-17 16:38:08 +01:00
.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;
}
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
< / style >
{% endblock %}
{% block content %}
< div class = "container-fluid px-4" >
<!-- Ticket Header -->
2026-01-10 21:09:29 +01:00
< 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 }}" >
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
{{ ticket.priority.title() }} Priority
< / span >
2026-01-10 21:09:29 +01:00
<!-- 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 >
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
< / div >
2026-01-10 21:09:29 +01:00
<!-- 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 >
2025-12-17 07:56:33 +01:00
< / div >
2026-01-10 21:09:29 +01:00
{% endif %}
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
< / div >
2026-01-10 21:09:29 +01:00
<!-- Action Buttons Removed (Moved to specific sections) -->
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
< 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 >
2026-01-10 21:09:29 +01:00
<!-- 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 >
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
{% 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" >
2026-01-10 21:09:29 +01:00
< 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 >
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
< / 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" >
2026-01-10 21:09:29 +01:00
<!-- 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 >
2025-12-17 16:38:08 +01:00
< / 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 >
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
2026-01-10 21:09:29 +01:00
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
< / div >
< / div >
< / div >
{% endblock %}
{% block extra_js %}
< script >
2026-01-10 21:09:29 +01:00
// ============================================
// 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 {
2026-01-11 19:23:21 +01:00
const response = await fetch('/api/v1/prepaid-cards?status=active& customer_id={{ ticket.customer_id }}');
2026-01-10 21:09:29 +01:00
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);
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
}
2026-01-10 21:09:29 +01:00
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);
}
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
}
2025-12-17 07:56:33 +01:00
2026-01-10 21:09:29 +01:00
// ============================================
// TAGS MANAGEMENT
// ============================================
2025-12-17 07:56:33 +01:00
// 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');
2026-01-10 21:09:29 +01:00
if (!container) return; // Guard clause
2025-12-17 07:56:33 +01:00
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();
}
2025-12-17 16:38:08 +01:00
// ============================================
2026-01-10 21:09:29 +01:00
// CONTACTS MANAGEMENT (SEARCHABLE)
2025-12-17 16:38:08 +01:00
// ============================================
2026-01-10 21:09:29 +01:00
let allContactsCache = [];
let customersCache = [];
let selectedContactId = null;
2025-12-17 16:38:08 +01:00
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');
2026-01-10 21:09:29 +01:00
if (!container) return;
2025-12-17 16:38:08 +01:00
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() {
2026-01-10 21:09:29 +01:00
// 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 >
2025-12-17 16:38:08 +01:00
< / div >
2026-01-10 21:09:29 +01:00
<!-- 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" >
2025-12-17 16:38:08 +01:00
< / div >
2026-01-10 21:09:29 +01:00
< 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 >
2025-12-17 16:38:08 +01:00
< 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 >
2026-01-10 21:09:29 +01:00
2025-12-17 16:38:08 +01:00
< div class = "mb-3" id = "customRoleDiv" style = "display: none;" >
< label class = "form-label" > Custom Rolle< / label >
2026-01-10 21:09:29 +01:00
< input type = "text" class = "form-control" id = "customRoleInput" placeholder = "f.eks. projektleder" >
2025-12-17 16:38:08 +01:00
< / div >
2026-01-10 21:09:29 +01:00
2025-12-17 16:38:08 +01:00
< div class = "mb-3" >
< label class = "form-label" > Noter (valgfri)< / label >
2026-01-10 21:09:29 +01:00
< textarea class = "form-control" id = "contactNotes" rows = "2" placeholder = "Noter om rollen..." > < / textarea >
2025-12-17 16:38:08 +01:00
< / div >
< / div >
2026-01-10 21:09:29 +01:00
< / 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 >
2025-12-17 16:38:08 +01:00
< / div >
< / div >
< / div >
2026-01-10 21:09:29 +01:00
< / 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
};
2025-12-17 16:38:08 +01:00
2026-01-10 21:09:29 +01:00
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();
2025-12-17 16:38:08 +01:00
2026-01-10 21:09:29 +01:00
// Add to cache
allContactsCache.push(newContact);
2025-12-17 16:38:08 +01:00
2026-01-10 21:09:29 +01:00
// Hide create stage
document.getElementById('contactCreateStage').style.display = 'none';
2025-12-17 16:38:08 +01:00
2026-01-10 21:09:29 +01:00
// 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);
2025-12-17 16:38:08 +01:00
}
}
2026-01-10 21:09:29 +01:00
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();
}
2025-12-17 16:38:08 +01:00
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() {
2026-01-10 21:09:29 +01:00
if (!selectedContactId) {
2025-12-17 16:38:08 +01:00
alert('Vælg venligst en kontakt');
return;
}
2026-01-10 21:09:29 +01:00
let role = document.getElementById('roleSelect').value;
const notes = document.getElementById('contactNotes').value;
// Custom role logic
2025-12-17 16:38:08 +01:00
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 {
2026-01-10 21:09:29 +01:00
const url = `/api/v1/ticket/tickets/{{ ticket.id }}/contacts?contact_id=${selectedContactId}& role=${role}${notes ? '& notes=' + encodeURIComponent(notes) : ''}`;
2025-12-17 16:38:08 +01:00
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 ? '& notes=' + 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();
2026-01-10 21:09:29 +01:00
// 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)
})
2025-12-17 16:38:08 +01:00
});
2025-12-17 07:56:33 +01:00
2026-01-10 21:09:29 +01:00
// Global Tag Picker Override
2025-12-17 07:56:33 +01:00
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();
});
};
}
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
< / script >
{% endblock %}