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.
This commit is contained in:
Christian 2026-01-28 14:37:47 +01:00
parent c66d652283
commit f059cb6c95
7 changed files with 1037 additions and 11 deletions

View File

@ -343,6 +343,40 @@ async def delete_webshop_config(config_id: int):
raise HTTPException(status_code=500, detail=str(e))
# ==========================================================================
# PRODUCT SEARCH HELPERS
# ==========================================================================
@router.get("/webshop/products/search")
async def search_webshop_products(
search: Optional[str] = None,
limit: int = 20,
config_id: Optional[int] = None
):
try:
params: List = []
filters = "WHERE visible = TRUE"
if config_id is not None:
filters += " AND webshop_config_id = %s"
params.append(config_id)
if search:
pattern = f"%{search}%"
filters += " AND (name ILIKE %s OR product_number ILIKE %s OR category ILIKE %s)"
params.extend([pattern, pattern, pattern])
query = f"SELECT * FROM webshop_products {filters} ORDER BY updated_at DESC LIMIT %s"
params.append(limit)
return {"products": execute_query(query, tuple(params)) or []}
except Exception as e:
logger.error(f"❌ Error searching webshop products: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# WEBSHOP PRODUCT ENDPOINTS
# ============================================================================

View File

@ -68,6 +68,16 @@
<form id="webshopForm">
<input type="hidden" id="configId">
<!-- Kunde Search -->
<div class="mb-3">
<label for="customerSearch" class="form-label">Søg kunde</label>
<input type="search" class="form-control" id="customerSearch"
placeholder="Søg efter navn, CVR eller email">
<small class="form-text text-muted">
Begynd at skrive for at indsnævre listen og find den rigtige kunde hurtigt.
</small>
</div>
<!-- Kunde Selection -->
<div class="mb-3">
<label for="customerId" class="form-label form-label-required">Kunde</label>
@ -270,6 +280,7 @@
let webshopsData = [];
let currentWebshopConfig = null;
let webshopModal, productsModal, addProductModal;
let customerSearchTimeout;
// Load on page ready
document.addEventListener('DOMContentLoaded', () => {
@ -280,6 +291,7 @@ document.addEventListener('DOMContentLoaded', () => {
loadWebshops();
loadCustomers();
initCustomerSearch();
// Color picker sync
document.getElementById('primaryColor').addEventListener('input', (e) => {
@ -389,9 +401,25 @@ function renderWebshops() {
`).join('');
}
async function loadCustomers() {
function initCustomerSearch() {
const searchInput = document.getElementById('customerSearch');
if (!searchInput) return;
searchInput.addEventListener('input', (event) => {
const term = event.target.value.trim();
clearTimeout(customerSearchTimeout);
customerSearchTimeout = setTimeout(() => loadCustomers(term), 300);
});
}
async function loadCustomers(searchTerm = '') {
try {
const response = await fetch('/api/v1/customers?limit=1000');
const params = new URLSearchParams({ limit: '1000' });
if (searchTerm) {
params.set('search', searchTerm);
}
const response = await fetch(`/api/v1/customers?${params.toString()}`);
const data = await response.json();
const customers = Array.isArray(data) ? data : (data.customers || []);
@ -403,11 +431,14 @@ async function loadCustomers() {
}
}
function openCreateModal() {
async function openCreateModal() {
document.getElementById('modalTitle').textContent = 'Opret Webshop';
document.getElementById('webshopForm').reset();
document.getElementById('configId').value = '';
document.getElementById('enabled').checked = true;
const searchInput = document.getElementById('customerSearch');
if (searchInput) searchInput.value = '';
await loadCustomers();
webshopModal.show();
}
@ -417,6 +448,9 @@ async function openEditModal(configId) {
document.getElementById('modalTitle').textContent = 'Rediger Webshop';
document.getElementById('configId').value = ws.id;
const searchInput = document.getElementById('customerSearch');
if (searchInput) searchInput.value = '';
await loadCustomers();
document.getElementById('customerId').value = ws.customer_id;
document.getElementById('webshopName').value = ws.name;
document.getElementById('emailDomains').value = ws.allowed_email_domains;

View File

@ -3,10 +3,11 @@ Opportunities (Pipeline) Router
Hub-local sales pipeline
"""
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from typing import Optional, List
from datetime import date
from typing import Optional, List, Dict
from datetime import date, datetime
import json
import logging
from app.core.database import execute_query, execute_query_single, execute_update
@ -74,6 +75,52 @@ class OpportunityStageUpdate(BaseModel):
user_id: Optional[int] = None
class OpportunityLineBase(BaseModel):
name: str
quantity: int = 1
unit_price: float = 0.0
product_number: Optional[str] = None
description: Optional[str] = None
class OpportunityLineCreate(OpportunityLineBase):
pass
class OpportunityCommentBase(BaseModel):
content: str
author_name: Optional[str] = None
user_id: Optional[int] = None
email_id: Optional[int] = None
contract_number: Optional[str] = None
contract_context: Optional[str] = None
contract_link: Optional[str] = None
metadata: Optional[Dict] = None
class OpportunityCommentCreate(OpportunityCommentBase):
pass
class OpportunityCommentResponse(BaseModel):
id: int
opportunity_id: int
content: str
author_name: Optional[str] = None
user_id: Optional[int] = None
user_full_name: Optional[str] = None
username: Optional[str] = None
email_id: Optional[int] = None
email_subject: Optional[str] = None
email_sender: Optional[str] = None
contract_number: Optional[str] = None
contract_context: Optional[str] = None
contract_link: Optional[str] = None
metadata: Optional[Dict] = None
created_at: datetime
updated_at: datetime
def _get_stage(stage_id: int):
stage = execute_query_single(
"SELECT * FROM pipeline_stages WHERE id = %s AND is_active = TRUE",
@ -119,6 +166,32 @@ def _insert_stage_history(opportunity_id: int, from_stage_id: Optional[int], to_
)
def _fetch_opportunity_comments(opportunity_id: int):
query = """
SELECT c.*, u.full_name AS user_full_name, u.username,
em.subject AS email_subject, em.sender_email AS email_sender
FROM pipeline_opportunity_comments c
LEFT JOIN users u ON u.user_id = c.user_id
LEFT JOIN email_messages em ON em.id = c.email_id
WHERE c.opportunity_id = %s
ORDER BY c.created_at DESC
"""
return execute_query(query, (opportunity_id,)) or []
def _fetch_comment(comment_id: int):
query = """
SELECT c.*, u.full_name AS user_full_name, u.username,
em.subject AS email_subject, em.sender_email AS email_sender
FROM pipeline_opportunity_comments c
LEFT JOIN users u ON u.user_id = c.user_id
LEFT JOIN email_messages em ON em.id = c.email_id
WHERE c.id = %s
"""
result = execute_query(query, (comment_id,))
return result[0] if result else None
# ============================
# Pipeline Stages
# ============================
@ -305,3 +378,127 @@ async def update_opportunity_stage(opportunity_id: int, update: OpportunityStage
handle_stage_change(updated, new_stage)
return updated
@router.get("/opportunities/{opportunity_id}/lines", tags=["Opportunities"])
async def list_opportunity_lines(opportunity_id: int):
query = """
SELECT id, opportunity_id, product_number, name, description, quantity, unit_price,
quantity * unit_price AS total_price
FROM pipeline_opportunity_lines
WHERE opportunity_id = %s
ORDER BY id ASC
"""
return execute_query(query, (opportunity_id,)) or []
@router.post("/opportunities/{opportunity_id}/lines", tags=["Opportunities"])
async def add_opportunity_line(opportunity_id: int, line: OpportunityLineCreate):
query = """
INSERT INTO pipeline_opportunity_lines
(opportunity_id, product_number, name, description, quantity, unit_price)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id, opportunity_id, product_number, name, description, quantity, unit_price,
quantity * unit_price AS total_price
"""
result = execute_query(
query,
(
opportunity_id,
line.product_number,
line.name,
line.description,
line.quantity,
line.unit_price
)
)
if not result:
raise HTTPException(status_code=500, detail="Failed to create line item")
return result[0]
@router.delete("/opportunities/{opportunity_id}/lines/{line_id}", tags=["Opportunities"])
async def remove_opportunity_line(opportunity_id: int, line_id: int):
query = """
DELETE FROM pipeline_opportunity_lines
WHERE opportunity_id = %s AND id = %s
RETURNING id
"""
result = execute_query(query, (opportunity_id, line_id))
if not result:
raise HTTPException(status_code=404, detail="Line item not found")
return {"success": True, "line_id": line_id}
@router.get(
"/opportunities/{opportunity_id}/comments",
response_model=List[OpportunityCommentResponse],
tags=["Opportunities"]
)
async def list_opportunity_comments(opportunity_id: int):
_get_opportunity(opportunity_id)
return _fetch_opportunity_comments(opportunity_id)
@router.post(
"/opportunities/{opportunity_id}/comments",
response_model=OpportunityCommentResponse,
tags=["Opportunities"]
)
async def add_opportunity_comment(opportunity_id: int, comment: OpportunityCommentCreate):
_get_opportunity(opportunity_id)
author_name = comment.author_name or 'Hub Bruger'
metadata_json = json.dumps(comment.metadata) if comment.metadata else None
query = """
INSERT INTO pipeline_opportunity_comments
(opportunity_id, user_id, author_name, content, email_id,
contract_number, contract_context, contract_link, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
"""
result = execute_query(
query,
(
opportunity_id,
comment.user_id,
author_name,
comment.content,
comment.email_id,
comment.contract_number,
comment.contract_context,
comment.contract_link,
metadata_json
)
)
if not result:
raise HTTPException(status_code=500, detail="Kunne ikke oprette kommentar")
comment_id = result[0]["id"]
return _fetch_comment(comment_id)
@router.get(
"/contracts/search",
tags=["Opportunities"],
response_model=List[Dict]
)
async def search_contracts(query: str = Query(..., min_length=2), limit: int = Query(10, ge=1, le=50)):
sql = """
SELECT contract_number,
MAX(created_at) AS last_seen,
COUNT(*) AS hits
FROM extraction_lines
WHERE contract_number IS NOT NULL
AND contract_number <> ''
AND contract_number ILIKE %s
GROUP BY contract_number
ORDER BY MAX(created_at) DESC
LIMIT %s
"""
params = (f"%{query}%", limit)
results = execute_query(sql, params)
return results or []

View File

@ -149,9 +149,26 @@ let stages = [];
let customers = [];
document.addEventListener('DOMContentLoaded', async () => {
await loadStages();
await loadCustomers();
await loadOpportunities();
try {
await loadStages();
} catch (error) {
console.error('Error loading stages:', error);
}
try {
await loadCustomers();
} catch (error) {
console.error('Error loading customers:', error);
}
try {
await loadOpportunities();
} catch (error) {
console.error('Error loading opportunities:', error);
const tbody = document.getElementById('opportunitiesTable');
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-danger py-5">Fejl ved indlæsning af muligheder</td></tr>';
document.getElementById('countLabel').textContent = '0 muligheder';
}
});
async function loadStages() {
@ -167,7 +184,7 @@ async function loadStages() {
}
async function loadCustomers() {
const response = await fetch('/api/v1/customers?limit=10000');
const response = await fetch('/api/v1/customers?limit=1000');
const data = await response.json();
customers = Array.isArray(data) ? data : (data.customers || []);

View File

@ -26,6 +26,65 @@
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 %}
@ -133,6 +192,119 @@
</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 %}
@ -140,8 +312,51 @@
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();
});
@ -164,6 +379,8 @@ async function loadOpportunity() {
opportunity = await response.json();
renderOpportunity();
await loadLineItems();
await loadComments();
}
function renderOpportunity() {
@ -206,11 +423,470 @@ async function saveOpportunity() {
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 %}

View File

@ -0,0 +1,31 @@
-- =========================================================================
-- Migration 017: Opportunity Line Items
-- =========================================================================
CREATE TABLE IF NOT EXISTS pipeline_opportunity_lines (
id SERIAL PRIMARY KEY,
opportunity_id INTEGER NOT NULL REFERENCES pipeline_opportunities(id) ON DELETE CASCADE,
product_number VARCHAR(100),
name VARCHAR(255) NOT NULL,
description TEXT,
quantity INTEGER NOT NULL DEFAULT 1,
unit_price NUMERIC(12, 2) NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_pipeline_opportunity_lines_opportunity_id ON pipeline_opportunity_lines(opportunity_id);
CREATE INDEX IF NOT EXISTS idx_pipeline_opportunity_lines_product_number ON pipeline_opportunity_lines(product_number);
CREATE OR REPLACE FUNCTION update_pipeline_opportunity_lines_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_pipeline_opportunity_lines_updated_at
BEFORE UPDATE ON pipeline_opportunity_lines
FOR EACH ROW
EXECUTE FUNCTION update_pipeline_opportunity_lines_updated_at();

View File

@ -0,0 +1,37 @@
-- Migration 018: Opportunity Comments
-- Adds a lightweight discussion thread for each pipeline opportunity
CREATE TABLE IF NOT EXISTS pipeline_opportunity_comments (
id SERIAL PRIMARY KEY,
opportunity_id INTEGER NOT NULL REFERENCES pipeline_opportunities(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
author_name VARCHAR(255) NOT NULL DEFAULT 'Hub Bruger',
content TEXT NOT NULL,
email_id INTEGER REFERENCES email_messages(id) ON DELETE SET NULL,
contract_number VARCHAR(100),
contract_context TEXT,
contract_link TEXT,
metadata JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_pipeline_opportunity_comments_opportunity_id ON pipeline_opportunity_comments(opportunity_id);
CREATE INDEX IF NOT EXISTS idx_pipeline_opportunity_comments_email_id ON pipeline_opportunity_comments(email_id);
CREATE INDEX IF NOT EXISTS idx_pipeline_opportunity_comments_contract_number ON pipeline_opportunity_comments(contract_number);
CREATE OR REPLACE FUNCTION update_pipeline_opportunity_comments_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trigger_pipeline_opportunity_comments_updated_at ON pipeline_opportunity_comments;
CREATE TRIGGER trigger_pipeline_opportunity_comments_updated_at
BEFORE UPDATE ON pipeline_opportunity_comments
FOR EACH ROW
EXECUTE FUNCTION update_pipeline_opportunity_comments_updated_at();
COMMENT ON TABLE pipeline_opportunity_comments IS 'Comments, emails, and contract context captured from the pipeline detail view.';