- 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.
893 lines
33 KiB
HTML
893 lines
33 KiB
HTML
{% 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 pipeline‑visning</p>
|
||
</div>
|
||
<div class="d-flex gap-2">
|
||
<a class="btn btn-outline-secondary" href="/opportunities">
|
||
<i class="bi bi-arrow-left me-2"></i>Tilbage
|
||
</a>
|
||
<button class="btn btn-primary" onclick="saveOpportunity()">
|
||
<i class="bi bi-check-lg me-2"></i>Gem
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="detail-grid">
|
||
<div class="d-flex flex-column gap-3">
|
||
<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">Pipeline‑status</div>
|
||
<div class="d-flex justify-content-between mb-2">
|
||
<span class="text-muted">Kunde</span>
|
||
<span id="customerNameBadge">-</span>
|
||
</div>
|
||
<div class="d-flex justify-content-between mb-2">
|
||
<span class="text-muted">Stage</span>
|
||
<span id="stageBadge">-</span>
|
||
</div>
|
||
<div class="d-flex justify-content-between mb-2">
|
||
<span class="text-muted">Værdi</span>
|
||
<span id="amountBadge">-</span>
|
||
</div>
|
||
<div class="d-flex justify-content-between">
|
||
<span class="text-muted">Sandsynlighed</span>
|
||
<span id="probabilityBadge">-</span>
|
||
</div>
|
||
</div>
|
||
<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 %}
|