feat(email): add deadline and enhanced company search in email-to-sag flow

This commit is contained in:
Christian 2026-03-17 22:08:05 +01:00
parent 15feb18361
commit 074ab6a62a
3 changed files with 162 additions and 18 deletions

28
RELEASE_NOTES_v2.2.54.md Normal file
View 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.

View File

@ -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:

View File

@ -476,6 +476,48 @@
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) {
.email-list-sidebar { .email-list-sidebar {
@ -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,