bmc_hub/app/opportunities/frontend/opportunity_detail.html
Christian 25168108d6 feat(sag): Initialize case management module with CRUD operations, relations, and tags
- Added backend API routes for case management including listing, creating, updating, and deleting cases.
- Implemented relations and tags functionality for cases.
- Created frontend views for displaying case lists and details with filtering options.
- Added database migration scripts to set up necessary tables and indexes.
- Included HTML templates for case listing and detail views with responsive design.
- Configured module metadata in module.json for integration.
2026-01-29 23:07:33 +01:00

1619 lines
64 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "shared/frontend/base.html" %}
{% block extra_css %}
<style>
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr 320px;
gap: 1.5rem;
align-items: start;
}
.section-card {
background: var(--bg-card);
border-radius: var(--border-radius);
padding: 1.5rem;
box-shadow: 0 2px 15px rgba(0,0,0,0.03);
}
.section-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 1rem;
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;
}
.comment-thread {
display: flex;
flex-direction: column;
gap: 1rem;
}
.comment-entry {
background: var(--bg-body);
padding: 1rem;
border-radius: 8px;
border-left: 3px solid var(--accent);
}
.comment-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.comment-badges {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.comment-badge {
font-size: 0.75rem;
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;
}
.comment-badge:hover {
background: var(--accent-light);
color: var(--accent);
border-color: var(--accent);
}
.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);
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;
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;
}
}
</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">
<!-- 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>
<!-- Contact Role Modal (choose common title or type your own) -->
<div class="modal fade" id="contactRoleModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Tilføj kontaktperson</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<div class="text-muted small">Kontakt</div>
<div class="fw-medium" id="contactRoleModalContactName">-</div>
</div>
<label class="form-label">Rolle / titel</label>
<input
type="text"
class="form-control"
id="contactRoleInput"
placeholder="Vælg fra listen eller skriv selv…"
list="contactRoleTitles"
autocomplete="off"
>
<datalist id="contactRoleTitles">
<option value="Direktør"></option>
<option value="IT kontakt"></option>
<option value="3. part"></option>
<option value="Beslutningstager"></option>
</datalist>
<div class="form-text">Du kan altid skrive en custom titel.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" id="contactRoleSaveBtn">Tilføj</button>
</div>
</div>
</div>
</div>
<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>
<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>
</div>
</div>
<div class="sticky-panel d-flex flex-column gap-3">
<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>
<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>
<!-- 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>
{% endblock %}
{% block extra_js %}
<script>
const opportunityId = parseInt(window.location.pathname.split('/').pop());
const API_BASE = ""; // Relative path as frontend is served by same backend
let stages = [];
let opportunity = null;
let lineItems = [];
let lineModal = null;
let selectedProductCandidate = null;
let productSearchTimeout = null;
let comments = [];
let selectedCommentEmail = null;
let contractFiles = [];
let selectedEmailAttachments = [];
let commentEmailSearchTimeout = null;
document.addEventListener('DOMContentLoaded', async () => {
// 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');
let contactRoleModal = null;
let pendingContactId = null;
const contactSuggestionMap = new Map();
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;
contactSuggestionMap.clear();
for (const c of (contacts || [])) {
if (c && typeof c.id === 'number') contactSuggestionMap.set(c.id, c);
}
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) {
pendingContactId = contactId;
const contact = contactSuggestionMap.get(contactId);
if (!contactRoleModal) {
contactRoleModal = new bootstrap.Modal(document.getElementById('contactRoleModal'));
document.getElementById('contactRoleSaveBtn')?.addEventListener('click', submitContactRole);
document.getElementById('contactRoleInput')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
submitContactRole();
}
});
}
const nameEl = document.getElementById('contactRoleModalContactName');
if (nameEl && contact) {
nameEl.textContent = `${contact.first_name || ''} ${contact.last_name || ''}`.trim() || `#${contactId}`;
} else if (nameEl) {
nameEl.textContent = `#${contactId}`;
}
const roleInput = document.getElementById('contactRoleInput');
if (roleInput) {
roleInput.value = '';
setTimeout(() => roleInput.focus(), 150);
}
contactRoleModal.show();
};
async function submitContactRole() {
if (!pendingContactId) return;
const roleInput = document.getElementById('contactRoleInput');
const roleValue = roleInput?.value?.trim() || null;
try {
const resp = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}/contacts`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({contact_id: pendingContactId, role: roleValue})
});
if (resp.ok) {
opportunity = await resp.json();
if(opportunity.linked_contacts) renderLinkedContacts(opportunity.linked_contacts);
document.getElementById('contactSearchInput').value = '';
pendingContactId = null;
contactRoleModal?.hide();
} else {
try {
const errJson = await resp.json();
alert(errJson.detail || 'Fejl ved tilføjelse');
} catch(e) {
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.phone ? `<div class="text-muted small"><a href="tel:${c.phone}" class="text-decoration-none text-muted"><i class="bi bi-telephone"></i> ${escapeHtml(c.phone)}</a></div>` : ''}
${c.mobile ? `<div class="text-muted small"><a href="tel:${c.mobile}" class="text-decoration-none text-muted"><i class="bi bi-phone"></i> ${escapeHtml(c.mobile)}</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);
});
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);
});
attachmentInput?.addEventListener('change', (event) => {
uploadContractFiles(event.target.files);
event.target.value = '';
});
renderEmailAttachmentPreview();
await loadStages();
await loadOpportunity();
await loadContractFiles();
});
async function loadStages() {
const response = await fetch(`${API_BASE}/api/v1/pipeline/stages`);
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() {
const response = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}`);
if (!response.ok) {
alert('Mulighed ikke fundet');
window.location.href = '/opportunities';
return;
}
opportunity = await response.json();
renderOpportunity();
await loadLineItems();
await loadComments();
}
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}%`;
if (opportunity.linked_emails && window.renderLinkedEmails) {
window.renderLinkedEmails(opportunity.linked_emails);
}
if (opportunity.linked_contacts && window.renderLinkedContacts) {
window.renderLinkedContacts(opportunity.linked_contacts);
}
}
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)
};
const response = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}`, {
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 {
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 {
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 {
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 {
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) {
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 = `
<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>
${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;
}
const formData = new FormData();
formData.append('content', content);
formData.append('author_name', getCurrentUserDisplayName());
if (selectedCommentEmail) {
formData.append('email_id', selectedCommentEmail.id);
formData.append('metadata', JSON.stringify({ linked_email_subject: selectedCommentEmail.subject }));
}
try {
const response = await fetch(`${API_BASE}/api/v1/opportunities/${opportunityId}/comments`, {
method: 'POST',
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');
}
}
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 = '';
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 = '';
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 = '';
fetchEmailAttachments(email.id);
}
async function fetchEmailAttachments(emailId) {
if (!emailId) {
selectedEmailAttachments = [];
renderEmailAttachmentPreview();
return;
}
try {
const response = await fetch(`/api/v1/emails/${emailId}`);
if (!response.ok) {
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) {
console.error('Error fetching email attachments:', error);
selectedEmailAttachments = [];
}
renderEmailAttachmentPreview();
}
function renderEmailAttachmentPreview() {
const container = document.getElementById('contractEmailAttachmentList');
if (!container) return;
if (!selectedEmailAttachments.length) {
container.innerHTML = '<div class="text-muted small">Ingen filer fra den valgte email</div>';
return;
}
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('');
}
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();
}
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;
}
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('');
}
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';
}
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]}`;
}
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;
}
</script>
{% endblock %}