bmc_hub/app/opportunities/frontend/opportunity_detail.html

893 lines
33 KiB
HTML
Raw Normal View History

2026-01-28 07:48:10 +01:00
{% extends "shared/frontend/base.html" %}
{% block title %}Mulighed - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.detail-grid {
display: grid;
grid-template-columns: 1.2fr 1.2fr 0.8fr;
gap: 1.5rem;
}
.sticky-panel {
position: sticky;
top: 90px;
}
.section-card {
border: 1px solid rgba(0,0,0,0.06);
border-radius: 12px;
padding: 1.25rem;
background: var(--bg-card);
}
.section-title {
font-weight: 700;
margin-bottom: 1rem;
}
.comment-thread {
display: flex;
flex-direction: column;
gap: 1rem;
max-height: 320px;
overflow-y: auto;
padding-right: 0.5rem;
}
.comment-entry {
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 12px;
padding: 1rem;
background: var(--bg-card);
box-shadow: 0 2px 8px rgba(15, 76, 117, 0.08);
}
.comment-entry .comment-header {
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.comment-entry .comment-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.comment-badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.comment-badge {
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.75rem;
border-radius: 999px;
border: 1px solid rgba(15, 76, 117, 0.2);
color: var(--accent);
background: rgba(15, 76, 117, 0.05);
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.comment-no-data {
color: var(--text-secondary);
font-size: 0.9rem;
text-align: center;
}
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">
<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>
<div class="text-muted small">Felt til dokumentlink og kontraktstatus kommer i næste version.</div>
</div>
</div>
<div class="sticky-panel d-flex flex-column gap-3">
<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>
<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="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Linket email</label>
<input type="search" class="form-control" id="commentEmailSearch" placeholder="Søg email (emne, afsender eller ID)">
<div id="commentEmailResults" class="list-group list-group-flush mt-2"></div>
<div id="linkedEmailBadge" class="d-flex align-items-center gap-2 mt-2" style="display:none;">
<i class="bi bi-envelope-fill text-primary"></i>
<span class="small text-truncate" id="linkedEmailLabel"></span>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="clearLinkedEmail()">Fjern</button>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Kontrakt</label>
<input type="search" class="form-control" id="commentContractSearch" placeholder="Søg kontraktnr. eller nøgleord">
<div id="commentContractResults" class="list-group list-group-flush mt-2"></div>
<div class="small text-muted mt-2" id="selectedContractInfo"></div>
</div>
</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-28 07:48:10 +01:00
{% endblock %}
{% block extra_js %}
<script>
const opportunityId = parseInt(window.location.pathname.split('/').pop());
let stages = [];
let opportunity = null;
let lineItems = [];
let lineModal = null;
let selectedProductCandidate = null;
let productSearchTimeout = null;
let comments = [];
let selectedCommentEmail = null;
let selectedContractCandidate = null;
let commentEmailSearchTimeout = null;
let contractSearchTimeout = null;
2026-01-28 07:48:10 +01:00
document.addEventListener('DOMContentLoaded', async () => {
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 contractSearchInput = document.getElementById('commentContractSearch');
contractSearchInput?.addEventListener('input', (event) => {
const term = event.target.value.trim();
clearTimeout(contractSearchTimeout);
if (!term || term.length < 2) {
renderContractSuggestions([]);
return;
}
contractSearchTimeout = setTimeout(() => searchContracts(term), 250);
});
2026-01-28 07:48:10 +01:00
await loadStages();
await loadOpportunity();
});
async function loadStages() {
const response = await fetch('/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/v1/opportunities/${opportunityId}`);
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}%`;
}
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/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/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/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/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/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 label = comment.email_subject
? `${comment.email_subject}`
: `Email #${comment.email_id}`;
const safeLink = escapeHtml(`/emails/${comment.email_id}`);
emailBadge = `
<a class="comment-badge" href="${safeLink}" target="_blank" rel="noreferrer"
title="${escapeHtml(comment.email_sender || 'Åben email')}" >
<i class="bi bi-envelope"></i>${escapeHtml(label)}
</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>
</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 payload = {
content,
author_name: getCurrentUserDisplayName()
};
if (selectedCommentEmail) {
payload.email_id = selectedCommentEmail.id;
payload.metadata = { linked_email_subject: selectedCommentEmail.subject };
}
if (selectedContractCandidate) {
payload.contract_number = selectedContractCandidate.contract_number;
payload.contract_context = `Registreret ${selectedContractCandidate.hits} gange`;
}
try {
const response = await fetch(`/api/v1/opportunities/${opportunityId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
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 clearCommentForm() {
const contentEl = document.getElementById('commentContent');
if (contentEl) contentEl.value = '';
const emailInput = document.getElementById('commentEmailSearch');
if (emailInput) emailInput.value = '';
const contractInput = document.getElementById('commentContractSearch');
if (contractInput) contractInput.value = '';
renderEmailSuggestions([]);
renderContractSuggestions([]);
clearLinkedEmail();
clearSelectedContract();
}
function clearLinkedEmail() {
selectedCommentEmail = null;
const badge = document.getElementById('linkedEmailBadge');
const label = document.getElementById('linkedEmailLabel');
if (badge) badge.style.display = 'none';
if (label) label.textContent = '';
}
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 = '';
}
async function searchContracts(term) {
if (!term) {
renderContractSuggestions([]);
return;
}
try {
const params = new URLSearchParams({ query: term, limit: '6' });
const response = await fetch(`/api/v1/contracts/search?${params}`);
if (!response.ok) {
renderContractSuggestions([]);
return;
}
const data = await response.json();
renderContractSuggestions(data);
} catch (error) {
console.error('Error searching contracts:', error);
renderContractSuggestions([]);
}
}
function renderContractSuggestions(results) {
const container = document.getElementById('commentContractResults');
if (!container) return;
if (!results || results.length === 0) {
container.innerHTML = '<div class="text-muted small">Ingen kontrakter fundet</div>';
return;
}
container.innerHTML = results.map(contract => `
<button type="button" class="list-group-item list-group-item-action d-flex flex-column align-items-start" data-contract-number="${escapeHtml(contract.contract_number)}">
<div class="fw-semibold">${escapeHtml(contract.contract_number)}</div>
<small class="text-muted">${contract.hits || 0} linjer • ${formatCommentTimestamp(contract.last_seen)}</small>
</button>
`).join('');
container.querySelectorAll('button').forEach(button => {
button.addEventListener('click', () => {
const contractNumber = button.dataset.contractNumber;
const matched = results.find(item => item.contract_number === contractNumber);
if (matched) selectContractSuggestion(matched);
});
});
}
function selectContractSuggestion(contract) {
selectedContractCandidate = contract;
const contractInput = document.getElementById('commentContractSearch');
if (contractInput) contractInput.value = contract.contract_number;
const info = document.getElementById('selectedContractInfo');
if (info) {
info.textContent = `${contract.hits || 0} match • Senest ${formatCommentTimestamp(contract.last_seen)}`;
}
document.getElementById('commentContractResults').innerHTML = '';
}
function clearSelectedContract() {
selectedContractCandidate = null;
const info = document.getElementById('selectedContractInfo');
if (info) info.textContent = '';
}
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
}
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 %}