From 56cc1bdcc425c88921e5f98d55d6db545e4a583f Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 30 Jul 2026 20:11:43 +0200 Subject: [PATCH] feat: Enhance AnyDesk integration and vendor email domain management - Updated the AnyDesk quick connect modal to improve user experience with new UI elements and functionality. - Added support for saving and managing multiple AnyDesk IDs associated with cases, including hardware and contact information. - Implemented backend endpoints for managing vendor email domains, allowing addition, deletion, and retrieval of domains linked to vendors. - Created a new database table for vendor email domains to support multiple exact domains per vendor. - Added tests for bankruptcy workflow to ensure correct case creation and alert linking based on exact CVR matches. --- app/customers/backend/router.py | 66 +++ app/customers/frontend/customer_detail.html | 57 +++ app/emails/backend/router.py | 380 +++++++++++++-- app/emails/frontend/emails_v2.html | 500 +++++++++++++++++++- app/modules/locations/backend/router.py | 129 ++++- app/modules/locations/frontend/views.py | 2 +- app/modules/locations/templates/detail.html | 177 +++++-- app/modules/sag/frontend/views.py | 29 +- app/modules/sag/templates/detail_v3.html | 318 +++++++++---- app/routers/anydesk.py | 126 ++++- app/services/email_processor_service.py | 12 +- app/services/email_workflow_service.py | 127 ++++- app/vendors/backend/router.py | 55 +++ app/vendors/frontend/vendor_detail.html | 57 +++ migrations/1014_vendor_email_domains.sql | 26 + migrations/1015_add_accounting_group.sql | 9 + tests/test_bankruptcy_workflow.py | 66 +++ 17 files changed, 1931 insertions(+), 205 deletions(-) create mode 100644 migrations/1014_vendor_email_domains.sql create mode 100644 migrations/1015_add_accounting_group.sql create mode 100644 tests/test_bankruptcy_workflow.py diff --git a/app/customers/backend/router.py b/app/customers/backend/router.py index dc6d96b..cad05f1 100644 --- a/app/customers/backend/router.py +++ b/app/customers/backend/router.py @@ -135,6 +135,19 @@ class CustomerUpdate(BaseModel): department: Optional[str] = None +class EmailDomainCreate(BaseModel): + domain: str + + +def _normalize_email_domain(value: str) -> str: + domain = str(value or "").strip().lower() + domain = domain.removeprefix("https://").removeprefix("http://").removeprefix("www.") + domain = domain.split("/", 1)[0].strip(". ") + if not domain or "." not in domain or "@" in domain: + raise HTTPException(status_code=400, detail="Ugyldigt emaildomæne") + return domain + + class ContactCreate(BaseModel): first_name: str last_name: str @@ -461,6 +474,59 @@ async def verify_customer_linking(): raise HTTPException(status_code=500, detail=str(e)) +@router.get("/customers/{customer_id}/email-domains") +async def list_customer_email_domains(customer_id: int): + customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,)) + if not customer: + raise HTTPException(status_code=404, detail="Kunde ikke fundet") + rows = execute_query( + """ + SELECT domain, created_at + FROM email_domain_customer_mappings + WHERE customer_id = %s + ORDER BY domain + """, + (customer_id,), + ) or [] + return rows + + +@router.post("/customers/{customer_id}/email-domains") +async def add_customer_email_domain(customer_id: int, payload: EmailDomainCreate): + if not execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,)): + raise HTTPException(status_code=404, detail="Kunde ikke fundet") + domain = _normalize_email_domain(payload.domain) + existing = execute_query_single( + "SELECT customer_id FROM email_domain_customer_mappings WHERE domain = %s", + (domain,), + ) + if existing and int(existing["customer_id"]) != customer_id: + raise HTTPException(status_code=409, detail="Domænet tilhører allerede en anden kunde") + return execute_query_single( + """ + INSERT INTO email_domain_customer_mappings (domain, customer_id, source) + VALUES (%s, %s, 'manual_customer') + ON CONFLICT (domain) DO UPDATE SET + customer_id = EXCLUDED.customer_id, + source = EXCLUDED.source + RETURNING domain, customer_id, created_at + """, + (domain, customer_id), + ) + + +@router.delete("/customers/{customer_id}/email-domains/{domain}") +async def delete_customer_email_domain(customer_id: int, domain: str): + normalized = _normalize_email_domain(domain) + deleted = execute_update( + "DELETE FROM email_domain_customer_mappings WHERE customer_id = %s AND domain = %s", + (customer_id, normalized), + ) + if not deleted: + raise HTTPException(status_code=404, detail="Domænet blev ikke fundet") + return {"success": True} + + @router.get("/customers/{customer_id}") async def get_customer(customer_id: int): """Get single customer by ID with contact count and vTiger BMC Låst status""" diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html index f7af305..5842d92 100644 --- a/app/customers/frontend/customer_detail.html +++ b/app/customers/frontend/customer_detail.html @@ -1000,6 +1000,16 @@ - + +
+
Emaildomæner
+

Kun eksakte domæner bruges til automatisk match.

+
+ + +
+
+
@@ -2468,6 +2478,7 @@ async function loadCustomer() { customerData = await response.json(); displayCustomer(customerData); + await loadCustomerEmailDomains(); await loadUtilityCompany(); await loadCustomerTags(); @@ -2482,6 +2493,52 @@ async function loadCustomer() { } } +async function loadCustomerEmailDomains() { + const host = document.getElementById('customerEmailDomains'); + if (!host) return; + try { + const response = await fetch(`/api/v1/customers/${customerId}/email-domains`); + if (!response.ok) throw new Error('Kunne ikke hente domæner'); + const rows = await response.json(); + host.innerHTML = rows.length ? rows.map(row => ` + + ${escapeHtml(row.domain)} + + + `).join('') : 'Ingen domæner registreret.'; + } catch (error) { + host.innerHTML = `${escapeHtml(error.message)}`; + } +} + +async function addCustomerEmailDomain() { + const input = document.getElementById('customerEmailDomainInput'); + const domain = input.value.trim(); + if (!domain) return; + const response = await fetch(`/api/v1/customers/${customerId}/email-domains`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ domain }) + }); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + alert(error.detail || 'Domænet kunne ikke tilføjes'); + return; + } + input.value = ''; + await loadCustomerEmailDomains(); +} + +async function deleteCustomerEmailDomain(encodedDomain) { + const response = await fetch(`/api/v1/customers/${customerId}/email-domains/${encodedDomain}`, { method: 'DELETE' }); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + alert(error.detail || 'Domænet kunne ikke fjernes'); + return; + } + await loadCustomerEmailDomains(); +} + async function loadCustomerVendorLinks() { const container = document.getElementById('customerVendorLinksContainer'); const empty = document.getElementById('customerVendorLinksEmpty'); diff --git a/app/emails/backend/router.py b/app/emails/backend/router.py index c7b8275..098ea3d 100644 --- a/app/emails/backend/router.py +++ b/app/emails/backend/router.py @@ -17,6 +17,7 @@ from app.utils.safe_html import sanitize_safe_html from app.services.email_processor_service import EmailProcessorService from app.services.email_workflow_service import email_workflow_service from app.services.ollama_service import ollama_service +from app.services.simple_classifier import simple_classifier logger = logging.getLogger(__name__) @@ -388,6 +389,11 @@ class CreateSagFromEmailRequest(BaseModel): relation_type: str = "mail" +class EmailQuickActionRequest(BaseModel): + action: str + titel: Optional[str] = None + + class EmailReadStateUpdate(BaseModel): is_read: bool @@ -505,6 +511,11 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]: steps = wf.get('workflow_steps') steps_total = len(steps) if isinstance(steps, list) else 0 + actions = [ + str(step.get("action") or "").strip() + for step in (steps or []) + if isinstance(step, dict) and str(step.get("action") or "").strip() + ] row = { 'id': wf.get('id'), 'name': wf.get('name'), @@ -515,12 +526,17 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]: 'sender_pattern': sender_pattern, 'subject_pattern': subject_pattern, 'steps_total': steps_total, + 'actions': actions, 'matches': bool(matches), 'reasons': reasons, } candidates.append(row) if matches: matching.append(row) + # Preview must mirror execution: a completed stop-on-match workflow + # prevents lower-priority workflows from running. + if row["stop_on_match"]: + break system_matches = [] if classification == 'bankruptcy': @@ -529,12 +545,16 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]: 'name': 'System: Bankruptcy Analysis', 'matches': True, 'reason': 'classification == bankruptcy', + 'effect': 'Ved eksakt CVR-match oprettes en sag og en kritisk Alert Note på kunden', + 'automatic': True, }) has_hint = email_workflow_service.has_helpdesk_routing_hint(email_data) + supplier_document = classification in {'invoice', 'order_confirmation', 'freight_note'} hard_skip = {'newsletter', 'spam'} should_try_helpdesk = ( - classification not in hard_skip + not supplier_document + and classification not in hard_skip and ( classification not in email_workflow_service.HELPDESK_SKIP_CLASSIFICATIONS or has_hint @@ -545,6 +565,15 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]: 'name': 'System: Helpdesk SAG routing', 'matches': bool(should_try_helpdesk), 'reason': 'hint_or_allowed_classification' if should_try_helpdesk else 'classification_in_skip_list', + 'effect': ( + 'Forsøger at finde en eksisterende sag eller oprette en sag for en kendt kunde' + if should_try_helpdesk + else ( + 'Ingen supportsag oprettes – dokumentet behandles som leverandørbilag' + if supplier_document + else 'Ingen sag oprettes eller tilknyttes automatisk' + ) + ), }) return { @@ -559,7 +588,60 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]: 'matching_workflows': matching, 'workflow_candidates': candidates, 'auto_run_enabled': bool(getattr(settings, 'EMAIL_WORKFLOW_AUTORUN_ENABLED', False)), + 'automatic_execution': classification == 'bankruptcy', } + + +def _find_existing_vendor_for_email(email_data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Match only trusted exact identifiers. Company-name guessing is forbidden.""" + extracted_cvr = str(email_data.get("extracted_vendor_cvr") or "").strip() + sender_email = str(email_data.get("sender_email") or "").strip().lower() + sender_domain = sender_email.rsplit("@", 1)[1] if "@" in sender_email else "" + + if extracted_cvr: + match = execute_query_single( + """ + SELECT id, name, cvr_number, domain, email, 100 AS match_score, + 'exact_cvr'::text AS source + FROM vendors + WHERE is_active = true + AND TRIM(COALESCE(cvr_number, '')) = %s + ORDER BY id ASC LIMIT 1 + """, + (extracted_cvr,), + ) + if match: + return match + + if sender_email: + match = execute_query_single( + """ + SELECT id, name, cvr_number, domain, email, 100 AS match_score, + 'exact_email'::text AS source + FROM vendors + WHERE is_active = true + AND LOWER(TRIM(COALESCE(email, ''))) = %s + ORDER BY id ASC LIMIT 1 + """, + (sender_email,), + ) + if match: + return match + + if not sender_domain: + return None + + return execute_query_single( + """ + SELECT v.id, v.name, v.cvr_number, v.domain, v.email, + 100 AS match_score, 'exact_domain'::text AS source + FROM vendor_email_domains d + JOIN vendors v ON v.id = d.vendor_id + WHERE v.is_active = true AND LOWER(TRIM(d.domain)) = %s + LIMIT 1 + """, + (sender_domain,), + ) context: Optional[str] = None @@ -782,35 +864,6 @@ async def get_domain_customer_suggestion(email_id: int): }, } - partial = execute_query_single( - """ - SELECT id, name, email_domain, cvr_number - FROM customers - WHERE is_active = true - AND COALESCE(email_domain, '') ILIKE %s - ORDER BY name ASC - LIMIT 1 - """, - (f"%{sender_domain}%",), - ) - - if partial: - return { - "email_id": email_id, - "domain": sender_domain, - "has_customer": False, - "ignored": False, - "suggestion": { - "customer_id": partial["id"], - "customer_name": partial["name"], - "email_domain": partial.get("email_domain"), - "cvr_number": partial.get("cvr_number"), - "confidence": "medium", - "score": 70, - "source": "partial_domain", - }, - } - return { "email_id": email_id, "domain": sender_domain, @@ -1252,15 +1305,22 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques requested_case_type = _normalize_case_type(payload.case_type) customer_id = payload.customer_id or email_data.get('customer_id') - if not customer_id and _is_supplier_case_type(requested_case_type): - customer_id = _ensure_customer_from_vendor(email_data.get('supplier_id')) + # A vendor is not a customer. Supplier-originated cases are owned by + # the internal procurement customer, while the email keeps only its + # supplier_id relation. + if not customer_id and email_data.get('supplier_id'): + customer_id = _resolve_procurement_customer_id() if not customer_id and _is_supplier_case_type(requested_case_type): customer_id = _resolve_procurement_customer_id() if not customer_id: raise HTTPException(status_code=400, detail="customer_id is required (missing on email and payload)") - if not email_data.get('customer_id') and customer_id: + if ( + not _is_supplier_case_type(requested_case_type) + and not email_data.get('customer_id') + and customer_id + ): execute_update( "UPDATE email_messages SET customer_id = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s", (customer_id, email_id), @@ -1268,7 +1328,7 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques sender_domain = _extract_sender_domain(email_data.get("sender_email")) if sender_domain and not _is_ignored_sender_domain(sender_domain): - _upsert_domain_mapping(sender_domain, int(customer_id), "supplier_auto") + _upsert_domain_mapping(sender_domain, int(customer_id), "case_creation") titel = (payload.titel or email_data.get('subject') or f"E-mail fra {email_data.get('sender_email', 'ukendt afsender')}").strip() beskrivelse_raw = payload.beskrivelse or email_data.get('body_text') or email_data.get('body_html') or '' @@ -1377,6 +1437,121 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques raise HTTPException(status_code=500, detail=str(e)) +@router.post("/emails/{email_id}/quick-action") +async def run_email_quick_action(email_id: int, payload: EmailQuickActionRequest): + """Run one explicit, identity-safe action from the email decision panel.""" + action = str(payload.action or "").strip().lower() + allowed_actions = { + "create_customer_case", + "create_supplier_invoice", + "create_accounting_case", + "archive_entity_mail", + } + if action not in allowed_actions: + raise HTTPException(status_code=400, detail="Ukendt emailhandling") + + email_data = execute_query_single( + """ + SELECT id, subject, sender_email, body_text, body_html, classification, + confidence_score, customer_id, supplier_id, linked_case_id + FROM email_messages + WHERE id = %s AND deleted_at IS NULL + """, + (email_id,), + ) + if not email_data: + raise HTTPException(status_code=404, detail="Email blev ikke fundet") + + customer_id = email_data.get("customer_id") + supplier_id = email_data.get("supplier_id") + title = str(payload.titel or email_data.get("subject") or "Sag fra email").strip() + + if action in {"create_customer_case", "create_accounting_case"}: + if not customer_id: + raise HTTPException( + status_code=409, + detail="Fastslå og tilknyt kunden, før du opretter en kundesag", + ) + + group_id = None + case_type = "support" + if action == "create_accounting_case": + group = execute_query_single( + """ + SELECT id + FROM groups + WHERE LOWER(TRIM(name)) IN ('bogholderi', 'økonomi', 'okonomi', 'accounting') + ORDER BY CASE WHEN LOWER(TRIM(name)) = 'bogholderi' THEN 0 ELSE 1 END, id + LIMIT 1 + """ + ) + if not group: + raise HTTPException( + status_code=409, + detail="Bogholderi-gruppen mangler i Indstillinger → Grupper", + ) + group_id = int(group["id"]) + case_type = "bogholderi" + + return await create_sag_from_email( + email_id, + CreateSagFromEmailRequest( + titel=title, + customer_id=int(customer_id), + case_type=case_type, + assigned_group_id=group_id, + ), + ) + + if action == "create_supplier_invoice": + if not supplier_id: + raise HTTPException( + status_code=409, + detail="Fastslå og tilknyt leverandøren, før fakturaen behandles", + ) + + # This button is an explicit user decision and therefore authoritative. + email_data["classification"] = "invoice" + email_data["confidence_score"] = 1.0 + execute_update( + """ + UPDATE email_messages + SET classification = 'invoice', confidence_score = 1.0, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + (email_id,), + ) + + result = await email_workflow_service.execute_workflows(email_data) + return { + "success": True, + "action": action, + "result": result, + "message": "Leverandørfaktura sendt til behandling", + } + + if not customer_id and not supplier_id: + raise HTTPException( + status_code=409, + detail="Fastslå kunde eller leverandør, før mailen arkiveres", + ) + + execute_update( + """ + UPDATE email_messages + SET status = 'archived', folder = 'Archive', updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + (email_id,), + ) + return { + "success": True, + "action": action, + "message": "Mailen er arkiveret under den fastsatte kunde eller leverandør", + } + + @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.""" @@ -2240,12 +2415,29 @@ async def upload_emails(files: List[UploadFile] = File(...)): except Exception as e: logger.warning(f"⚠️ Classification failed for uploaded email: {e}") - # Execute workflows - try: - logger.info(f"⚙️ Executing workflows for email {email_id}...") - await email_workflow_service.execute_workflows_for_email(email_id) - except Exception as e: - logger.warning(f"⚠️ Workflow execution failed for uploaded email: {e}") + # Manual uploads follow the same approval policy as mailbox imports: + # classify and preview now; execute only after explicit user approval. + if classification == "bankruptcy": + try: + logger.info("⚙️ Kører automatisk konkursflow for email %s", email_id) + await email_workflow_service.execute_workflows_for_email(email_id) + except Exception as e: + logger.warning("⚠️ Automatisk konkursflow fejlede for email %s: %s", email_id, e) + elif getattr(settings, "EMAIL_REQUIRE_MANUAL_APPROVAL", True): + execute_update( + """ + UPDATE email_messages + SET status = 'awaiting_user_action' + WHERE id = %s + """, + (email_id,), + ) + else: + try: + logger.info(f"⚙️ Executing workflows for email {email_id}...") + await email_workflow_service.execute_workflows_for_email(email_id) + except Exception as e: + logger.warning(f"⚠️ Workflow execution failed for uploaded email: {e}") results.append({ "filename": file.filename, @@ -2779,7 +2971,7 @@ async def execute_workflows_for_email(email_id: int): query = """ SELECT id, message_id, subject, sender_email, sender_name, body_text, body_html, in_reply_to, email_references, thread_key, - classification, confidence_score, status + classification, confidence_score, status, customer_id, supplier_id FROM email_messages WHERE id = %s AND deleted_at IS NULL """ @@ -2789,6 +2981,22 @@ async def execute_workflows_for_email(email_id: int): raise HTTPException(status_code=404, detail="Email not found") email_data = email_result[0] # Get first row as dict + if not str(email_data.get("classification") or "").strip(): + classified = simple_classifier.classify(email_data) + email_data["classification"] = classified["classification"] + email_data["confidence_score"] = classified["confidence"] + execute_update( + """ + UPDATE email_messages + SET classification = %s, confidence_score = %s, updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + ( + classified["classification"], + classified["confidence"], + email_id, + ), + ) # Execute workflows result = await email_workflow_service.execute_workflows(email_data) @@ -2807,11 +3015,16 @@ async def preview_workflows_for_email(email_id: int): """Preview which workflows would match an email without executing them.""" try: query = """ - SELECT id, message_id, subject, sender_email, sender_name, body_text, - body_html, in_reply_to, email_references, thread_key, - classification, confidence_score, status - FROM email_messages - WHERE id = %s AND deleted_at IS NULL + SELECT em.id, em.message_id, em.subject, em.sender_email, em.sender_name, + em.body_text, em.body_html, em.in_reply_to, em.email_references, + em.thread_key, em.classification, em.confidence_score, em.status, + em.customer_id, em.supplier_id, em.extracted_vendor_name, + em.extracted_vendor_cvr, c.name AS customer_name, + v.name AS supplier_name, v.cvr_number AS supplier_cvr + FROM email_messages em + LEFT JOIN customers c ON c.id = em.customer_id + LEFT JOIN vendors v ON v.id = em.supplier_id + WHERE em.id = %s AND em.deleted_at IS NULL """ email_result = execute_query(query, (email_id,)) @@ -2819,7 +3032,80 @@ async def preview_workflows_for_email(email_id: int): raise HTTPException(status_code=404, detail="Email not found") email_data = email_result[0] - return _compute_workflow_preview(email_data) + if not str(email_data.get("classification") or "").strip(): + classified = simple_classifier.classify(email_data) + email_data["classification"] = classified["classification"] + email_data["confidence_score"] = classified["confidence"] + execute_update( + """ + UPDATE email_messages + SET classification = %s, confidence_score = %s, updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + ( + classified["classification"], + classified["confidence"], + email_id, + ), + ) + preview = _compute_workflow_preview(email_data) + + supplier_document = str(email_data.get("classification") or "").lower() in { + "invoice", + "order_confirmation", + "freight_note", + } + customer_identity = ( + { + "has_customer": False, + "not_applicable": True, + "reason": "supplier_document", + "domain": _extract_sender_domain(email_data.get("sender_email")), + "suggestion": None, + } + if supplier_document + else await get_domain_customer_suggestion(email_id) + ) + vendor_identity = { + "has_vendor": bool(email_data.get("supplier_id")), + "suggestion": None, + } + if email_data.get("supplier_id"): + vendor_identity["suggestion"] = { + "vendor_id": email_data.get("supplier_id"), + "name": email_data.get("supplier_name"), + "cvr_number": email_data.get("supplier_cvr"), + "match_score": 100, + "source": "linked_email", + } + else: + extracted_cvr = str(email_data.get("extracted_vendor_cvr") or "").strip() + extracted_name = str(email_data.get("extracted_vendor_name") or "").strip() + matched_vendor = _find_existing_vendor_for_email(email_data) + if matched_vendor: + vendor_identity["suggestion"] = { + "vendor_id": matched_vendor.get("id"), + "name": matched_vendor.get("name"), + "cvr_number": matched_vendor.get("cvr_number"), + "domain": matched_vendor.get("domain"), + "email": matched_vendor.get("email"), + "match_score": matched_vendor.get("match_score"), + "source": matched_vendor.get("source"), + } + elif extracted_name or extracted_cvr: + vendor_identity["suggestion"] = { + "vendor_id": None, + "name": extracted_name or None, + "cvr_number": extracted_cvr or None, + "match_score": 0, + "source": "extracted_email_data", + } + + preview["identity"] = { + "customer": customer_identity, + "vendor": vendor_identity, + } + return preview except HTTPException: raise diff --git a/app/emails/frontend/emails_v2.html b/app/emails/frontend/emails_v2.html index 5b4ec9b..e249a06 100644 --- a/app/emails/frontend/emails_v2.html +++ b/app/emails/frontend/emails_v2.html @@ -77,6 +77,42 @@ background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card)); } + .email-ai-decision { + border: 2px solid color-mix(in srgb, var(--accent, #0f4c75) 42%, var(--border-color)); + border-radius: 12px; + padding: 0.9rem; + margin-bottom: 0.85rem; + background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card)); + } + + .email-ai-type { + font-size: 1.15rem; + font-weight: 750; + } + + .email-ai-confidence { + height: 7px; + border-radius: 99px; + overflow: hidden; + background: var(--border-color); + } + + .email-ai-confidence > span { + display: block; + height: 100%; + background: var(--accent); + } + + .email-ai-actions { + margin: 0; + padding-left: 1.2rem; + font-size: 0.86rem; + } + + .email-ai-actions li { + margin-bottom: 0.32rem; + } + .emails-shortcuts { font-size: 0.72rem; color: var(--text-secondary); @@ -200,6 +236,27 @@ background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card)); } + .emails-v2-quick-toolbar { + padding: 0.65rem 1rem; + border-bottom: 1px solid var(--border-color); + background: var(--bg-card); + } + + .emails-v2-quick-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.45rem; + } + + .emails-v2-quick-grid .btn { + min-height: 38px; + font-size: 0.82rem; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .emails-v2-mail-body { flex: 1; overflow: auto; @@ -428,6 +485,8 @@
Vælg en email for at se info
+
+
Vælg en email fra listen
Ingen email valgt
@@ -700,7 +759,9 @@ const rows = await apiFetch(url); let emails = Array.isArray(rows) ? rows : []; - if (state.filter === 'active' && !state.query) { + // Processed/archived emails must never reappear in the active + // inbox, including when the user searches. + if (state.filter === 'active') { emails = emails.filter((e) => ['new', 'awaiting_user_action'].includes((e.status || '').toLowerCase())); } @@ -713,6 +774,13 @@ const stillExists = emails.some((e) => Number(e.id) === Number(state.selectedEmailId)); if (stillExists) { await selectEmail(state.selectedEmailId, { silentListRefresh: true }); + } else { + state.selectedEmailId = null; + state.selectedEmail = null; + renderDetail(null); + if (emails.length) { + await selectEmail(Number(emails[0].id), { silentListRefresh: true }); + } } } else if (emails.length) { await selectEmail(Number(emails[0].id), { silentListRefresh: true }); @@ -823,6 +891,15 @@ try { const preview = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/workflow-preview`); state.workflowPreview = preview || null; + if (preview?.identity) { + state.domainCustomerSuggestion = preview.identity.customer || { + domain: String(state.selectedEmail?.sender_email || '').split('@')[1] || null, + suggestion: null, + }; + state.vendorSuggestion = preview.identity.vendor?.suggestion || {}; + renderDomainCustomerSuggestion(); + renderVendorSuggestion(); + } renderWorkflowPreview(); } catch (error) { state.workflowPreview = null; @@ -845,18 +922,236 @@ } } + const CLASSIFICATION_LABELS = { + invoice: 'Leverandørfaktura', + freight_note: 'Fragtbrev', + order_confirmation: 'Ordrebekræftelse', + time_confirmation: 'Tidsbekræftelse', + case_notification: 'Sagsnotifikation', + customer_email: 'Kundehenvendelse', + bankruptcy: 'Konkursmeddelelse', + general: 'Almindelig email', + spam: 'Spam', + newsletter: 'Nyhedsbrev', + unknown: 'Ukendt type', + }; + + const WORKFLOW_ACTION_LABELS = { + create_ticket: 'Opretter en sag/opgave', + link_email_to_ticket: 'Knytter emailen til en eksisterende sag', + route_helpdesk_sag: 'Finder eller opretter den relevante sag', + create_time_entry: 'Opretter en tidsregistrering', + link_to_vendor: 'Knytter emailen til leverandøren', + link_to_customer: 'Knytter emailen til kunden', + extract_invoice_data: 'Sender PDF-bilaget til Leverandørfakturaer → Mangler behandling', + extract_tracking_number: 'Udtrækker trackingnummer', + regex_extract_and_link: 'Finder reference og opretter tilknytning', + process_also_cloud_billing: 'Importerer ALSO-fakturafilen og opretter ordrekladder', + send_slack_notification: 'Sender en Slack-notifikation', + send_email_notification: 'Sender en emailnotifikation', + mark_as_processed: 'Markerer emailen som behandlet', + flag_for_review: 'Markerer emailen til manuel kontrol', + }; + + function renderIdentityAssessment() { + const email = state.selectedEmail || {}; + const customerData = state.domainCustomerSuggestion; + const customerSuggestion = customerData?.suggestion; + const vendorSuggestion = state.vendorSuggestion; + const senderDomain = customerData?.domain + || String(email.sender_email || '').split('@')[1] + || ''; + + let customerHtml; + if (customerData?.not_applicable) { + customerHtml = ` +
Ikke relevant
+
Dette er et leverandørdokument. Afsenderen matches kun mod leverandører.
+ `; + } else if (email.customer_id) { + customerHtml = ` +
${escapeHtml(email.customer_name || `Kunde #${email.customer_id}`)}
+
Allerede tilknyttet denne email
+ `; + } else if (customerSuggestion?.customer_id) { + customerHtml = ` +
${escapeHtml(customerSuggestion.customer_name || `Kunde #${customerSuggestion.customer_id}`)}
+
${escapeHtml(customerSuggestion.confidence || 'ukendt')} sikkerhed · ${escapeHtml(customerSuggestion.source || 'domænematch')}
+ + `; + } else if (customerData) { + const proposedName = email.sender_name || senderDomain || 'Ny kunde'; + customerHtml = ` +
${escapeHtml(proposedName)}
+
Ingen eksisterende kunde fundet${senderDomain ? ` for ${escapeHtml(senderDomain)}` : ''}
+ + `; + } else { + customerHtml = '
Finder kunde…
'; + } + + let vendorHtml; + if (email.supplier_id) { + vendorHtml = ` +
${escapeHtml(email.supplier_name || `Leverandør #${email.supplier_id}`)}
+
Allerede tilknyttet denne email
+ `; + } else if (vendorSuggestion?.vendor_id) { + vendorHtml = ` +
${escapeHtml(vendorSuggestion.name || `Leverandør #${vendorSuggestion.vendor_id}`)}
+
Matchscore ${Number(vendorSuggestion.match_score || 0)}${vendorSuggestion.cvr_number ? ` · CVR ${escapeHtml(vendorSuggestion.cvr_number)}` : ''}
+ + `; + } else if (vendorSuggestion?.name) { + vendorHtml = ` +
${escapeHtml(vendorSuggestion.name)}
+
Ny leverandør${vendorSuggestion.cvr_number ? ` · CVR ${escapeHtml(vendorSuggestion.cvr_number)}` : ''}
+ + `; + } else if (vendorSuggestion) { + vendorHtml = ` +
Systemet fandt ikke en sikker leverandør.
+ + `; + } else { + vendorHtml = '
Finder leverandør…
'; + } + + return ` +
+
+
+
Systemet tror kunden er
+ ${customerHtml} +
+
+
+
+
Systemet tror leverandøren er
+ ${vendorHtml} +
+
+
+ `; + } + + function bindIdentityDecisionActions() { + document.getElementById('v2DecisionLinkCustomer')?.addEventListener('click', applyDomainCustomerSuggestion); + document.getElementById('v2DecisionCreateCustomer')?.addEventListener('click', createCustomerFromSuggestion); + document.getElementById('v2DecisionLinkVendor')?.addEventListener('click', linkVendorSuggestion); + document.getElementById('v2DecisionCreateVendor')?.addEventListener('click', createVendorFromSuggestion); + } + + function renderClassificationDecision(errorMessage) { + const host = document.getElementById('v2DecisionCard'); + if (!host) return; + if (errorMessage) { + host.innerHTML = ` +
Vurdering kunne ikke indlæses
+
${escapeHtml(errorMessage)}
+ `; + return; + } + + const preview = state.workflowPreview; + if (!preview) { + host.innerHTML = '
Systemet vurderer mailen…
'; + return; + } + + const meta = preview.email || {}; + const classification = String(meta.classification || 'unknown').toLowerCase(); + const typeLabel = CLASSIFICATION_LABELS[classification] || classification; + const rawConfidence = Number(meta.confidence_score || 0); + const confidencePercent = Math.round(Math.max(0, Math.min(1, rawConfidence)) * 100); + const confidenceLabel = confidencePercent >= 80 + ? 'Høj sikkerhed' + : confidencePercent >= 55 ? 'Middel sikkerhed' : 'Lav sikkerhed – kontrollér manuelt'; + + const effects = []; + (preview.system_matches || []).filter((row) => row.matches).forEach((row) => { + if (row.effect) effects.push(row.effect); + }); + (preview.matching_workflows || []).forEach((workflow) => { + const actions = Array.isArray(workflow.actions) ? workflow.actions : []; + actions.forEach((action) => effects.push( + WORKFLOW_ACTION_LABELS[action] || `Kører handlingen “${action}”` + )); + }); + const uniqueEffects = [...new Set(effects)]; + const hasActions = uniqueEffects.length > 0; + const automaticExecution = Boolean(preview.automatic_execution); + + host.innerHTML = ` +
Systemets vurdering
+ +
+ ${escapeHtml(confidenceLabel)} + ${confidencePercent}% +
+ + ${renderIdentityAssessment()} +
${automaticExecution ? 'Dette konkursflow køres automatisk:' : 'Når du godkender, gør systemet dette:'}
+ ${hasActions ? ` + + ${automaticExecution ? ` +
+ + Automatisk sikkerhedsflow. Der handles kun ved eksakt CVR-match. +
` : ` + `} + ` : ` +
+ Ingen automatiske handlinger foreslås. Vælg selv “Opret sag” eller tilknyt en eksisterende sag nedenfor. +
+ `} +
+ Godkend-knappen udfører kun de handlinger, der er vist ovenfor. +
+ `; + document.getElementById('v2ApproveActions')?.addEventListener('click', approveSuggestedActions); + bindIdentityDecisionActions(); + } + + async function approveSuggestedActions() { + const button = document.getElementById('v2ApproveActions'); + if (!button || !state.selectedEmailId) return; + button.disabled = true; + button.innerHTML = 'Udfører…'; + try { + await executeWorkflowsCurrent(); + } finally { + if (document.body.contains(button)) { + button.disabled = false; + button.innerHTML = 'Godkend og udfør'; + } + } + } + function renderWorkflowPreview(errorMessage) { const host = document.getElementById('v2WorkflowPreview'); if (!host) return; if (errorMessage) { host.innerHTML = `
${escapeHtml(errorMessage)}
`; + renderClassificationDecision(errorMessage); return; } const preview = state.workflowPreview; if (!preview) { host.innerHTML = '
Ingen preview endnu
'; + renderClassificationDecision(); return; } @@ -898,6 +1193,7 @@ ${matchingHtml}
`; + renderClassificationDecision(); const autoRunBtn = document.getElementById('v2AutoRun'); if (autoRunBtn) { @@ -931,6 +1227,7 @@ const caseTypeEl = document.getElementById('v2CaseType'); const titelEl = document.getElementById('v2CaseTitle'); + const createButton = document.getElementById('v2CreateCase'); const payload = { titel: String(titelEl?.value || state.selectedEmail.subject || '').trim(), case_type: String(caseTypeEl?.value || 'support'), @@ -941,6 +1238,10 @@ } try { + if (createButton) { + createButton.disabled = true; + createButton.innerHTML = 'Opretter…'; + } const result = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/create-sag`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -952,6 +1253,49 @@ await selectEmail(state.selectedEmailId); } catch (error) { setDetailStatus(`Kunne ikke oprette sag: ${error.message}`); + window.alert(`Sagen kunne ikke oprettes:\n${error.message}`); + } finally { + if (createButton && document.body.contains(createButton)) { + createButton.disabled = false; + createButton.innerHTML = 'Opret'; + } + } + } + + async function runQuickAction(action, button) { + if (!state.selectedEmailId || !state.selectedEmail || !action) return; + const originalHtml = button?.innerHTML || ''; + try { + if (button) { + button.disabled = true; + button.innerHTML = 'Udfører…'; + } + const title = String(document.getElementById('v2CaseTitle')?.value + || state.selectedEmail.subject + || '').trim(); + const result = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/quick-action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, titel: title }), + }); + + if (action === 'archive_entity_mail') { + state.selectedEmailId = null; + state.selectedEmail = null; + renderDetail(null); + await loadEmails(); + } else { + await selectEmail(state.selectedEmailId); + } + setDetailStatus(result?.message || 'Handlingen er udført'); + } catch (error) { + setDetailStatus(`Handlingen fejlede: ${error.message}`); + window.alert(`Handlingen kunne ikke udføres:\n${error.message}`); + } finally { + if (button && document.body.contains(button)) { + button.disabled = false; + button.innerHTML = originalHtml; + } } } @@ -1007,8 +1351,11 @@ }); state.vendorSuggestion = suggestion || null; renderVendorSuggestion(); + renderClassificationDecision(); setSupplierStatus('Forslag opdateret', 'success'); } catch (error) { + state.vendorSuggestion = {}; + renderClassificationDecision(); setSupplierStatus(`Kunne ikke udtrække forslag: ${error.message}`, 'error'); } } @@ -1063,8 +1410,14 @@ const suggestion = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/domain-customer-suggestion`); state.domainCustomerSuggestion = suggestion || null; renderDomainCustomerSuggestion(); + renderClassificationDecision(); setDomainStatus('Forslag opdateret', 'success'); } catch (error) { + state.domainCustomerSuggestion = { + domain: String(state.selectedEmail?.sender_email || '').split('@')[1] || null, + suggestion: null, + }; + renderClassificationDecision(); setDomainStatus(`Kunne ikke hente forslag: ${error.message}`, 'error'); } } @@ -1088,6 +1441,98 @@ } } + async function createCustomerFromSuggestion() { + if (!state.selectedEmailId || !state.selectedEmail) return; + const email = state.selectedEmail; + const domain = state.domainCustomerSuggestion?.domain + || String(email.sender_email || '').split('@')[1] + || null; + const name = String(email.sender_name || domain || '').trim(); + if (!name) { + setDetailStatus('Kunden kan ikke oprettes uden et navn'); + return; + } + try { + setDetailStatus(`Opretter kunden ${name}…`); + const customer = await apiFetch('/api/v1/customers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + email: email.sender_email || null, + email_domain: domain, + is_active: true, + country: 'DK', + }), + }); + await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ customer_id: Number(customer.id) }), + }); + setDetailStatus(`Kunden ${name} blev oprettet og tilknyttet`); + await selectEmail(state.selectedEmailId); + } catch (error) { + setDetailStatus(`Kunne ikke oprette kunden: ${error.message}`); + } + } + + async function linkVendorSuggestion() { + const vendorId = Number(state.vendorSuggestion?.vendor_id || 0); + if (!state.selectedEmailId || !vendorId) return; + try { + setDetailStatus('Tilknytter leverandøren…'); + await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ supplier_id: vendorId }), + }); + setDetailStatus('Leverandøren blev tilknyttet'); + await selectEmail(state.selectedEmailId); + } catch (error) { + setDetailStatus(`Kunne ikke tilknytte leverandøren: ${error.message}`); + } + } + + async function createVendorFromSuggestion() { + if (!state.selectedEmailId || !state.selectedEmail) return; + const email = state.selectedEmail; + const suggestion = state.vendorSuggestion || {}; + const senderDomain = String(email.sender_email || '').split('@')[1] || null; + const name = String(suggestion.name || email.sender_name || senderDomain || '').trim(); + if (!name) { + setDetailStatus('Leverandøren kan ikke oprettes uden et navn'); + return; + } + try { + setDetailStatus(`Opretter leverandøren ${name}…`); + const vendor = await apiFetch('/api/v1/vendors', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + cvr_number: suggestion.cvr_number || null, + email: suggestion.email || email.sender_email || null, + phone: suggestion.phone || null, + address: suggestion.address || null, + domain: suggestion.domain || senderDomain, + category: 'supplier', + notes: `Oprettet fra email #${state.selectedEmailId}`, + is_active: true, + }), + }); + await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ supplier_id: Number(vendor.id) }), + }); + setDetailStatus(`Leverandøren ${name} blev oprettet og tilknyttet`); + await selectEmail(state.selectedEmailId); + } catch (error) { + setDetailStatus(`Kunne ikke oprette leverandøren: ${error.message}`); + } + } + async function searchSager(query) { const host = document.getElementById('v2SagResults'); if (!host) return; @@ -1126,12 +1571,14 @@ function renderDetail(email) { const mailHeader = document.getElementById('v2MailHeader'); + const quickActions = document.getElementById('v2QuickActions'); const mailBody = document.getElementById('v2MailBody'); const sideActions = document.getElementById('v2SideActions'); - if (!mailHeader || !mailBody || !sideActions) return; + if (!mailHeader || !quickActions || !mailBody || !sideActions) return; if (!email) { mailHeader.innerHTML = 'Vælg en email for at se info'; + quickActions.innerHTML = ''; mailBody.className = 'emails-v2-detail-empty'; mailBody.textContent = 'Vælg en email fra listen'; sideActions.className = 'emails-v2-detail-empty'; @@ -1154,6 +1601,31 @@ `; + const hasCustomer = Boolean(email.customer_id); + const hasSupplier = Boolean(email.supplier_id); + const hasIdentity = hasCustomer || hasSupplier; + quickActions.className = 'emails-v2-quick-toolbar'; + quickActions.innerHTML = email.linked_case_id ? ` + + Åbn SAG #${Number(email.linked_case_id)} + + ` : ` +
+ + + + +
+ `; + mailBody.className = 'emails-v2-mail-body'; const hasAttachments = Array.isArray(email.attachments) && email.attachments.length > 0; const rawHtml = String(email.body_html || '').trim(); @@ -1182,6 +1654,13 @@ sideActions.className = 'emails-v2-actions-pane'; sideActions.innerHTML = ` +
+
+ + Systemet vurderer mailtype og handlinger… +
+
+ ${email.linked_case_id ? `
Allerede knyttet til sag
@@ -1197,9 +1676,9 @@
@@ -1288,6 +1767,11 @@ document.getElementById('v2CreateCase')?.addEventListener('click', createCaseFromCurrent); document.getElementById('v2ExtractVendor')?.addEventListener('click', extractVendorSuggestionCurrent); document.getElementById('v2DomainSuggestionBtn')?.addEventListener('click', loadDomainCustomerSuggestionCurrent); + document.querySelectorAll('[data-v2-quick-action]').forEach((button) => { + button.addEventListener('click', () => { + runQuickAction(button.getAttribute('data-v2-quick-action'), button); + }); + }); state.vendorSuggestion = null; state.domainCustomerSuggestion = null; @@ -1296,6 +1780,14 @@ renderDomainCustomerSuggestion(); renderWorkflowPreview(); loadWorkflowPreviewCurrent(); + const vendorClassifications = new Set(['invoice', 'order_confirmation', 'freight_note']); + if (email.supplier_id || vendorClassifications.has(String(email.classification || '').toLowerCase()) + || email.extracted_vendor_name || email.extracted_vendor_cvr) { + extractVendorSuggestionCurrent(); + } else { + state.vendorSuggestion = {}; + renderClassificationDecision(); + } setMailStatus(`Email #${email.id} vises`); document.getElementById('v2SagSearch')?.addEventListener('input', (event) => { diff --git a/app/modules/locations/backend/router.py b/app/modules/locations/backend/router.py index 669e70f..e27ea8c 100644 --- a/app/modules/locations/backend/router.py +++ b/app/modules/locations/backend/router.py @@ -853,9 +853,124 @@ def _replace_switch_port_if_confirmed( ) +def _validate_outlet_port_references( + *, + location_id: int, + cross_field_port_id: Optional[int], + switch_hardware_id: Optional[int], + switch_port: Optional[str], +) -> None: + """Only allow active, explicitly created ports belonging to this location.""" + if cross_field_port_id is not None: + cross_field_port = execute_query( + '''SELECT p.id + FROM locations_cross_field_ports p + JOIN locations_cross_fields cf ON cf.id = p.cross_field_id + WHERE p.id = %s + AND p.is_active = TRUE + AND cf.location_id = %s + AND cf.is_active = TRUE + AND cf.deleted_at IS NULL''', + (cross_field_port_id, location_id), + ) or [] + if not cross_field_port: + raise HTTPException( + status_code=400, + detail='Den valgte krydsfelt-port findes ikke eller er ikke aktiv på lokationen', + ) + + if switch_hardware_id is None: + return + + hardware = execute_query( + '''SELECT id, hardware_specs + FROM hardware_assets + WHERE id = %s + AND current_location_id = %s + AND deleted_at IS NULL + AND LOWER(COALESCE(asset_type, '')) = 'netværk' ''', + (switch_hardware_id, location_id), + ) or [] + if not hardware: + raise HTTPException(status_code=400, detail='Den valgte switch findes ikke på lokationen') + + if switch_port: + specs = hardware[0].get('hardware_specs') or {} + if isinstance(specs, str): + try: + specs = json.loads(specs) + except (TypeError, ValueError): + specs = {} + port_count = int((specs or {}).get('port_count') or 0) + valid_ports = {str(number) for number in range(1, port_count + 1)} + if str(switch_port).strip() not in valid_ports: + raise HTTPException( + status_code=400, + detail='Den valgte switch-port findes ikke blandt switchens oprettede porte', + ) + + +def _validate_single_customer_wan( + *, + location_id: int, + customer_id: Optional[int], + is_wan: bool, + exclude_outlet_id: Optional[int] = None, +) -> None: + if not is_wan: + return + + effective_customer = customer_id + if effective_customer is None: + location = execute_query( + '''SELECT customer_id + FROM locations_locations + WHERE id = %s AND deleted_at IS NULL''', + (location_id,), + ) or [] + effective_customer = location[0].get('customer_id') if location else None + if effective_customer is None: + raise HTTPException(status_code=400, detail='Vælg en kunde, før porten markeres som WAN') + + params = [effective_customer] + exclude_sql = '' + if exclude_outlet_id is not None: + exclude_sql = 'AND o.id <> %s' + params.append(exclude_outlet_id) + existing = execute_query( + f'''SELECT o.id, o.outlet_number + FROM locations_wall_outlets o + JOIN locations_locations l ON l.id = o.location_id + WHERE COALESCE(o.customer_id, l.customer_id) = %s + AND o.is_wan = TRUE + AND o.is_active = TRUE + AND o.deleted_at IS NULL + {exclude_sql} + LIMIT 1''', + tuple(params), + ) or [] + if existing: + outlet_name = existing[0].get('outlet_number') or f"#{existing[0]['id']}" + raise HTTPException( + status_code=409, + detail=f'Kunden har allerede WAN-porten {outlet_name}. Der kan kun være én WAN-port pr. kunde', + ) + + @router.post('/locations/outlets', response_model=WallOutlet, status_code=201) async def create_wall_outlet(data: WallOutletCreate): _outlet_location(data.location_id) + _validate_outlet_port_references( + location_id=data.location_id, + cross_field_port_id=data.cross_field_port_id, + switch_hardware_id=data.switch_hardware_id, + switch_port=data.switch_port, + ) + _validate_single_customer_wan( + location_id=data.location_id, + customer_id=data.customer_id, + is_wan=data.is_wan, + ) _replace_switch_port_if_confirmed( switch_hardware_id=data.switch_hardware_id, switch_name=data.switch_name, @@ -888,12 +1003,24 @@ async def update_wall_outlet(outlet_id: int, data: WallOutletUpdate): if 'outlet_number' in changes: changes['outlet_number'] = (changes['outlet_number'] or '').strip() or None current = execute_query( - '''SELECT switch_hardware_id, switch_name, switch_port + '''SELECT location_id, customer_id, cross_field_port_id, switch_hardware_id, switch_name, switch_port, is_wan FROM locations_wall_outlets WHERE id = %s AND deleted_at IS NULL''', (outlet_id,), ) or [] if not current: raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet') + _validate_outlet_port_references( + location_id=changes.get('location_id', current[0]['location_id']), + cross_field_port_id=changes.get('cross_field_port_id', current[0].get('cross_field_port_id')), + switch_hardware_id=changes.get('switch_hardware_id', current[0].get('switch_hardware_id')), + switch_port=changes.get('switch_port', current[0].get('switch_port')), + ) + _validate_single_customer_wan( + location_id=changes.get('location_id', current[0]['location_id']), + customer_id=changes.get('customer_id', current[0].get('customer_id')), + is_wan=changes.get('is_wan', current[0].get('is_wan', False)), + exclude_outlet_id=outlet_id, + ) _replace_switch_port_if_confirmed( switch_hardware_id=changes.get('switch_hardware_id', current[0].get('switch_hardware_id')), switch_name=changes.get('switch_name', current[0].get('switch_name')), diff --git a/app/modules/locations/frontend/views.py b/app/modules/locations/frontend/views.py index 392042a..b0a8aa5 100644 --- a/app/modules/locations/frontend/views.py +++ b/app/modules/locations/frontend/views.py @@ -788,7 +788,7 @@ def detail_location_view(id: int = Path(..., gt=0)): FROM locations_cross_field_ports p LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL LEFT JOIN locations_locations l ON l.id = o.location_id - WHERE p.cross_field_id = %s ORDER BY p.port_order""", + WHERE p.cross_field_id = %s AND p.is_active = TRUE ORDER BY p.port_order""", (cross_field["id"],), ) or [] for port in cross_field["ports"]: diff --git a/app/modules/locations/templates/detail.html b/app/modules/locations/templates/detail.html index 2dca007..8ccf012 100644 --- a/app/modules/locations/templates/detail.html +++ b/app/modules/locations/templates/detail.html @@ -1138,11 +1138,11 @@
Kontroller før du gemmer. Funktionen forbinder et sammenhængende område af krydsfelt-porte med samme antal switch-porte.
-
-
+
+
-
-
+
+
Vælg område og switch for at se en forhåndsvisning.
@@ -1591,67 +1591,171 @@ document.addEventListener('DOMContentLoaded', function() { const bulkFieldSelect = document.getElementById('bulkCrossField'); const bulkSwitchSelect = document.getElementById('bulkSwitch'); const bulkCustomerSelect = document.getElementById('bulkCustomer'); + let bulkCustomers = []; + + function updateBulkCustomerOptions(query = '') { + const normalizedQuery = query.trim().toLocaleLowerCase('da'); + const matches = normalizedQuery + ? bulkCustomers.filter(customer => + String(customer.name || customer.navn || '').toLocaleLowerCase('da').includes(normalizedQuery) + ).slice(0, 25) + : []; + bulkCustomerSelect.innerHTML = '' + matches + .map(customer => ``) + .join(''); + } function selectedBulkField() { return (locationCrossFields || []).find(field => Number(field.id) === Number(bulkFieldSelect.value)); } + function selectableBulkFieldPorts(field) { + return (field?.ports || []).filter(port => port.is_active !== false); + } + + function selectableSwitchPorts(hardware) { + return (hardware?.switch_ports || []).filter(port => port && port.port_number !== null && port.port_number !== undefined); + } + + function populateBulkFieldPorts() { + const ports = selectableBulkFieldPorts(selectedBulkField()); + document.getElementById('bulkFromPort').innerHTML = ports + .map((port, index) => ``) + .join(''); + document.getElementById('bulkFromPort').value = ports.length ? '0' : ''; + populateBulkEndPorts(Math.min(23, ports.length - 1)); + } + + function populateBulkEndPorts(preferredIndex = null) { + const ports = selectableBulkFieldPorts(selectedBulkField()); + const from = Number(document.getElementById('bulkFromPort').value); + const firstIndex = Number.isInteger(from) && from >= 0 ? from : 0; + const endSelect = document.getElementById('bulkToPort'); + endSelect.innerHTML = ports + .map((port, index) => ({port, index})) + .filter(item => item.index >= firstIndex) + .map(item => ``) + .join(''); + const requested = Number(preferredIndex); + const target = Number.isInteger(requested) && requested >= firstIndex && requested < ports.length + ? requested + : firstIndex; + endSelect.value = ports.length ? String(target) : ''; + } + + function populateBulkSwitchPorts() { + const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value)); + const ports = selectableSwitchPorts(hardware); + document.getElementById('bulkSwitchStart').innerHTML = ports + .map((port, index) => ``) + .join(''); + } + + function populateBulkWanPorts() { + const ports = selectableBulkFieldPorts(selectedBulkField()); + const from = Number(document.getElementById('bulkFromPort').value); + const to = Number(document.getElementById('bulkToPort').value); + const selectedPorts = Number.isInteger(from) && Number.isInteger(to) && to >= from + ? ports.slice(from, to + 1) + : []; + const wanSelect = document.getElementById('bulkWanPort'); + const previous = wanSelect.value; + wanSelect.innerHTML = '' + selectedPorts + .map(port => ``) + .join(''); + const existingWan = selectedPorts.find(port => port.is_wan); + wanSelect.value = selectedPorts.some(port => String(port.id) === previous) + ? previous + : (existingWan ? String(existingWan.id) : ''); + } + function updateBulkPreview() { const field = selectedBulkField(); const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value)); + const fieldPorts = selectableBulkFieldPorts(field); + const switchPorts = selectableSwitchPorts(hardware); const from = Number(document.getElementById('bulkFromPort').value); const to = Number(document.getElementById('bulkToPort').value); const switchStart = Number(document.getElementById('bulkSwitchStart').value); const count = Number.isInteger(from) && Number.isInteger(to) && to >= from ? to - from + 1 : 0; - const firstPort = field?.ports?.[from - 1]?.port_number || '—'; - const lastPort = field?.ports?.[to - 1]?.port_number || '—'; - document.getElementById('bulkPatchPreview').innerHTML = count - ? `${count} forbindelser: ${field?.name || '—'} port ${firstPort}–${lastPort} → ${hardware ? switchDisplayName(hardware) : '—'} port ${switchStart}–${switchStart + count - 1}` + const selectedSwitchPorts = switchPorts.slice(switchStart, switchStart + count); + const firstPort = fieldPorts[from]?.port_number || '—'; + const lastPort = fieldPorts[to]?.port_number || '—'; + const firstSwitchPort = selectedSwitchPorts[0]?.port_number || '—'; + const lastSwitchPort = selectedSwitchPorts[selectedSwitchPorts.length - 1]?.port_number || '—'; + document.getElementById('bulkPatchPreview').innerHTML = count && selectedSwitchPorts.length === count + ? `${count} forbindelser: ${field?.name || '—'} port ${firstPort}–${lastPort} → ${hardware ? switchDisplayName(hardware) : '—'} port ${firstSwitchPort}–${lastSwitchPort}` + : count + ? 'Det valgte område går ud over de oprettede switch-porte.' : 'Vælg et gyldigt portområde.'; } async function openBulkPatch() { - bulkFieldSelect.innerHTML = (locationCrossFields || []).map(field => ``).join(''); - const switches = (locationHardware || []).filter(item => String(item.asset_type || '').toLowerCase() === 'netværk'); - bulkSwitchSelect.innerHTML = switches.map(item => ``).join(''); + const fields = (locationCrossFields || []).filter(field => selectableBulkFieldPorts(field).length); + bulkFieldSelect.innerHTML = fields.map(field => { + const ports = selectableBulkFieldPorts(field); + const firstPort = ports[0]?.port_number || '—'; + const lastPort = ports[ports.length - 1]?.port_number || '—'; + return ``; + }).join(''); + const switches = (locationHardware || []).filter(item => + String(item.asset_type || '').toLowerCase() === 'netværk' && selectableSwitchPorts(item).length + ); + bulkSwitchSelect.innerHTML = switches.map(item => ``).join(''); + populateBulkFieldPorts(); + populateBulkSwitchPorts(); + populateBulkWanPorts(); const response = await fetch('/api/v1/customers?limit=1000&offset=0'); const data = response.ok ? await response.json() : []; - const customers = Array.isArray(data) ? data : (data.customers || []); - bulkCustomerSelect.innerHTML = '' + customers.map(customer => ``).join(''); - const firstField = selectedBulkField(); - document.getElementById('bulkToPort').value = Math.min(24, firstField?.ports?.length || 1); + bulkCustomers = Array.isArray(data) ? data : (data.customers || []); + document.getElementById('bulkCustomerSearch').value = ''; + updateBulkCustomerOptions(); updateBulkPreview(); bulkPatchModal?.show(); } document.getElementById('openBulkPatchBtn')?.addEventListener('click', openBulkPatch); - ['bulkCrossField', 'bulkSwitch', 'bulkFromPort', 'bulkToPort', 'bulkSwitchStart'].forEach(id => document.getElementById(id)?.addEventListener('input', updateBulkPreview)); + bulkFieldSelect?.addEventListener('change', () => { populateBulkFieldPorts(); populateBulkWanPorts(); updateBulkPreview(); }); + bulkSwitchSelect?.addEventListener('change', () => { populateBulkSwitchPorts(); updateBulkPreview(); }); + document.getElementById('bulkFromPort')?.addEventListener('change', () => { + populateBulkEndPorts(Number(document.getElementById('bulkToPort').value)); + populateBulkWanPorts(); + updateBulkPreview(); + }); + document.getElementById('bulkToPort')?.addEventListener('change', () => { + populateBulkWanPorts(); + updateBulkPreview(); + }); + document.getElementById('bulkSwitchStart')?.addEventListener('change', updateBulkPreview); document.getElementById('bulkCustomerSearch')?.addEventListener('input', event => { - const query = event.target.value.trim().toLocaleLowerCase('da'); - Array.from(bulkCustomerSelect.options).forEach((option, index) => { - option.hidden = index > 0 && Boolean(query) && !option.textContent.toLocaleLowerCase('da').includes(query); - }); - const match = Array.from(bulkCustomerSelect.options).find((option, index) => index > 0 && !option.hidden); - if (query && match) bulkCustomerSelect.value = match.value; + updateBulkCustomerOptions(event.target.value); }); document.getElementById('bulkPatchForm')?.addEventListener('submit', async event => { event.preventDefault(); const field = selectedBulkField(); const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value)); + const fieldPorts = selectableBulkFieldPorts(field); + const switchPorts = selectableSwitchPorts(hardware); const from = Number(document.getElementById('bulkFromPort').value); const to = Number(document.getElementById('bulkToPort').value); const switchStart = Number(document.getElementById('bulkSwitchStart').value); - if (!field || !hardware || !Number.isInteger(from) || !Number.isInteger(to) || from < 1 || to < from || to > field.ports.length) { + if (!field || !hardware || !Number.isInteger(from) || !Number.isInteger(to) || from < 0 || to < from || to >= fieldPorts.length) { alert('Vælg et gyldigt krydsfelt, en switch og et portområde.'); return; } - const ports = field.ports.slice(from - 1, to); - if (switchStart < 1 || switchStart + ports.length - 1 > switchPortCount(hardware)) { - alert('Portområdet går ud over switchens registrerede antal porte.'); + const ports = fieldPorts.slice(from, to + 1); + const selectedSwitchPorts = switchPorts.slice(switchStart, switchStart + ports.length); + if (switchStart < 0 || selectedSwitchPorts.length !== ports.length) { + alert('Portområdet går ud over switchens oprettede porte.'); return; } - const conflicts = ports.map((port, index) => switchPortConflict(hardware, switchDisplayName(hardware), switchStart + index, port.outlet_id || null)).filter(Boolean); + const conflicts = ports.map((port, index) => switchPortConflict( + hardware, + switchDisplayName(hardware), + selectedSwitchPorts[index].port_number, + port.outlet_id || null + )).filter(Boolean); if (conflicts.length) { alert(`Massepatch blev stoppet: ${conflicts.length} switch-port(e) er allerede knyttet til andre vægstik.`); return; @@ -1662,8 +1766,15 @@ document.addEventListener('DOMContentLoaded', function() { submit.disabled = true; submit.textContent = 'Gemmer…'; const customerId = bulkCustomerSelect.value ? Number(bulkCustomerSelect.value) : null; - const isWan = document.getElementById('bulkIsWan').checked; - const results = await Promise.all(ports.map(async (port, index) => { + const wanPortId = document.getElementById('bulkWanPort').value + ? Number(document.getElementById('bulkWanPort').value) + : null; + const orderedPorts = [ + ...ports.map((port, index) => ({port, index})).filter(item => Number(item.port.id) !== wanPortId), + ...ports.map((port, index) => ({port, index})).filter(item => Number(item.port.id) === wanPortId) + ]; + const results = []; + for (const {port, index} of orderedPorts) { const payload = { outlet_number: port.outlet_number || `${field.name}-${port.port_number}`, customer_id: customerId, @@ -1673,8 +1784,8 @@ document.addEventListener('DOMContentLoaded', function() { cross_field_port_id: Number(port.id), switch_hardware_id: Number(hardware.id), switch_name: switchDisplayName(hardware), - switch_port: String(switchStart + index), - is_wan: isWan, + switch_port: String(selectedSwitchPorts[index].port_number), + is_wan: Number(port.id) === wanPortId, status: 'active', notes: port.outlet_notes || null }; @@ -1684,8 +1795,8 @@ document.addEventListener('DOMContentLoaded', function() { headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }); - return response.ok; - })); + results.push(response.ok); + } const failed = results.filter(ok => !ok).length; if (failed) { alert(`${results.length - failed} forbindelser blev gemt, men ${failed} fejlede. Siden genindlæses.`); diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py index c672e12..1c045df 100644 --- a/app/modules/sag/frontend/views.py +++ b/app/modules/sag/frontend/views.py @@ -660,7 +660,34 @@ async def sag_detaljer(request: Request, sag_id: int): customer = None hovedkontakt = None if sag.get('customer_id'): - customer_query = "SELECT * FROM customers WHERE id = %s" + customer_query = """ + SELECT + c.*, + (vendor_link.vendor_id IS NOT NULL) AS is_vendor, + vendor_link.vendor_id, + vendor_link.vendor_name, + vendor_link.relationship_type AS vendor_relationship_type + FROM customers c + LEFT JOIN LATERAL ( + SELECT + cvl.vendor_id, + v.name AS vendor_name, + cvl.relationship_type + FROM customer_vendor_links cvl + JOIN vendors v ON v.id = cvl.vendor_id + WHERE cvl.customer_id = c.id + AND v.is_active IS NOT FALSE + ORDER BY + CASE cvl.relationship_type + WHEN 'supplier' THEN 0 + WHEN 'reseller' THEN 1 + ELSE 2 + END, + cvl.id + LIMIT 1 + ) vendor_link ON TRUE + WHERE c.id = %s + """ customer_result = execute_query(customer_query, (sag['customer_id'],)) if customer_result: customer = customer_result[0] diff --git a/app/modules/sag/templates/detail_v3.html b/app/modules/sag/templates/detail_v3.html index 4c37066..6222813 100644 --- a/app/modules/sag/templates/detail_v3.html +++ b/app/modules/sag/templates/detail_v3.html @@ -2550,6 +2550,69 @@ background: transparent; } + .anydesk-connect-modal .modal-content { + overflow: hidden; + border: 0; + border-radius: 14px; + box-shadow: 0 24px 70px rgba(15, 42, 65, 0.28); + } + + .anydesk-connect-modal .modal-header { + color: #fff; + border: 0; + padding: 0.8rem 1rem; + background: linear-gradient(135deg, #0f4c75, #1677a8); + } + + .anydesk-connect-modal .modal-header .btn-close { + filter: invert(1); + } + + .anydesk-id-entry { + border: 1px solid color-mix(in srgb, var(--border-color) 80%, #1677a8); + border-radius: 9px; + background: color-mix(in srgb, var(--bg-card) 94%, #dff4ff); + } + + .anydesk-id-display { + font-variant-numeric: tabular-nums; + letter-spacing: 0.08em; + } + + .anydesk-saved-device { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.45rem; + align-items: center; + min-height: 42px; + padding: 0.35rem 0.45rem 0.35rem 0.65rem; + border-bottom: 1px solid var(--border-color); + } + + .anydesk-saved-device:last-child { + border-bottom: 0; + } + + .anydesk-saved-device .device-name { + overflow: hidden; + font-size: 0.82rem; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + } + + .anydesk-saved-list { + max-height: 180px; + overflow-y: auto; + border: 1px solid var(--border-color); + border-radius: 9px; + } + + .anydesk-saved-device .device-id { + color: var(--text-secondary); + font-size: 0.74rem; + } + [data-bs-theme="dark"] .module-title { color: #e5edf5; } @@ -3439,6 +3502,11 @@

{{ customer.name if customer else 'Ingen kunde valgt' }} + {% if customer and customer.is_vendor %} + + Leverandør + + {% endif %} | {{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Ingen kontakt' }} | @@ -4652,60 +4720,72 @@

-