feat(email): add deadline and enhanced company search in email-to-sag flow
This commit is contained in:
parent
15feb18361
commit
074ab6a62a
28
RELEASE_NOTES_v2.2.54.md
Normal file
28
RELEASE_NOTES_v2.2.54.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Release Notes - v2.2.54
|
||||||
|
|
||||||
|
Dato: 17. marts 2026
|
||||||
|
|
||||||
|
## Fokus
|
||||||
|
|
||||||
|
Forbedringer i email til SAG workflow med deadline-felt og markant bedre firma/kunde-søgning i UI.
|
||||||
|
|
||||||
|
## Tilføjet
|
||||||
|
|
||||||
|
- Deadline understøttes nu i email->sag oprettelse.
|
||||||
|
- Backend request-model udvidet med `deadline`.
|
||||||
|
- `create-sag` gemmer nu deadline på `sag_sager`.
|
||||||
|
- Frontend forslagspanel har fået dedikeret deadline-felt.
|
||||||
|
- Kundevalg i email-panelet er opgraderet til en “super firma-søgning”:
|
||||||
|
- Live dropdown-resultater i stedet for simpel datalist.
|
||||||
|
- Bedre ranking af resultater (exact/prefix/relevans).
|
||||||
|
- Hurtig valg med klik, inklusive visning af CVR/domæne/email metadata.
|
||||||
|
|
||||||
|
## Opdaterede filer
|
||||||
|
|
||||||
|
- `app/emails/backend/router.py`
|
||||||
|
- `app/emails/frontend/emails.html`
|
||||||
|
|
||||||
|
## Bemærkninger
|
||||||
|
|
||||||
|
- Ingen breaking API changes.
|
||||||
|
- Ingen ekstra migration nødvendig for denne release.
|
||||||
@ -155,6 +155,7 @@ class CreateSagFromEmailRequest(BaseModel):
|
|||||||
case_type: str = "support"
|
case_type: str = "support"
|
||||||
secondary_label: Optional[str] = None
|
secondary_label: Optional[str] = None
|
||||||
start_date: Optional[date] = None
|
start_date: Optional[date] = None
|
||||||
|
deadline: Optional[date] = None
|
||||||
priority: Optional[str] = None
|
priority: Optional[str] = None
|
||||||
ansvarlig_bruger_id: Optional[int] = None
|
ansvarlig_bruger_id: Optional[int] = None
|
||||||
assigned_group_id: Optional[int] = None
|
assigned_group_id: Optional[int] = None
|
||||||
@ -202,10 +203,25 @@ async def get_sag_assignment_options():
|
|||||||
async def search_customers(q: str = Query(..., min_length=1), limit: int = Query(20, ge=1, le=100)):
|
async def search_customers(q: str = Query(..., min_length=1), limit: int = Query(20, ge=1, le=100)):
|
||||||
"""Autocomplete customers for email-to-case flow."""
|
"""Autocomplete customers for email-to-case flow."""
|
||||||
try:
|
try:
|
||||||
like = f"%{q.strip()}%"
|
q_clean = q.strip()
|
||||||
|
like = f"%{q_clean}%"
|
||||||
|
prefix = f"{q_clean}%"
|
||||||
rows = execute_query(
|
rows = execute_query(
|
||||||
"""
|
"""
|
||||||
SELECT id, name, email_domain
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
email_domain,
|
||||||
|
cvr_number,
|
||||||
|
CASE
|
||||||
|
WHEN LOWER(name) = LOWER(%s) THEN 500
|
||||||
|
WHEN LOWER(name) LIKE LOWER(%s) THEN 300
|
||||||
|
WHEN COALESCE(email_domain, '') ILIKE %s THEN 200
|
||||||
|
WHEN COALESCE(cvr_number, '') ILIKE %s THEN 180
|
||||||
|
WHEN COALESCE(email, '') ILIKE %s THEN 120
|
||||||
|
ELSE 50
|
||||||
|
END AS rank_score
|
||||||
FROM customers
|
FROM customers
|
||||||
WHERE (
|
WHERE (
|
||||||
name ILIKE %s
|
name ILIKE %s
|
||||||
@ -213,10 +229,10 @@ async def search_customers(q: str = Query(..., min_length=1), limit: int = Query
|
|||||||
OR COALESCE(email_domain, '') ILIKE %s
|
OR COALESCE(email_domain, '') ILIKE %s
|
||||||
OR COALESCE(cvr_number, '') ILIKE %s
|
OR COALESCE(cvr_number, '') ILIKE %s
|
||||||
)
|
)
|
||||||
ORDER BY name
|
ORDER BY rank_score DESC, name ASC
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
""",
|
""",
|
||||||
(like, like, like, like, limit)
|
(q_clean, prefix, prefix, prefix, prefix, like, like, like, like, limit)
|
||||||
)
|
)
|
||||||
return rows or []
|
return rows or []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -507,9 +523,9 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
|
|||||||
case_result = execute_query(
|
case_result = execute_query(
|
||||||
"""
|
"""
|
||||||
INSERT INTO sag_sager
|
INSERT INTO sag_sager
|
||||||
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, priority, start_date)
|
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, priority, start_date, deadline)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id, titel, customer_id, status, template_key, priority, start_date, created_at
|
RETURNING id, titel, customer_id, status, template_key, priority, start_date, deadline, created_at
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
titel,
|
titel,
|
||||||
@ -522,6 +538,7 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
|
|||||||
payload.created_by_user_id,
|
payload.created_by_user_id,
|
||||||
priority,
|
priority,
|
||||||
payload.start_date,
|
payload.start_date,
|
||||||
|
payload.deadline,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not case_result:
|
if not case_result:
|
||||||
|
|||||||
@ -475,6 +475,48 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.customer-search-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-search-results {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid rgba(0,0,0,0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 12;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-search-item {
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-search-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-search-item:hover {
|
||||||
|
background: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-search-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-search-meta {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive Design */
|
/* Responsive Design */
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
@ -1355,6 +1397,7 @@ let selectedEmails = new Set();
|
|||||||
let emailSearchTimeout = null;
|
let emailSearchTimeout = null;
|
||||||
let autoRefreshInterval = null;
|
let autoRefreshInterval = null;
|
||||||
let sagAssignmentOptions = { users: [], groups: [] };
|
let sagAssignmentOptions = { users: [], groups: [] };
|
||||||
|
let customerSearchHideTimeout = null;
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
@ -1854,8 +1897,10 @@ function renderEmailAnalysis(email) {
|
|||||||
|
|
||||||
<div class="suggestion-field full">
|
<div class="suggestion-field full">
|
||||||
<label for="caseCustomerSearch">Kunde</label>
|
<label for="caseCustomerSearch">Kunde</label>
|
||||||
<input id="caseCustomerSearch" list="caseCustomerResults" value="${escapeHtml(selectedCustomerName)}" placeholder="Søg kunde..." oninput="searchCustomersForCurrentEmail(this.value)">
|
<div class="customer-search-wrap">
|
||||||
<datalist id="caseCustomerResults"></datalist>
|
<input id="caseCustomerSearch" value="${escapeHtml(selectedCustomerName)}" placeholder="Søg kunde, CVR, domæne..." oninput="searchCustomersForCurrentEmail(this.value)" onfocus="searchCustomersForCurrentEmail(this.value)" onblur="hideCustomerSearchResultsDelayed()">
|
||||||
|
<div id="caseCustomerResults" class="customer-search-results" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
<input id="caseCustomerId" type="hidden" value="${email.customer_id || ''}">
|
<input id="caseCustomerId" type="hidden" value="${email.customer_id || ''}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1880,6 +1925,11 @@ function renderEmailAnalysis(email) {
|
|||||||
<input id="caseStartDate" type="date" value="${todayAsDateString()}">
|
<input id="caseStartDate" type="date" value="${todayAsDateString()}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="suggestion-field">
|
||||||
|
<label for="caseDeadline">Deadline</label>
|
||||||
|
<input id="caseDeadline" type="date" value="">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="suggestion-field">
|
<div class="suggestion-field">
|
||||||
<label for="casePriority">Prioritet</label>
|
<label for="casePriority">Prioritet</label>
|
||||||
<select id="casePriority">
|
<select id="casePriority">
|
||||||
@ -2023,18 +2073,67 @@ function getSelectedIdFromDatalist(inputId, datalistId) {
|
|||||||
return option ? option.dataset.id : null;
|
return option ? option.dataset.id : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hideCustomerSearchResultsDelayed() {
|
||||||
|
clearTimeout(customerSearchHideTimeout);
|
||||||
|
customerSearchHideTimeout = setTimeout(() => {
|
||||||
|
const resultsEl = document.getElementById('caseCustomerResults');
|
||||||
|
if (resultsEl) resultsEl.style.display = 'none';
|
||||||
|
}, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCustomerSearchResults(customers) {
|
||||||
|
const resultsEl = document.getElementById('caseCustomerResults');
|
||||||
|
if (!resultsEl) return;
|
||||||
|
|
||||||
|
if (!customers || customers.length === 0) {
|
||||||
|
resultsEl.innerHTML = '<div class="customer-search-item"><div class="customer-search-meta">Ingen kunder fundet</div></div>';
|
||||||
|
resultsEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsEl.innerHTML = customers.map((customer) => {
|
||||||
|
const metaParts = [];
|
||||||
|
if (customer.cvr_number) metaParts.push(`CVR ${escapeHtml(customer.cvr_number)}`);
|
||||||
|
if (customer.email_domain) metaParts.push(escapeHtml(customer.email_domain));
|
||||||
|
if (customer.email) metaParts.push(escapeHtml(customer.email));
|
||||||
|
const meta = metaParts.join(' • ');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="customer-search-item" onmousedown="selectCustomerForCurrentEmail(${customer.id}, '${escapeHtml(customer.name).replace(/'/g, "\\'")}')">
|
||||||
|
<div class="customer-search-name">${escapeHtml(customer.name)} <span class="text-muted">#${customer.id}</span></div>
|
||||||
|
<div class="customer-search-meta">${meta || 'Ingen ekstra data'}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
resultsEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCustomerForCurrentEmail(customerId, customerName) {
|
||||||
|
const input = document.getElementById('caseCustomerSearch');
|
||||||
|
const hidden = document.getElementById('caseCustomerId');
|
||||||
|
const resultsEl = document.getElementById('caseCustomerResults');
|
||||||
|
|
||||||
|
if (input) input.value = customerName;
|
||||||
|
if (hidden) hidden.value = String(customerId);
|
||||||
|
if (resultsEl) resultsEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
async function searchCustomersForCurrentEmail(query) {
|
async function searchCustomersForCurrentEmail(query) {
|
||||||
if (!query || query.length < 2) return;
|
const hidden = document.getElementById('caseCustomerId');
|
||||||
|
if (hidden) hidden.value = '';
|
||||||
|
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
const resultsEl = document.getElementById('caseCustomerResults');
|
||||||
|
if (resultsEl) resultsEl.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/emails/search-customers?q=${encodeURIComponent(query)}`);
|
const response = await fetch(`/api/v1/emails/search-customers?q=${encodeURIComponent(query)}`);
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
const customers = await response.json();
|
const customers = await response.json();
|
||||||
const datalist = document.getElementById('caseCustomerResults');
|
renderCustomerSearchResults(customers);
|
||||||
if (!datalist) return;
|
|
||||||
datalist.innerHTML = customers.map((customer) => {
|
|
||||||
const display = `${customer.name} (#${customer.id})`;
|
|
||||||
return `<option value="${escapeHtml(display)}" data-id="${customer.id}"></option>`;
|
|
||||||
}).join('');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Customer search failed:', error);
|
console.warn('Customer search failed:', error);
|
||||||
}
|
}
|
||||||
@ -2063,8 +2162,7 @@ function confirmSuggestion() {
|
|||||||
|
|
||||||
function getCaseFormPayload() {
|
function getCaseFormPayload() {
|
||||||
const customerIdHidden = document.getElementById('caseCustomerId')?.value;
|
const customerIdHidden = document.getElementById('caseCustomerId')?.value;
|
||||||
const customerIdFromSearch = getSelectedIdFromDatalist('caseCustomerSearch', 'caseCustomerResults');
|
const resolvedCustomerId = customerIdHidden || null;
|
||||||
const resolvedCustomerId = customerIdFromSearch || customerIdHidden || null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
titel: document.getElementById('caseTitle')?.value?.trim() || null,
|
titel: document.getElementById('caseTitle')?.value?.trim() || null,
|
||||||
@ -2072,6 +2170,7 @@ function getCaseFormPayload() {
|
|||||||
case_type: document.getElementById('casePrimaryType')?.value || 'support',
|
case_type: document.getElementById('casePrimaryType')?.value || 'support',
|
||||||
secondary_label: document.getElementById('caseSecondaryLabel')?.value?.trim() || null,
|
secondary_label: document.getElementById('caseSecondaryLabel')?.value?.trim() || null,
|
||||||
start_date: document.getElementById('caseStartDate')?.value || null,
|
start_date: document.getElementById('caseStartDate')?.value || null,
|
||||||
|
deadline: document.getElementById('caseDeadline')?.value || null,
|
||||||
priority: document.getElementById('casePriority')?.value || 'normal',
|
priority: document.getElementById('casePriority')?.value || 'normal',
|
||||||
ansvarlig_bruger_id: document.getElementById('caseAssignee')?.value ? Number(document.getElementById('caseAssignee').value) : null,
|
ansvarlig_bruger_id: document.getElementById('caseAssignee')?.value ? Number(document.getElementById('caseAssignee').value) : null,
|
||||||
assigned_group_id: document.getElementById('caseGroup')?.value ? Number(document.getElementById('caseGroup').value) : null,
|
assigned_group_id: document.getElementById('caseGroup')?.value ? Number(document.getElementById('caseGroup').value) : null,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user