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:
parent
c66d652283
commit
f059cb6c95
@ -343,6 +343,40 @@ async def delete_webshop_config(config_id: int):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
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
|
# WEBSHOP PRODUCT ENDPOINTS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@ -68,6 +68,16 @@
|
|||||||
<form id="webshopForm">
|
<form id="webshopForm">
|
||||||
<input type="hidden" id="configId">
|
<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 -->
|
<!-- Kunde Selection -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="customerId" class="form-label form-label-required">Kunde</label>
|
<label for="customerId" class="form-label form-label-required">Kunde</label>
|
||||||
@ -270,6 +280,7 @@
|
|||||||
let webshopsData = [];
|
let webshopsData = [];
|
||||||
let currentWebshopConfig = null;
|
let currentWebshopConfig = null;
|
||||||
let webshopModal, productsModal, addProductModal;
|
let webshopModal, productsModal, addProductModal;
|
||||||
|
let customerSearchTimeout;
|
||||||
|
|
||||||
// Load on page ready
|
// Load on page ready
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
@ -280,6 +291,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
loadWebshops();
|
loadWebshops();
|
||||||
loadCustomers();
|
loadCustomers();
|
||||||
|
initCustomerSearch();
|
||||||
|
|
||||||
// Color picker sync
|
// Color picker sync
|
||||||
document.getElementById('primaryColor').addEventListener('input', (e) => {
|
document.getElementById('primaryColor').addEventListener('input', (e) => {
|
||||||
@ -389,12 +401,28 @@ function renderWebshops() {
|
|||||||
`).join('');
|
`).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 {
|
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 data = await response.json();
|
||||||
const customers = Array.isArray(data) ? data : (data.customers || []);
|
const customers = Array.isArray(data) ? data : (data.customers || []);
|
||||||
|
|
||||||
const select = document.getElementById('customerId');
|
const select = document.getElementById('customerId');
|
||||||
select.innerHTML = '<option value="">Vælg kunde...</option>' +
|
select.innerHTML = '<option value="">Vælg kunde...</option>' +
|
||||||
customers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
|
customers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
|
||||||
@ -403,11 +431,14 @@ async function loadCustomers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreateModal() {
|
async function openCreateModal() {
|
||||||
document.getElementById('modalTitle').textContent = 'Opret Webshop';
|
document.getElementById('modalTitle').textContent = 'Opret Webshop';
|
||||||
document.getElementById('webshopForm').reset();
|
document.getElementById('webshopForm').reset();
|
||||||
document.getElementById('configId').value = '';
|
document.getElementById('configId').value = '';
|
||||||
document.getElementById('enabled').checked = true;
|
document.getElementById('enabled').checked = true;
|
||||||
|
const searchInput = document.getElementById('customerSearch');
|
||||||
|
if (searchInput) searchInput.value = '';
|
||||||
|
await loadCustomers();
|
||||||
webshopModal.show();
|
webshopModal.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -417,6 +448,9 @@ async function openEditModal(configId) {
|
|||||||
|
|
||||||
document.getElementById('modalTitle').textContent = 'Rediger Webshop';
|
document.getElementById('modalTitle').textContent = 'Rediger Webshop';
|
||||||
document.getElementById('configId').value = ws.id;
|
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('customerId').value = ws.customer_id;
|
||||||
document.getElementById('webshopName').value = ws.name;
|
document.getElementById('webshopName').value = ws.name;
|
||||||
document.getElementById('emailDomains').value = ws.allowed_email_domains;
|
document.getElementById('emailDomains').value = ws.allowed_email_domains;
|
||||||
|
|||||||
@ -3,10 +3,11 @@ Opportunities (Pipeline) Router
|
|||||||
Hub-local sales pipeline
|
Hub-local sales pipeline
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional, List, Dict
|
||||||
from datetime import date
|
from datetime import date, datetime
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_query_single, execute_update
|
from app.core.database import execute_query, execute_query_single, execute_update
|
||||||
@ -74,6 +75,52 @@ class OpportunityStageUpdate(BaseModel):
|
|||||||
user_id: Optional[int] = None
|
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):
|
def _get_stage(stage_id: int):
|
||||||
stage = execute_query_single(
|
stage = execute_query_single(
|
||||||
"SELECT * FROM pipeline_stages WHERE id = %s AND is_active = TRUE",
|
"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
|
# Pipeline Stages
|
||||||
# ============================
|
# ============================
|
||||||
@ -305,3 +378,127 @@ async def update_opportunity_stage(opportunity_id: int, update: OpportunityStage
|
|||||||
handle_stage_change(updated, new_stage)
|
handle_stage_change(updated, new_stage)
|
||||||
|
|
||||||
return updated
|
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 []
|
||||||
|
|||||||
@ -149,9 +149,26 @@ let stages = [];
|
|||||||
let customers = [];
|
let customers = [];
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
await loadStages();
|
try {
|
||||||
await loadCustomers();
|
await loadStages();
|
||||||
await loadOpportunities();
|
} 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() {
|
async function loadStages() {
|
||||||
@ -167,7 +184,7 @@ async function loadStages() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadCustomers() {
|
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();
|
const data = await response.json();
|
||||||
customers = Array.isArray(data) ? data : (data.customers || []);
|
customers = Array.isArray(data) ? data : (data.customers || []);
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,65 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 1rem;
|
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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -133,6 +192,119 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
@ -140,8 +312,51 @@
|
|||||||
const opportunityId = parseInt(window.location.pathname.split('/').pop());
|
const opportunityId = parseInt(window.location.pathname.split('/').pop());
|
||||||
let stages = [];
|
let stages = [];
|
||||||
let opportunity = null;
|
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 () => {
|
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 loadStages();
|
||||||
await loadOpportunity();
|
await loadOpportunity();
|
||||||
});
|
});
|
||||||
@ -164,6 +379,8 @@ async function loadOpportunity() {
|
|||||||
|
|
||||||
opportunity = await response.json();
|
opportunity = await response.json();
|
||||||
renderOpportunity();
|
renderOpportunity();
|
||||||
|
await loadLineItems();
|
||||||
|
await loadComments();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOpportunity() {
|
function renderOpportunity() {
|
||||||
@ -206,11 +423,470 @@ async function saveOpportunity() {
|
|||||||
|
|
||||||
opportunity = await response.json();
|
opportunity = await response.json();
|
||||||
renderOpportunity();
|
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) {
|
function formatCurrency(value, currency) {
|
||||||
const num = parseFloat(value || 0);
|
const num = parseFloat(value || 0);
|
||||||
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(num);
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
31
migrations/017_opportunity_lines.sql
Normal file
31
migrations/017_opportunity_lines.sql
Normal 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();
|
||||||
37
migrations/018_opportunity_comments.sql
Normal file
37
migrations/018_opportunity_comments.sql
Normal 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.';
|
||||||
Loading…
Reference in New Issue
Block a user