2026-01-28 07:48:10 +01:00
|
|
|
|
|
2026-01-29 00:36:32 +01:00
|
|
|
|
{% extends "shared/frontend/base.html" %}
|
2026-01-28 07:48:10 +01:00
|
|
|
|
|
|
|
|
|
|
{% block extra_css %}
|
|
|
|
|
|
<style>
|
|
|
|
|
|
.detail-grid {
|
|
|
|
|
|
display: grid;
|
2026-01-29 00:36:32 +01:00
|
|
|
|
grid-template-columns: 1fr 1fr 320px;
|
2026-01-28 07:48:10 +01:00
|
|
|
|
gap: 1.5rem;
|
2026-01-29 00:36:32 +01:00
|
|
|
|
align-items: start;
|
2026-01-28 07:48:10 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.section-card {
|
|
|
|
|
|
background: var(--bg-card);
|
2026-01-29 00:36:32 +01:00
|
|
|
|
border-radius: var(--border-radius);
|
|
|
|
|
|
padding: 1.5rem;
|
|
|
|
|
|
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
|
2026-01-28 07:48:10 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.section-title {
|
2026-01-29 00:36:32 +01:00
|
|
|
|
font-size: 1rem;
|
|
|
|
|
|
font-weight: 600;
|
2026-01-28 07:48:10 +01:00
|
|
|
|
margin-bottom: 1rem;
|
2026-01-29 00:36:32 +01:00
|
|
|
|
color: var(--text-primary);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sticky-panel {
|
|
|
|
|
|
position: sticky;
|
|
|
|
|
|
top: 100px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.attachment-dropzone {
|
|
|
|
|
|
border: 2px dashed rgba(0,0,0,0.1);
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
padding: 2rem 1rem;
|
|
|
|
|
|
background-color: var(--bg-body);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: all 0.2s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.attachment-dropzone:hover, .attachment-dropzone.drag-over {
|
|
|
|
|
|
border-color: var(--accent);
|
|
|
|
|
|
background-color: var(--accent-light);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.attachment-item {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
padding: 0.75rem;
|
|
|
|
|
|
background: var(--bg-body);
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
border: 1px solid rgba(0,0,0,0.05);
|
|
|
|
|
|
margin-bottom: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.attachment-actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.5rem;
|
2026-01-28 07:48:10 +01:00
|
|
|
|
}
|
2026-01-28 14:37:47 +01:00
|
|
|
|
|
|
|
|
|
|
.comment-thread {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.comment-entry {
|
2026-01-29 00:36:32 +01:00
|
|
|
|
background: var(--bg-body);
|
2026-01-28 14:37:47 +01:00
|
|
|
|
padding: 1rem;
|
2026-01-29 00:36:32 +01:00
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
border-left: 3px solid var(--accent);
|
2026-01-28 14:37:47 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 00:36:32 +01:00
|
|
|
|
.comment-header {
|
2026-01-28 14:37:47 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
2026-01-29 00:36:32 +01:00
|
|
|
|
margin-bottom: 0.5rem;
|
|
|
|
|
|
font-size: 0.9rem;
|
2026-01-28 14:37:47 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.comment-badges {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.5rem;
|
2026-01-29 00:36:32 +01:00
|
|
|
|
margin-bottom: 0.5rem;
|
2026-01-28 14:37:47 +01:00
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.comment-badge {
|
|
|
|
|
|
font-size: 0.75rem;
|
2026-01-29 00:36:32 +01:00
|
|
|
|
background: white;
|
|
|
|
|
|
border: 1px solid rgba(0,0,0,0.1);
|
|
|
|
|
|
padding: 0.25rem 0.5rem;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
|
color: var(--text-secondary);
|
2026-01-28 14:37:47 +01:00
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
}
|
2026-01-29 00:36:32 +01:00
|
|
|
|
|
|
|
|
|
|
.comment-badge:hover {
|
|
|
|
|
|
background: var(--accent-light);
|
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
|
border-color: var(--accent);
|
|
|
|
|
|
}
|
2026-01-28 14:37:47 +01:00
|
|
|
|
|
2026-01-29 00:36:32 +01:00
|
|
|
|
.comment-attachment-stack {
|
|
|
|
|
|
margin-top: 0.75rem;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.comment-attachment-group {
|
|
|
|
|
|
border-top: 1px solid rgba(0,0,0,0.05);
|
|
|
|
|
|
padding-top: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.comment-attachment-label {
|
|
|
|
|
|
font-size: 0.7rem;
|
|
|
|
|
|
text-transform: uppercase;
|
2026-01-28 14:37:47 +01:00
|
|
|
|
color: var(--text-secondary);
|
2026-01-29 00:36:32 +01:00
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
margin-bottom: 0.25rem;
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.comment-attachment-entry {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
padding: 0.5rem;
|
|
|
|
|
|
background: white;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
margin-bottom: 0.25rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.comment-no-data {
|
2026-01-28 14:37:47 +01:00
|
|
|
|
text-align: center;
|
2026-01-29 00:36:32 +01:00
|
|
|
|
padding: 2rem;
|
|
|
|
|
|
color: var(--text-secondary);
|
|
|
|
|
|
background: var(--bg-body);
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
font-style: italic;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 1200px) {
|
|
|
|
|
|
.detail-grid {
|
|
|
|
|
|
grid-template-columns: 1fr 1fr;
|
|
|
|
|
|
}
|
|
|
|
|
|
.sticky-panel {
|
|
|
|
|
|
grid-column: span 2;
|
|
|
|
|
|
position: static;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.detail-grid {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
}
|
|
|
|
|
|
.sticky-panel {
|
|
|
|
|
|
grid-column: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
.comment-attachment-entry {
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
}
|
2026-01-28 14:37:47 +01:00
|
|
|
|
}
|
2026-01-28 07:48:10 +01:00
|
|
|
|
</style>
|
|
|
|
|
|
{% endblock %}
|
|
|
|
|
|
|
|
|
|
|
|
{% block content %}
|
|
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 class="fw-bold mb-1" id="pageTitle">Mulighed</h2>
|
|
|
|
|
|
<p class="text-muted mb-0">Detaljeret pipeline‑visning</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="d-flex gap-2">
|
|
|
|
|
|
<a class="btn btn-outline-secondary" href="/opportunities">
|
|
|
|
|
|
<i class="bi bi-arrow-left me-2"></i>Tilbage
|
|
|
|
|
|
</a>
|
|
|
|
|
|
<button class="btn btn-primary" onclick="saveOpportunity()">
|
|
|
|
|
|
<i class="bi bi-check-lg me-2"></i>Gem
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="detail-grid">
|
|
|
|
|
|
<div class="d-flex flex-column gap-3">
|
2026-01-29 00:36:32 +01:00
|
|
|
|
<!-- Pipeline Status (Moved from right) -->
|
|
|
|
|
|
<div class="section-card">
|
|
|
|
|
|
<div class="section-title">Pipeline‑status</div>
|
|
|
|
|
|
<div class="d-flex justify-content-between mb-2">
|
|
|
|
|
|
<span class="text-muted">Kunde</span>
|
|
|
|
|
|
<span id="customerNameBadge">-</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="d-flex justify-content-between mb-2">
|
|
|
|
|
|
<span class="text-muted">Stage</span>
|
|
|
|
|
|
<span id="stageBadge">-</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="d-flex justify-content-between mb-2">
|
|
|
|
|
|
<span class="text-muted">Værdi</span>
|
|
|
|
|
|
<span id="amountBadge">-</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="d-flex justify-content-between">
|
|
|
|
|
|
<span class="text-muted">Sandsynlighed</span>
|
|
|
|
|
|
<span id="probabilityBadge">-</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Contact Persons -->
|
|
|
|
|
|
<div class="section-card">
|
|
|
|
|
|
<div class="section-title">Kontaktpersoner</div>
|
|
|
|
|
|
<div id="linkedContactsList" class="d-flex flex-column gap-2 mb-2"></div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="dropdown mt-2">
|
|
|
|
|
|
<input type="text" class="form-control" id="contactSearchInput" placeholder="+ Tilføj kontaktperson..." data-bs-toggle="dropdown" autocomplete="off">
|
|
|
|
|
|
<ul class="dropdown-menu w-100" id="contactSearchResults">
|
|
|
|
|
|
<li><span class="dropdown-item text-muted small">Søg for at tilføje...</span></li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-28 07:48:10 +01:00
|
|
|
|
<div class="section-card">
|
|
|
|
|
|
<div class="section-title">Grundoplysninger</div>
|
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
|
<label class="form-label">Titel *</label>
|
|
|
|
|
|
<input type="text" class="form-control" id="title" required>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
|
<label class="form-label">Kunde</label>
|
|
|
|
|
|
<input type="text" class="form-control" id="customerName" disabled>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="mb-0">
|
|
|
|
|
|
<label class="form-label">Beskrivelse</label>
|
|
|
|
|
|
<textarea class="form-control" id="description" rows="4"></textarea>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="section-card">
|
|
|
|
|
|
<div class="section-title">Salgsstatus</div>
|
|
|
|
|
|
<div class="row g-3">
|
|
|
|
|
|
<div class="col-md-6">
|
|
|
|
|
|
<label class="form-label">Stage</label>
|
|
|
|
|
|
<select class="form-select" id="stageId"></select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="col-md-6">
|
|
|
|
|
|
<label class="form-label">Sandsynlighed</label>
|
|
|
|
|
|
<input type="text" class="form-control" id="probability" disabled>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="col-md-6">
|
|
|
|
|
|
<label class="form-label">Forventet lukning</label>
|
|
|
|
|
|
<input type="date" class="form-control" id="expectedCloseDate">
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="d-flex flex-column gap-3">
|
|
|
|
|
|
<div class="section-card">
|
|
|
|
|
|
<div class="section-title">Løsning & Salgsdetaljer</div>
|
|
|
|
|
|
<div class="row g-3">
|
|
|
|
|
|
<div class="col-md-6">
|
|
|
|
|
|
<label class="form-label">Beløb</label>
|
|
|
|
|
|
<input type="number" step="0.01" class="form-control" id="amount">
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="col-md-6">
|
|
|
|
|
|
<label class="form-label">Valuta</label>
|
|
|
|
|
|
<select class="form-select" id="currency">
|
|
|
|
|
|
<option value="DKK">DKK</option>
|
|
|
|
|
|
<option value="EUR">EUR</option>
|
|
|
|
|
|
<option value="USD">USD</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="section-card">
|
|
|
|
|
|
<div class="section-title">Tilbud & Kontrakt</div>
|
2026-01-29 00:36:32 +01:00
|
|
|
|
<p class="text-muted small mb-3">Vedhæft relevante dokumenter og se filer fra valgte emails.</p>
|
|
|
|
|
|
<div id="contractAttachmentDropzone" class="attachment-dropzone d-flex flex-column justify-content-center align-items-center text-center">
|
|
|
|
|
|
<i class="bi bi-cloud-upload-fill fs-3"></i>
|
|
|
|
|
|
<span class="small">Træk filer hertil eller klik for at vælge</span>
|
|
|
|
|
|
<small class="text-muted">Støtter dokumenter, regneark og billeder</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<input type="file" id="contractAttachmentInput" multiple class="d-none">
|
|
|
|
|
|
<div id="contractAttachmentList" class="mt-2 attachment-list"></div>
|
|
|
|
|
|
<div id="contractEmailAttachmentList" class="mt-2"></div>
|
2026-01-28 07:48:10 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="sticky-panel d-flex flex-column gap-3">
|
2026-01-29 00:36:32 +01:00
|
|
|
|
<div class="section-card">
|
|
|
|
|
|
<div class="section-title">Linket email</div>
|
|
|
|
|
|
<input type="search" class="form-control mb-2" id="opportunityEmailSearch" placeholder="Søg email (emne, afsender eller ID)">
|
|
|
|
|
|
<div id="opportunityEmailResults" class="list-group list-group-flush mt-2"></div>
|
|
|
|
|
|
<!-- Linked Emails Container with Drop Zone -->
|
|
|
|
|
|
<div id="emailDropZone" class="border rounded p-2 mt-2" style="border: 2px dashed transparent !important; transition: all 0.2s;">
|
|
|
|
|
|
<div id="opportunityLinkedEmailsList" class="d-flex flex-column gap-2"></div>
|
|
|
|
|
|
<div class="text-center text-muted small mt-2 fst-italic py-2" style="pointer-events: none;">
|
|
|
|
|
|
<i class="bi bi-cloud-upload me-1"></i> Træk emails hertil (.msg, .eml)
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-28 07:48:10 +01:00
|
|
|
|
<div class="section-card">
|
|
|
|
|
|
<div class="section-title">Næste aktivitet</div>
|
|
|
|
|
|
<div class="text-muted small">Aktivitetsmodul kommer senere.</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-28 14:37:47 +01:00
|
|
|
|
|
|
|
|
|
|
<div class="section-card mt-4">
|
|
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h5 class="mb-0">Varelinjer</h5>
|
|
|
|
|
|
<p class="text-muted small mb-0">Tilføj eller fjern produkter som indgår i tilbuddet.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button class="btn btn-outline-primary btn-sm" onclick="openAddLineModal()">
|
|
|
|
|
|
<i class="bi bi-plus-lg me-1"></i>Tilføj varelinje
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
|
<label class="form-label">Søg efter produkt</label>
|
|
|
|
|
|
<input type="search" class="form-control" id="lineProductSearch" placeholder="Søg efter varenavn, varenr. eller kategori">
|
|
|
|
|
|
<div id="productSearchResults" class="list-group list-group-flush mt-2"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="table-responsive">
|
|
|
|
|
|
<table class="table table-sm table-hover">
|
|
|
|
|
|
<thead class="table-light">
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>Navn</th>
|
|
|
|
|
|
<th>Varenr.</th>
|
|
|
|
|
|
<th class="text-end">Antal</th>
|
|
|
|
|
|
<th class="text-end">Enhedspris</th>
|
|
|
|
|
|
<th class="text-end">Linjetotal</th>
|
|
|
|
|
|
<th class="text-end">Handling</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody id="lineItemsTableBody">
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td colspan="6" class="text-center text-muted py-4">Ingen varelinjer endnu</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="text-end small text-muted" id="lineItemsSummary">Total: 0 kr</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="section-card mt-4" id="commentsSection">
|
|
|
|
|
|
<div class="section-title">Kommentarer & aktiviteter</div>
|
|
|
|
|
|
<div id="commentThread" class="comment-thread"></div>
|
|
|
|
|
|
<div id="commentEmptyState" class="comment-no-data">Ingen kommentarer endnu</div>
|
|
|
|
|
|
<div class="mt-4">
|
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
|
<label class="form-label">Kommentar *</label>
|
|
|
|
|
|
<textarea class="form-control" id="commentContent" rows="3" required></textarea>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="d-flex justify-content-end gap-2">
|
|
|
|
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="clearCommentForm()">Nulstil</button>
|
|
|
|
|
|
<button type="button" class="btn btn-primary btn-sm" onclick="submitComment()">Gem kommentar</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Add Line Modal -->
|
|
|
|
|
|
<div class="modal fade" id="lineModal" tabindex="-1">
|
|
|
|
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
|
|
|
|
<div class="modal-content">
|
|
|
|
|
|
<div class="modal-header">
|
|
|
|
|
|
<h5 class="modal-title">Tilføj varelinje</h5>
|
|
|
|
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-body">
|
|
|
|
|
|
<form id="lineItemForm">
|
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
|
<label class="form-label">Produktnavn *</label>
|
|
|
|
|
|
<input type="text" class="form-control" id="lineName" required>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
|
<label class="form-label">Varenummer</label>
|
|
|
|
|
|
<input type="text" class="form-control" id="lineProductNumber">
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="row g-3">
|
|
|
|
|
|
<div class="col-6">
|
|
|
|
|
|
<label class="form-label">Antal *</label>
|
|
|
|
|
|
<input type="number" class="form-control" id="lineQuantity" value="1" min="1" required>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="col-6">
|
|
|
|
|
|
<label class="form-label">Enhedspris *</label>
|
|
|
|
|
|
<input type="number" class="form-control" id="lineUnitPrice" step="0.01" value="0.00" required>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="mb-3 mt-3">
|
|
|
|
|
|
<label class="form-label">Beskrivelse</label>
|
|
|
|
|
|
<textarea class="form-control" id="lineDescription" rows="2"></textarea>
|
|
|
|
|
|
</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="addLineItem()">Gem varelinje</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-29 00:36:32 +01:00
|
|
|
|
|
|
|
|
|
|
<!-- Linked Email Modal -->
|
|
|
|
|
|
<div class="modal fade" id="linkedEmailModal" tabindex="-1" aria-labelledby="linkedEmailModalTitle" aria-hidden="true">
|
|
|
|
|
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
|
|
|
|
|
<div class="modal-content">
|
|
|
|
|
|
<div class="modal-header">
|
|
|
|
|
|
<h5 class="modal-title" id="linkedEmailModalTitle">Email</h5>
|
|
|
|
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-body" id="linkedEmailModalBody">
|
|
|
|
|
|
<div class="text-muted">Indlæser...</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-01-28 07:48:10 +01:00
|
|
|
|
{% endblock %}
|
|
|
|
|
|
|
|
|
|
|
|
{% block extra_js %}
|
|
|
|
|
|
<script>
|
|
|
|
|
|
const opportunityId = parseInt(window.location.pathname.split('/').pop());
|
2026-01-29 00:36:32 +01:00
|
|
|
|
const API_BASE = ""; // Relative path as frontend is served by same backend
|
2026-01-28 07:48:10 +01:00
|
|
|
|
let stages = [];
|
|
|
|
|
|
let opportunity = null;
|
2026-01-28 14:37:47 +01:00
|
|
|
|
let lineItems = [];
|
|
|
|
|
|
let lineModal = null;
|
|
|
|
|
|
let selectedProductCandidate = null;
|
|
|
|
|
|
let productSearchTimeout = null;
|
|
|
|
|
|
let comments = [];
|
|
|
|
|
|
let selectedCommentEmail = null;
|
2026-01-29 00:36:32 +01:00
|
|
|
|
let contractFiles = [];
|
|
|
|
|
|
let selectedEmailAttachments = [];
|
2026-01-28 14:37:47 +01:00
|
|
|
|
let commentEmailSearchTimeout = null;
|
2026-01-28 07:48:10 +01:00
|
|
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
2026-01-29 00:36:32 +01:00
|
|
|
|
// Email search for opportunity-level email link
|
|
|
|
|
|
const opportunityEmailSearchInput = document.getElementById('opportunityEmailSearch');
|
|
|
|
|
|
let opportunityEmailSearchTimeout = null;
|
|
|
|
|
|
opportunityEmailSearchInput?.addEventListener('input', (event) => {
|
|
|
|
|
|
const term = event.target.value.trim();
|
|
|
|
|
|
clearTimeout(opportunityEmailSearchTimeout);
|
|
|
|
|
|
if (!term || term.length < 2) {
|
|
|
|
|
|
renderOpportunityEmailSuggestions([]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
opportunityEmailSearchTimeout = setTimeout(() => searchOpportunityEmails(term), 250);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Render suggestions for opportunity-level email link
|
|
|
|
|
|
function renderOpportunityEmailSuggestions(results) {
|
|
|
|
|
|
const container = document.getElementById('opportunityEmailResults');
|
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
if (!results || results.length === 0) {
|
|
|
|
|
|
container.innerHTML = '<div class="text-muted small">Ingen emails fundet</div>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
container.innerHTML = results.map(email => {
|
|
|
|
|
|
// Show a preview of the email body (first 200 chars, plain text)
|
|
|
|
|
|
let preview = '';
|
|
|
|
|
|
if (email.body_text) {
|
|
|
|
|
|
preview = email.body_text.substring(0, 200).replace(/\n/g, ' ');
|
|
|
|
|
|
} else if (email.body_html) {
|
|
|
|
|
|
// Strip HTML tags for preview
|
|
|
|
|
|
const tmp = document.createElement('div');
|
|
|
|
|
|
tmp.innerHTML = email.body_html;
|
|
|
|
|
|
preview = tmp.textContent.substring(0, 200);
|
|
|
|
|
|
}
|
|
|
|
|
|
return `
|
|
|
|
|
|
<button type="button" class="list-group-item list-group-item-action flex-column align-items-start text-start" data-email-id="${email.id}">
|
|
|
|
|
|
<div class="fw-semibold">${escapeHtml(email.subject || 'Ingen emne')}</div>
|
|
|
|
|
|
<small class="text-muted">${escapeHtml(email.sender_email || '-')}</small>
|
|
|
|
|
|
<small class="text-muted">${formatCommentTimestamp(email.received_date)}</small>
|
|
|
|
|
|
<div class="email-preview small mt-1 text-secondary">${escapeHtml(preview)}</div>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
container.querySelectorAll('button').forEach(button => {
|
|
|
|
|
|
button.addEventListener('click', () => {
|
|
|
|
|
|
const emailId = button.dataset.emailId;
|
|
|
|
|
|
const matched = results.find(item => item.id.toString() === emailId);
|
|
|
|
|
|
if (matched) selectOpportunityEmailSuggestion(matched);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Search emails for opportunity-level link
|
|
|
|
|
|
async function searchOpportunityEmails(term) {
|
|
|
|
|
|
if (!term) {
|
|
|
|
|
|
renderOpportunityEmailSuggestions([]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const params = new URLSearchParams({ q: term, limit: '6' });
|
|
|
|
|
|
const response = await fetch(`${API_BASE}/api/v1/emails?${params}`);
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
renderOpportunityEmailSuggestions([]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
renderOpportunityEmailSuggestions(Array.isArray(data) ? data : data || []);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error searching emails:', error);
|
|
|
|
|
|
renderOpportunityEmailSuggestions([]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Select email for opportunity-level link
|
|
|
|
|
|
function selectOpportunityEmailSuggestion(email) {
|
|
|
|
|
|
// Save the selected email to the opportunity via POST (Many-to-Many)
|
|
|
|
|
|
fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}/email-links`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ email_id: email.id })
|
|
|
|
|
|
}).then(async resp => {
|
|
|
|
|
|
if (!resp.ok) {
|
|
|
|
|
|
alert('Kunne ikke gemme email-link');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const updatedOpp = await resp.json();
|
|
|
|
|
|
opportunity = updatedOpp; // Update global state
|
|
|
|
|
|
if (opportunity.linked_emails) {
|
|
|
|
|
|
renderLinkedEmails(opportunity.linked_emails);
|
|
|
|
|
|
}
|
|
|
|
|
|
document.getElementById('opportunityEmailResults').innerHTML = '';
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
window.renderLinkedEmails = function(emails) {
|
|
|
|
|
|
const container = document.getElementById('opportunityLinkedEmailsList');
|
|
|
|
|
|
if(!container) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (!emails || emails.length === 0) {
|
|
|
|
|
|
container.innerHTML = '';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
container.innerHTML = emails.map(email => {
|
|
|
|
|
|
const safeSubject = escapeHtml(email.subject || 'Email');
|
|
|
|
|
|
const safeSender = escapeHtml(email.sender_email || '');
|
|
|
|
|
|
const dateStr = formatCommentTimestamp(email.received_date);
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="d-flex align-items-center gap-2 p-2 border rounded bg-white">
|
|
|
|
|
|
<i class="bi bi-envelope-fill text-primary"></i>
|
|
|
|
|
|
<div class="flex-grow-1" style="min-width:0;">
|
|
|
|
|
|
<div class="text-truncate fw-medium" style="font-size:0.9rem;">${safeSubject}</div>
|
|
|
|
|
|
<div class="text-truncate text-muted small">${safeSender} • ${dateStr}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="d-flex gap-1">
|
|
|
|
|
|
<button type="button" class="btn btn-sm btn-light text-primary" title="Se email" onclick="showLinkedEmailModal(${email.id})">
|
|
|
|
|
|
<i class="bi bi-envelope-open"></i>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" class="btn btn-sm btn-light text-danger" onclick="removeLinkedEmail(${email.id})" title="Fjern link">
|
|
|
|
|
|
<i class="bi bi-x"></i>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
window.removeLinkedEmail = async function(emailId) {
|
|
|
|
|
|
if(!confirm('Vil du opbryde linket til denne email?')) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const resp = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}/email-links/${emailId}`, {
|
|
|
|
|
|
method: 'DELETE'
|
|
|
|
|
|
});
|
|
|
|
|
|
if(resp.ok) {
|
|
|
|
|
|
if(opportunity.linked_emails) {
|
|
|
|
|
|
opportunity.linked_emails = opportunity.linked_emails.filter(e => e.id !== emailId);
|
|
|
|
|
|
renderLinkedEmails(opportunity.linked_emails);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alert('Kunne ikke fjerne link');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch(e) {
|
|
|
|
|
|
console.error(e);
|
|
|
|
|
|
alert('Fejl ved fjernelse af link');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
window.showLinkedEmailModal = function(emailId) {
|
|
|
|
|
|
const modal = document.getElementById('linkedEmailModal');
|
|
|
|
|
|
const modalTitle = document.getElementById('linkedEmailModalTitle');
|
|
|
|
|
|
const modalBody = document.getElementById('linkedEmailModalBody');
|
|
|
|
|
|
if (!modal || !modalTitle || !modalBody) return;
|
|
|
|
|
|
modalTitle.textContent = 'Indlæser...';
|
|
|
|
|
|
modalBody.innerHTML = '<div class="text-muted">Henter email...</div>';
|
|
|
|
|
|
|
|
|
|
|
|
fetch(`${API_BASE}/api/v1/emails/${emailId}`)
|
|
|
|
|
|
.then(resp => resp.ok ? resp.json() : Promise.reject('Ikke fundet'))
|
|
|
|
|
|
.then(email => {
|
|
|
|
|
|
modalTitle.textContent = email.subject || 'Email';
|
|
|
|
|
|
let html = `<div class='mb-2'><strong>Fra:</strong> ${escapeHtml(email.sender_email || '')}</div>`;
|
|
|
|
|
|
html += `<div class='mb-2'><strong>Til:</strong> ${escapeHtml(email.recipient_email || '')}</div>`;
|
|
|
|
|
|
html += `<div class='mb-2'><strong>Modtaget:</strong> ${formatCommentTimestamp(email.received_date)}</div>`;
|
|
|
|
|
|
html += `<hr>`;
|
|
|
|
|
|
if (email.body_html) {
|
|
|
|
|
|
html += `<div style='background:#f8f9fa;padding:1rem;border-radius:8px;max-height:400px;overflow:auto'><div>${email.body_html}</div></div>`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
html += `<pre style='background:#f8f9fa;padding:1rem;border-radius:8px;max-height:400px;overflow:auto'>${escapeHtml(email.body_text || '')}</pre>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (email.attachments && email.attachments.length) {
|
|
|
|
|
|
html += `<div class='mt-3'><strong>Vedhæftede filer:</strong><ul>`;
|
|
|
|
|
|
for (const att of email.attachments) {
|
|
|
|
|
|
const url = `/api/v1/emails/${email.id}/attachments/${att.id}`;
|
|
|
|
|
|
html += `<li><a href='${url}' target='_blank' rel='noreferrer'>${escapeHtml(att.filename)}</a></li>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
html += `</ul></div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
modalBody.innerHTML = html;
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
modalTitle.textContent = 'Email ikke fundet';
|
|
|
|
|
|
modalBody.innerHTML = '<div class="text-danger">Kunne ikke hente email-detaljer.</div>';
|
|
|
|
|
|
});
|
|
|
|
|
|
const bsModal = new bootstrap.Modal(modal);
|
|
|
|
|
|
bsModal.show();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// --- Drag & Drop for Emails ---
|
|
|
|
|
|
const dropZone = document.getElementById('emailDropZone');
|
|
|
|
|
|
if (dropZone) {
|
|
|
|
|
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
|
|
|
|
|
dropZone.addEventListener(eventName, preventDefaults, false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function preventDefaults(e) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
dropZone.addEventListener('dragover', () => {
|
|
|
|
|
|
dropZone.style.borderColor = 'var(--accent)';
|
|
|
|
|
|
dropZone.style.backgroundColor = 'var(--accent-light)';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
dropZone.addEventListener('dragleave', () => {
|
|
|
|
|
|
dropZone.style.borderColor = 'transparent';
|
|
|
|
|
|
dropZone.style.backgroundColor = 'transparent';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
dropZone.addEventListener('drop', handleDrop);
|
|
|
|
|
|
|
|
|
|
|
|
async function handleDrop(e) {
|
|
|
|
|
|
dropZone.style.borderColor = 'transparent';
|
|
|
|
|
|
dropZone.style.backgroundColor = 'transparent';
|
|
|
|
|
|
|
|
|
|
|
|
const dt = e.dataTransfer;
|
|
|
|
|
|
const files = dt.files;
|
|
|
|
|
|
|
|
|
|
|
|
if (!files || files.length === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
const validExtensions = ['.msg', '.eml'];
|
|
|
|
|
|
let uploadCount = 0;
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < files.length; i++) {
|
|
|
|
|
|
const file = files[i];
|
|
|
|
|
|
const ext = '.' + file.name.split('.').pop().toLowerCase();
|
|
|
|
|
|
|
|
|
|
|
|
if (validExtensions.includes(ext)) {
|
|
|
|
|
|
await uploadEmailFile(file);
|
|
|
|
|
|
uploadCount++;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (uploadCount === 0 && files.length > 0) {
|
|
|
|
|
|
alert('Kun .msg og .eml filer er understøttet her.');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function uploadEmailFile(file) {
|
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
|
formData.append('file', file);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const badgeLabel = document.querySelector('#emailDropZone .text-muted');
|
|
|
|
|
|
const originalText = badgeLabel ? badgeLabel.innerHTML : '';
|
|
|
|
|
|
if(badgeLabel) badgeLabel.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Uploader...';
|
|
|
|
|
|
|
|
|
|
|
|
const response = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}/upload-email`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: formData
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const errJson = await response.json();
|
|
|
|
|
|
alert(`Fejl ved upload af ${file.name}: ${errJson.detail || 'Ukendt fejl'}`);
|
|
|
|
|
|
} catch(e) {
|
|
|
|
|
|
alert(`Fejl ved upload af ${file.name}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const updatedOpp = await response.json();
|
|
|
|
|
|
opportunity = updatedOpp;
|
|
|
|
|
|
if (opportunity.linked_emails) {
|
|
|
|
|
|
renderLinkedEmails(opportunity.linked_emails);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if(badgeLabel) badgeLabel.innerHTML = originalText;
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error uploading email:', error);
|
|
|
|
|
|
alert('Netværksfejl ved upload');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Contact Persons Logic ---
|
|
|
|
|
|
let contactSearchTimeout = null;
|
|
|
|
|
|
const contactInput = document.getElementById('contactSearchInput');
|
|
|
|
|
|
|
|
|
|
|
|
contactInput?.addEventListener('input', (e) => {
|
|
|
|
|
|
const term = e.target.value.trim();
|
|
|
|
|
|
clearTimeout(contactSearchTimeout);
|
|
|
|
|
|
if (term.length < 2) {
|
|
|
|
|
|
renderContactSuggestions([]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
contactSearchTimeout = setTimeout(() => searchContacts(term), 300);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
async function searchContacts(term) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const resp = await fetch(`${API_BASE}/api/v1/contacts?search=${encodeURIComponent(term)}&limit=5`);
|
|
|
|
|
|
if(resp.ok) {
|
|
|
|
|
|
const data = await resp.json();
|
|
|
|
|
|
renderContactSuggestions(data.contacts || []);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch(e) {
|
|
|
|
|
|
console.error(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderContactSuggestions(contacts) {
|
|
|
|
|
|
const container = document.getElementById('contactSearchResults');
|
|
|
|
|
|
if(!container) return;
|
|
|
|
|
|
|
|
|
|
|
|
if(contacts.length === 0) {
|
|
|
|
|
|
container.innerHTML = '<li><span class="dropdown-item text-muted small">Ingen fundet</span></li>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
container.innerHTML = contacts.map(c => {
|
|
|
|
|
|
// Check if already linked
|
|
|
|
|
|
const isLinked = opportunity.linked_contacts && opportunity.linked_contacts.some(lc => lc.id === c.id);
|
|
|
|
|
|
if (isLinked) return '';
|
|
|
|
|
|
|
|
|
|
|
|
const company = c.company_name || (c.company_names && c.company_names[0]) || '-';
|
|
|
|
|
|
return `<li><a class="dropdown-item" href="#" onclick="dataSelectContact(${c.id})">
|
|
|
|
|
|
<div class="fw-bold">${escapeHtml(c.first_name)} ${escapeHtml(c.last_name)}</div>
|
|
|
|
|
|
<div class="small text-muted">${escapeHtml(company)} • ${escapeHtml(c.email || '')}</div>
|
|
|
|
|
|
</a></li>`;
|
|
|
|
|
|
}).join('') || '<li><span class="dropdown-item text-muted small">Alle fundne er allerede tilføjet</span></li>';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Expose to global scope for onclick in string literal
|
|
|
|
|
|
window.dataSelectContact = async function(contactId) {
|
|
|
|
|
|
const role = prompt("Rolle (f.eks. Beslutningstager, Influencer)?", "");
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const resp = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}/contacts`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {'Content-Type': 'application/json'},
|
|
|
|
|
|
body: JSON.stringify({contact_id: contactId, role: role})
|
|
|
|
|
|
});
|
|
|
|
|
|
if(resp.ok) {
|
|
|
|
|
|
opportunity = await resp.json();
|
|
|
|
|
|
if(opportunity.linked_contacts) renderLinkedContacts(opportunity.linked_contacts);
|
|
|
|
|
|
document.getElementById('contactSearchInput').value = '';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alert('Fejl ved tilføjelse');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch(e) {
|
|
|
|
|
|
console.error(e);
|
|
|
|
|
|
alert('Fejl ved tilføjelse');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
window.renderLinkedContacts = function(contacts) {
|
|
|
|
|
|
const container = document.getElementById('linkedContactsList');
|
|
|
|
|
|
if(!container) return;
|
|
|
|
|
|
|
|
|
|
|
|
if(!contacts || contacts.length === 0) {
|
|
|
|
|
|
container.innerHTML = '<div class="text-muted small fst-italic">Ingen kontaktpersoner</div>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
container.innerHTML = contacts.map(c => `
|
|
|
|
|
|
<div class="d-flex align-items-center justify-content-between p-2 border rounded bg-white">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="fw-medium">${escapeHtml(c.first_name)} ${escapeHtml(c.last_name)}</div>
|
|
|
|
|
|
<div class="text-muted small">${escapeHtml(c.role || 'Ingen rolle')}</div>
|
|
|
|
|
|
${c.email ? `<div class="text-muted small"><a href="mailto:${c.email}" class="text-decoration-none text-muted"><i class="bi bi-envelope"></i> ${escapeHtml(c.email)}</a></div>` : ''}
|
|
|
|
|
|
${c.mobile_phone ? `<div class="text-muted small"><a href="tel:${c.mobile_phone}" class="text-decoration-none text-muted"><i class="bi bi-phone"></i> ${escapeHtml(c.mobile_phone)}</a></div>` : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button class="btn btn-sm btn-light text-danger" onclick="removeContactLink(${c.id})" title="Fjern">
|
|
|
|
|
|
<i class="bi bi-x"></i>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
window.removeContactLink = async function(contactId) {
|
|
|
|
|
|
if(!confirm('Fjern kontaktperson?')) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const resp = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}/contacts/${contactId}`, {
|
|
|
|
|
|
method: 'DELETE'
|
|
|
|
|
|
});
|
|
|
|
|
|
if(resp.ok) {
|
|
|
|
|
|
opportunity = await resp.json();
|
|
|
|
|
|
if(opportunity.linked_contacts) renderLinkedContacts(opportunity.linked_contacts);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch(e) { console.error(e); }
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-28 14:37:47 +01:00
|
|
|
|
lineModal = new bootstrap.Modal(document.getElementById('lineModal'));
|
|
|
|
|
|
const searchInput = document.getElementById('lineProductSearch');
|
|
|
|
|
|
searchInput?.addEventListener('input', (event) => {
|
|
|
|
|
|
const term = event.target.value.trim();
|
|
|
|
|
|
clearTimeout(productSearchTimeout);
|
|
|
|
|
|
if (!term || term.length < 2) {
|
|
|
|
|
|
renderProductSuggestions([]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
productSearchTimeout = setTimeout(() => searchProducts(term), 250);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const emailSearchInput = document.getElementById('commentEmailSearch');
|
|
|
|
|
|
emailSearchInput?.addEventListener('input', (event) => {
|
|
|
|
|
|
const term = event.target.value.trim();
|
|
|
|
|
|
clearTimeout(commentEmailSearchTimeout);
|
|
|
|
|
|
if (!term || term.length < 2) {
|
|
|
|
|
|
renderEmailSuggestions([]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
commentEmailSearchTimeout = setTimeout(() => searchEmails(term), 250);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-01-29 00:36:32 +01:00
|
|
|
|
const attachmentInput = document.getElementById('contractAttachmentInput');
|
|
|
|
|
|
const dropzone = document.getElementById('contractAttachmentDropzone');
|
|
|
|
|
|
dropzone?.addEventListener('click', () => attachmentInput?.click());
|
|
|
|
|
|
dropzone?.addEventListener('dragover', (event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
dropzone.classList.add('drag-over');
|
|
|
|
|
|
});
|
|
|
|
|
|
dropzone?.addEventListener('dragleave', () => dropzone.classList.remove('drag-over'));
|
|
|
|
|
|
dropzone?.addEventListener('drop', (event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
dropzone.classList.remove('drag-over');
|
|
|
|
|
|
uploadContractFiles(event.dataTransfer.files);
|
2026-01-28 14:37:47 +01:00
|
|
|
|
});
|
2026-01-29 00:36:32 +01:00
|
|
|
|
attachmentInput?.addEventListener('change', (event) => {
|
|
|
|
|
|
uploadContractFiles(event.target.files);
|
|
|
|
|
|
event.target.value = '';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
renderEmailAttachmentPreview();
|
2026-01-28 14:37:47 +01:00
|
|
|
|
|
2026-01-28 07:48:10 +01:00
|
|
|
|
await loadStages();
|
|
|
|
|
|
await loadOpportunity();
|
2026-01-29 00:36:32 +01:00
|
|
|
|
await loadContractFiles();
|
2026-01-28 07:48:10 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
async function loadStages() {
|
2026-01-29 00:36:32 +01:00
|
|
|
|
const response = await fetch(`${API_BASE}/api/v1/pipeline/stages`);
|
2026-01-28 07:48:10 +01:00
|
|
|
|
stages = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
const select = document.getElementById('stageId');
|
|
|
|
|
|
select.innerHTML = stages.map(s => `<option value="${s.id}">${s.name}</option>`).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadOpportunity() {
|
2026-01-29 00:36:32 +01:00
|
|
|
|
const response = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}`);
|
2026-01-28 07:48:10 +01:00
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
alert('Mulighed ikke fundet');
|
|
|
|
|
|
window.location.href = '/opportunities';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
opportunity = await response.json();
|
|
|
|
|
|
renderOpportunity();
|
2026-01-28 14:37:47 +01:00
|
|
|
|
await loadLineItems();
|
|
|
|
|
|
await loadComments();
|
2026-01-28 07:48:10 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderOpportunity() {
|
|
|
|
|
|
document.getElementById('pageTitle').textContent = opportunity.title;
|
|
|
|
|
|
document.getElementById('title').value = opportunity.title;
|
|
|
|
|
|
document.getElementById('customerName').value = opportunity.customer_name || '-';
|
|
|
|
|
|
document.getElementById('description').value = opportunity.description || '';
|
|
|
|
|
|
document.getElementById('amount').value = opportunity.amount || 0;
|
|
|
|
|
|
document.getElementById('currency').value = opportunity.currency || 'DKK';
|
|
|
|
|
|
document.getElementById('expectedCloseDate').value = opportunity.expected_close_date || '';
|
|
|
|
|
|
document.getElementById('stageId').value = opportunity.stage_id;
|
|
|
|
|
|
document.getElementById('probability').value = `${opportunity.probability || 0}%`;
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('customerNameBadge').textContent = opportunity.customer_name || '-';
|
|
|
|
|
|
document.getElementById('stageBadge').textContent = opportunity.stage_name || '-';
|
|
|
|
|
|
document.getElementById('amountBadge').textContent = formatCurrency(opportunity.amount, opportunity.currency);
|
|
|
|
|
|
document.getElementById('probabilityBadge').textContent = `${opportunity.probability || 0}%`;
|
2026-01-29 00:36:32 +01:00
|
|
|
|
|
|
|
|
|
|
if (opportunity.linked_emails && window.renderLinkedEmails) {
|
|
|
|
|
|
window.renderLinkedEmails(opportunity.linked_emails);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (opportunity.linked_contacts && window.renderLinkedContacts) {
|
|
|
|
|
|
window.renderLinkedContacts(opportunity.linked_contacts);
|
|
|
|
|
|
}
|
2026-01-28 07:48:10 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveOpportunity() {
|
|
|
|
|
|
const payload = {
|
|
|
|
|
|
title: document.getElementById('title').value,
|
|
|
|
|
|
description: document.getElementById('description').value || null,
|
|
|
|
|
|
amount: parseFloat(document.getElementById('amount').value || 0),
|
|
|
|
|
|
currency: document.getElementById('currency').value,
|
|
|
|
|
|
expected_close_date: document.getElementById('expectedCloseDate').value || null,
|
|
|
|
|
|
stage_id: parseInt(document.getElementById('stageId').value)
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-29 00:36:32 +01:00
|
|
|
|
const response = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}`, {
|
2026-01-28 07:48:10 +01:00
|
|
|
|
method: 'PUT',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
alert('Kunne ikke gemme mulighed');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
opportunity = await response.json();
|
|
|
|
|
|
renderOpportunity();
|
2026-01-28 14:37:47 +01:00
|
|
|
|
await loadLineItems();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadLineItems() {
|
|
|
|
|
|
try {
|
2026-01-29 00:36:32 +01:00
|
|
|
|
const response = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}/lines`);
|
2026-01-28 14:37:47 +01:00
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
lineItems = await response.json();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
lineItems = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading line items:', error);
|
|
|
|
|
|
lineItems = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderLineItems();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderLineItems() {
|
|
|
|
|
|
const tbody = document.getElementById('lineItemsTableBody');
|
|
|
|
|
|
const summary = document.getElementById('lineItemsSummary');
|
|
|
|
|
|
if (!tbody) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (!lineItems || lineItems.length === 0) {
|
|
|
|
|
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-4">Ingen varelinjer endnu</td></tr>';
|
|
|
|
|
|
if (summary) summary.textContent = 'Total: 0 kr';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let total = 0;
|
|
|
|
|
|
tbody.innerHTML = lineItems.map(line => {
|
|
|
|
|
|
total += parseFloat(line.total_price || 0);
|
|
|
|
|
|
return `
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td>${escapeHtml(line.name)}</td>
|
|
|
|
|
|
<td>${escapeHtml(line.product_number || '-')}</td>
|
|
|
|
|
|
<td class="text-end">${line.quantity}</td>
|
|
|
|
|
|
<td class="text-end">${formatCurrency(line.unit_price, opportunity?.currency || 'DKK')}</td>
|
|
|
|
|
|
<td class="text-end">${formatCurrency(line.total_price || 0, opportunity?.currency || 'DKK')}</td>
|
|
|
|
|
|
<td class="text-end">
|
|
|
|
|
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteLineItem(${line.id})">
|
|
|
|
|
|
<i class="bi bi-trash"></i>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
|
|
if (summary) {
|
|
|
|
|
|
summary.textContent = `Total: ${formatCurrency(total, opportunity?.currency || 'DKK')}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function searchProducts(term) {
|
|
|
|
|
|
if (!term) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const params = new URLSearchParams({ search: term, limit: '8' });
|
|
|
|
|
|
const response = await fetch(`/api/v1/webshop/products/search?${params}`);
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
renderProductSuggestions([]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
renderProductSuggestions(data.products || []);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error searching products:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderProductSuggestions(products) {
|
|
|
|
|
|
const container = document.getElementById('productSearchResults');
|
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (!products || products.length === 0) {
|
|
|
|
|
|
container.innerHTML = '<div class="text-muted small">Ingen produkter fundet</div>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
container.innerHTML = products.map(product => `
|
|
|
|
|
|
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-start" data-product-id="${product.id}">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>${escapeHtml(product.name)}</strong>
|
|
|
|
|
|
<div class="small text-muted">${escapeHtml(product.product_number || '')} ${escapeHtml(product.category || '')}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="text-primary">${formatCurrency(product.base_price, opportunity?.currency || 'DKK')}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
`).join('');
|
|
|
|
|
|
|
|
|
|
|
|
container.querySelectorAll('button').forEach(button => {
|
|
|
|
|
|
button.addEventListener('click', () => {
|
|
|
|
|
|
const productId = button.dataset.productId;
|
|
|
|
|
|
const product = products.find(p => p.id.toString() === productId);
|
|
|
|
|
|
if (product) selectProductSuggestion(product);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function selectProductSuggestion(product) {
|
|
|
|
|
|
selectedProductCandidate = product;
|
|
|
|
|
|
document.getElementById('lineProductSearch').value = `${product.name} (${product.product_number || 'N/A'})`;
|
|
|
|
|
|
document.getElementById('lineName').value = product.name;
|
|
|
|
|
|
document.getElementById('lineProductNumber').value = product.product_number || '';
|
|
|
|
|
|
document.getElementById('lineUnitPrice').value = parseFloat(product.base_price || 0).toFixed(2);
|
|
|
|
|
|
renderProductSuggestions([]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openAddLineModal() {
|
|
|
|
|
|
clearLineModal();
|
|
|
|
|
|
lineModal?.show();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clearLineModal() {
|
|
|
|
|
|
selectedProductCandidate = null;
|
|
|
|
|
|
document.getElementById('lineItemForm')?.reset();
|
|
|
|
|
|
document.getElementById('lineQuantity').value = '1';
|
|
|
|
|
|
document.getElementById('lineUnitPrice').value = '0.00';
|
|
|
|
|
|
document.getElementById('lineProductSearch').value = '';
|
|
|
|
|
|
renderProductSuggestions([]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function addLineItem() {
|
|
|
|
|
|
const form = document.getElementById('lineItemForm');
|
|
|
|
|
|
if (!form.checkValidity()) {
|
|
|
|
|
|
form.reportValidity();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const payload = {
|
|
|
|
|
|
name: document.getElementById('lineName').value.trim(),
|
|
|
|
|
|
product_number: document.getElementById('lineProductNumber').value.trim() || selectedProductCandidate?.product_number,
|
|
|
|
|
|
description: document.getElementById('lineDescription').value.trim() || null,
|
|
|
|
|
|
quantity: parseInt(document.getElementById('lineQuantity').value || '1', 10),
|
|
|
|
|
|
unit_price: parseFloat(document.getElementById('lineUnitPrice').value || '0')
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-01-29 00:36:32 +01:00
|
|
|
|
const response = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}/lines`, {
|
2026-01-28 14:37:47 +01:00
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify(payload)
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const error = await response.text();
|
|
|
|
|
|
alert(error || 'Kunne ikke tilføje varelinje');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
lineModal?.hide();
|
|
|
|
|
|
await loadLineItems();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error adding line item:', error);
|
|
|
|
|
|
alert('Fejl ved tilføjelse af varelinje');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function deleteLineItem(lineId) {
|
|
|
|
|
|
if (!confirm('Vil du fjerne denne varelinje?')) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-01-29 00:36:32 +01:00
|
|
|
|
const response = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}/lines/${lineId}`, {
|
2026-01-28 14:37:47 +01:00
|
|
|
|
method: 'DELETE'
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
await loadLineItems();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error deleting line item:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadComments() {
|
|
|
|
|
|
try {
|
2026-01-29 00:36:32 +01:00
|
|
|
|
const response = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}/comments`);
|
2026-01-28 14:37:47 +01:00
|
|
|
|
comments = response.ok ? await response.json() : [];
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error loading comments:', error);
|
|
|
|
|
|
comments = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderComments();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderComments() {
|
|
|
|
|
|
const thread = document.getElementById('commentThread');
|
|
|
|
|
|
const emptyState = document.getElementById('commentEmptyState');
|
|
|
|
|
|
if (!thread || !emptyState) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (!comments.length) {
|
|
|
|
|
|
thread.innerHTML = '';
|
|
|
|
|
|
emptyState.style.display = 'block';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
emptyState.style.display = 'none';
|
|
|
|
|
|
thread.innerHTML = comments.map(comment => {
|
|
|
|
|
|
const authorLabel = comment.author_name || comment.user_full_name || comment.username || 'Hub Bruger';
|
|
|
|
|
|
let emailBadge = '';
|
|
|
|
|
|
if (comment.email_id) {
|
2026-01-29 00:36:32 +01:00
|
|
|
|
const subject = comment.email_subject ? comment.email_subject : `Email #${comment.email_id}`;
|
|
|
|
|
|
const sender = comment.email_sender || '';
|
|
|
|
|
|
const date = comment.email_received_date ? formatCommentTimestamp(comment.email_received_date) : '';
|
2026-01-28 14:37:47 +01:00
|
|
|
|
const safeLink = escapeHtml(`/emails/${comment.email_id}`);
|
|
|
|
|
|
emailBadge = `
|
2026-01-29 00:36:32 +01:00
|
|
|
|
<a class="comment-badge d-inline-flex flex-column align-items-start" href="${safeLink}" target="_blank" rel="noreferrer"
|
|
|
|
|
|
title="Se email detaljer" >
|
|
|
|
|
|
<div><i class="bi bi-envelope me-1"></i><span class="fw-semibold">${escapeHtml(subject)}</span></div>
|
|
|
|
|
|
<div class="small text-muted">${escapeHtml(sender)}</div>
|
|
|
|
|
|
<div class="small text-muted">${escapeHtml(date)}</div>
|
2026-01-28 14:37:47 +01:00
|
|
|
|
</a>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let contractBadge = '';
|
|
|
|
|
|
if (comment.contract_number) {
|
|
|
|
|
|
const label = `Kontrakt: ${comment.contract_number}`;
|
|
|
|
|
|
const title = comment.contract_context ? escapeHtml(comment.contract_context) : '';
|
|
|
|
|
|
if (comment.contract_link) {
|
|
|
|
|
|
const safeLink = escapeHtml(comment.contract_link);
|
|
|
|
|
|
contractBadge = `
|
|
|
|
|
|
<a class="comment-badge" href="${safeLink}" target="_blank" rel="noreferrer" title="${title}">
|
|
|
|
|
|
<i class="bi bi-file-earmark-text"></i>${escapeHtml(label)}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
contractBadge = `<span class="comment-badge" title="${title}"><i class="bi bi-file-earmark-text"></i>${escapeHtml(label)}</span>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="comment-entry">
|
|
|
|
|
|
<div class="comment-header">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>${escapeHtml(authorLabel)}</strong>
|
|
|
|
|
|
<div class="comment-meta">${formatCommentTimestamp(comment.created_at)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="comment-badges">
|
|
|
|
|
|
${emailBadge}
|
|
|
|
|
|
${contractBadge}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="comment-body">${formatCommentBody(comment.content)}</div>
|
2026-01-29 00:36:32 +01:00
|
|
|
|
${renderCommentAttachments(comment)}
|
2026-01-28 14:37:47 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatCommentTimestamp(value) {
|
|
|
|
|
|
if (!value) return '';
|
|
|
|
|
|
const parsed = new Date(value);
|
|
|
|
|
|
if (Number.isNaN(parsed.getTime())) return '';
|
|
|
|
|
|
return parsed.toLocaleString('da-DK', { dateStyle: 'medium', timeStyle: 'short' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatCommentBody(text) {
|
|
|
|
|
|
if (!text) return '';
|
|
|
|
|
|
return escapeHtml(text).replace(/\n/g, '<br>');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function submitComment() {
|
|
|
|
|
|
const contentEl = document.getElementById('commentContent');
|
|
|
|
|
|
if (!contentEl) return;
|
|
|
|
|
|
const content = contentEl.value.trim();
|
|
|
|
|
|
if (!content) {
|
|
|
|
|
|
alert('Kommentar er påkrævet');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 00:36:32 +01:00
|
|
|
|
const formData = new FormData();
|
|
|
|
|
|
formData.append('content', content);
|
|
|
|
|
|
formData.append('author_name', getCurrentUserDisplayName());
|
2026-01-28 14:37:47 +01:00
|
|
|
|
|
|
|
|
|
|
if (selectedCommentEmail) {
|
2026-01-29 00:36:32 +01:00
|
|
|
|
formData.append('email_id', selectedCommentEmail.id);
|
|
|
|
|
|
formData.append('metadata', JSON.stringify({ linked_email_subject: selectedCommentEmail.subject }));
|
2026-01-28 14:37:47 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-01-29 00:36:32 +01:00
|
|
|
|
const response = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}/comments`, {
|
2026-01-28 14:37:47 +01:00
|
|
|
|
method: 'POST',
|
2026-01-29 00:36:32 +01:00
|
|
|
|
body: formData
|
2026-01-28 14:37:47 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const error = await response.text();
|
|
|
|
|
|
alert(error || 'Kunne ikke gemme kommentar');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
clearCommentForm();
|
|
|
|
|
|
await loadComments();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error saving comment:', error);
|
|
|
|
|
|
alert('Fejl ved gemning af kommentar');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 00:36:32 +01:00
|
|
|
|
function renderCommentAttachments(comment) {
|
|
|
|
|
|
const saved = comment.attachments || [];
|
|
|
|
|
|
const emailFiles = comment.email_attachments || [];
|
|
|
|
|
|
if (!saved.length && !emailFiles.length) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const sections = [];
|
|
|
|
|
|
|
|
|
|
|
|
if (saved.length) {
|
|
|
|
|
|
sections.push(`
|
|
|
|
|
|
<div class="comment-attachment-group">
|
|
|
|
|
|
<span class="comment-attachment-label">Vedhæftede filer</span>
|
|
|
|
|
|
${saved.map(file => {
|
|
|
|
|
|
const safeUrl = escapeHtml(file.download_url);
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="comment-attachment-entry">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>${escapeHtml(file.filename)}</strong>
|
|
|
|
|
|
<div class="small text-muted">${formatFileSize(file.size_bytes)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="attachment-actions">
|
|
|
|
|
|
<a class="btn btn-sm btn-outline-primary" href="${safeUrl}" target="_blank" rel="noreferrer">Se</a>
|
|
|
|
|
|
<a class="btn btn-sm btn-outline-secondary" href="${safeUrl}" download target="_blank" rel="noreferrer">Download</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (emailFiles.length) {
|
|
|
|
|
|
sections.push(`
|
|
|
|
|
|
<div class="comment-attachment-group">
|
|
|
|
|
|
<span class="comment-attachment-label">Emailvedhæftede filer</span>
|
|
|
|
|
|
${emailFiles.map(file => {
|
|
|
|
|
|
const safeUrl = escapeHtml(file.download_url);
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="comment-attachment-entry">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>${escapeHtml(file.filename)}</strong>
|
|
|
|
|
|
<div class="small text-muted">${formatFileSize(file.size_bytes)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="attachment-actions">
|
|
|
|
|
|
<a class="btn btn-sm btn-outline-primary" href="${safeUrl}" target="_blank" rel="noreferrer">Se</a>
|
|
|
|
|
|
<a class="btn btn-sm btn-outline-secondary" href="${safeUrl}" download target="_blank" rel="noreferrer">Download</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return `<div class="comment-attachment-stack">${sections.join('')}</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-28 14:37:47 +01:00
|
|
|
|
function clearCommentForm() {
|
|
|
|
|
|
const contentEl = document.getElementById('commentContent');
|
|
|
|
|
|
if (contentEl) contentEl.value = '';
|
|
|
|
|
|
const emailInput = document.getElementById('commentEmailSearch');
|
|
|
|
|
|
if (emailInput) emailInput.value = '';
|
2026-01-29 00:36:32 +01:00
|
|
|
|
const attachmentInput = document.getElementById('contractAttachmentInput');
|
|
|
|
|
|
if (attachmentInput) attachmentInput.value = '';
|
|
|
|
|
|
selectedEmailAttachments = [];
|
|
|
|
|
|
renderEmailAttachmentPreview();
|
2026-01-28 14:37:47 +01:00
|
|
|
|
renderEmailSuggestions([]);
|
|
|
|
|
|
clearLinkedEmail();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clearLinkedEmail() {
|
|
|
|
|
|
selectedCommentEmail = null;
|
|
|
|
|
|
const badge = document.getElementById('linkedEmailBadge');
|
|
|
|
|
|
const label = document.getElementById('linkedEmailLabel');
|
|
|
|
|
|
if (badge) badge.style.display = 'none';
|
|
|
|
|
|
if (label) label.textContent = '';
|
2026-01-29 00:36:32 +01:00
|
|
|
|
selectedEmailAttachments = [];
|
|
|
|
|
|
renderEmailAttachmentPreview();
|
2026-01-28 14:37:47 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function searchEmails(term) {
|
|
|
|
|
|
if (!term) {
|
|
|
|
|
|
renderEmailSuggestions([]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const params = new URLSearchParams({ q: term, limit: '6' });
|
|
|
|
|
|
const response = await fetch(`/api/v1/emails?${params}`);
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
renderEmailSuggestions([]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
renderEmailSuggestions(Array.isArray(data) ? data : data || []);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error searching emails:', error);
|
|
|
|
|
|
renderEmailSuggestions([]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderEmailSuggestions(results) {
|
|
|
|
|
|
const container = document.getElementById('commentEmailResults');
|
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (!results || results.length === 0) {
|
|
|
|
|
|
container.innerHTML = '<div class="text-muted small">Ingen emails fundet</div>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
container.innerHTML = results.map(email => `
|
|
|
|
|
|
<button type="button" class="list-group-item list-group-item-action d-flex flex-column align-items-start" data-email-id="${email.id}">
|
|
|
|
|
|
<div class="fw-semibold">${escapeHtml(email.subject || 'Ingen emne')}</div>
|
|
|
|
|
|
<small class="text-muted">${escapeHtml(email.sender_email || '-')}</small>
|
|
|
|
|
|
<small class="text-muted">${formatCommentTimestamp(email.received_date)}</small>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
`).join('');
|
|
|
|
|
|
|
|
|
|
|
|
container.querySelectorAll('button').forEach(button => {
|
|
|
|
|
|
button.addEventListener('click', () => {
|
|
|
|
|
|
const emailId = button.dataset.emailId;
|
|
|
|
|
|
const matched = results.find(item => item.id.toString() === emailId);
|
|
|
|
|
|
if (matched) selectEmailSuggestion(matched);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function selectEmailSuggestion(email) {
|
|
|
|
|
|
selectedCommentEmail = email;
|
|
|
|
|
|
const emailInput = document.getElementById('commentEmailSearch');
|
|
|
|
|
|
if (emailInput) emailInput.value = email.subject || '';
|
|
|
|
|
|
const badge = document.getElementById('linkedEmailBadge');
|
|
|
|
|
|
const label = document.getElementById('linkedEmailLabel');
|
|
|
|
|
|
if (badge) badge.style.display = 'flex';
|
|
|
|
|
|
if (label) label.textContent = `${email.subject || 'Email'} • ${email.sender_email || ''}`;
|
|
|
|
|
|
document.getElementById('commentEmailResults').innerHTML = '';
|
2026-01-29 00:36:32 +01:00
|
|
|
|
fetchEmailAttachments(email.id);
|
2026-01-28 14:37:47 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 00:36:32 +01:00
|
|
|
|
async function fetchEmailAttachments(emailId) {
|
|
|
|
|
|
if (!emailId) {
|
|
|
|
|
|
selectedEmailAttachments = [];
|
|
|
|
|
|
renderEmailAttachmentPreview();
|
2026-01-28 14:37:47 +01:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-01-29 00:36:32 +01:00
|
|
|
|
const response = await fetch(`/api/v1/emails/${emailId}`);
|
2026-01-28 14:37:47 +01:00
|
|
|
|
if (!response.ok) {
|
2026-01-29 00:36:32 +01:00
|
|
|
|
selectedEmailAttachments = [];
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
const attachments = Array.isArray(data.attachments) ? data.attachments : [];
|
|
|
|
|
|
selectedEmailAttachments = attachments.map(att => ({
|
|
|
|
|
|
...att,
|
|
|
|
|
|
download_url: `/api/v1/emails/${emailId}/attachments/${att.id}`
|
|
|
|
|
|
}));
|
2026-01-28 14:37:47 +01:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2026-01-29 00:36:32 +01:00
|
|
|
|
console.error('Error fetching email attachments:', error);
|
|
|
|
|
|
selectedEmailAttachments = [];
|
2026-01-28 14:37:47 +01:00
|
|
|
|
}
|
2026-01-29 00:36:32 +01:00
|
|
|
|
|
|
|
|
|
|
renderEmailAttachmentPreview();
|
2026-01-28 14:37:47 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 00:36:32 +01:00
|
|
|
|
function renderEmailAttachmentPreview() {
|
|
|
|
|
|
const container = document.getElementById('contractEmailAttachmentList');
|
2026-01-28 14:37:47 +01:00
|
|
|
|
if (!container) return;
|
|
|
|
|
|
|
2026-01-29 00:36:32 +01:00
|
|
|
|
if (!selectedEmailAttachments.length) {
|
|
|
|
|
|
container.innerHTML = '<div class="text-muted small">Ingen filer fra den valgte email</div>';
|
2026-01-28 14:37:47 +01:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 00:36:32 +01:00
|
|
|
|
container.innerHTML = selectedEmailAttachments.map(att => {
|
|
|
|
|
|
const safeUrl = escapeHtml(att.download_url);
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="attachment-item">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>${escapeHtml(att.filename)}</strong>
|
|
|
|
|
|
<div class="small text-muted">${formatFileSize(att.size_bytes)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="attachment-actions">
|
|
|
|
|
|
<a class="btn btn-sm btn-outline-primary" href="${safeUrl}" target="_blank" rel="noreferrer">Se</a>
|
|
|
|
|
|
<a class="btn btn-sm btn-outline-secondary" href="${safeUrl}" download target="_blank" rel="noreferrer">Download</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
}
|
2026-01-28 14:37:47 +01:00
|
|
|
|
|
2026-01-29 00:36:32 +01:00
|
|
|
|
async function loadContractFiles() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}/contract-files`);
|
|
|
|
|
|
contractFiles = response.ok ? await response.json() : [];
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error fetching contract files:', error);
|
|
|
|
|
|
contractFiles = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
renderContractFiles();
|
2026-01-28 14:37:47 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 00:36:32 +01:00
|
|
|
|
function renderContractFiles() {
|
|
|
|
|
|
const container = document.getElementById('contractAttachmentList');
|
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (!contractFiles.length) {
|
|
|
|
|
|
container.innerHTML = '<div class="text-muted small">Ingen vedhæftede filer</div>';
|
|
|
|
|
|
return;
|
2026-01-28 14:37:47 +01:00
|
|
|
|
}
|
2026-01-29 00:36:32 +01:00
|
|
|
|
|
|
|
|
|
|
container.innerHTML = contractFiles.map(file => {
|
|
|
|
|
|
const safeUrl = escapeHtml(file.download_url);
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="attachment-item">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>${escapeHtml(file.filename)}</strong>
|
|
|
|
|
|
<div class="small text-muted">${formatFileSize(file.size_bytes)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="attachment-actions">
|
|
|
|
|
|
<a class="btn btn-sm btn-outline-primary" href="${safeUrl}" target="_blank" rel="noreferrer">Se</a>
|
|
|
|
|
|
<a class="btn btn-sm btn-outline-secondary" href="${safeUrl}" download target="_blank" rel="noreferrer">Download</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
2026-01-28 14:37:47 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 00:36:32 +01:00
|
|
|
|
async function uploadContractFiles(fileList) {
|
|
|
|
|
|
if (!fileList || !fileList.length) return;
|
|
|
|
|
|
|
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
|
Array.from(fileList).forEach(file => formData.append('files', file));
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}/contract-files`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: formData
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const error = await response.text();
|
|
|
|
|
|
alert(error || 'Kunne ikke uploade filer');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await loadContractFiles();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error uploading contract files:', error);
|
|
|
|
|
|
alert('Fejl ved upload af filer');
|
|
|
|
|
|
}
|
2026-01-28 14:37:47 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getCurrentUserDisplayName() {
|
|
|
|
|
|
const profile = document.querySelector('.dropdown .small.fw-bold');
|
|
|
|
|
|
return profile ? profile.textContent.trim() : 'Hub Bruger';
|
2026-01-28 07:48:10 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 00:36:32 +01:00
|
|
|
|
function formatFileSize(bytes) {
|
|
|
|
|
|
const units = ['B', 'KB', 'MB', 'GB'];
|
|
|
|
|
|
let value = Number(bytes) || 0;
|
|
|
|
|
|
let index = 0;
|
|
|
|
|
|
while (value >= 1024 && index < units.length - 1) {
|
|
|
|
|
|
value /= 1024;
|
|
|
|
|
|
index += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
const decimals = index === 0 ? 0 : 1;
|
|
|
|
|
|
return `${value.toFixed(decimals)} ${units[index]}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-28 07:48:10 +01:00
|
|
|
|
function formatCurrency(value, currency) {
|
|
|
|
|
|
const num = parseFloat(value || 0);
|
|
|
|
|
|
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(num);
|
|
|
|
|
|
}
|
2026-01-28 14:37:47 +01:00
|
|
|
|
|
|
|
|
|
|
function escapeHtml(text) {
|
|
|
|
|
|
if (!text) return '';
|
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
|
div.textContent = text;
|
|
|
|
|
|
return div.innerHTML;
|
|
|
|
|
|
}
|
2026-01-28 07:48:10 +01:00
|
|
|
|
</script>
|
|
|
|
|
|
{% endblock %}
|