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 ? ` -