bmc_hub/app/opportunities/frontend/opportunity_detail.html

1533 lines
60 KiB
HTML
Raw Normal View History

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
}
.comment-thread {
display: flex;
flex-direction: column;
gap: 1rem;
}
.comment-entry {
2026-01-29 00:36:32 +01:00
background: var(--bg-body);
padding: 1rem;
2026-01-29 00:36:32 +01:00
border-radius: 8px;
border-left: 3px solid var(--accent);
}
2026-01-29 00:36:32 +01:00
.comment-header {
display: flex;
justify-content: space-between;
2026-01-29 00:36:32 +01:00
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.comment-badges {
display: flex;
gap: 0.5rem;
2026-01-29 00:36:32 +01:00
margin-bottom: 0.5rem;
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);
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-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;
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 {
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 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 pipelinevisning</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">Pipelinestatus</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>
<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;
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 = [];
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); }
};
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-29 00:36:32 +01:00
attachmentInput?.addEventListener('change', (event) => {
uploadContractFiles(event.target.files);
event.target.value = '';
});
renderEmailAttachmentPreview();
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();
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();
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`);
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`, {
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}`, {
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`);
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) : '';
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>
</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)}
</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());
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 }));
}
try {
2026-01-29 00:36:32 +01:00
const response = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}/comments`, {
method: 'POST',
2026-01-29 00:36:32 +01:00
body: formData
});
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>`;
}
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();
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();
}
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-29 00:36:32 +01:00
async function fetchEmailAttachments(emailId) {
if (!emailId) {
selectedEmailAttachments = [];
renderEmailAttachmentPreview();
return;
}
try {
2026-01-29 00:36:32 +01:00
const response = await fetch(`/api/v1/emails/${emailId}`);
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}`
}));
}
} catch (error) {
2026-01-29 00:36:32 +01:00
console.error('Error fetching email attachments:', error);
selectedEmailAttachments = [];
}
2026-01-29 00:36:32 +01:00
renderEmailAttachmentPreview();
}
2026-01-29 00:36:32 +01:00
function renderEmailAttachmentPreview() {
const container = document.getElementById('contractEmailAttachmentList');
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>';
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-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-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-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-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');
}
}
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);
}
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 %}