bmc_hub/app/opportunities/frontend/opportunity_detail.html
Christian f059cb6c95 feat: Add product search endpoint and enhance opportunity management
- Implemented a new endpoint for searching webshop products with filters for visibility and configuration.
- Enhanced the webshop frontend to include a customer search feature for improved user experience.
- Added opportunity line items management with CRUD operations and comments functionality.
- Created database migrations for opportunity line items and comments, including necessary triggers and indexes.
2026-01-28 14:37:47 +01:00

893 lines
33 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 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;
}
</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>
{% 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;
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);
});
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();
}
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';
}
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 %}