diff --git a/.github/skills/gui-starter/SKILL.md b/.github/skills/gui-starter/SKILL.md new file mode 100644 index 0000000..ff2a360 --- /dev/null +++ b/.github/skills/gui-starter/SKILL.md @@ -0,0 +1,38 @@ +--- +name: gui-starter +description: "Use when building or updating BMC Hub GUI pages, templates, layout, styling, dark mode toggle, responsive Bootstrap 5 UI, or Nordic Top themed frontend components." +--- + +# BMC Hub GUI Starter + +## Purpose +Use this skill when implementing or refining frontend UI in BMC Hub. + +## Project UI Rules +- Follow the Nordic Top style from `docs/design_reference/`. +- Keep a minimalist, clean layout with card-based sections. +- Use Deep Blue as default primary accent: `#0f4c75`. +- Support dark mode with a visible toggle. +- Use CSS variables so accent colors can be changed dynamically. +- Build mobile-first with Bootstrap 5 grid utilities. + +## Preferred Workflow +1. Identify existing template/page and preserve established structure when present. +2. Define or update theme tokens as CSS variables (light + dark). +3. Implement responsive layout first, then enhance desktop spacing/typography. +4. Add or maintain dark mode toggle logic (persist preference in localStorage when relevant). +5. Reuse patterns from `docs/design_reference/components.html`, `docs/design_reference/index.html`, `docs/design_reference/customers.html`, and `docs/design_reference/form.html`. +6. Validate visual consistency and avoid introducing one-off styles unless necessary. + +## Implementation Guardrails +- Do not hardcode colors repeatedly; map them to CSS variables. +- Do not remove dark mode support from existing pages. +- Do not break existing navigation/topbar behavior. +- Avoid large framework changes unless explicitly requested. +- Keep accessibility basics in place: color contrast, visible focus states, semantic HTML. + +## Deliverables +When using this skill, provide: +- Updated frontend files (HTML/CSS/JS) with concise, intentional styling. +- A short summary of what changed and why. +- Notes about any remaining UI tradeoffs or follow-up refinements. diff --git a/app/core/config.py b/app/core/config.py index 80f2246..c624aa4 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -105,6 +105,7 @@ class Settings(BaseSettings): EMAIL_AI_ENABLED: bool = False EMAIL_AUTO_CLASSIFY: bool = True # Enable classification by default (uses keywords if AI disabled) EMAIL_AI_CONFIDENCE_THRESHOLD: float = 0.7 + EMAIL_REQUIRE_MANUAL_APPROVAL: bool = True # Phase 1: human approval before case creation/routing EMAIL_MAX_FETCH_PER_RUN: int = 50 EMAIL_PROCESS_INTERVAL_MINUTES: int = 5 EMAIL_WORKFLOWS_ENABLED: bool = True diff --git a/app/emails/backend/router.py b/app/emails/backend/router.py index 5cc7e44..5e03dba 100644 --- a/app/emails/backend/router.py +++ b/app/emails/backend/router.py @@ -142,10 +142,115 @@ class ProcessingStats(BaseModel): fetched: int = 0 saved: int = 0 classified: int = 0 + awaiting_user_action: int = 0 rules_matched: int = 0 errors: int = 0 +class CreateSagFromEmailRequest(BaseModel): + titel: Optional[str] = None + beskrivelse: Optional[str] = None + customer_id: Optional[int] = None + contact_id: Optional[int] = None + case_type: str = "support" + secondary_label: Optional[str] = None + start_date: Optional[date] = None + priority: Optional[str] = None + ansvarlig_bruger_id: Optional[int] = None + assigned_group_id: Optional[int] = None + created_by_user_id: int = 1 + relation_type: str = "kommentar" + + +class LinkEmailToSagRequest(BaseModel): + sag_id: int + relation_type: str = "kommentar" + note: Optional[str] = None + forfatter: str = "E-mail Motor" + mark_processed: bool = True + + +@router.get("/emails/sag-options") +async def get_sag_assignment_options(): + """Return users and groups for SAG assignment controls in email UI.""" + try: + users = execute_query( + """ + SELECT user_id AS id, + COALESCE(full_name, username, CONCAT('Bruger #', user_id::text)) AS name + FROM users + WHERE is_active = true + ORDER BY COALESCE(full_name, username, user_id::text) + """ + ) or [] + + groups = execute_query( + """ + SELECT id, name + FROM groups + ORDER BY name + """ + ) or [] + + return {"users": users, "groups": groups} + except Exception as e: + logger.error("❌ Error loading SAG assignment options: %s", e) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/emails/search-customers") +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.""" + try: + like = f"%{q.strip()}%" + rows = execute_query( + """ + SELECT id, name, email_domain + FROM customers + WHERE ( + name ILIKE %s + OR COALESCE(email, '') ILIKE %s + OR COALESCE(email_domain, '') ILIKE %s + OR COALESCE(cvr_number, '') ILIKE %s + ) + ORDER BY name + LIMIT %s + """, + (like, like, like, like, limit) + ) + return rows or [] + except Exception as e: + logger.error("❌ Error searching customers from email router: %s", e) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/emails/search-sager") +async def search_sager(q: str = Query(..., min_length=1), limit: int = Query(20, ge=1, le=100)): + """Autocomplete SAG cases for linking emails to existing cases.""" + try: + like = f"%{q.strip()}%" + rows = execute_query( + """ + SELECT s.id, s.titel, s.status, s.priority, c.name AS customer_name + FROM sag_sager s + LEFT JOIN customers c ON c.id = s.customer_id + WHERE s.deleted_at IS NULL + AND ( + s.titel ILIKE %s + OR COALESCE(s.beskrivelse, '') ILIKE %s + OR CAST(s.id AS TEXT) ILIKE %s + ) + ORDER BY s.updated_at DESC + LIMIT %s + """, + (like, like, like, limit) + ) + return rows or [] + except Exception as e: + logger.error("❌ Error searching sager from email router: %s", e) + raise HTTPException(status_code=500, detail=str(e)) + + # Email Endpoints @router.get("/emails", response_model=List[EmailListItem]) async def list_emails( @@ -375,6 +480,201 @@ async def link_email(email_id: int, payload: Dict): raise HTTPException(status_code=500, detail=str(e)) +@router.post("/emails/{email_id}/create-sag") +async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailRequest): + """Create a new SAG from an email and persist the email-case relation.""" + try: + email_row = execute_query( + "SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL", + (email_id,) + ) + if not email_row: + raise HTTPException(status_code=404, detail="Email not found") + + email_data = email_row[0] + customer_id = payload.customer_id or email_data.get('customer_id') + if not customer_id: + raise HTTPException(status_code=400, detail="customer_id is required (missing on email and payload)") + + titel = (payload.titel or email_data.get('subject') or f"E-mail fra {email_data.get('sender_email', 'ukendt afsender')}").strip() + beskrivelse = payload.beskrivelse or email_data.get('body_text') or email_data.get('body_html') or '' + template_key = (payload.case_type or 'support').strip().lower()[:50] + priority = (payload.priority or 'normal').strip().lower() + + if priority not in {'low', 'normal', 'high', 'urgent'}: + raise HTTPException(status_code=400, detail="priority must be one of: low, normal, high, urgent") + + case_result = execute_query( + """ + INSERT INTO sag_sager + (titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, priority, start_date) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id, titel, customer_id, status, template_key, priority, start_date, created_at + """, + ( + titel, + beskrivelse, + template_key, + 'åben', + customer_id, + payload.ansvarlig_bruger_id, + payload.assigned_group_id, + payload.created_by_user_id, + priority, + payload.start_date, + ) + ) + if not case_result: + raise HTTPException(status_code=500, detail="Failed to create SAG") + + sag = case_result[0] + sag_id = sag['id'] + + # Link email to SAG (audit trail) + execute_update( + """ + INSERT INTO sag_emails (sag_id, email_id) + VALUES (%s, %s) + ON CONFLICT (sag_id, email_id) DO NOTHING + """, + (sag_id, email_id) + ) + + execute_update( + """ + UPDATE email_messages + SET linked_case_id = %s, + status = 'processed', + folder = 'Processed', + processed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + (sag_id, email_id) + ) + + if payload.contact_id: + execute_update( + """ + INSERT INTO sag_kontakter (sag_id, contact_id, role) + VALUES (%s, %s, %s) + ON CONFLICT DO NOTHING + """, + (sag_id, payload.contact_id, 'primary') + ) + + relation_type = (payload.relation_type or 'kommentar').strip().lower() + if relation_type in {'kommentar', 'intern_note', 'kundeopdatering'}: + system_note = ( + f"E-mail knyttet som {relation_type}.\n" + f"Emne: {email_data.get('subject') or '(ingen emne)'}\n" + f"Fra: {email_data.get('sender_email') or '(ukendt)'}" + ) + if payload.secondary_label: + system_note += f"\nLabel: {payload.secondary_label.strip()[:60]}" + + execute_update( + """ + INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked) + VALUES (%s, %s, %s, %s) + """, + (sag_id, 'E-mail Motor', system_note, True) + ) + + return { + "success": True, + "email_id": email_id, + "sag": sag, + "message": "SAG oprettet fra e-mail" + } + + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error creating SAG from email %s: %s", email_id, e) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/emails/{email_id}/link-sag") +async def link_email_to_sag(email_id: int, payload: LinkEmailToSagRequest): + """Link an email to an existing SAG and optionally append a system note.""" + try: + email_row = execute_query( + "SELECT id, subject, sender_email FROM email_messages WHERE id = %s AND deleted_at IS NULL", + (email_id,) + ) + if not email_row: + raise HTTPException(status_code=404, detail="Email not found") + + sag_row = execute_query( + "SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL", + (payload.sag_id,) + ) + if not sag_row: + raise HTTPException(status_code=404, detail="SAG not found") + + execute_update( + """ + INSERT INTO sag_emails (sag_id, email_id) + VALUES (%s, %s) + ON CONFLICT (sag_id, email_id) DO NOTHING + """, + (payload.sag_id, email_id) + ) + + if payload.mark_processed: + execute_update( + """ + UPDATE email_messages + SET linked_case_id = %s, + status = 'processed', + folder = 'Processed', + processed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + (payload.sag_id, email_id) + ) + else: + execute_update( + """ + UPDATE email_messages + SET linked_case_id = %s, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + (payload.sag_id, email_id) + ) + + relation_type = (payload.relation_type or 'kommentar').strip().lower() + if relation_type in {'kommentar', 'intern_note', 'kundeopdatering'}: + email_data = email_row[0] + note = payload.note or ( + f"E-mail knyttet som {relation_type}. " + f"Emne: {email_data.get('subject') or '(ingen emne)'}" + ) + execute_update( + """ + INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked) + VALUES (%s, %s, %s, %s) + """, + (payload.sag_id, payload.forfatter, note, True) + ) + + return { + "success": True, + "email_id": email_id, + "sag_id": payload.sag_id, + "message": "E-mail knyttet til SAG" + } + + except HTTPException: + raise + except Exception as e: + logger.error("❌ Error linking email %s to SAG %s: %s", email_id, payload.sag_id, e) + raise HTTPException(status_code=500, detail=str(e)) + + @router.post("/emails/{email_id}/extract-vendor-suggestion") async def extract_vendor_suggestion(email_id: int): """ @@ -1206,6 +1506,7 @@ async def get_processing_stats(): SELECT COUNT(*) as total_emails, COUNT(*) FILTER (WHERE status = 'new') as new_emails, + COUNT(*) FILTER (WHERE status = 'awaiting_user_action') as awaiting_user_action, COUNT(*) FILTER (WHERE status = 'processed') as processed_emails, COUNT(*) FILTER (WHERE status = 'error') as error_emails, COUNT(*) FILTER (WHERE has_attachments = true) as with_attachments, @@ -1225,6 +1526,7 @@ async def get_processing_stats(): return { "total_emails": 0, "new_emails": 0, + "awaiting_user_action": 0, "processed_emails": 0, "error_emails": 0, "with_attachments": 0, @@ -1494,6 +1796,7 @@ async def get_email_stats(): SELECT COUNT(*) as total_emails, COUNT(CASE WHEN status = 'new' THEN 1 END) as new_emails, + COUNT(CASE WHEN status = 'awaiting_user_action' THEN 1 END) as awaiting_user_action, COUNT(CASE WHEN status = 'processed' THEN 1 END) as processed_emails, COUNT(CASE WHEN classification = 'invoice' THEN 1 END) as invoices, COUNT(CASE WHEN classification = 'time_confirmation' THEN 1 END) as time_confirmations, diff --git a/app/emails/frontend/emails.html b/app/emails/frontend/emails.html index a44ca15..331a48e 100644 --- a/app/emails/frontend/emails.html +++ b/app/emails/frontend/emails.html @@ -436,6 +436,45 @@ color: var(--accent); margin-bottom: 0.75rem; } + + .suggestion-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.6rem; + } + + .suggestion-field { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .suggestion-field label { + font-size: 0.72rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.4px; + } + + .suggestion-field input, + .suggestion-field select { + border: 1px solid rgba(0,0,0,0.1); + border-radius: 8px; + background: var(--bg-body); + color: var(--text-primary); + padding: 0.45rem 0.6rem; + font-size: 0.85rem; + } + + .suggestion-field.full { + grid-column: 1 / -1; + } + + .quick-action-row { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + } /* Responsive Design */ @media (max-width: 1200px) { @@ -871,6 +910,9 @@ + @@ -1312,6 +1354,7 @@ let emails = []; let selectedEmails = new Set(); let emailSearchTimeout = null; let autoRefreshInterval = null; +let sagAssignmentOptions = { users: [], groups: [] }; // Initialize document.addEventListener('DOMContentLoaded', () => { @@ -1322,6 +1365,7 @@ document.addEventListener('DOMContentLoaded', () => { loadStats(); setupEventListeners(); setupKeyboardShortcuts(); + preloadSagAssignmentOptions(); startAutoRefresh(); }); @@ -1431,13 +1475,12 @@ async function loadEmails(searchQuery = '') { // Handle special filters if (currentFilter === 'active') { - // Show only new, error, or flagged (pending review) emails - // If searching, ignore status filter to allow global search - if (!searchQuery) { - url += '&status=new'; - } + // Active queue includes both new and awaiting manual handling. + // We fetch list data and filter client-side because API status filter is single-value. } else if (currentFilter === 'processed') { url += '&status=processed'; + } else if (currentFilter === 'awaiting_user_action') { + url += '&status=awaiting_user_action'; } else if (currentFilter !== 'all') { // Classification filter url += `&classification=${currentFilter}`; @@ -1455,6 +1498,11 @@ async function loadEmails(searchQuery = '') { } emails = await response.json(); + + if (currentFilter === 'active' && !searchQuery) { + emails = emails.filter((email) => ['new', 'awaiting_user_action'].includes(email.status || 'new')); + } + console.log('Loaded emails:', emails.length, 'items'); renderEmailList(emails); @@ -1757,52 +1805,131 @@ function renderEmailAnalysis(email) { console.error('aiAnalysisTab element not found in DOM'); return; } - + const classification = email.classification || 'general'; const confidence = email.confidence_score || 0; - - // Opdater kun AI Analysis tab indholdet - ikke hele sidebar - aiAnalysisTab.innerHTML = ` - ${!email.customer_id && !email.supplier_id ? ` -
-
Ukendt Afsender
-
- ${escapeHtml(email.sender_email || '')} - ${email.sender_name ? `
${escapeHtml(email.sender_name)}` : ''} -
-
- - -
-
- ` : ` -
-
Linket Til
- -
- `} + const primaryType = suggestPrimaryType(email); + const secondaryLabel = suggestSecondaryLabel(email); + const selectedCustomerName = email.customer_name || ''; - ${getClassificationActions(email) ? ` + const userOptions = (sagAssignmentOptions.users || []).map((user) => + `` + ).join(''); + + const groupOptions = (sagAssignmentOptions.groups || []).map((group) => + `` + ).join(''); + + aiAnalysisTab.innerHTML = `
-
Hurtig Action
-
- ${getClassificationActions(email)} +
System Forslag
+
+ + + +
+ +
+
+ + +
+ +
+ + +
+ +
+ + + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + + + +
+ +
- ` : ''} - +
AI Klassificering
-
@@ -1812,7 +1939,7 @@ function renderEmailAnalysis(email) { ${Math.round(confidence * 100)}%
- + - +
- -
-
Metadata
- -
- - ${email.matched_rules && email.matched_rules.length > 0 ? ` -
-
Matchede Regler
-
- - ${email.matched_rules.length} regel(er) matchet -
-
- ` : ''} `; + + const statusBadge = document.getElementById('emailActionStatus'); + if (statusBadge) { + statusBadge.textContent = email.status || 'new'; + } +} + +function todayAsDateString() { + return new Date().toISOString().split('T')[0]; +} + +function suggestPrimaryType(email) { + const classification = (email.classification || '').toLowerCase(); + if (classification === 'invoice') return 'bogholderi'; + if (classification === 'time_confirmation') return 'support'; + if (classification === 'case_notification') return 'support'; + if (email.supplier_id) return 'leverandor'; + return 'support'; +} + +function suggestSecondaryLabel(email) { + const classification = (email.classification || '').toLowerCase(); + const mapping = { + invoice: 'Fakturasporgsmal', + time_confirmation: 'Tidsbekraftelse', + case_notification: 'Sag opdatering', + order_confirmation: 'Ordre bekraftelse', + freight_note: 'Fragt opdatering', + general: 'Generel henvendelse' + }; + return mapping[classification] || 'Kunde henvendelse'; +} + +async function preloadSagAssignmentOptions() { + try { + const response = await fetch('/api/v1/emails/sag-options'); + if (!response.ok) return; + sagAssignmentOptions = await response.json(); + } catch (error) { + console.warn('Could not preload sag assignment options:', error); + } +} + +function focusTypeEditor() { + document.getElementById('casePrimaryType')?.focus(); +} + +function setPrimaryType(value) { + const el = document.getElementById('casePrimaryType'); + if (el) el.value = value; +} + +function toggleLinkExistingPanel() { + const panel = document.getElementById('linkExistingPanel'); + if (!panel) return; + panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; +} + +function getSelectedIdFromDatalist(inputId, datalistId) { + const input = document.getElementById(inputId); + const datalist = document.getElementById(datalistId); + if (!input || !datalist) return null; + const option = [...datalist.options].find((opt) => opt.value === input.value); + return option ? option.dataset.id : null; +} + +async function searchCustomersForCurrentEmail(query) { + if (!query || query.length < 2) return; + try { + const response = await fetch(`/api/v1/emails/search-customers?q=${encodeURIComponent(query)}`); + if (!response.ok) return; + const customers = await response.json(); + const datalist = document.getElementById('caseCustomerResults'); + if (!datalist) return; + datalist.innerHTML = customers.map((customer) => { + const display = `${customer.name} (#${customer.id})`; + return ``; + }).join(''); + } catch (error) { + console.warn('Customer search failed:', error); + } +} + +async function searchSagerForCurrentEmail(query) { + if (!query || query.length < 2) return; + try { + const response = await fetch(`/api/v1/emails/search-sager?q=${encodeURIComponent(query)}`); + if (!response.ok) return; + const sager = await response.json(); + const datalist = document.getElementById('existingSagResults'); + if (!datalist) return; + datalist.innerHTML = sager.map((sag) => { + const display = `SAG-${sag.id}: ${sag.titel || '(uden titel)'}`; + return ``; + }).join(''); + } catch (error) { + console.warn('SAG search failed:', error); + } +} + +function confirmSuggestion() { + createCaseFromCurrentForm(); +} + +function getCaseFormPayload() { + const customerIdHidden = document.getElementById('caseCustomerId')?.value; + const customerIdFromSearch = getSelectedIdFromDatalist('caseCustomerSearch', 'caseCustomerResults'); + const resolvedCustomerId = customerIdFromSearch || customerIdHidden || null; + + return { + titel: document.getElementById('caseTitle')?.value?.trim() || null, + customer_id: resolvedCustomerId ? Number(resolvedCustomerId) : null, + case_type: document.getElementById('casePrimaryType')?.value || 'support', + secondary_label: document.getElementById('caseSecondaryLabel')?.value?.trim() || null, + start_date: document.getElementById('caseStartDate')?.value || null, + priority: document.getElementById('casePriority')?.value || 'normal', + 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, + relation_type: 'kommentar' + }; +} + +async function createCaseFromCurrentForm() { + if (!currentEmailId) return; + + const payload = getCaseFormPayload(); + if (!payload.customer_id) { + showError('Vælg kunde før sag-oprettelse'); + return; + } + + try { + const response = await fetch(`/api/v1/emails/${currentEmailId}/create-sag`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.detail || 'Sag-oprettelse fejlede'); + } + + const result = await response.json(); + showSuccess(`SAG-${result.sag.id} oprettet og e-mail linket`); + loadEmails(); + await loadEmailDetail(currentEmailId); + } catch (error) { + showError(error.message || 'Kunne ikke oprette sag'); + } +} + +async function linkCurrentEmailToExistingSag() { + if (!currentEmailId) return; + const selectedSagId = getSelectedIdFromDatalist('existingSagSearch', 'existingSagResults') || document.getElementById('existingSagId')?.value; + if (!selectedSagId) { + showError('Vælg en eksisterende sag først'); + return; + } + + try { + const response = await fetch(`/api/v1/emails/${currentEmailId}/link-sag`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sag_id: Number(selectedSagId), + relation_type: document.getElementById('existingSagRelationType')?.value || 'kommentar' + }) + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.detail || 'Tilknytning fejlede'); + } + + showSuccess(`E-mail knyttet til SAG-${selectedSagId}`); + loadEmails(); + await loadEmailDetail(currentEmailId); + } catch (error) { + showError(error.message || 'Kunne ikke knytte e-mail til sag'); + } } function showEmptyState() { @@ -1914,12 +2182,14 @@ async function loadStats() { const response = await fetch('/api/v1/emails/stats/summary'); const stats = await response.json(); - // Calculate active emails (new + error + flagged) - const activeCount = stats.new_emails || 0; + const newCount = stats.new_emails || 0; + const awaitingCount = stats.awaiting_user_action || 0; + const activeCount = newCount + awaitingCount; document.getElementById('countActive').textContent = activeCount; document.getElementById('countAll').textContent = stats.total_emails || 0; document.getElementById('countProcessed').textContent = stats.processed_emails || 0; + document.getElementById('countAwaiting').textContent = awaitingCount; document.getElementById('countInvoice').textContent = stats.invoices || 0; document.getElementById('countOrder').textContent = 0; document.getElementById('countFreight').textContent = 0; @@ -2272,11 +2542,20 @@ async function createTimeEntry(emailId) { } async function createCase(emailId) { - showError('Sags-modul er ikke implementeret endnu'); + if (currentEmailId !== emailId) { + await selectEmail(emailId); + } + setPrimaryType('support'); + focusTypeEditor(); + showInfo('Sagsforslag klar. Udfyld felter og klik Opret Ny Sag.'); } async function linkToCustomer(emailId) { - showError('Kunde-linking er ikke implementeret endnu'); + if (currentEmailId !== emailId) { + await selectEmail(emailId); + } + document.getElementById('caseCustomerSearch')?.focus(); + showInfo('Søg og vælg kunde i forslagspanelet.'); } // ─── Quick Create Customer ──────────────────────────────────────────────── @@ -2688,6 +2967,10 @@ function formatClassification(classification) { function getStatusBadge(email) { const status = email.status || 'new'; const approvalStatus = email.approval_status; + + if (status === 'awaiting_user_action') { + return 'Afventer'; + } if (status === 'processed' || status === 'archived') { return 'Behandlet'; diff --git a/app/services/email_processor_service.py b/app/services/email_processor_service.py index fd4a49d..6c90f2b 100644 --- a/app/services/email_processor_service.py +++ b/app/services/email_processor_service.py @@ -49,6 +49,7 @@ class EmailProcessorService: 'fetched': 0, 'saved': 0, 'classified': 0, + 'awaiting_user_action': 0, 'rules_matched': 0, 'errors': 0 } @@ -86,6 +87,8 @@ class EmailProcessorService: if result.get('classified'): stats['classified'] += 1 + if result.get('awaiting_user_action'): + stats['awaiting_user_action'] += 1 if result.get('rules_matched'): stats['rules_matched'] += 1 @@ -109,6 +112,7 @@ class EmailProcessorService: email_id = email_data.get('id') stats = { 'classified': False, + 'awaiting_user_action': False, 'workflows_executed': 0, 'rules_matched': False } @@ -123,6 +127,22 @@ class EmailProcessorService: if settings.EMAIL_AUTO_CLASSIFY: await self._classify_and_update(email_data) stats['classified'] = True + + # Step 3.5: Gate automation by manual-approval policy and confidence + # Phase-1 policy: suggestions are generated automatically, actions are user-approved. + classification = (email_data.get('classification') or '').strip().lower() + confidence = float(email_data.get('confidence_score') or 0.0) + require_manual_approval = getattr(settings, 'EMAIL_REQUIRE_MANUAL_APPROVAL', True) + + if require_manual_approval: + await self._set_awaiting_user_action(email_id, reason='manual_approval_required') + stats['awaiting_user_action'] = True + return stats + + if not classification or confidence < settings.EMAIL_AI_CONFIDENCE_THRESHOLD: + await self._set_awaiting_user_action(email_id, reason='low_confidence') + stats['awaiting_user_action'] = True + return stats # Step 4: Execute workflows based on classification workflow_processed = False @@ -172,6 +192,25 @@ class EmailProcessorService: except Exception as e: logger.error(f"❌ Error in process_single_email for {email_id}: {e}") raise e + + async def _set_awaiting_user_action(self, email_id: Optional[int], reason: str): + """Park an email for manual review before any automatic routing/action.""" + if not email_id: + return + + execute_update( + """ + UPDATE email_messages + SET status = 'awaiting_user_action', + folder = COALESCE(folder, 'INBOX'), + auto_processed = false, + processed_at = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + (email_id,) + ) + logger.info("🛑 Email %s moved to awaiting_user_action (%s)", email_id, reason) async def _classify_and_update(self, email_data: Dict): """Classify email and update database""" diff --git a/backups/email_feature/README.md b/backups/email_feature/README.md new file mode 100644 index 0000000..af2c777 --- /dev/null +++ b/backups/email_feature/README.md @@ -0,0 +1,37 @@ +# Email Feature Backup + +Backup artifact for current email handling implementation. + +## Artifact + +- `email_feature_backup_20260317_214413.zip` + +## Contents + +- `app/emails/` +- `app/services/email_service.py` +- `app/services/email_processor_service.py` +- `app/services/email_analysis_service.py` +- `app/services/email_workflow_service.py` +- `app/services/email_activity_logger.py` +- `app/modules/sag/templates/detail.html` +- `migrations/013_email_system.sql` +- `migrations/014_email_workflows.sql` +- `migrations/050_email_activity_log.sql` +- `migrations/056_email_import_method.sql` +- `migrations/084_sag_files_and_emails.sql` +- `migrations/140_email_extracted_vendor_fields.sql` +- `migrations/141_email_threading_headers.sql` + +## Restore (code only) + +From repository root: + +```bash +unzip -o backups/email_feature/email_feature_backup_20260317_214413.zip -d . +``` + +## Notes + +- This restore only replaces code files included in the artifact. +- Database rollback must be handled separately if schema/data has changed. diff --git a/backups/email_feature/email_feature_backup_20260317_214413.zip b/backups/email_feature/email_feature_backup_20260317_214413.zip new file mode 100644 index 0000000..8d55b27 Binary files /dev/null and b/backups/email_feature/email_feature_backup_20260317_214413.zip differ diff --git a/migrations/145_sag_start_date.sql b/migrations/145_sag_start_date.sql new file mode 100644 index 0000000..d749e32 --- /dev/null +++ b/migrations/145_sag_start_date.sql @@ -0,0 +1,10 @@ +-- Migration 145: Add start date to SAG for email-driven case creation flow + +ALTER TABLE sag_sager + ADD COLUMN IF NOT EXISTS start_date DATE; + +CREATE INDEX IF NOT EXISTS idx_sag_sager_start_date + ON sag_sager(start_date) + WHERE deleted_at IS NULL; + +COMMENT ON COLUMN sag_sager.start_date IS 'Planned start date for case execution (used by email-to-case workflow).';