2025-12-06 02:22:01 +01:00
{% extends "shared/frontend/base.html" %}
{% block title %}Kontakter - BMC Hub{% endblock %}
{% block extra_css %}
< style >
2026-04-01 21:34:58 +02:00
.contacts-toolbar {
gap: 1rem;
}
.toolbar-search-slot {
flex: 1;
display: flex;
justify-content: center;
}
.search-wrap {
position: relative;
min-width: 280px;
2026-05-02 11:02:29 +02:00
max-width: 520px;
width: min(52vw, 520px);
}
.search-label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.74rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 700;
color: var(--accent);
margin-bottom: 0.35rem;
}
.search-input-shell {
position: relative;
border: 1px solid rgba(15, 76, 117, 0.28);
border-radius: 12px;
background: linear-gradient(180deg, rgba(15, 76, 117, 0.09) 0%, rgba(15, 76, 117, 0.04) 100%);
box-shadow: 0 8px 20px rgba(2, 32, 71, 0.1);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.search-input-shell:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(15, 76, 117, 0.18), 0 10px 24px rgba(2, 32, 71, 0.14);
}
.search-icon {
position: absolute;
left: 0.8rem;
top: 50%;
transform: translateY(-50%);
color: var(--accent);
font-size: 0.95rem;
pointer-events: none;
2026-04-01 21:34:58 +02:00
}
.search-wrap .header-search {
width: 100%;
2026-05-02 11:02:29 +02:00
border: 0;
background: transparent;
padding-left: 2.25rem;
2026-04-01 21:34:58 +02:00
padding-right: 2.4rem;
2026-05-02 11:02:29 +02:00
font-weight: 500;
}
.search-wrap .header-search::placeholder {
color: var(--text-secondary);
opacity: 0.95;
2026-04-01 21:34:58 +02:00
}
.search-clear {
position: absolute;
right: 0.45rem;
top: 50%;
transform: translateY(-50%);
border: 0;
width: 1.8rem;
height: 1.8rem;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
background: transparent;
}
.search-clear:hover {
background: rgba(15, 76, 117, 0.12);
color: var(--text-primary);
}
.search-clear.d-none {
display: none !important;
}
2025-12-06 02:22:01 +01:00
.filter-btn {
background: var(--bg-card);
border: 1px solid rgba(0,0,0,0.1);
color: var(--text-secondary);
padding: 0.5rem 1.2rem;
border-radius: 20px;
font-size: 0.9rem;
transition: all 0.2s;
cursor: pointer;
}
.filter-btn:hover, .filter-btn.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
2026-04-01 21:34:58 +02:00
2026-05-02 11:02:29 +02:00
.table-utility-bar {
display: flex;
justify-content: flex-end;
margin-bottom: 0.8rem;
}
.column-toggle-btn {
border: 1px solid rgba(15, 76, 117, 0.2);
background: var(--bg-card);
color: var(--text-primary);
border-radius: 10px;
padding: 0.35rem 0.7rem;
font-size: 0.82rem;
font-weight: 600;
}
.column-toggle-btn:hover {
background: rgba(15, 76, 117, 0.08);
}
.column-toggle-menu {
border: 1px solid rgba(15, 76, 117, 0.16);
border-radius: 12px;
padding: 0.4rem;
min-width: 200px;
box-shadow: 0 10px 24px rgba(2, 32, 71, 0.14);
}
.column-toggle-item {
border-radius: 8px;
padding: 0.3rem 0.45rem;
margin-bottom: 0.1rem;
}
.column-toggle-item:hover {
background: rgba(15, 76, 117, 0.08);
}
2026-04-01 21:34:58 +02:00
.contacts-shell {
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 14px;
box-shadow: 0 10px 30px rgba(2, 32, 71, 0.06);
}
.contacts-table-wrap {
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 12px;
2026-05-06 07:01:43 +02:00
max-height: min(68vh, 780px);
overflow: auto;
2026-04-01 21:34:58 +02:00
}
.contacts-table {
margin-bottom: 0;
}
.contacts-shell .table > :not(caption) > * > * {
padding-top: 0.85rem;
padding-bottom: 0.85rem;
vertical-align: middle;
}
.contacts-shell .table-hover > tbody > tr:hover {
--bs-table-accent-bg: rgba(15, 76, 117, 0.05);
}
.contacts-shell .table tbody tr {
cursor: pointer;
transition: background-color 0.18s ease;
}
.contacts-shell .table tbody tr:nth-child(even) {
background: rgba(15, 76, 117, 0.015);
}
.contacts-shell .table thead th {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
border-bottom-width: 1px;
position: sticky;
top: 0;
z-index: 2;
background: var(--bg-card);
box-shadow: 0 1px 0 rgba(15, 76, 117, 0.12);
}
2026-05-02 11:02:29 +02:00
.sort-btn {
border: 0;
background: transparent;
color: inherit;
font-size: inherit;
letter-spacing: inherit;
text-transform: inherit;
font-weight: 700;
padding: 0;
display: inline-flex;
align-items: center;
gap: 0.28rem;
}
.sort-btn:hover {
color: var(--accent);
}
.sort-btn .sort-indicator {
font-size: 0.72rem;
opacity: 0.45;
}
.sort-btn.active .sort-indicator {
opacity: 1;
}
.contacts-shell .table thead th.col-key {
min-width: 220px;
}
.contacts-shell .table thead th.col-updated {
min-width: 130px;
}
2026-04-01 21:34:58 +02:00
.contact-name {
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.contact-subline {
font-size: 0.82rem;
color: var(--text-secondary);
margin-top: 0.1rem;
}
.contact-info-main {
font-weight: 500;
color: var(--text-primary);
}
.contact-quick-actions {
display: flex;
align-items: center;
gap: 0.35rem;
2026-05-02 11:02:29 +02:00
margin-top: 0.08rem;
2026-04-01 21:34:58 +02:00
flex-wrap: wrap;
}
.contact-quick-actions .btn {
border-radius: 999px;
2026-05-02 11:02:29 +02:00
padding: 0.04rem 0.45rem;
font-size: 0.69rem;
2026-04-01 21:34:58 +02:00
line-height: 1.2;
}
2026-05-02 11:02:29 +02:00
.contact-data-stack {
display: grid;
gap: 0.1rem;
}
.key-line {
display: inline-flex;
align-items: center;
gap: 0.32rem;
min-height: 1.2rem;
line-height: 1.15;
}
.key-label {
font-size: 0.61rem;
text-transform: uppercase;
letter-spacing: 0.05em;
border-radius: 999px;
padding: 0.08rem 0.35rem;
background: rgba(15, 76, 117, 0.08);
color: var(--accent);
font-weight: 600;
}
.key-value {
font-size: 0.8rem;
color: var(--text-primary);
font-weight: 500;
text-decoration: none;
}
.key-value:hover {
color: var(--accent);
}
.muted-data {
color: var(--text-secondary);
font-size: 0.79rem;
}
.updated-at {
font-size: 0.8rem;
color: var(--text-secondary);
white-space: nowrap;
}
2026-04-01 21:34:58 +02:00
.company-count-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 1px solid rgba(15, 76, 117, 0.2);
background: rgba(15, 76, 117, 0.06);
color: var(--accent);
border-radius: 999px;
padding: 0.2rem 0.58rem;
font-size: 0.75rem;
font-weight: 600;
}
.status-pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
padding: 0.24rem 0.62rem;
border: 1px solid transparent;
}
.status-pill.active {
background: rgba(17, 153, 84, 0.12);
border-color: rgba(17, 153, 84, 0.24);
color: #0b6b3a;
}
.status-pill.inactive {
background: rgba(108, 117, 125, 0.13);
border-color: rgba(108, 117, 125, 0.24);
color: #5b6570;
}
.btn-table-action {
width: 32px;
height: 32px;
border-radius: 10px;
border: 1px solid rgba(15, 76, 117, 0.16);
background: var(--bg-card);
color: var(--accent);
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-table-action:hover {
background: rgba(15, 76, 117, 0.08);
border-color: rgba(15, 76, 117, 0.28);
}
2025-12-06 02:22:01 +01:00
.contact-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--accent-light);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 0.9rem;
2026-04-01 21:34:58 +02:00
box-shadow: inset 0 0 0 1px rgba(15, 76, 117, 0.12);
2025-12-06 02:22:01 +01:00
}
.pagination-btn {
border: 1px solid rgba(0,0,0,0.1);
padding: 0.5rem 1rem;
background: var(--bg-card);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
}
.pagination-btn:hover:not(:disabled) {
background: var(--accent-light);
border-color: var(--accent);
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
2026-04-01 21:34:58 +02:00
.create-contact-modal .modal-content {
border: 1px solid rgba(15, 76, 117, 0.14);
border-radius: 16px;
box-shadow: 0 22px 50px rgba(2, 32, 71, 0.22);
}
.create-contact-modal .modal-header {
border-bottom: 1px solid rgba(15, 76, 117, 0.12);
background: linear-gradient(180deg, rgba(15, 76, 117, 0.06) 0%, rgba(15, 76, 117, 0.02) 100%);
}
.create-contact-modal .modal-title {
font-weight: 700;
color: var(--accent);
}
.company-picker {
border: 1px solid rgba(15, 76, 117, 0.18);
border-radius: 12px;
padding: 0.6rem;
background: rgba(15, 76, 117, 0.02);
}
.company-search-input {
border-radius: 10px;
}
.company-results {
margin-top: 0.5rem;
max-height: 180px;
overflow: auto;
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 10px;
background: var(--bg-card);
}
.company-result-item {
width: 100%;
border: 0;
border-bottom: 1px solid rgba(15, 76, 117, 0.08);
padding: 0.5rem 0.65rem;
text-align: left;
background: transparent;
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
cursor: pointer;
}
.company-result-item:last-child {
border-bottom: 0;
}
.company-result-item:hover {
background: rgba(15, 76, 117, 0.08);
}
.company-result-item.selected {
background: rgba(15, 76, 117, 0.12);
color: var(--accent);
font-weight: 600;
}
.selected-companies {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.55rem;
}
.company-chip {
border-radius: 999px;
border: 1px solid rgba(15, 76, 117, 0.25);
background: rgba(15, 76, 117, 0.1);
color: var(--accent);
font-size: 0.76rem;
padding: 0.22rem 0.55rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.company-chip button {
border: 0;
background: transparent;
color: inherit;
line-height: 1;
padding: 0;
}
2026-08-28 20:49:55 +02:00
.create-mail-dropzone {
min-width: 210px;
border: 1px dashed rgba(15, 76, 117, 0.55);
border-radius: 12px;
background: rgba(15, 76, 117, 0.05);
color: var(--accent);
padding: 0.55rem 0.8rem;
cursor: pointer;
transition: background-color .2s, border-color .2s, transform .2s;
}
.create-mail-dropzone:hover,
.create-mail-dropzone.is-dragging {
background: rgba(15, 76, 117, 0.14);
border-color: var(--accent);
transform: translateY(-1px);
}
2026-04-01 21:34:58 +02:00
@media (max-width: 992px) {
.contacts-toolbar {
width: 100%;
flex-direction: column;
align-items: stretch !important;
}
.toolbar-search-slot {
width: 100%;
justify-content: stretch;
}
.search-wrap {
width: 100%;
max-width: 100%;
}
}
2025-12-06 02:22:01 +01:00
< / style >
{% endblock %}
{% block content %}
2026-04-01 21:34:58 +02:00
< div class = "d-flex justify-content-between align-items-center mb-5 contacts-toolbar" >
2025-12-06 02:22:01 +01:00
< div >
< h2 class = "fw-bold mb-1" > Kontakter< / h2 >
< p class = "text-muted mb-0" > Administrer kontaktpersoner< / p >
< / div >
2026-04-01 21:34:58 +02:00
< div class = "toolbar-search-slot" >
< div class = "search-wrap" >
2026-05-02 11:02:29 +02:00
< div class = "search-label" > < i class = "bi bi-search" > < / i > Hurtig søgning< / div >
< div class = "search-input-shell" >
< i class = "bi bi-search search-icon" > < / i >
< input type = "search" id = "searchInput" class = "header-search" placeholder = "Søg navn, email, telefon eller firma..." autocomplete = "off" spellcheck = "false" >
< button type = "button" id = "searchClearBtn" class = "search-clear d-none" aria-label = "Ryd søgning" title = "Ryd søgning" >
< i class = "bi bi-x-lg" > < / i >
< / button >
< / div >
2026-04-01 21:34:58 +02:00
< / div >
2025-12-06 02:22:01 +01:00
< / div >
2026-08-28 20:49:55 +02:00
< div class = "d-flex gap-2 flex-wrap" >
< div id = "createFromEmailDropzone" class = "create-mail-dropzone d-flex align-items-center gap-2" role = "button" tabindex = "0" title = "Træk en mail direkte fra Outlook hertil" >
< i class = "bi bi-envelope-arrow-down fs-5" > < / i >
< div > < div class = "fw-semibold small" > Træk Outlook-mail hertil< / div > < div class = "text-muted" style = "font-size:.7rem" > eller klik for at vælge< / div > < / div >
< / div >
< input id = "createFromEmailInput" type = "file" accept = ".msg,.eml,message/rfc822,application/vnd.ms-outlook" hidden >
< button class = "btn btn-primary" onclick = "showCreateContactModal()" >
< i class = "bi bi-plus-lg me-2" > < / i > Opret Kontakt
< / button >
< / div >
2025-12-06 02:22:01 +01:00
< / div >
< div class = "mb-4 d-flex gap-2 flex-wrap" >
< button class = "filter-btn active" data-filter = "all" onclick = "setFilter('all')" >
Alle Kontakter < span id = "countAll" class = "ms-1" > < / span >
< / button >
< button class = "filter-btn" data-filter = "active" onclick = "setFilter('active')" >
Aktive < span id = "countActive" class = "ms-1" > < / span >
< / button >
< button class = "filter-btn" data-filter = "inactive" onclick = "setFilter('inactive')" >
Inaktive < span id = "countInactive" class = "ms-1" > < / span >
< / button >
< / div >
2026-04-01 21:34:58 +02:00
< div class = "card p-4 contacts-shell" >
2026-05-02 11:02:29 +02:00
< div class = "table-utility-bar" >
< div class = "dropdown" >
< button class = "column-toggle-btn dropdown-toggle" type = "button" data-bs-toggle = "dropdown" aria-expanded = "false" >
< i class = "bi bi-layout-three-columns me-1" > < / i > Kolonner
< / button >
< div class = "dropdown-menu dropdown-menu-end column-toggle-menu" id = "columnToggleMenu" >
< label class = "dropdown-item column-toggle-item" >
< input class = "form-check-input me-2 column-toggle-input" type = "checkbox" data-column = "title" checked >
Titel
< / label >
< label class = "dropdown-item column-toggle-item" >
< input class = "form-check-input me-2 column-toggle-input" type = "checkbox" data-column = "companies" checked >
Firmaer
< / label >
< label class = "dropdown-item column-toggle-item" >
< input class = "form-check-input me-2 column-toggle-input" type = "checkbox" data-column = "updated" checked >
Opdateret
< / label >
< label class = "dropdown-item column-toggle-item" >
< input class = "form-check-input me-2 column-toggle-input" type = "checkbox" data-column = "status" checked >
Status
< / label >
< / div >
< / div >
< / div >
2026-04-01 21:34:58 +02:00
< div class = "table-responsive contacts-table-wrap" >
< table class = "table table-hover align-middle contacts-table" >
2025-12-06 02:22:01 +01:00
< thead >
< tr >
2026-05-02 11:02:29 +02:00
< th >
< button type = "button" class = "sort-btn" data-sort-key = "name" onclick = "setSort('name')" >
Navn < i class = "bi bi-arrow-down-up sort-indicator" > < / i >
< / button >
< / th >
< th class = "col-key" > Vigtig Info< / th >
< th class = "col-title" >
< button type = "button" class = "sort-btn" data-sort-key = "title" onclick = "setSort('title')" >
Titel < i class = "bi bi-arrow-down-up sort-indicator" > < / i >
< / button >
< / th >
< th class = "col-companies" >
< button type = "button" class = "sort-btn" data-sort-key = "company_count" onclick = "setSort('company_count')" >
Firmaer < i class = "bi bi-arrow-down-up sort-indicator" > < / i >
< / button >
< / th >
< th class = "col-updated" >
< button type = "button" class = "sort-btn" data-sort-key = "updated_at" onclick = "setSort('updated_at')" >
Opdateret < i class = "bi bi-arrow-down-up sort-indicator" > < / i >
< / button >
< / th >
< th class = "col-status" >
< button type = "button" class = "sort-btn" data-sort-key = "is_active" onclick = "setSort('is_active')" >
Status < i class = "bi bi-arrow-down-up sort-indicator" > < / i >
< / button >
< / th >
2025-12-06 02:22:01 +01:00
< th class = "text-end" > Handlinger< / th >
< / tr >
< / thead >
< tbody id = "contactsTableBody" >
< tr >
2026-05-02 11:02:29 +02:00
< td colspan = "7" class = "text-center py-5" >
2025-12-06 02:22:01 +01:00
< div class = "spinner-border text-primary" role = "status" >
< span class = "visually-hidden" > Loading...< / span >
< / div >
< / td >
< / tr >
< / tbody >
< / table >
< / div >
<!-- Pagination -->
< div class = "d-flex justify-content-between align-items-center mt-4" >
< div class = "text-muted small" >
Viser < span id = "showingStart" > 0< / span > -< span id = "showingEnd" > 0< / span > af < span id = "totalCount" > 0< / span > kontakter
< / div >
< div class = "d-flex gap-2" >
< button class = "pagination-btn" id = "prevBtn" onclick = "previousPage()" >
< i class = "bi bi-chevron-left" > < / i > Forrige
< / button >
< button class = "pagination-btn" id = "nextBtn" onclick = "nextPage()" >
Næste < i class = "bi bi-chevron-right" > < / i >
< / button >
< / div >
< / div >
< / div >
2026-08-28 20:49:55 +02:00
<!-- Create contact from Outlook email -->
< div class = "modal fade" id = "createFromEmailModal" tabindex = "-1" >
< div class = "modal-dialog modal-lg modal-dialog-scrollable" >
< div class = "modal-content" >
< div class = "modal-header" >
< div >
< h5 class = "modal-title" > Opret kontakt ud fra mail< / h5 >
< div id = "createFromEmailMeta" class = "small text-muted" > < / div >
< / div >
< button type = "button" class = "btn-close" data-bs-dismiss = "modal" > < / button >
< / div >
< div class = "modal-body" >
< div id = "createFromEmailStatus" class = "alert alert-info d-none" > < / div >
< h6 class = "fw-bold" > Kontaktoplysninger< / h6 >
< div class = "row g-3 mb-4" >
< div class = "col-md-6" > < label class = "form-label" > Fornavn< / label > < input id = "mailContactFirstName" class = "form-control" > < / div >
< div class = "col-md-6" > < label class = "form-label" > Efternavn< / label > < input id = "mailContactLastName" class = "form-control" > < / div >
< div class = "col-md-6" > < label class = "form-label" > E-mail< / label > < input id = "mailContactEmail" type = "email" class = "form-control" > < / div >
< div class = "col-md-6" > < label class = "form-label" > Mobil< / label > < input id = "mailContactMobile" class = "form-control" > < / div >
< div class = "col-md-6" > < label class = "form-label" > Telefon< / label > < input id = "mailContactPhone" class = "form-control" > < / div >
< div class = "col-md-6" > < label class = "form-label" > Titel< / label > < input id = "mailContactTitle" class = "form-control" > < / div >
< div class = "col-md-6" > < label class = "form-label" > Afdeling< / label > < input id = "mailContactDepartment" class = "form-control" > < / div >
< / div >
< div id = "mailCompanySection" class = "border rounded-3 p-3 d-none" >
< div class = "form-check mb-3" >
< input id = "mailUseCompany" class = "form-check-input" type = "checkbox" checked >
< label id = "mailUseCompanyLabel" class = "form-check-label fw-semibold" for = "mailUseCompany" > Tilknyt virksomhed< / label >
< / div >
< div class = "row g-3" >
< div class = "col-md-7" > < label class = "form-label" > Virksomhedsnavn< / label > < input id = "mailCompanyName" class = "form-control" > < / div >
< div class = "col-md-5" > < label class = "form-label" > CVR< / label > < input id = "mailCompanyCvr" class = "form-control" maxlength = "8" > < / div >
< div class = "col-md-7" > < label class = "form-label" > Adresse< / label > < input id = "mailCompanyAddress" class = "form-control" > < / div >
< div class = "col-md-2" > < label class = "form-label" > Postnr.< / label > < input id = "mailCompanyPostalCode" class = "form-control" > < / div >
< div class = "col-md-3" > < label class = "form-label" > By< / label > < input id = "mailCompanyCity" class = "form-control" > < / div >
< / div >
< div id = "mailCompanyHint" class = "form-text mt-2" > < / div >
< / div >
< div id = "mailNoCompany" class = "text-muted small border rounded-3 p-3" > Intet CVR fundet. Der oprettes ikke en virksomhed automatisk.< / div >
< / div >
< div class = "modal-footer" >
< button type = "button" class = "btn btn-secondary" data-bs-dismiss = "modal" > Annuller< / button >
< button id = "approveCreateFromEmailBtn" type = "button" class = "btn btn-primary" onclick = "approveCreateFromEmail()" >
< i class = "bi bi-check-lg me-1" > < / i > Godkend og opret
< / button >
< / div >
< / div >
< / div >
< / div >
2025-12-06 02:22:01 +01:00
<!-- Create Contact Modal -->
2026-04-01 21:34:58 +02:00
< div class = "modal fade create-contact-modal" id = "createContactModal" tabindex = "-1" >
2025-12-06 02:22:01 +01:00
< div class = "modal-dialog modal-lg" >
< div class = "modal-content" >
< div class = "modal-header" >
< h5 class = "modal-title" > Opret Ny Kontakt< / h5 >
< button type = "button" class = "btn-close" data-bs-dismiss = "modal" > < / button >
< / div >
< div class = "modal-body" >
< form id = "createContactForm" >
< div class = "row g-3" >
< div class = "col-md-6" >
< label class = "form-label" > Fornavn < span class = "text-danger" > *< / span > < / label >
< input type = "text" class = "form-control" id = "firstNameInput" required >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Efternavn < span class = "text-danger" > *< / span > < / label >
< input type = "text" class = "form-control" id = "lastNameInput" required >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Email< / label >
< input type = "email" class = "form-control" id = "emailInput" >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Telefon< / label >
< input type = "text" class = "form-control" id = "phoneInput" >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Mobil< / label >
< input type = "text" class = "form-control" id = "mobileInput" >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Titel< / label >
< input type = "text" class = "form-control" id = "titleInput" placeholder = "CEO, CTO, Manager..." >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Afdeling< / label >
< input type = "text" class = "form-control" id = "departmentInput" >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Rolle< / label >
< input type = "text" class = "form-control" id = "roleInput" placeholder = "Primær kontakt, Fakturering..." >
< / div >
< div class = "col-12" >
< label class = "form-label" > Firmaer< / label >
2026-04-01 21:34:58 +02:00
< div class = "company-picker" >
< input
type="search"
id="companySearchInput"
class="form-control company-search-input"
placeholder="Søg firma..."
autocomplete="off"
spellcheck="false"
>
< div class = "company-results" id = "companyResults" > < / div >
< div class = "selected-companies" id = "selectedCompanies" > < / div >
< / div >
< div class = "form-text" > Vælg et eller flere firmaer ved at søge og klikke.< / div >
2025-12-06 02:22:01 +01:00
< / div >
< div class = "col-12" >
< div class = "form-check" >
< input class = "form-check-input" type = "checkbox" id = "isPrimaryInput" >
< label class = "form-check-label" for = "isPrimaryInput" >
Primær kontakt (for første valgte firma)
< / label >
< / div >
< / div >
< div class = "col-12" >
< label class = "form-label" > Noter< / label >
< textarea class = "form-control" id = "notesInput" rows = "3" > < / textarea >
< / div >
< div class = "col-12" >
< div class = "form-check" >
< input class = "form-check-input" type = "checkbox" id = "isActiveInput" checked >
< label class = "form-check-label" for = "isActiveInput" >
Aktiv kontakt
< / label >
< / div >
< / div >
< / div >
< / form >
< / 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 = "createContact()" >
< i class = "bi bi-plus-lg me-2" > < / i > Opret Kontakt
< / button >
< / div >
< / div >
< / div >
< / div >
2026-02-06 10:47:14 +01:00
<!-- Edit Contact Modal -->
< div class = "modal fade" id = "editContactModal" tabindex = "-1" >
< div class = "modal-dialog modal-lg" >
< div class = "modal-content" >
< div class = "modal-header" >
< h5 class = "modal-title" > Rediger Kontakt< / h5 >
< button type = "button" class = "btn-close" data-bs-dismiss = "modal" > < / button >
< / div >
< div class = "modal-body" >
< form id = "editContactForm" >
< input type = "hidden" id = "editContactId" >
< div class = "row g-3" >
< div class = "col-md-6" >
< label class = "form-label" > Fornavn < span class = "text-danger" > *< / span > < / label >
< input type = "text" class = "form-control" id = "editFirstNameInput" required >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Efternavn < span class = "text-danger" > *< / span > < / label >
< input type = "text" class = "form-control" id = "editLastNameInput" required >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Email< / label >
< input type = "email" class = "form-control" id = "editEmailInput" >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Telefon< / label >
< input type = "text" class = "form-control" id = "editPhoneInput" >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Mobil< / label >
< input type = "text" class = "form-control" id = "editMobileInput" >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Titel< / label >
< input type = "text" class = "form-control" id = "editTitleInput" placeholder = "CEO, CTO, Manager..." >
< / div >
< div class = "col-md-6" >
< label class = "form-label" > Afdeling< / label >
< input type = "text" class = "form-control" id = "editDepartmentInput" >
< / div >
< div class = "col-12" >
< div class = "form-check" >
< input class = "form-check-input" type = "checkbox" id = "editIsActiveInput" >
< label class = "form-check-label" for = "editIsActiveInput" >
Aktiv kontakt
< / label >
< / div >
< / div >
< / div >
< / form >
< / 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 = "saveEditContact()" >
< i class = "bi bi-check-lg me-2" > < / i > Gem Ændringer
< / button >
< / div >
< / div >
< / div >
< / div >
2025-12-06 02:22:01 +01:00
{% endblock %}
{% block extra_js %}
< script >
let currentPage = 0;
let pageSize = 20;
let currentFilter = 'all';
let searchQuery = '';
let totalContacts = 0;
2026-04-01 21:34:58 +02:00
let searchTimeout = null;
let currentRequestController = null;
2026-05-06 07:01:43 +02:00
let lastLoadedQueryKey = '';
2026-04-01 21:34:58 +02:00
let availableCompanies = [];
let selectedCompanyIds = new Set();
2026-05-02 11:02:29 +02:00
let currentContactsData = [];
2026-07-10 08:10:31 +02:00
let pendingCreateModalCustomerId = null;
let pendingCreateReturnTo = null;
2026-08-28 20:49:55 +02:00
let analyzedMailCompany = null;
2026-05-02 11:02:29 +02:00
let currentSort = {
key: 'name',
direction: 'asc'
};
let visibleColumns = {
title: true,
companies: true,
updated: true,
status: true
};
2025-12-06 02:22:01 +01:00
// Load contacts on page load
document.addEventListener('DOMContentLoaded', () => {
2026-08-28 20:49:55 +02:00
initializeCreateFromEmailDropzone();
document.getElementById('createFromEmailInput')?.addEventListener('change', event => {
const file = event.target.files?.[0];
if (file) analyzeEmailForNewContact(file);
event.target.value = '';
});
2026-07-10 08:10:31 +02:00
const urlParams = new URLSearchParams(window.location.search);
const preselectedCustomerId = Number(urlParams.get('customer_id'));
const shouldOpenCreateModal = urlParams.get('create') === '1';
pendingCreateReturnTo = urlParams.get('return_to') || null;
pendingCreateModalCustomerId = Number.isFinite(preselectedCustomerId) & & preselectedCustomerId > 0
? preselectedCustomerId
: null;
2026-05-02 11:02:29 +02:00
loadTablePreferences();
applyColumnVisibility();
updateSortIndicators();
2025-12-06 02:22:01 +01:00
loadContacts();
loadCompaniesForSelect();
2026-04-01 21:34:58 +02:00
2026-07-10 08:10:31 +02:00
if (shouldOpenCreateModal) {
setTimeout(() => {
showCreateContactModal();
}, 0);
}
2026-04-01 21:34:58 +02:00
const searchInput = document.getElementById('searchInput');
const clearBtn = document.getElementById('searchClearBtn');
const triggerSearch = () => {
const nextSearch = searchInput.value.trim();
if (nextSearch === searchQuery) {
toggleClearButton(nextSearch);
return;
}
searchQuery = nextSearch;
currentPage = 0;
toggleClearButton(searchQuery);
loadContacts();
};
searchInput.addEventListener('input', (e) => {
2025-12-06 02:22:01 +01:00
clearTimeout(searchTimeout);
2026-04-01 21:34:58 +02:00
toggleClearButton(e.target.value.trim());
2025-12-06 02:22:01 +01:00
searchTimeout = setTimeout(() => {
2026-04-01 21:34:58 +02:00
triggerSearch();
2025-12-06 02:22:01 +01:00
}, 300);
});
2026-04-01 21:34:58 +02:00
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
clearTimeout(searchTimeout);
triggerSearch();
return;
}
if (e.key === 'Escape') {
if (!searchInput.value) {
toggleClearButton('');
return;
}
searchInput.value = '';
clearTimeout(searchTimeout);
triggerSearch();
}
});
clearBtn.addEventListener('click', () => {
if (!searchInput.value) {
toggleClearButton('');
return;
}
searchInput.value = '';
clearTimeout(searchTimeout);
triggerSearch();
searchInput.focus();
});
document.getElementById('companySearchInput')?.addEventListener('input', (e) => {
renderCompanyResults(e.target.value || '');
});
2026-05-02 11:02:29 +02:00
document.querySelectorAll('.column-toggle-input').forEach((input) => {
input.addEventListener('change', (e) => {
const column = e.target.getAttribute('data-column');
visibleColumns[column] = !!e.target.checked;
persistTablePreferences();
applyColumnVisibility();
});
});
2025-12-06 02:22:01 +01:00
});
2026-08-28 20:49:55 +02:00
function initializeCreateFromEmailDropzone() {
const zone = document.getElementById('createFromEmailDropzone');
const input = document.getElementById('createFromEmailInput');
if (!zone || !input) return;
const chooseFile = () => input.click();
zone.addEventListener('click', chooseFile);
zone.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
chooseFile();
}
});
['dragenter', 'dragover'].forEach(type => zone.addEventListener(type, event => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
zone.classList.add('is-dragging');
}));
['dragleave', 'dragend'].forEach(type => zone.addEventListener(type, event => {
event.preventDefault();
zone.classList.remove('is-dragging');
}));
zone.addEventListener('drop', event => {
event.preventDefault();
event.stopPropagation();
zone.classList.remove('is-dragging');
const file = event.dataTransfer?.files?.[0]
|| Array.from(event.dataTransfer?.items || []).find(item => item.kind === 'file')?.getAsFile();
if (!file) {
alert('Outlook afleverede ikke mailen som en fil. Prøv at gemme eller trække den som .msg/.eml, eller klik på boksen.');
return;
}
analyzeEmailForNewContact(file);
});
}
2025-12-06 02:22:01 +01:00
function setFilter(filter) {
currentFilter = filter;
currentPage = 0;
// Update active button
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.remove('active');
});
event.target.classList.add('active');
loadContacts();
}
2026-07-10 08:10:31 +02:00
async function loadContacts(force = false) {
2025-12-06 02:22:01 +01:00
const tbody = document.getElementById('contactsTableBody');
2026-05-02 11:02:29 +02:00
tbody.innerHTML = '< tr > < td colspan = "7" class = "text-center py-5" > < div class = "spinner-border text-primary" > < / div > < / td > < / tr > ';
2026-04-01 21:34:58 +02:00
if (currentRequestController) {
currentRequestController.abort();
}
currentRequestController = new AbortController();
2025-12-06 02:22:01 +01:00
try {
// Build query parameters
let params = new URLSearchParams({
limit: pageSize,
offset: currentPage * pageSize
});
if (searchQuery) {
params.append('search', searchQuery);
}
if (currentFilter === 'active') {
params.append('is_active', 'true');
} else if (currentFilter === 'inactive') {
params.append('is_active', 'false');
}
2026-04-01 21:34:58 +02:00
2026-05-06 07:01:43 +02:00
const queryKey = `${currentPage}|${pageSize}|${searchQuery}|${currentFilter}`;
2026-07-10 08:10:31 +02:00
if (!force & & queryKey === lastLoadedQueryKey) {
2026-05-06 07:01:43 +02:00
return;
}
lastLoadedQueryKey = queryKey;
2026-04-01 21:34:58 +02:00
const response = await fetch(`/api/v1/contacts?${params}`, { signal: currentRequestController.signal });
2025-12-06 02:22:01 +01:00
const data = await response.json();
totalContacts = data.total;
2026-05-02 11:02:29 +02:00
currentContactsData = Array.isArray(data.contacts) ? data.contacts : [];
displayContacts(currentContactsData);
2026-06-11 01:12:25 +02:00
updatePagination(data.total, currentContactsData.length);
2025-12-06 02:22:01 +01:00
} catch (error) {
2026-04-01 21:34:58 +02:00
if (error.name === 'AbortError') {
return;
}
2025-12-06 02:22:01 +01:00
console.error('Failed to load contacts:', error);
2026-05-02 11:02:29 +02:00
tbody.innerHTML = '< tr > < td colspan = "7" class = "text-center py-5 text-danger" > Kunne ikke indlæse kontakter< / td > < / tr > ';
2026-04-01 21:34:58 +02:00
} finally {
currentRequestController = null;
2025-12-06 02:22:01 +01:00
}
}
2026-04-01 21:34:58 +02:00
function toggleClearButton(value) {
document.getElementById('searchClearBtn')?.classList.toggle('d-none', !value);
}
2025-12-06 02:22:01 +01:00
function displayContacts(contacts) {
const tbody = document.getElementById('contactsTableBody');
2026-05-02 11:02:29 +02:00
const sortedContacts = getSortedContacts(contacts);
2025-12-06 02:22:01 +01:00
2026-05-02 11:02:29 +02:00
if (!sortedContacts || sortedContacts.length === 0) {
tbody.innerHTML = '< tr > < td colspan = "7" class = "text-center py-5 text-muted" > Ingen kontakter fundet< / td > < / tr > ';
applyColumnVisibility();
updateSortIndicators();
2025-12-06 02:22:01 +01:00
return;
}
2026-05-02 11:02:29 +02:00
tbody.innerHTML = sortedContacts.map(contact => {
2025-12-06 02:22:01 +01:00
const initials = getInitials(contact.first_name, contact.last_name);
2026-04-01 21:34:58 +02:00
const statusBadge = contact.is_active
? '< span class = "status-pill active" > Aktiv< / span > '
: '< span class = "status-pill inactive" > Inaktiv< / span > ';
2025-12-06 02:22:01 +01:00
const companyCount = contact.company_count || 0;
const companyNames = contact.company_names || [];
2026-05-06 07:01:43 +02:00
const companyDisplay = companyNames.length > 0
2025-12-06 02:22:01 +01:00
? companyNames.slice(0, 2).join(', ') + (companyNames.length > 2 ? '...' : '')
2026-05-06 07:01:43 +02:00
: '-';
2026-02-14 02:26:29 +01:00
const fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
2026-05-02 11:02:29 +02:00
const preferredPhone = contact.mobile || contact.phone || '';
const hasEmail = !!contact.email;
const hasPreferredPhone = !!preferredPhone;
2026-04-01 21:34:58 +02:00
const safeName = escapeHtml(`${contact.first_name || ''} ${contact.last_name || ''}`.trim() || '-');
const safeDepartment = escapeHtml(contact.department || '-');
const safeEmail = escapeHtml(contact.email || '-');
const safeTitle = escapeHtml(contact.title || '-');
2026-05-02 11:02:29 +02:00
const safePhone = escapeHtml(preferredPhone || '-');
2026-05-06 07:01:43 +02:00
const companiesTitle = escapeHtml(companyNames.join(', '));
2026-05-02 11:02:29 +02:00
const updatedAt = formatContactDate(contact.updated_at || contact.created_at);
2025-12-06 02:22:01 +01:00
return `
2026-04-01 21:34:58 +02:00
< tr onclick = "viewContact(${contact.id})" >
2025-12-06 02:22:01 +01:00
< td >
< div class = "d-flex align-items-center" >
< div class = "contact-avatar me-3" > ${initials}< / div >
< div >
2026-04-01 21:34:58 +02:00
< div class = "contact-name" > ${safeName}< / div >
< div class = "contact-subline" > ${safeDepartment}< / div >
2025-12-06 02:22:01 +01:00
< / div >
< / div >
< / td >
< td >
2026-05-02 11:02:29 +02:00
< div class = "contact-data-stack" >
< div class = "key-line" >
< span class = "key-label" > Email< / span >
${hasEmail
? `< a class = "key-value" href = "mailto:${safeEmail}" onclick = "event.stopPropagation()" > ${safeEmail}< / a > `
: '< span class = "muted-data" > Ikke angivet< / span > '}
< / div >
< div class = "key-line" >
< span class = "key-label" > Telefon< / span >
< span class = "key-value" > ${safePhone}< / span >
< / div >
< div class = "contact-quick-actions" >
${hasPreferredPhone
? `< button class = "btn btn-sm btn-outline-success py-0 px-2" onclick = "event.stopPropagation(); contactsCallViaYealink('${escapeHtml(preferredPhone)}')" > Ring op< / button > `
: ''}
${contact.mobile
? `< button class = "btn btn-sm btn-outline-primary py-0 px-2" onclick = "event.stopPropagation(); openSmsPrompt('${escapeHtml(contact.mobile)}', '${escapeHtml(fullName)}', ${contact.id || 'null'})" > SMS< / button > `
: ''}
< / div >
< / div >
2025-12-06 02:22:01 +01:00
< / td >
2026-05-02 11:02:29 +02:00
< td class = "text-muted col-title" > ${safeTitle}< / td >
< td class = "col-companies" >
2026-04-01 21:34:58 +02:00
< span class = "company-count-chip" title = "${companiesTitle}" >
2026-05-06 07:01:43 +02:00
< i class = "bi bi-building" > < / i > ${companyCount}
2025-12-06 02:22:01 +01:00
< / span >
2026-04-01 21:34:58 +02:00
${companyDisplay !== '-' ? '< div class = "small text-muted mt-1" > ' + escapeHtml(companyDisplay) + '< / div > ' : ''}
2025-12-06 02:22:01 +01:00
< / td >
2026-05-02 11:02:29 +02:00
< td class = "col-updated" >
< span class = "updated-at" > ${updatedAt}< / span >
< / td >
< td class = "col-status" > ${statusBadge}< / td >
2025-12-06 02:22:01 +01:00
< td class = "text-end" >
< div class = "btn-group" >
2026-04-01 21:34:58 +02:00
< button class = "btn btn-sm btn-table-action" onclick = "event.stopPropagation(); viewContact(${contact.id})" title = "Vis kontakt" >
2025-12-06 02:22:01 +01:00
< i class = "bi bi-eye" > < / i >
< / button >
2026-04-01 21:34:58 +02:00
< button class = "btn btn-sm btn-table-action" onclick = "event.stopPropagation(); editContact(${contact.id})" title = "Rediger kontakt" >
2025-12-06 02:22:01 +01:00
< i class = "bi bi-pencil" > < / i >
< / button >
< / div >
< / td >
< / tr >
`;
}).join('');
2026-05-02 11:02:29 +02:00
applyColumnVisibility();
updateSortIndicators();
}
function setSort(key) {
if (currentSort.key === key) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.key = key;
currentSort.direction = 'asc';
}
persistTablePreferences();
displayContacts(currentContactsData);
}
function getSortedContacts(contacts) {
const list = Array.isArray(contacts) ? [...contacts] : [];
const direction = currentSort.direction === 'desc' ? -1 : 1;
return list.sort((a, b) => {
const key = currentSort.key;
if (key === 'company_count') {
return ((Number(a.company_count) || 0) - (Number(b.company_count) || 0)) * direction;
}
if (key === 'updated_at') {
const dateA = new Date(a.updated_at || a.created_at || 0).getTime() || 0;
const dateB = new Date(b.updated_at || b.created_at || 0).getTime() || 0;
return (dateA - dateB) * direction;
}
if (key === 'is_active') {
return ((a.is_active ? 1 : 0) - (b.is_active ? 1 : 0)) * direction;
}
if (key === 'title') {
const titleA = String(a.title || '').toLowerCase();
const titleB = String(b.title || '').toLowerCase();
return titleA.localeCompare(titleB, 'da') * direction;
}
const nameA = `${a.last_name || ''} ${a.first_name || ''}`.trim().toLowerCase();
const nameB = `${b.last_name || ''} ${b.first_name || ''}`.trim().toLowerCase();
return nameA.localeCompare(nameB, 'da') * direction;
});
}
function updateSortIndicators() {
document.querySelectorAll('.sort-btn').forEach((btn) => {
const key = btn.getAttribute('data-sort-key');
const icon = btn.querySelector('.sort-indicator');
if (!icon) return;
btn.classList.remove('active');
icon.className = 'bi bi-arrow-down-up sort-indicator';
if (key === currentSort.key) {
btn.classList.add('active');
icon.className = currentSort.direction === 'asc'
? 'bi bi-sort-down-alt sort-indicator'
: 'bi bi-sort-up-alt sort-indicator';
}
});
}
function applyColumnVisibility() {
toggleColumnClass('col-title', visibleColumns.title);
toggleColumnClass('col-companies', visibleColumns.companies);
toggleColumnClass('col-updated', visibleColumns.updated);
toggleColumnClass('col-status', visibleColumns.status);
document.querySelectorAll('.column-toggle-input').forEach((input) => {
const key = input.getAttribute('data-column');
input.checked = !!visibleColumns[key];
});
}
function toggleColumnClass(columnClass, isVisible) {
document.querySelectorAll(`.${columnClass}`).forEach((el) => {
el.classList.toggle('d-none', !isVisible);
});
}
function loadTablePreferences() {
try {
const raw = localStorage.getItem('contactsTablePrefsV1');
if (!raw) return;
const parsed = JSON.parse(raw);
if (parsed & & parsed.visibleColumns) {
visibleColumns = {
...visibleColumns,
...parsed.visibleColumns
};
}
if (parsed & & parsed.currentSort & & parsed.currentSort.key) {
currentSort = {
key: parsed.currentSort.key,
direction: parsed.currentSort.direction === 'desc' ? 'desc' : 'asc'
};
}
} catch (error) {
console.warn('Kunne ikke indlæse tabel-indstillinger', error);
}
}
function persistTablePreferences() {
try {
localStorage.setItem('contactsTablePrefsV1', JSON.stringify({
visibleColumns,
currentSort
}));
} catch (error) {
console.warn('Kunne ikke gemme tabel-indstillinger', error);
}
2025-12-06 02:22:01 +01:00
}
2026-06-11 01:12:25 +02:00
function updatePagination(total, rowsOnPage = 0) {
const safeRowsOnPage = Number.isFinite(Number(rowsOnPage)) ? Math.max(0, Number(rowsOnPage)) : 0;
const start = total > 0 ? (currentPage * pageSize + 1) : 0;
const end = total > 0 ? Math.min(currentPage * pageSize + safeRowsOnPage, total) : 0;
2025-12-06 02:22:01 +01:00
2026-06-11 01:12:25 +02:00
document.getElementById('showingStart').textContent = start;
2025-12-06 02:22:01 +01:00
document.getElementById('showingEnd').textContent = end;
document.getElementById('totalCount').textContent = total;
// Update buttons
document.getElementById('prevBtn').disabled = currentPage === 0;
document.getElementById('nextBtn').disabled = end >= total;
}
function previousPage() {
if (currentPage > 0) {
currentPage--;
loadContacts();
}
}
function nextPage() {
if ((currentPage + 1) * pageSize < totalContacts ) {
currentPage++;
loadContacts();
}
}
function viewContact(contactId) {
window.location.href = `/contacts/${contactId}`;
}
function editContact(contactId) {
2026-02-06 10:47:14 +01:00
// Load contact data and open edit modal
loadContactForEdit(contactId);
}
2026-02-14 02:26:29 +01:00
let contactsCurrentUserId = null;
async function ensureContactsCurrentUserId() {
if (contactsCurrentUserId !== null) return contactsCurrentUserId;
try {
const res = await fetch('/api/v1/auth/me', { credentials: 'include' });
if (!res.ok) return null;
const me = await res.json();
contactsCurrentUserId = Number(me?.id) || null;
return contactsCurrentUserId;
} catch (e) {
return null;
}
}
async function contactsCallViaYealink(number) {
const clean = String(number || '').trim();
if (!clean || clean === '-') {
alert('Intet gyldigt nummer at ringe til');
return;
}
const userId = await ensureContactsCurrentUserId();
try {
const res = await fetch('/api/v1/telefoni/click-to-call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ number: clean, user_id: userId })
});
if (!res.ok) {
const t = await res.text();
alert('Ring ud fejlede: ' + t);
return;
}
alert('Ringer ud via Yealink...');
} catch (e) {
alert('Kunne ikke starte opkald');
}
}
2026-02-06 10:47:14 +01:00
async function loadContactForEdit(contactId) {
try {
const response = await fetch(`/api/v1/contacts/${contactId}`);
if (!response.ok) throw new Error('Kunne ikke indlæse kontakt');
const contact = await response.json();
// Fill form
document.getElementById('editContactId').value = contactId;
document.getElementById('editFirstNameInput').value = contact.first_name || '';
document.getElementById('editLastNameInput').value = contact.last_name || '';
document.getElementById('editEmailInput').value = contact.email || '';
document.getElementById('editPhoneInput').value = contact.phone || '';
document.getElementById('editMobileInput').value = contact.mobile || '';
document.getElementById('editTitleInput').value = contact.title || '';
document.getElementById('editDepartmentInput').value = contact.department || '';
document.getElementById('editIsActiveInput').checked = contact.is_active || false;
// Show modal
const modal = new bootstrap.Modal(document.getElementById('editContactModal'));
modal.show();
} catch (error) {
console.error('Failed to load contact:', error);
alert('Fejl: Kunne ikke indlæse kontakt');
}
}
async function saveEditContact() {
const contactId = document.getElementById('editContactId').value;
const firstName = document.getElementById('editFirstNameInput').value.trim();
const lastName = document.getElementById('editLastNameInput').value.trim();
if (!firstName || !lastName) {
alert('Fornavn og efternavn er påkrævet');
return;
}
try {
const response = await fetch(`/api/v1/contacts/${contactId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
first_name: firstName,
last_name: lastName,
email: document.getElementById('editEmailInput').value || null,
phone: document.getElementById('editPhoneInput').value || null,
mobile: document.getElementById('editMobileInput').value || null,
title: document.getElementById('editTitleInput').value || null,
department: document.getElementById('editDepartmentInput').value || null,
is_active: document.getElementById('editIsActiveInput').checked
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Kunne ikke gemme kontakt');
}
// Close modal and reload
const modal = bootstrap.Modal.getInstance(document.getElementById('editContactModal'));
modal.hide();
loadContacts();
} catch (error) {
console.error('Failed to save contact:', error);
alert('Fejl: ' + error.message);
}
2025-12-06 02:22:01 +01:00
}
async function loadCompaniesForSelect() {
try {
const response = await fetch('/api/v1/customers?limit=1000');
const data = await response.json();
2026-04-01 21:34:58 +02:00
availableCompanies = Array.isArray(data.customers)
? data.customers.map((c) => ({ id: Number(c.id), name: String(c.name || '').trim() }))
: [];
2026-07-10 08:10:31 +02:00
if (pendingCreateModalCustomerId & & availableCompanies.some((c) => c.id === pendingCreateModalCustomerId)) {
selectedCompanyIds.add(pendingCreateModalCustomerId);
}
2026-04-01 21:34:58 +02:00
renderCompanyResults(document.getElementById('companySearchInput')?.value || '');
renderSelectedCompanies();
2025-12-06 02:22:01 +01:00
} catch (error) {
console.error('Failed to load companies:', error);
}
}
2026-04-01 21:34:58 +02:00
function renderCompanyResults(query) {
const host = document.getElementById('companyResults');
if (!host) return;
const needle = String(query || '').trim().toLowerCase();
let list = availableCompanies;
if (needle) {
list = availableCompanies.filter((c) => c.name.toLowerCase().includes(needle));
}
list = list.slice(0, 80);
if (!list.length) {
host.innerHTML = '< div class = "px-3 py-2 text-muted small" > Ingen firmaer fundet< / div > ';
return;
}
host.innerHTML = list.map((c) => {
const selected = selectedCompanyIds.has(c.id);
return `
< button type = "button" class = "company-result-item ${selected ? 'selected' : ''}" onclick = "toggleCompanySelection(${c.id})" >
< span > ${escapeHtml(c.name)}< / span >
< span > ${selected ? '< i class = "bi bi-check2" > < / i > ' : ''}< / span >
< / button >
`;
}).join('');
}
function toggleCompanySelection(companyId) {
const id = Number(companyId);
if (!Number.isFinite(id)) return;
if (selectedCompanyIds.has(id)) {
selectedCompanyIds.delete(id);
} else {
selectedCompanyIds.add(id);
}
renderSelectedCompanies();
renderCompanyResults(document.getElementById('companySearchInput')?.value || '');
}
function renderSelectedCompanies() {
const host = document.getElementById('selectedCompanies');
if (!host) return;
const selected = availableCompanies.filter((c) => selectedCompanyIds.has(c.id));
if (!selected.length) {
host.innerHTML = '< span class = "text-muted small" > Ingen firmaer valgt< / span > ';
return;
}
host.innerHTML = selected.map((c) => `
< span class = "company-chip" >
${escapeHtml(c.name)}
< button type = "button" title = "Fjern" onclick = "toggleCompanySelection(${c.id})" > < i class = "bi bi-x-lg" > < / i > < / button >
< / span >
`).join('');
}
2026-08-28 20:49:55 +02:00
async function analyzeEmailForNewContact(file) {
const form = new FormData();
form.append('file', file);
const zone = document.getElementById('createFromEmailDropzone');
const original = zone?.innerHTML;
if (zone) { zone.style.pointerEvents = 'none'; zone.innerHTML = '< span class = "spinner-border spinner-border-sm" > < / span > < span class = "small fw-semibold" > Analyserer…< / span > '; }
try {
const response = await fetch('/api/v1/contacts/analyze-email', { method: 'POST', body: form });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.detail || 'Mailen kunne ikke analyseres');
const contact = data.contact || {};
const values = {
mailContactFirstName: contact.first_name,
mailContactLastName: contact.last_name,
mailContactEmail: contact.email,
mailContactMobile: contact.mobile,
mailContactPhone: contact.phone,
mailContactTitle: contact.title,
mailContactDepartment: contact.department,
mailCompanyName: data.company?.existing_name || data.company?.name,
mailCompanyCvr: data.company?.cvr_number,
mailCompanyAddress: data.company?.address,
mailCompanyPostalCode: data.company?.postal_code,
mailCompanyCity: data.company?.city,
};
Object.entries(values).forEach(([id, value]) => { document.getElementById(id).value = value || ''; });
analyzedMailCompany = data.company || {};
document.getElementById('createFromEmailMeta').textContent = [data.filename, data.mail?.subject, data.mail?.sender_email].filter(Boolean).join(' · ');
const hasCvr = !!data.company?.cvr_number;
document.getElementById('mailCompanySection').classList.toggle('d-none', !hasCvr);
document.getElementById('mailNoCompany').classList.toggle('d-none', hasCvr);
if (hasCvr) {
const existing = !!data.company.existing_id;
document.getElementById('mailUseCompanyLabel').textContent = existing ? 'Tilknyt eksisterende virksomhed' : 'Opret og tilknyt ny virksomhed';
document.getElementById('mailCompanyHint').textContent = existing
? `CVR findes allerede som ${data.company.existing_name}. Der oprettes ikke en dublet.`
: data.company.lookup_found
? `Virksomhedsdata er hentet fra CVR-opslaget (${data.company.source || 'CVR-registeret'}). Kontrollér oplysningerne før oprettelse.`
: 'CVR findes ikke i systemet, men kunne ikke valideres i CVR-opslaget. Kontrollér oplysningerne før oprettelse.';
document.getElementById('mailCompanyName').readOnly = existing;
document.getElementById('mailCompanyCvr').readOnly = existing;
document.getElementById('mailUseCompany').checked = true;
}
document.getElementById('createFromEmailStatus').classList.add('d-none');
bootstrap.Modal.getOrCreateInstance(document.getElementById('createFromEmailModal')).show();
} catch (error) {
alert(error.message);
} finally {
if (zone) { zone.style.pointerEvents = ''; zone.innerHTML = original; }
}
}
async function approveCreateFromEmail() {
const value = id => document.getElementById(id).value.trim() || null;
const contactData = {
first_name: value('mailContactFirstName'), last_name: value('mailContactLastName') || '',
email: value('mailContactEmail'), mobile: value('mailContactMobile'), phone: value('mailContactPhone'),
title: value('mailContactTitle'), department: value('mailContactDepartment'), is_active: true,
};
if (!contactData.first_name) { alert('Fornavn skal udfyldes'); return; }
const button = document.getElementById('approveCreateFromEmailBtn');
const status = document.getElementById('createFromEmailStatus');
button.disabled = true;
status.className = 'alert alert-info';
status.textContent = 'Opretter…';
try {
let companyId = document.getElementById('mailUseCompany').checked ? analyzedMailCompany?.existing_id : null;
if (document.getElementById('mailUseCompany').checked & & analyzedMailCompany?.cvr_number & & !companyId) {
const companyName = value('mailCompanyName');
const cvrNumber = (value('mailCompanyCvr') || '').replace(/\D/g, '');
if (!companyName || cvrNumber.length !== 8) throw new Error('Virksomhedsnavn og et gyldigt CVR på 8 cifre skal udfyldes');
const companyResponse = await fetch('/api/v1/contacts/resolve-email-company', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: companyName, cvr_number: cvrNumber,
email: analyzedMailCompany?.email || contactData.email,
phone: analyzedMailCompany?.phone || null,
address: value('mailCompanyAddress'), postal_code: value('mailCompanyPostalCode'),
city: value('mailCompanyCity'), website: analyzedMailCompany?.website || null
})
});
const company = await companyResponse.json().catch(() => ({}));
if (!companyResponse.ok) throw new Error(company.detail || 'Virksomheden kunne ikke oprettes');
companyId = company.id;
}
if (companyId) { contactData.company_ids = [Number(companyId)]; contactData.is_primary = true; }
const response = await fetch('/api/v1/contacts', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(contactData)
});
const contact = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(contact.detail || 'Kontakten kunne ikke oprettes');
window.location.href = `/contacts/${contact.id}`;
} catch (error) {
status.className = 'alert alert-danger';
status.textContent = error.message;
button.disabled = false;
}
}
2025-12-06 02:22:01 +01:00
function showCreateContactModal() {
// Reset form
document.getElementById('createContactForm').reset();
document.getElementById('isActiveInput').checked = true;
2026-04-01 21:34:58 +02:00
selectedCompanyIds = new Set();
2026-07-10 08:10:31 +02:00
if (pendingCreateModalCustomerId) {
selectedCompanyIds.add(pendingCreateModalCustomerId);
}
2026-04-01 21:34:58 +02:00
const companySearchInput = document.getElementById('companySearchInput');
if (companySearchInput) {
companySearchInput.value = '';
}
renderCompanyResults('');
renderSelectedCompanies();
2025-12-06 02:22:01 +01:00
// Show modal
const modal = new bootstrap.Modal(document.getElementById('createContactModal'));
modal.show();
}
async function createContact() {
const firstName = document.getElementById('firstNameInput').value.trim();
const lastName = document.getElementById('lastNameInput').value.trim();
if (!firstName || !lastName) {
alert('Fornavn og efternavn er påkrævet');
return;
}
// Get selected company IDs
2026-04-01 21:34:58 +02:00
const companyIds = Array.from(selectedCompanyIds);
2025-12-06 02:22:01 +01:00
const contactData = {
first_name: firstName,
last_name: lastName,
email: document.getElementById('emailInput').value.trim() || null,
phone: document.getElementById('phoneInput').value.trim() || null,
mobile: document.getElementById('mobileInput').value.trim() || null,
title: document.getElementById('titleInput').value.trim() || null,
department: document.getElementById('departmentInput').value.trim() || null,
company_ids: companyIds,
is_primary: document.getElementById('isPrimaryInput').checked,
role: document.getElementById('roleInput').value.trim() || null,
notes: document.getElementById('notesInput').value.trim() || null,
is_active: document.getElementById('isActiveInput').checked
};
try {
const response = await fetch('/api/v1/contacts', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(contactData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Kunne ikke oprette kontakt');
}
const newContact = await response.json();
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('createContactModal'));
modal.hide();
2026-07-10 08:10:31 +02:00
const createdContactId = Number(newContact?.id) || null;
if (pendingCreateReturnTo) {
window.location.href = pendingCreateReturnTo;
return;
}
if (createdContactId) {
window.location.href = `/contacts/${createdContactId}`;
return;
}
2025-12-06 02:22:01 +01:00
// Reload contact list
2026-07-10 08:10:31 +02:00
lastLoadedQueryKey = '';
currentPage = 0;
searchQuery = '';
document.getElementById('searchInput').value = '';
toggleClearButton('');
await loadContacts(true);
if (pendingCreateModalCustomerId) {
const cleanUrl = new URL(window.location.href);
cleanUrl.searchParams.delete('create');
cleanUrl.searchParams.delete('return_to');
window.history.replaceState({}, '', cleanUrl.toString());
pendingCreateModalCustomerId = null;
}
2025-12-06 02:22:01 +01:00
// Show success message
alert('Kontakt oprettet succesfuldt!');
} catch (error) {
console.error('Failed to create contact:', error);
alert('Fejl ved oprettelse af kontakt: ' + error.message);
}
}
function getInitials(firstName, lastName) {
if (!firstName & & !lastName) return '?';
const first = firstName ? firstName[0] : '';
const last = lastName ? lastName[0] : '';
return (first + last).toUpperCase();
}
2026-05-02 11:02:29 +02:00
function formatContactDate(value) {
if (!value) return '-';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '-';
return parsed.toLocaleDateString('da-DK', {
day: '2-digit',
month: '2-digit',
year: '2-digit'
});
}
2025-12-06 02:22:01 +01:00
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
< / script >
{% endblock %}