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 @@
-
+
+
@@ -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 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(typeLabel)}
+
+ ${escapeHtml(confidenceLabel)}
+ ${confidencePercent}%
+
+
+ ${renderIdentityAssessment()}
+
${automaticExecution ? 'Dette konkursflow køres automatisk:' : 'Når du godkender, gør systemet dette:'}
+ ${hasActions ? `
+
+ ${uniqueEffects.map((effect) => `- ${escapeHtml(effect)}
`).join('')}
+
+ ${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 = '
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 @@
-