diff --git a/app/modules/webshop/backend/router.py b/app/modules/webshop/backend/router.py index 8da946b..12a1184 100644 --- a/app/modules/webshop/backend/router.py +++ b/app/modules/webshop/backend/router.py @@ -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 # ============================================================================ diff --git a/app/modules/webshop/frontend/index.html b/app/modules/webshop/frontend/index.html index 78e9c9e..d4a9f89 100644 --- a/app/modules/webshop/frontend/index.html +++ b/app/modules/webshop/frontend/index.html @@ -68,6 +68,16 @@
+ +
+ + + + Begynd at skrive for at indsnævre listen og find den rigtige kunde hurtigt. + +
+
@@ -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,12 +401,28 @@ 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 || []); - + const select = document.getElementById('customerId'); select.innerHTML = '' + customers.map(c => ``).join(''); @@ -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; diff --git a/app/opportunities/backend/router.py b/app/opportunities/backend/router.py index 57d607e..3ecef46 100644 --- a/app/opportunities/backend/router.py +++ b/app/opportunities/backend/router.py @@ -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 [] diff --git a/app/opportunities/frontend/opportunities.html b/app/opportunities/frontend/opportunities.html index 1bfe9eb..1466e7f 100644 --- a/app/opportunities/frontend/opportunities.html +++ b/app/opportunities/frontend/opportunities.html @@ -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 = 'Fejl ved indlæsning af muligheder'; + 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 || []); diff --git a/app/opportunities/frontend/opportunity_detail.html b/app/opportunities/frontend/opportunity_detail.html index 9a8d9ac..f9c3b44 100644 --- a/app/opportunities/frontend/opportunity_detail.html +++ b/app/opportunities/frontend/opportunity_detail.html @@ -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; + } {% endblock %} @@ -133,6 +192,119 @@
+ +
+
+
+
Varelinjer
+

Tilføj eller fjern produkter som indgår i tilbuddet.

+
+ +
+
+ + +
+
+
+ + + + + + + + + + + + + + + + +
NavnVarenr.AntalEnhedsprisLinjetotalHandling
Ingen varelinjer endnu
+
+
Total: 0 kr
+
+ +
+
Kommentarer & aktiviteter
+
+
Ingen kommentarer endnu
+
+
+ + +
+
+
+ + +
+ +
+
+ + +
+
+
+
+
+ + +
+
+
+ + +
{% 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 = 'Ingen varelinjer endnu'; + if (summary) summary.textContent = 'Total: 0 kr'; + return; + } + + let total = 0; + tbody.innerHTML = lineItems.map(line => { + total += parseFloat(line.total_price || 0); + return ` + + ${escapeHtml(line.name)} + ${escapeHtml(line.product_number || '-')} + ${line.quantity} + ${formatCurrency(line.unit_price, opportunity?.currency || 'DKK')} + ${formatCurrency(line.total_price || 0, opportunity?.currency || 'DKK')} + + + + + `; + }).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 = '
Ingen produkter fundet
'; + return; + } + + container.innerHTML = products.map(product => ` + + `).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 = ` + + ${escapeHtml(label)} + + `; + } + + 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 = ` + + ${escapeHtml(label)} + + `; + } else { + contractBadge = `${escapeHtml(label)}`; + } + } + + return ` +
+
+
+ ${escapeHtml(authorLabel)} +
${formatCommentTimestamp(comment.created_at)}
+
+
+ ${emailBadge} + ${contractBadge} +
+
+
${formatCommentBody(comment.content)}
+
+ `; + }).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, '
'); +} + +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 = '
Ingen emails fundet
'; + return; + } + + container.innerHTML = results.map(email => ` + + `).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 = '
Ingen kontrakter fundet
'; + return; + } + + container.innerHTML = results.map(contract => ` + + `).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; +} {% endblock %} diff --git a/migrations/017_opportunity_lines.sql b/migrations/017_opportunity_lines.sql new file mode 100644 index 0000000..1713a0f --- /dev/null +++ b/migrations/017_opportunity_lines.sql @@ -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(); diff --git a/migrations/018_opportunity_comments.sql b/migrations/018_opportunity_comments.sql new file mode 100644 index 0000000..de4e08f --- /dev/null +++ b/migrations/018_opportunity_comments.sql @@ -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.';