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.
This commit is contained in:
parent
d81a8f41b4
commit
56cc1bdcc4
@ -135,6 +135,19 @@ class CustomerUpdate(BaseModel):
|
|||||||
department: Optional[str] = None
|
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):
|
class ContactCreate(BaseModel):
|
||||||
first_name: str
|
first_name: str
|
||||||
last_name: str
|
last_name: str
|
||||||
@ -461,6 +474,59 @@ async def verify_customer_linking():
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
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}")
|
@router.get("/customers/{customer_id}")
|
||||||
async def get_customer(customer_id: int):
|
async def get_customer(customer_id: int):
|
||||||
"""Get single customer by ID with contact count and vTiger BMC Låst status"""
|
"""Get single customer by ID with contact count and vTiger BMC Låst status"""
|
||||||
|
|||||||
@ -1000,6 +1000,16 @@
|
|||||||
<span class="info-value" id="wikiLink">-</span>
|
<span class="info-value" id="wikiLink">-</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="info-card mt-4">
|
||||||
|
<h5 class="fw-bold mb-2">Emaildomæner</h5>
|
||||||
|
<p class="small text-muted">Kun eksakte domæner bruges til automatisk match.</p>
|
||||||
|
<div class="input-group input-group-sm mb-3">
|
||||||
|
<input id="customerEmailDomainInput" class="form-control" placeholder="fx firma.dk">
|
||||||
|
<button class="btn btn-primary" onclick="addCustomerEmailDomain()">Tilføj</button>
|
||||||
|
</div>
|
||||||
|
<div id="customerEmailDomains" class="d-flex flex-wrap gap-2"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
@ -2468,6 +2478,7 @@ async function loadCustomer() {
|
|||||||
|
|
||||||
customerData = await response.json();
|
customerData = await response.json();
|
||||||
displayCustomer(customerData);
|
displayCustomer(customerData);
|
||||||
|
await loadCustomerEmailDomains();
|
||||||
|
|
||||||
await loadUtilityCompany();
|
await loadUtilityCompany();
|
||||||
await loadCustomerTags();
|
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 => `
|
||||||
|
<span class="badge bg-light text-dark border p-2">
|
||||||
|
${escapeHtml(row.domain)}
|
||||||
|
<button class="btn btn-link btn-sm text-danger p-0 ms-2" onclick="deleteCustomerEmailDomain('${encodeURIComponent(row.domain)}')" title="Fjern">×</button>
|
||||||
|
</span>
|
||||||
|
`).join('') : '<span class="small text-muted">Ingen domæner registreret.</span>';
|
||||||
|
} catch (error) {
|
||||||
|
host.innerHTML = `<span class="small text-danger">${escapeHtml(error.message)}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
async function loadCustomerVendorLinks() {
|
||||||
const container = document.getElementById('customerVendorLinksContainer');
|
const container = document.getElementById('customerVendorLinksContainer');
|
||||||
const empty = document.getElementById('customerVendorLinksEmpty');
|
const empty = document.getElementById('customerVendorLinksEmpty');
|
||||||
|
|||||||
@ -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_processor_service import EmailProcessorService
|
||||||
from app.services.email_workflow_service import email_workflow_service
|
from app.services.email_workflow_service import email_workflow_service
|
||||||
from app.services.ollama_service import ollama_service
|
from app.services.ollama_service import ollama_service
|
||||||
|
from app.services.simple_classifier import simple_classifier
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -388,6 +389,11 @@ class CreateSagFromEmailRequest(BaseModel):
|
|||||||
relation_type: str = "mail"
|
relation_type: str = "mail"
|
||||||
|
|
||||||
|
|
||||||
|
class EmailQuickActionRequest(BaseModel):
|
||||||
|
action: str
|
||||||
|
titel: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class EmailReadStateUpdate(BaseModel):
|
class EmailReadStateUpdate(BaseModel):
|
||||||
is_read: bool
|
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 = wf.get('workflow_steps')
|
||||||
steps_total = len(steps) if isinstance(steps, list) else 0
|
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 = {
|
row = {
|
||||||
'id': wf.get('id'),
|
'id': wf.get('id'),
|
||||||
'name': wf.get('name'),
|
'name': wf.get('name'),
|
||||||
@ -515,12 +526,17 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
'sender_pattern': sender_pattern,
|
'sender_pattern': sender_pattern,
|
||||||
'subject_pattern': subject_pattern,
|
'subject_pattern': subject_pattern,
|
||||||
'steps_total': steps_total,
|
'steps_total': steps_total,
|
||||||
|
'actions': actions,
|
||||||
'matches': bool(matches),
|
'matches': bool(matches),
|
||||||
'reasons': reasons,
|
'reasons': reasons,
|
||||||
}
|
}
|
||||||
candidates.append(row)
|
candidates.append(row)
|
||||||
if matches:
|
if matches:
|
||||||
matching.append(row)
|
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 = []
|
system_matches = []
|
||||||
if classification == 'bankruptcy':
|
if classification == 'bankruptcy':
|
||||||
@ -529,12 +545,16 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
'name': 'System: Bankruptcy Analysis',
|
'name': 'System: Bankruptcy Analysis',
|
||||||
'matches': True,
|
'matches': True,
|
||||||
'reason': 'classification == bankruptcy',
|
'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)
|
has_hint = email_workflow_service.has_helpdesk_routing_hint(email_data)
|
||||||
|
supplier_document = classification in {'invoice', 'order_confirmation', 'freight_note'}
|
||||||
hard_skip = {'newsletter', 'spam'}
|
hard_skip = {'newsletter', 'spam'}
|
||||||
should_try_helpdesk = (
|
should_try_helpdesk = (
|
||||||
classification not in hard_skip
|
not supplier_document
|
||||||
|
and classification not in hard_skip
|
||||||
and (
|
and (
|
||||||
classification not in email_workflow_service.HELPDESK_SKIP_CLASSIFICATIONS
|
classification not in email_workflow_service.HELPDESK_SKIP_CLASSIFICATIONS
|
||||||
or has_hint
|
or has_hint
|
||||||
@ -545,6 +565,15 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
'name': 'System: Helpdesk SAG routing',
|
'name': 'System: Helpdesk SAG routing',
|
||||||
'matches': bool(should_try_helpdesk),
|
'matches': bool(should_try_helpdesk),
|
||||||
'reason': 'hint_or_allowed_classification' if should_try_helpdesk else 'classification_in_skip_list',
|
'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 {
|
return {
|
||||||
@ -559,7 +588,60 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
'matching_workflows': matching,
|
'matching_workflows': matching,
|
||||||
'workflow_candidates': candidates,
|
'workflow_candidates': candidates,
|
||||||
'auto_run_enabled': bool(getattr(settings, 'EMAIL_WORKFLOW_AUTORUN_ENABLED', False)),
|
'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
|
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 {
|
return {
|
||||||
"email_id": email_id,
|
"email_id": email_id,
|
||||||
"domain": sender_domain,
|
"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)
|
requested_case_type = _normalize_case_type(payload.case_type)
|
||||||
|
|
||||||
customer_id = payload.customer_id or email_data.get('customer_id')
|
customer_id = payload.customer_id or email_data.get('customer_id')
|
||||||
if not customer_id and _is_supplier_case_type(requested_case_type):
|
# A vendor is not a customer. Supplier-originated cases are owned by
|
||||||
customer_id = _ensure_customer_from_vendor(email_data.get('supplier_id'))
|
# 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):
|
if not customer_id and _is_supplier_case_type(requested_case_type):
|
||||||
customer_id = _resolve_procurement_customer_id()
|
customer_id = _resolve_procurement_customer_id()
|
||||||
|
|
||||||
if not customer_id:
|
if not customer_id:
|
||||||
raise HTTPException(status_code=400, detail="customer_id is required (missing on email and payload)")
|
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(
|
execute_update(
|
||||||
"UPDATE email_messages SET customer_id = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
|
"UPDATE email_messages SET customer_id = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||||
(customer_id, email_id),
|
(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"))
|
sender_domain = _extract_sender_domain(email_data.get("sender_email"))
|
||||||
if sender_domain and not _is_ignored_sender_domain(sender_domain):
|
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()
|
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 ''
|
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))
|
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")
|
@router.post("/emails/{email_id}/link-sag")
|
||||||
async def link_email_to_sag(email_id: int, payload: LinkEmailToSagRequest):
|
async def link_email_to_sag(email_id: int, payload: LinkEmailToSagRequest):
|
||||||
"""Link an email to an existing SAG and optionally append a system note."""
|
"""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:
|
except Exception as e:
|
||||||
logger.warning(f"⚠️ Classification failed for uploaded email: {e}")
|
logger.warning(f"⚠️ Classification failed for uploaded email: {e}")
|
||||||
|
|
||||||
# Execute workflows
|
# Manual uploads follow the same approval policy as mailbox imports:
|
||||||
try:
|
# classify and preview now; execute only after explicit user approval.
|
||||||
logger.info(f"⚙️ Executing workflows for email {email_id}...")
|
if classification == "bankruptcy":
|
||||||
await email_workflow_service.execute_workflows_for_email(email_id)
|
try:
|
||||||
except Exception as e:
|
logger.info("⚙️ Kører automatisk konkursflow for email %s", email_id)
|
||||||
logger.warning(f"⚠️ Workflow execution failed for uploaded email: {e}")
|
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({
|
results.append({
|
||||||
"filename": file.filename,
|
"filename": file.filename,
|
||||||
@ -2779,7 +2971,7 @@ async def execute_workflows_for_email(email_id: int):
|
|||||||
query = """
|
query = """
|
||||||
SELECT id, message_id, subject, sender_email, sender_name, body_text,
|
SELECT id, message_id, subject, sender_email, sender_name, body_text,
|
||||||
body_html, in_reply_to, email_references, thread_key,
|
body_html, in_reply_to, email_references, thread_key,
|
||||||
classification, confidence_score, status
|
classification, confidence_score, status, customer_id, supplier_id
|
||||||
FROM email_messages
|
FROM email_messages
|
||||||
WHERE id = %s AND deleted_at IS NULL
|
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")
|
raise HTTPException(status_code=404, detail="Email not found")
|
||||||
|
|
||||||
email_data = email_result[0] # Get first row as dict
|
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
|
# Execute workflows
|
||||||
result = await email_workflow_service.execute_workflows(email_data)
|
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."""
|
"""Preview which workflows would match an email without executing them."""
|
||||||
try:
|
try:
|
||||||
query = """
|
query = """
|
||||||
SELECT id, message_id, subject, sender_email, sender_name, body_text,
|
SELECT em.id, em.message_id, em.subject, em.sender_email, em.sender_name,
|
||||||
body_html, in_reply_to, email_references, thread_key,
|
em.body_text, em.body_html, em.in_reply_to, em.email_references,
|
||||||
classification, confidence_score, status
|
em.thread_key, em.classification, em.confidence_score, em.status,
|
||||||
FROM email_messages
|
em.customer_id, em.supplier_id, em.extracted_vendor_name,
|
||||||
WHERE id = %s AND deleted_at IS NULL
|
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,))
|
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")
|
raise HTTPException(status_code=404, detail="Email not found")
|
||||||
|
|
||||||
email_data = email_result[0]
|
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:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@ -77,6 +77,42 @@
|
|||||||
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
|
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 {
|
.emails-shortcuts {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@ -200,6 +236,27 @@
|
|||||||
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
|
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 {
|
.emails-v2-mail-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@ -428,6 +485,8 @@
|
|||||||
|
|
||||||
<div id="v2MailHeader" class="emails-v2-mail-header small text-muted">Vælg en email for at se info</div>
|
<div id="v2MailHeader" class="emails-v2-mail-header small text-muted">Vælg en email for at se info</div>
|
||||||
|
|
||||||
|
<div id="v2QuickActions"></div>
|
||||||
|
|
||||||
<div id="v2MailBody" class="emails-v2-detail-empty">Vælg en email fra listen</div>
|
<div id="v2MailBody" class="emails-v2-detail-empty">Vælg en email fra listen</div>
|
||||||
|
|
||||||
<div id="v2MailStatus" class="emails-v2-status">Ingen email valgt</div>
|
<div id="v2MailStatus" class="emails-v2-status">Ingen email valgt</div>
|
||||||
@ -700,7 +759,9 @@
|
|||||||
const rows = await apiFetch(url);
|
const rows = await apiFetch(url);
|
||||||
let emails = Array.isArray(rows) ? rows : [];
|
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()));
|
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));
|
const stillExists = emails.some((e) => Number(e.id) === Number(state.selectedEmailId));
|
||||||
if (stillExists) {
|
if (stillExists) {
|
||||||
await selectEmail(state.selectedEmailId, { silentListRefresh: true });
|
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) {
|
} else if (emails.length) {
|
||||||
await selectEmail(Number(emails[0].id), { silentListRefresh: true });
|
await selectEmail(Number(emails[0].id), { silentListRefresh: true });
|
||||||
@ -823,6 +891,15 @@
|
|||||||
try {
|
try {
|
||||||
const preview = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/workflow-preview`);
|
const preview = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/workflow-preview`);
|
||||||
state.workflowPreview = preview || null;
|
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();
|
renderWorkflowPreview();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
state.workflowPreview = null;
|
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 = `
|
||||||
|
<div class="fw-semibold">Ikke relevant</div>
|
||||||
|
<div class="small text-muted">Dette er et leverandørdokument. Afsenderen matches kun mod leverandører.</div>
|
||||||
|
`;
|
||||||
|
} else if (email.customer_id) {
|
||||||
|
customerHtml = `
|
||||||
|
<div class="fw-semibold text-success">${escapeHtml(email.customer_name || `Kunde #${email.customer_id}`)}</div>
|
||||||
|
<div class="small text-muted">Allerede tilknyttet denne email</div>
|
||||||
|
`;
|
||||||
|
} else if (customerSuggestion?.customer_id) {
|
||||||
|
customerHtml = `
|
||||||
|
<div class="fw-semibold">${escapeHtml(customerSuggestion.customer_name || `Kunde #${customerSuggestion.customer_id}`)}</div>
|
||||||
|
<div class="small text-muted">${escapeHtml(customerSuggestion.confidence || 'ukendt')} sikkerhed · ${escapeHtml(customerSuggestion.source || 'domænematch')}</div>
|
||||||
|
<button id="v2DecisionLinkCustomer" class="btn btn-sm btn-outline-primary mt-2">Tilknyt kunden</button>
|
||||||
|
`;
|
||||||
|
} else if (customerData) {
|
||||||
|
const proposedName = email.sender_name || senderDomain || 'Ny kunde';
|
||||||
|
customerHtml = `
|
||||||
|
<div class="fw-semibold">${escapeHtml(proposedName)}</div>
|
||||||
|
<div class="small text-muted">Ingen eksisterende kunde fundet${senderDomain ? ` for ${escapeHtml(senderDomain)}` : ''}</div>
|
||||||
|
<button id="v2DecisionCreateCustomer" class="btn btn-sm btn-primary mt-2">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Opret og tilknyt kunde
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
customerHtml = '<div class="small text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Finder kunde…</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
let vendorHtml;
|
||||||
|
if (email.supplier_id) {
|
||||||
|
vendorHtml = `
|
||||||
|
<div class="fw-semibold text-success">${escapeHtml(email.supplier_name || `Leverandør #${email.supplier_id}`)}</div>
|
||||||
|
<div class="small text-muted">Allerede tilknyttet denne email</div>
|
||||||
|
`;
|
||||||
|
} else if (vendorSuggestion?.vendor_id) {
|
||||||
|
vendorHtml = `
|
||||||
|
<div class="fw-semibold">${escapeHtml(vendorSuggestion.name || `Leverandør #${vendorSuggestion.vendor_id}`)}</div>
|
||||||
|
<div class="small text-muted">Matchscore ${Number(vendorSuggestion.match_score || 0)}${vendorSuggestion.cvr_number ? ` · CVR ${escapeHtml(vendorSuggestion.cvr_number)}` : ''}</div>
|
||||||
|
<button id="v2DecisionLinkVendor" class="btn btn-sm btn-outline-primary mt-2">Tilknyt leverandøren</button>
|
||||||
|
`;
|
||||||
|
} else if (vendorSuggestion?.name) {
|
||||||
|
vendorHtml = `
|
||||||
|
<div class="fw-semibold">${escapeHtml(vendorSuggestion.name)}</div>
|
||||||
|
<div class="small text-muted">Ny leverandør${vendorSuggestion.cvr_number ? ` · CVR ${escapeHtml(vendorSuggestion.cvr_number)}` : ''}</div>
|
||||||
|
<button id="v2DecisionCreateVendor" class="btn btn-sm btn-primary mt-2">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Opret og tilknyt leverandør
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
} else if (vendorSuggestion) {
|
||||||
|
vendorHtml = `
|
||||||
|
<div class="small text-muted">Systemet fandt ikke en sikker leverandør.</div>
|
||||||
|
<button id="v2DecisionCreateVendor" class="btn btn-sm btn-outline-primary mt-2">
|
||||||
|
Opret leverandør fra afsender
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
vendorHtml = '<div class="small text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Finder leverandør…</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="border rounded p-2">
|
||||||
|
<div class="small text-uppercase fw-bold text-muted mb-1"><i class="bi bi-building me-1"></i>Systemet tror kunden er</div>
|
||||||
|
${customerHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="border rounded p-2">
|
||||||
|
<div class="small text-uppercase fw-bold text-muted mb-1"><i class="bi bi-truck me-1"></i>Systemet tror leverandøren er</div>
|
||||||
|
${vendorHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="fw-semibold text-danger">Vurdering kunne ikke indlæses</div>
|
||||||
|
<div class="small text-muted mt-1">${escapeHtml(errorMessage)}</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = state.workflowPreview;
|
||||||
|
if (!preview) {
|
||||||
|
host.innerHTML = '<div class="small text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Systemet vurderer mailen…</div>';
|
||||||
|
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 = `
|
||||||
|
<div class="small text-uppercase fw-bold text-muted mb-1">Systemets vurdering</div>
|
||||||
|
<div class="email-ai-type">${escapeHtml(typeLabel)}</div>
|
||||||
|
<div class="d-flex justify-content-between small mt-2 mb-1">
|
||||||
|
<span>${escapeHtml(confidenceLabel)}</span>
|
||||||
|
<strong>${confidencePercent}%</strong>
|
||||||
|
</div>
|
||||||
|
<div class="email-ai-confidence mb-3"><span style="width:${confidencePercent}%"></span></div>
|
||||||
|
${renderIdentityAssessment()}
|
||||||
|
<div class="fw-semibold small mb-2">${automaticExecution ? 'Dette konkursflow køres automatisk:' : 'Når du godkender, gør systemet dette:'}</div>
|
||||||
|
${hasActions ? `
|
||||||
|
<ol class="email-ai-actions mb-3">
|
||||||
|
${uniqueEffects.map((effect) => `<li>${escapeHtml(effect)}</li>`).join('')}
|
||||||
|
</ol>
|
||||||
|
${automaticExecution ? `
|
||||||
|
<div class="alert alert-danger py-2 small mb-0">
|
||||||
|
<i class="bi bi-lightning-charge-fill me-1"></i>
|
||||||
|
Automatisk sikkerhedsflow. Der handles kun ved eksakt CVR-match.
|
||||||
|
</div>` : `
|
||||||
|
<button id="v2ApproveActions" class="btn btn-success w-100">
|
||||||
|
<i class="bi bi-check-circle me-2"></i>Godkend og udfør
|
||||||
|
</button>`}
|
||||||
|
` : `
|
||||||
|
<div class="alert alert-secondary py-2 small mb-0">
|
||||||
|
Ingen automatiske handlinger foreslås. Vælg selv “Opret sag” eller tilknyt en eksisterende sag nedenfor.
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
<div class="small text-muted mt-2 ${automaticExecution ? 'd-none' : ''}">
|
||||||
|
Godkend-knappen udfører kun de handlinger, der er vist ovenfor.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = '<span class="spinner-border spinner-border-sm me-2"></span>Udfører…';
|
||||||
|
try {
|
||||||
|
await executeWorkflowsCurrent();
|
||||||
|
} finally {
|
||||||
|
if (document.body.contains(button)) {
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = '<i class="bi bi-check-circle me-2"></i>Godkend og udfør';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderWorkflowPreview(errorMessage) {
|
function renderWorkflowPreview(errorMessage) {
|
||||||
const host = document.getElementById('v2WorkflowPreview');
|
const host = document.getElementById('v2WorkflowPreview');
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
host.innerHTML = `<div class="small text-danger">${escapeHtml(errorMessage)}</div>`;
|
host.innerHTML = `<div class="small text-danger">${escapeHtml(errorMessage)}</div>`;
|
||||||
|
renderClassificationDecision(errorMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preview = state.workflowPreview;
|
const preview = state.workflowPreview;
|
||||||
if (!preview) {
|
if (!preview) {
|
||||||
host.innerHTML = '<div class="small text-muted">Ingen preview endnu</div>';
|
host.innerHTML = '<div class="small text-muted">Ingen preview endnu</div>';
|
||||||
|
renderClassificationDecision();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -898,6 +1193,7 @@
|
|||||||
${matchingHtml}
|
${matchingHtml}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
renderClassificationDecision();
|
||||||
|
|
||||||
const autoRunBtn = document.getElementById('v2AutoRun');
|
const autoRunBtn = document.getElementById('v2AutoRun');
|
||||||
if (autoRunBtn) {
|
if (autoRunBtn) {
|
||||||
@ -931,6 +1227,7 @@
|
|||||||
|
|
||||||
const caseTypeEl = document.getElementById('v2CaseType');
|
const caseTypeEl = document.getElementById('v2CaseType');
|
||||||
const titelEl = document.getElementById('v2CaseTitle');
|
const titelEl = document.getElementById('v2CaseTitle');
|
||||||
|
const createButton = document.getElementById('v2CreateCase');
|
||||||
const payload = {
|
const payload = {
|
||||||
titel: String(titelEl?.value || state.selectedEmail.subject || '').trim(),
|
titel: String(titelEl?.value || state.selectedEmail.subject || '').trim(),
|
||||||
case_type: String(caseTypeEl?.value || 'support'),
|
case_type: String(caseTypeEl?.value || 'support'),
|
||||||
@ -941,6 +1238,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (createButton) {
|
||||||
|
createButton.disabled = true;
|
||||||
|
createButton.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Opretter…';
|
||||||
|
}
|
||||||
const result = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/create-sag`, {
|
const result = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/create-sag`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@ -952,6 +1253,49 @@
|
|||||||
await selectEmail(state.selectedEmailId);
|
await selectEmail(state.selectedEmailId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setDetailStatus(`Kunne ikke oprette sag: ${error.message}`);
|
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 = '<i class="bi bi-plus-lg me-1"></i>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 = '<span class="spinner-border spinner-border-sm me-1"></span>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;
|
state.vendorSuggestion = suggestion || null;
|
||||||
renderVendorSuggestion();
|
renderVendorSuggestion();
|
||||||
|
renderClassificationDecision();
|
||||||
setSupplierStatus('Forslag opdateret', 'success');
|
setSupplierStatus('Forslag opdateret', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
state.vendorSuggestion = {};
|
||||||
|
renderClassificationDecision();
|
||||||
setSupplierStatus(`Kunne ikke udtrække forslag: ${error.message}`, 'error');
|
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`);
|
const suggestion = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/domain-customer-suggestion`);
|
||||||
state.domainCustomerSuggestion = suggestion || null;
|
state.domainCustomerSuggestion = suggestion || null;
|
||||||
renderDomainCustomerSuggestion();
|
renderDomainCustomerSuggestion();
|
||||||
|
renderClassificationDecision();
|
||||||
setDomainStatus('Forslag opdateret', 'success');
|
setDomainStatus('Forslag opdateret', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
state.domainCustomerSuggestion = {
|
||||||
|
domain: String(state.selectedEmail?.sender_email || '').split('@')[1] || null,
|
||||||
|
suggestion: null,
|
||||||
|
};
|
||||||
|
renderClassificationDecision();
|
||||||
setDomainStatus(`Kunne ikke hente forslag: ${error.message}`, 'error');
|
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) {
|
async function searchSager(query) {
|
||||||
const host = document.getElementById('v2SagResults');
|
const host = document.getElementById('v2SagResults');
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
@ -1126,12 +1571,14 @@
|
|||||||
|
|
||||||
function renderDetail(email) {
|
function renderDetail(email) {
|
||||||
const mailHeader = document.getElementById('v2MailHeader');
|
const mailHeader = document.getElementById('v2MailHeader');
|
||||||
|
const quickActions = document.getElementById('v2QuickActions');
|
||||||
const mailBody = document.getElementById('v2MailBody');
|
const mailBody = document.getElementById('v2MailBody');
|
||||||
const sideActions = document.getElementById('v2SideActions');
|
const sideActions = document.getElementById('v2SideActions');
|
||||||
if (!mailHeader || !mailBody || !sideActions) return;
|
if (!mailHeader || !quickActions || !mailBody || !sideActions) return;
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
mailHeader.innerHTML = 'Vælg en email for at se info';
|
mailHeader.innerHTML = 'Vælg en email for at se info';
|
||||||
|
quickActions.innerHTML = '';
|
||||||
mailBody.className = 'emails-v2-detail-empty';
|
mailBody.className = 'emails-v2-detail-empty';
|
||||||
mailBody.textContent = 'Vælg en email fra listen';
|
mailBody.textContent = 'Vælg en email fra listen';
|
||||||
sideActions.className = 'emails-v2-detail-empty';
|
sideActions.className = 'emails-v2-detail-empty';
|
||||||
@ -1154,6 +1601,31 @@
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
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 ? `
|
||||||
|
<a class="btn btn-sm btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
|
||||||
|
<i class="bi bi-box-arrow-up-right me-1"></i>Åbn SAG #${Number(email.linked_case_id)}
|
||||||
|
</a>
|
||||||
|
` : `
|
||||||
|
<div class="emails-v2-quick-grid" aria-label="Hurtig behandling">
|
||||||
|
<button class="btn btn-sm btn-outline-primary text-start" data-v2-quick-action="create_customer_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter en almindelig kundesag' : 'Fastslå kunden først'}">
|
||||||
|
<i class="bi bi-person-workspace me-1"></i>Kundesag
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-success text-start" data-v2-quick-action="create_supplier_invoice" ${hasSupplier ? '' : 'disabled'} title="${hasSupplier ? 'Sender fakturabilaget til behandling' : 'Fastslå leverandøren først'}">
|
||||||
|
<i class="bi bi-receipt me-1"></i>Leverandørfaktura
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-warning text-start" data-v2-quick-action="create_accounting_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter sag og tildeler Bogholderi' : 'Fastslå kunden først'}">
|
||||||
|
<i class="bi bi-calculator me-1"></i>Kundesag → Bogholderi
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary text-start" data-v2-quick-action="archive_entity_mail" ${hasIdentity ? '' : 'disabled'} title="${hasIdentity ? 'Arkiverer kun mailen; opretter intet' : 'Fastslå kunde eller leverandør først'}">
|
||||||
|
<i class="bi bi-archive me-1"></i>Arkivér mail
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
mailBody.className = 'emails-v2-mail-body';
|
mailBody.className = 'emails-v2-mail-body';
|
||||||
const hasAttachments = Array.isArray(email.attachments) && email.attachments.length > 0;
|
const hasAttachments = Array.isArray(email.attachments) && email.attachments.length > 0;
|
||||||
const rawHtml = String(email.body_html || '').trim();
|
const rawHtml = String(email.body_html || '').trim();
|
||||||
@ -1182,6 +1654,13 @@
|
|||||||
|
|
||||||
sideActions.className = 'emails-v2-actions-pane';
|
sideActions.className = 'emails-v2-actions-pane';
|
||||||
sideActions.innerHTML = `
|
sideActions.innerHTML = `
|
||||||
|
<div id="v2DecisionCard" class="email-ai-decision">
|
||||||
|
<div class="d-flex align-items-center gap-2 text-muted">
|
||||||
|
<span class="spinner-border spinner-border-sm"></span>
|
||||||
|
Systemet vurderer mailtype og handlinger…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
${email.linked_case_id ? `
|
${email.linked_case_id ? `
|
||||||
<div class="emails-v2-card emails-primary-action">
|
<div class="emails-v2-card emails-primary-action">
|
||||||
<h6>Allerede knyttet til sag</h6>
|
<h6>Allerede knyttet til sag</h6>
|
||||||
@ -1197,9 +1676,9 @@
|
|||||||
<input id="v2CaseTitle" class="form-control form-control-sm mb-2" value="${escapeHtml(email.subject || '')}" placeholder="Sagens titel">
|
<input id="v2CaseTitle" class="form-control form-control-sm mb-2" value="${escapeHtml(email.subject || '')}" placeholder="Sagens titel">
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<select id="v2CaseType" class="form-select form-select-sm">
|
<select id="v2CaseType" class="form-select form-select-sm">
|
||||||
<option value="support">Support</option>
|
<option value="support" ${!email.supplier_id ? 'selected' : ''}>Support</option>
|
||||||
<option value="bogholderi">Bogholderi</option>
|
<option value="bogholderi">Bogholderi</option>
|
||||||
<option value="leverandor">Leverandør</option>
|
<option value="leverandor" ${email.supplier_id ? 'selected' : ''}>Leverandør</option>
|
||||||
<option value="helhedsopgave">Projekt/helhedsopgave</option>
|
<option value="helhedsopgave">Projekt/helhedsopgave</option>
|
||||||
<option value="andet">Andet</option>
|
<option value="andet">Andet</option>
|
||||||
</select>
|
</select>
|
||||||
@ -1288,6 +1767,11 @@
|
|||||||
document.getElementById('v2CreateCase')?.addEventListener('click', createCaseFromCurrent);
|
document.getElementById('v2CreateCase')?.addEventListener('click', createCaseFromCurrent);
|
||||||
document.getElementById('v2ExtractVendor')?.addEventListener('click', extractVendorSuggestionCurrent);
|
document.getElementById('v2ExtractVendor')?.addEventListener('click', extractVendorSuggestionCurrent);
|
||||||
document.getElementById('v2DomainSuggestionBtn')?.addEventListener('click', loadDomainCustomerSuggestionCurrent);
|
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.vendorSuggestion = null;
|
||||||
state.domainCustomerSuggestion = null;
|
state.domainCustomerSuggestion = null;
|
||||||
@ -1296,6 +1780,14 @@
|
|||||||
renderDomainCustomerSuggestion();
|
renderDomainCustomerSuggestion();
|
||||||
renderWorkflowPreview();
|
renderWorkflowPreview();
|
||||||
loadWorkflowPreviewCurrent();
|
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`);
|
setMailStatus(`Email #${email.id} vises`);
|
||||||
|
|
||||||
document.getElementById('v2SagSearch')?.addEventListener('input', (event) => {
|
document.getElementById('v2SagSearch')?.addEventListener('input', (event) => {
|
||||||
|
|||||||
@ -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)
|
@router.post('/locations/outlets', response_model=WallOutlet, status_code=201)
|
||||||
async def create_wall_outlet(data: WallOutletCreate):
|
async def create_wall_outlet(data: WallOutletCreate):
|
||||||
_outlet_location(data.location_id)
|
_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(
|
_replace_switch_port_if_confirmed(
|
||||||
switch_hardware_id=data.switch_hardware_id,
|
switch_hardware_id=data.switch_hardware_id,
|
||||||
switch_name=data.switch_name,
|
switch_name=data.switch_name,
|
||||||
@ -888,12 +1003,24 @@ async def update_wall_outlet(outlet_id: int, data: WallOutletUpdate):
|
|||||||
if 'outlet_number' in changes:
|
if 'outlet_number' in changes:
|
||||||
changes['outlet_number'] = (changes['outlet_number'] or '').strip() or None
|
changes['outlet_number'] = (changes['outlet_number'] or '').strip() or None
|
||||||
current = execute_query(
|
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''',
|
FROM locations_wall_outlets WHERE id = %s AND deleted_at IS NULL''',
|
||||||
(outlet_id,),
|
(outlet_id,),
|
||||||
) or []
|
) or []
|
||||||
if not current:
|
if not current:
|
||||||
raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet')
|
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(
|
_replace_switch_port_if_confirmed(
|
||||||
switch_hardware_id=changes.get('switch_hardware_id', current[0].get('switch_hardware_id')),
|
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')),
|
switch_name=changes.get('switch_name', current[0].get('switch_name')),
|
||||||
|
|||||||
@ -788,7 +788,7 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
|||||||
FROM locations_cross_field_ports p
|
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_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
|
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"],),
|
(cross_field["id"],),
|
||||||
) or []
|
) or []
|
||||||
for port in cross_field["ports"]:
|
for port in cross_field["ports"]:
|
||||||
|
|||||||
@ -1138,11 +1138,11 @@
|
|||||||
<div class="alert alert-warning small"><strong>Kontroller før du gemmer.</strong> Funktionen forbinder et sammenhængende område af krydsfelt-porte med samme antal switch-porte.</div>
|
<div class="alert alert-warning small"><strong>Kontroller før du gemmer.</strong> Funktionen forbinder et sammenhængende område af krydsfelt-porte med samme antal switch-porte.</div>
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-6"><label class="form-label">Krydsfelt</label><select class="form-select" id="bulkCrossField" required></select></div>
|
<div class="col-md-6"><label class="form-label">Krydsfelt</label><select class="form-select" id="bulkCrossField" required></select></div>
|
||||||
<div class="col-md-3"><label class="form-label">Fra portposition</label><input class="form-control" id="bulkFromPort" type="number" min="1" value="1" required></div>
|
<div class="col-md-3"><label class="form-label">Fra krydsfelt-port</label><select class="form-select" id="bulkFromPort" required></select></div>
|
||||||
<div class="col-md-3"><label class="form-label">Til portposition</label><input class="form-control" id="bulkToPort" type="number" min="1" value="24" required></div>
|
<div class="col-md-3"><label class="form-label">Til krydsfelt-port</label><select class="form-select" id="bulkToPort" required></select></div>
|
||||||
<div class="col-md-6"><label class="form-label">Switch (UISP-hostnavn)</label><select class="form-select" id="bulkSwitch" required></select></div>
|
<div class="col-md-6"><label class="form-label">Switch (UISP-hostnavn)</label><select class="form-select" id="bulkSwitch" required></select></div>
|
||||||
<div class="col-md-3"><label class="form-label">Start switch-port</label><input class="form-control" id="bulkSwitchStart" type="number" min="1" value="1" required></div>
|
<div class="col-md-3"><label class="form-label">Start switch-port</label><select class="form-select" id="bulkSwitchStart" required></select></div>
|
||||||
<div class="col-md-3 d-flex align-items-end"><div class="form-check form-switch mb-2"><input class="form-check-input" id="bulkIsWan" type="checkbox"><label class="form-check-label fw-semibold" for="bulkIsWan">WAN</label></div></div>
|
<div class="col-md-3"><label class="form-label">WAN-port i området</label><select class="form-select" id="bulkWanPort"><option value="">Ingen WAN-port</option></select></div>
|
||||||
<div class="col-12"><label class="form-label">Firma</label><input class="form-control mb-2" id="bulkCustomerSearch" type="search" placeholder="Søg firma…"><select class="form-select" id="bulkCustomer"><option value="">Ingen specifik kunde</option></select></div>
|
<div class="col-12"><label class="form-label">Firma</label><input class="form-control mb-2" id="bulkCustomerSearch" type="search" placeholder="Søg firma…"><select class="form-select" id="bulkCustomer"><option value="">Ingen specifik kunde</option></select></div>
|
||||||
<div class="col-12"><div class="p-3 rounded border bg-light small" id="bulkPatchPreview">Vælg område og switch for at se en forhåndsvisning.</div></div>
|
<div class="col-12"><div class="p-3 rounded border bg-light small" id="bulkPatchPreview">Vælg område og switch for at se en forhåndsvisning.</div></div>
|
||||||
</div>
|
</div>
|
||||||
@ -1591,67 +1591,171 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const bulkFieldSelect = document.getElementById('bulkCrossField');
|
const bulkFieldSelect = document.getElementById('bulkCrossField');
|
||||||
const bulkSwitchSelect = document.getElementById('bulkSwitch');
|
const bulkSwitchSelect = document.getElementById('bulkSwitch');
|
||||||
const bulkCustomerSelect = document.getElementById('bulkCustomer');
|
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 = '<option value="">Ingen specifik kunde</option>' + matches
|
||||||
|
.map(customer => `<option value="${customer.id}">${customer.name || customer.navn || `Kunde #${customer.id}`}</option>`)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
function selectedBulkField() {
|
function selectedBulkField() {
|
||||||
return (locationCrossFields || []).find(field => Number(field.id) === Number(bulkFieldSelect.value));
|
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) => `<option value="${index}">${port.port_number}</option>`)
|
||||||
|
.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 => `<option value="${item.index}">${item.port.port_number}</option>`)
|
||||||
|
.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) => `<option value="${index}">${port.port_number}</option>`)
|
||||||
|
.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 = '<option value="">Ingen WAN-port</option>' + selectedPorts
|
||||||
|
.map(port => `<option value="${port.id}">${port.port_number}</option>`)
|
||||||
|
.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() {
|
function updateBulkPreview() {
|
||||||
const field = selectedBulkField();
|
const field = selectedBulkField();
|
||||||
const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value));
|
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 from = Number(document.getElementById('bulkFromPort').value);
|
||||||
const to = Number(document.getElementById('bulkToPort').value);
|
const to = Number(document.getElementById('bulkToPort').value);
|
||||||
const switchStart = Number(document.getElementById('bulkSwitchStart').value);
|
const switchStart = Number(document.getElementById('bulkSwitchStart').value);
|
||||||
const count = Number.isInteger(from) && Number.isInteger(to) && to >= from ? to - from + 1 : 0;
|
const count = Number.isInteger(from) && Number.isInteger(to) && to >= from ? to - from + 1 : 0;
|
||||||
const firstPort = field?.ports?.[from - 1]?.port_number || '—';
|
const selectedSwitchPorts = switchPorts.slice(switchStart, switchStart + count);
|
||||||
const lastPort = field?.ports?.[to - 1]?.port_number || '—';
|
const firstPort = fieldPorts[from]?.port_number || '—';
|
||||||
document.getElementById('bulkPatchPreview').innerHTML = count
|
const lastPort = fieldPorts[to]?.port_number || '—';
|
||||||
? `<strong>${count} forbindelser:</strong> ${field?.name || '—'} port ${firstPort}–${lastPort} → ${hardware ? switchDisplayName(hardware) : '—'} port ${switchStart}–${switchStart + count - 1}`
|
const firstSwitchPort = selectedSwitchPorts[0]?.port_number || '—';
|
||||||
|
const lastSwitchPort = selectedSwitchPorts[selectedSwitchPorts.length - 1]?.port_number || '—';
|
||||||
|
document.getElementById('bulkPatchPreview').innerHTML = count && selectedSwitchPorts.length === count
|
||||||
|
? `<strong>${count} forbindelser:</strong> ${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.';
|
: 'Vælg et gyldigt portområde.';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openBulkPatch() {
|
async function openBulkPatch() {
|
||||||
bulkFieldSelect.innerHTML = (locationCrossFields || []).map(field => `<option value="${field.id}">${field.name} · ${field.port_count} porte</option>`).join('');
|
const fields = (locationCrossFields || []).filter(field => selectableBulkFieldPorts(field).length);
|
||||||
const switches = (locationHardware || []).filter(item => String(item.asset_type || '').toLowerCase() === 'netværk');
|
bulkFieldSelect.innerHTML = fields.map(field => {
|
||||||
bulkSwitchSelect.innerHTML = switches.map(item => `<option value="${item.id}">${switchDisplayName(item)} · ${switchPortCount(item)} porte</option>`).join('');
|
const ports = selectableBulkFieldPorts(field);
|
||||||
|
const firstPort = ports[0]?.port_number || '—';
|
||||||
|
const lastPort = ports[ports.length - 1]?.port_number || '—';
|
||||||
|
return `<option value="${field.id}">${field.name} · ${firstPort}–${lastPort} · ${ports.length} porte</option>`;
|
||||||
|
}).join('');
|
||||||
|
const switches = (locationHardware || []).filter(item =>
|
||||||
|
String(item.asset_type || '').toLowerCase() === 'netværk' && selectableSwitchPorts(item).length
|
||||||
|
);
|
||||||
|
bulkSwitchSelect.innerHTML = switches.map(item => `<option value="${item.id}">${switchDisplayName(item)} · ${selectableSwitchPorts(item).length} oprettede porte</option>`).join('');
|
||||||
|
populateBulkFieldPorts();
|
||||||
|
populateBulkSwitchPorts();
|
||||||
|
populateBulkWanPorts();
|
||||||
const response = await fetch('/api/v1/customers?limit=1000&offset=0');
|
const response = await fetch('/api/v1/customers?limit=1000&offset=0');
|
||||||
const data = response.ok ? await response.json() : [];
|
const data = response.ok ? await response.json() : [];
|
||||||
const customers = Array.isArray(data) ? data : (data.customers || []);
|
bulkCustomers = Array.isArray(data) ? data : (data.customers || []);
|
||||||
bulkCustomerSelect.innerHTML = '<option value="">Ingen specifik kunde</option>' + customers.map(customer => `<option value="${customer.id}">${customer.name || customer.navn || `Kunde #${customer.id}`}</option>`).join('');
|
document.getElementById('bulkCustomerSearch').value = '';
|
||||||
const firstField = selectedBulkField();
|
updateBulkCustomerOptions();
|
||||||
document.getElementById('bulkToPort').value = Math.min(24, firstField?.ports?.length || 1);
|
|
||||||
updateBulkPreview();
|
updateBulkPreview();
|
||||||
bulkPatchModal?.show();
|
bulkPatchModal?.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('openBulkPatchBtn')?.addEventListener('click', openBulkPatch);
|
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 => {
|
document.getElementById('bulkCustomerSearch')?.addEventListener('input', event => {
|
||||||
const query = event.target.value.trim().toLocaleLowerCase('da');
|
updateBulkCustomerOptions(event.target.value);
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('bulkPatchForm')?.addEventListener('submit', async event => {
|
document.getElementById('bulkPatchForm')?.addEventListener('submit', async event => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const field = selectedBulkField();
|
const field = selectedBulkField();
|
||||||
const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value));
|
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 from = Number(document.getElementById('bulkFromPort').value);
|
||||||
const to = Number(document.getElementById('bulkToPort').value);
|
const to = Number(document.getElementById('bulkToPort').value);
|
||||||
const switchStart = Number(document.getElementById('bulkSwitchStart').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.');
|
alert('Vælg et gyldigt krydsfelt, en switch og et portområde.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ports = field.ports.slice(from - 1, to);
|
const ports = fieldPorts.slice(from, to + 1);
|
||||||
if (switchStart < 1 || switchStart + ports.length - 1 > switchPortCount(hardware)) {
|
const selectedSwitchPorts = switchPorts.slice(switchStart, switchStart + ports.length);
|
||||||
alert('Portområdet går ud over switchens registrerede antal porte.');
|
if (switchStart < 0 || selectedSwitchPorts.length !== ports.length) {
|
||||||
|
alert('Portområdet går ud over switchens oprettede porte.');
|
||||||
return;
|
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) {
|
if (conflicts.length) {
|
||||||
alert(`Massepatch blev stoppet: ${conflicts.length} switch-port(e) er allerede knyttet til andre vægstik.`);
|
alert(`Massepatch blev stoppet: ${conflicts.length} switch-port(e) er allerede knyttet til andre vægstik.`);
|
||||||
return;
|
return;
|
||||||
@ -1662,8 +1766,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
submit.disabled = true;
|
submit.disabled = true;
|
||||||
submit.textContent = 'Gemmer…';
|
submit.textContent = 'Gemmer…';
|
||||||
const customerId = bulkCustomerSelect.value ? Number(bulkCustomerSelect.value) : null;
|
const customerId = bulkCustomerSelect.value ? Number(bulkCustomerSelect.value) : null;
|
||||||
const isWan = document.getElementById('bulkIsWan').checked;
|
const wanPortId = document.getElementById('bulkWanPort').value
|
||||||
const results = await Promise.all(ports.map(async (port, index) => {
|
? 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 = {
|
const payload = {
|
||||||
outlet_number: port.outlet_number || `${field.name}-${port.port_number}`,
|
outlet_number: port.outlet_number || `${field.name}-${port.port_number}`,
|
||||||
customer_id: customerId,
|
customer_id: customerId,
|
||||||
@ -1673,8 +1784,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
cross_field_port_id: Number(port.id),
|
cross_field_port_id: Number(port.id),
|
||||||
switch_hardware_id: Number(hardware.id),
|
switch_hardware_id: Number(hardware.id),
|
||||||
switch_name: switchDisplayName(hardware),
|
switch_name: switchDisplayName(hardware),
|
||||||
switch_port: String(switchStart + index),
|
switch_port: String(selectedSwitchPorts[index].port_number),
|
||||||
is_wan: isWan,
|
is_wan: Number(port.id) === wanPortId,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
notes: port.outlet_notes || null
|
notes: port.outlet_notes || null
|
||||||
};
|
};
|
||||||
@ -1684,8 +1795,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
return response.ok;
|
results.push(response.ok);
|
||||||
}));
|
}
|
||||||
const failed = results.filter(ok => !ok).length;
|
const failed = results.filter(ok => !ok).length;
|
||||||
if (failed) {
|
if (failed) {
|
||||||
alert(`${results.length - failed} forbindelser blev gemt, men ${failed} fejlede. Siden genindlæses.`);
|
alert(`${results.length - failed} forbindelser blev gemt, men ${failed} fejlede. Siden genindlæses.`);
|
||||||
|
|||||||
@ -660,7 +660,34 @@ async def sag_detaljer(request: Request, sag_id: int):
|
|||||||
customer = None
|
customer = None
|
||||||
hovedkontakt = None
|
hovedkontakt = None
|
||||||
if sag.get('customer_id'):
|
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'],))
|
customer_result = execute_query(customer_query, (sag['customer_id'],))
|
||||||
if customer_result:
|
if customer_result:
|
||||||
customer = customer_result[0]
|
customer = customer_result[0]
|
||||||
|
|||||||
@ -2550,6 +2550,69 @@
|
|||||||
background: transparent;
|
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 {
|
[data-bs-theme="dark"] .module-title {
|
||||||
color: #e5edf5;
|
color: #e5edf5;
|
||||||
}
|
}
|
||||||
@ -3439,6 +3502,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-white-50 mb-0 case-header-meta-line">
|
<p class="text-white-50 mb-0 case-header-meta-line">
|
||||||
<i class="bi bi-building me-1"></i> <a href="#" onclick="event.preventDefault(); showCustomerSearch('replace')" class="text-white text-decoration-none" title="Skift kunde">{{ customer.name if customer else 'Ingen kunde valgt' }}</a>
|
<i class="bi bi-building me-1"></i> <a href="#" onclick="event.preventDefault(); showCustomerSearch('replace')" class="text-white text-decoration-none" title="Skift kunde">{{ customer.name if customer else 'Ingen kunde valgt' }}</a>
|
||||||
|
{% if customer and customer.is_vendor %}
|
||||||
|
<span class="badge rounded-pill bg-warning text-dark ms-1" title="Denne kunde er også registreret som leverandør{% if customer.vendor_name %}: {{ customer.vendor_name }}{% endif %}">
|
||||||
|
<i class="bi bi-truck me-1"></i>Leverandør
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
<span class="meta-divider">|</span>
|
<span class="meta-divider">|</span>
|
||||||
<i class="bi bi-person me-1"></i> <a href="#" onclick="event.preventDefault(); showKontaktModal()" class="text-white text-decoration-none" title="Se/Rediger Kontakt">{{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Ingen kontakt' }}</a>
|
<i class="bi bi-person me-1"></i> <a href="#" onclick="event.preventDefault(); showKontaktModal()" class="text-white text-decoration-none" title="Se/Rediger Kontakt">{{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else 'Ingen kontakt' }}</a>
|
||||||
<span class="meta-divider">|</span>
|
<span class="meta-divider">|</span>
|
||||||
@ -4652,60 +4720,72 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- AnyDesk quick-connect modal -->
|
<!-- AnyDesk quick-connect modal -->
|
||||||
<div class="modal fade" id="caseAnyDeskModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade anydesk-connect-modal" id="caseAnyDeskModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered" style="max-width:560px;">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title"><i class="bi bi-display me-2"></i>AnyDesk quick connect</h5>
|
<div>
|
||||||
|
<h5 class="modal-title"><i class="bi bi-display me-2"></i>Forbind med AnyDesk</h5>
|
||||||
|
</div>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body p-3">
|
||||||
<div class="mb-3">
|
<section class="mb-3">
|
||||||
<label class="form-label">AnyDesk ID</label>
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
<div class="input-group">
|
<label class="form-label fw-semibold mb-0">Gemte enheder</label>
|
||||||
<input type="text" class="form-control" id="caseAnydeskIdInput" placeholder="fx 123 456 789" oninput="onCaseAnyDeskIdInputChange()" />
|
<span class="badge text-bg-light border" id="caseAnyDeskSavedCount">–</span>
|
||||||
<a href="#" class="btn btn-outline-primary" id="caseAnydeskOpenLinkBtn" target="_self" style="display:none;">
|
|
||||||
<i class="bi bi-box-arrow-up-right me-1"></i>Åbn AnyDesk
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text">ID gemmes automatisk på sagen når du klikker forbind.</div>
|
<div id="caseAnyDeskSavedIds" class="anydesk-saved-list">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Gemte IDs på sagen</label>
|
|
||||||
<div id="caseAnyDeskSavedIds" class="d-flex flex-wrap gap-2">
|
|
||||||
<span class="text-muted small">Indlæser...</span>
|
<span class="text-muted small">Indlæser...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div class="mb-3">
|
<section class="anydesk-id-entry p-2 mb-2">
|
||||||
<label class="form-label">Relatér til hardware (valgfri)</label>
|
<label class="form-label small fw-semibold mb-1">Andet AnyDesk-ID</label>
|
||||||
<select class="form-select" id="caseAnydeskHardwareSelect">
|
<div class="input-group">
|
||||||
<option value="">Ingen hardware valgt</option>
|
<span class="input-group-text"><i class="bi bi-hash"></i></span>
|
||||||
</select>
|
<input type="text" inputmode="numeric" autocomplete="off" class="form-control anydesk-id-display" id="caseAnydeskIdInput" placeholder="123 456 789" oninput="onCaseAnyDeskIdInputChange()" />
|
||||||
<div class="form-text">Sagens hardware vises først. Derefter hardware hos kunden.</div>
|
<a id="caseAnyDeskConnectBtn" class="btn btn-primary px-4 disabled" href="#" role="button" aria-disabled="true" onclick="registerCaseAnyDeskSession()">
|
||||||
</div>
|
<i class="bi bi-plug-fill me-1"></i>Forbind
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div id="caseAnyDeskStatus" class="small text-muted mb-2" role="status"></div>
|
||||||
<label class="form-label">Kontakt (valgfri)</label>
|
|
||||||
<select class="form-select" id="caseAnydeskContactSelect">
|
<details class="border rounded-3 p-2">
|
||||||
<option value="">Ingen kontakt</option>
|
<summary class="small fw-semibold" style="cursor:pointer;">
|
||||||
{% for contact in contacts %}
|
<i class="bi bi-sliders me-1"></i>Hardware, kontakt og notat
|
||||||
<option value="{{ contact.contact_id }}">{{ contact.contact_name }}{% if contact.customer_name %} - {{ contact.customer_name }}{% endif %}</option>
|
</summary>
|
||||||
{% endfor %}
|
<div class="mt-3">
|
||||||
</select>
|
<label class="form-label">Relatér til hardware</label>
|
||||||
</div>
|
<select class="form-select" id="caseAnydeskHardwareSelect">
|
||||||
<div class="mb-1">
|
<option value="">Ingen hardware valgt</option>
|
||||||
<label class="form-label">Notat (valgfri)</label>
|
</select>
|
||||||
<textarea class="form-control" id="caseAnydeskNoteInput" rows="3" placeholder="Kort notat om supporten"></textarea>
|
<div class="form-text">Sagens hardware vises først. Derefter kundens øvrige hardware.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="small text-muted mt-2">
|
|
||||||
Når du klikker forbind oprettes sessionen på sagen med det samme, så varighed/status kan beriges via lokal AnyDesk sync.
|
<div class="mt-3">
|
||||||
</div>
|
<label class="form-label">Kontakt</label>
|
||||||
|
<select class="form-select" id="caseAnydeskContactSelect">
|
||||||
|
<option value="">Ingen kontakt</option>
|
||||||
|
{% for contact in contacts %}
|
||||||
|
<option value="{{ contact.contact_id }}">{{ contact.contact_name }}{% if contact.customer_name %} - {{ contact.customer_name }}{% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="form-label">Notat</label>
|
||||||
|
<textarea class="form-control" id="caseAnydeskNoteInput" rows="2" placeholder="Fx installation, fejlsøgning eller brugerhjælp"></textarea>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer border-0 pt-0">
|
||||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annuller</button>
|
<a href="/anydesk/sessions" class="btn btn-link text-decoration-none me-auto">
|
||||||
<button id="caseAnyDeskConnectBtn" type="button" class="btn btn-primary" onclick="registerCaseAnyDeskSession()"><i class="bi bi-plug me-1"></i>Forbind og gem</button>
|
<i class="bi bi-clock-history me-1"></i>Se historik
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Luk</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -9613,11 +9693,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (subjectInput && !subjectInput.value.trim()) {
|
if (subjectInput && !subjectInput.value.trim()) {
|
||||||
subjectInput.value = escapeHtmlForInput(
|
subjectInput.value = /^re:\s*/i.test(header.emne || '')
|
||||||
/^re:\s*/i.test(header.emne || '')
|
? (header.emne || `Sag #${caseIds}`)
|
||||||
? (header.emne || `Sag #${caseIds}`)
|
: `Re: ${header.emne || `Sag #${caseIds}`}`;
|
||||||
: `Re: ${header.emne || `Sag #${caseIds}`}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bodyInput && !bodyInput.value.trim()) {
|
if (bodyInput && !bodyInput.value.trim()) {
|
||||||
@ -13414,21 +13492,42 @@
|
|||||||
return digits || raw;
|
return digits || raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAnyDeskIdClient(rawValue) {
|
||||||
|
const id = normalizeAnyDeskIdClient(rawValue);
|
||||||
|
return id.replace(/(\d{3})(?=\d)/g, '$1 ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCaseAnyDeskStatus(message, type = 'muted') {
|
||||||
|
const status = document.getElementById('caseAnyDeskStatus');
|
||||||
|
if (!status) return;
|
||||||
|
status.className = `small mb-3 text-${type}`;
|
||||||
|
status.textContent = message || '';
|
||||||
|
}
|
||||||
|
|
||||||
function onCaseAnyDeskIdInputChange() {
|
function onCaseAnyDeskIdInputChange() {
|
||||||
const input = document.getElementById('caseAnydeskIdInput');
|
const input = document.getElementById('caseAnydeskIdInput');
|
||||||
const linkBtn = document.getElementById('caseAnydeskOpenLinkBtn');
|
const connectBtn = document.getElementById('caseAnyDeskConnectBtn');
|
||||||
if (!input || !linkBtn) return;
|
if (!input || !connectBtn) return;
|
||||||
|
|
||||||
const id = normalizeAnyDeskIdClient(input.value);
|
const id = normalizeAnyDeskIdClient(input.value);
|
||||||
if (!id) {
|
if (!id) {
|
||||||
linkBtn.style.display = 'none';
|
connectBtn.classList.add('disabled');
|
||||||
linkBtn.setAttribute('href', '#');
|
connectBtn.setAttribute('aria-disabled', 'true');
|
||||||
|
connectBtn.setAttribute('href', '#');
|
||||||
|
setCaseAnyDeskStatus('Indtast et AnyDesk-ID eller vælg en gemt enhed.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
input.value = id;
|
input.value = id;
|
||||||
linkBtn.style.display = '';
|
const valid = /^\d{6,12}$/.test(id);
|
||||||
linkBtn.setAttribute('href', `anydesk:${id}`);
|
const enabled = valid && !caseAnyDeskConnectInFlight;
|
||||||
|
connectBtn.classList.toggle('disabled', !enabled);
|
||||||
|
connectBtn.setAttribute('aria-disabled', enabled ? 'false' : 'true');
|
||||||
|
connectBtn.setAttribute('href', enabled ? `anydesk:${id}` : '#');
|
||||||
|
setCaseAnyDeskStatus(
|
||||||
|
valid ? `Klar til at forbinde til ${formatAnyDeskIdClient(id)}` : 'AnyDesk-ID skal være 6–12 cifre.',
|
||||||
|
valid ? 'success' : 'danger'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCaseAnyDeskInputFromSaved(anydeskId) {
|
function setCaseAnyDeskInputFromSaved(anydeskId) {
|
||||||
@ -13440,21 +13539,63 @@
|
|||||||
|
|
||||||
function renderCaseAnyDeskSavedIds(entries) {
|
function renderCaseAnyDeskSavedIds(entries) {
|
||||||
const container = document.getElementById('caseAnyDeskSavedIds');
|
const container = document.getElementById('caseAnyDeskSavedIds');
|
||||||
|
const count = document.getElementById('caseAnyDeskSavedCount');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
if (count) count.textContent = String(entries?.length || 0);
|
||||||
|
|
||||||
if (!entries?.length) {
|
if (!entries?.length) {
|
||||||
container.innerHTML = '<span class="text-muted small">Ingen gemte AnyDesk IDs på sagen endnu.</span>';
|
container.innerHTML = '<div class="border rounded-3 p-3 text-muted small"><i class="bi bi-info-circle me-1"></i>Ingen gemte enheder endnu. Det første ID gemmes automatisk.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = entries.map((entry) => {
|
container.innerHTML = entries.map((entry) => {
|
||||||
const primary = entry?.is_primary ? ' border-primary text-primary' : '';
|
const id = normalizeAnyDeskIdClient(entry?.anydesk_id);
|
||||||
const hardware = entry?.hardware_label ? ` <span class="text-muted">(${entry.hardware_label})</span>` : '';
|
const hardwareId = Number(entry?.hardware_asset_id || 0);
|
||||||
const badge = entry?.is_primary ? ' <span class="badge bg-primary-subtle text-primary-emphasis border border-primary-subtle">Primær</span>' : '';
|
const sourceLabels = {
|
||||||
return `<button type="button" class="btn btn-sm btn-outline-secondary${primary}" onclick="setCaseAnyDeskInputFromSaved('${String(entry.anydesk_id || '').replace(/'/g, "\\'")}')">${entry.anydesk_id}${badge}${hardware}</button>`;
|
case: 'Tidligere på sagen',
|
||||||
|
contact: 'Kontakt',
|
||||||
|
hardware: 'Kundens hardware',
|
||||||
|
history: 'Tidligere brugt'
|
||||||
|
};
|
||||||
|
const name = entry?.hardware_label
|
||||||
|
|| entry?.contact_name
|
||||||
|
|| sourceLabels[entry?.source]
|
||||||
|
|| 'AnyDesk-enhed';
|
||||||
|
const sourceLabel = sourceLabels[entry?.source] || 'Gemt';
|
||||||
|
const badge = entry?.is_primary
|
||||||
|
? '<i class="bi bi-star-fill text-warning ms-1" title="Primær"></i>'
|
||||||
|
: '';
|
||||||
|
const lastUsed = entry?.last_used_at
|
||||||
|
? new Date(entry.last_used_at).toLocaleDateString('da-DK')
|
||||||
|
: '–';
|
||||||
|
const tooltip = [
|
||||||
|
sourceLabel,
|
||||||
|
entry?.contact_name ? `Kontakt: ${entry.contact_name}` : '',
|
||||||
|
`Senest brugt: ${lastUsed}`
|
||||||
|
].filter(Boolean).join(' · ');
|
||||||
|
return `
|
||||||
|
<div class="anydesk-saved-device" title="${escapeHtml(tooltip)}">
|
||||||
|
<button type="button" class="btn btn-link text-start text-decoration-none p-0 text-body" onclick="setCaseAnyDeskInputFromSaved('${id}')">
|
||||||
|
<div class="device-name">${escapeHtml(name)}${badge}</div>
|
||||||
|
<div class="device-id anydesk-id-display">${escapeHtml(formatAnyDeskIdClient(id))}</div>
|
||||||
|
</button>
|
||||||
|
<a class="btn btn-sm btn-primary" href="anydesk:${id}" onclick="connectSavedCaseAnyDesk('${id}', ${hardwareId})">
|
||||||
|
<i class="bi bi-plug-fill me-1"></i>Forbind
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function connectSavedCaseAnyDesk(anydeskId, hardwareAssetId) {
|
||||||
|
setCaseAnyDeskInputFromSaved(anydeskId);
|
||||||
|
const hardwareSelect = document.getElementById('caseAnydeskHardwareSelect');
|
||||||
|
if (hardwareSelect && Number(hardwareAssetId || 0) > 0) {
|
||||||
|
hardwareSelect.value = String(Number(hardwareAssetId));
|
||||||
|
}
|
||||||
|
await registerCaseAnyDeskSession();
|
||||||
|
}
|
||||||
|
|
||||||
function renderCaseAnyDeskHardwareOptions(caseHardware, customerHardware) {
|
function renderCaseAnyDeskHardwareOptions(caseHardware, customerHardware) {
|
||||||
const select = document.getElementById('caseAnydeskHardwareSelect');
|
const select = document.getElementById('caseAnydeskHardwareSelect');
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
@ -13519,6 +13660,7 @@
|
|||||||
|
|
||||||
const noteInput = document.getElementById('caseAnydeskNoteInput');
|
const noteInput = document.getElementById('caseAnydeskNoteInput');
|
||||||
if (noteInput) noteInput.value = '';
|
if (noteInput) noteInput.value = '';
|
||||||
|
setCaseAnyDeskStatus('Henter gemte enheder…');
|
||||||
|
|
||||||
const saved = document.getElementById('caseAnyDeskSavedIds');
|
const saved = document.getElementById('caseAnyDeskSavedIds');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
@ -13538,8 +13680,14 @@
|
|||||||
|
|
||||||
const connectBtn = document.getElementById('caseAnyDeskConnectBtn');
|
const connectBtn = document.getElementById('caseAnyDeskConnectBtn');
|
||||||
if (connectBtn) {
|
if (connectBtn) {
|
||||||
connectBtn.disabled = false;
|
connectBtn.classList.add('disabled');
|
||||||
connectBtn.innerHTML = '<i class="bi bi-plug me-1"></i>Forbind og gem';
|
connectBtn.setAttribute('aria-disabled', 'true');
|
||||||
|
connectBtn.setAttribute('href', '#');
|
||||||
|
connectBtn.innerHTML = '<i class="bi bi-plug-fill me-1"></i>Forbind';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (caseAnyDeskModal && typeof caseAnyDeskModal.show === 'function') {
|
||||||
|
caseAnyDeskModal.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -13549,10 +13697,7 @@
|
|||||||
if (saved) {
|
if (saved) {
|
||||||
saved.innerHTML = `<span class="text-danger small">${message}</span>`;
|
saved.innerHTML = `<span class="text-danger small">${message}</span>`;
|
||||||
}
|
}
|
||||||
}
|
setCaseAnyDeskStatus(message, 'danger');
|
||||||
|
|
||||||
if (caseAnyDeskModal && typeof caseAnyDeskModal.show === 'function') {
|
|
||||||
caseAnyDeskModal.show();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -13567,13 +13712,13 @@
|
|||||||
const notes = (document.getElementById('caseAnydeskNoteInput')?.value || '').trim();
|
const notes = (document.getElementById('caseAnydeskNoteInput')?.value || '').trim();
|
||||||
|
|
||||||
if (!anydeskId) {
|
if (!anydeskId) {
|
||||||
alert('Udfyld AnyDesk ID');
|
setCaseAnyDeskStatus('Udfyld et AnyDesk-ID.', 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const customerId = {{ customer.id if customer else 'null' }};
|
const customerId = {{ customer.id if customer else 'null' }};
|
||||||
if (!customerId) {
|
if (!customerId) {
|
||||||
alert('Sagen har ingen kunde - kan ikke starte AnyDesk session');
|
setCaseAnyDeskStatus('Sagen har ingen kunde og kan derfor ikke starte en session.', 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -13592,9 +13737,11 @@
|
|||||||
const connectBtn = document.getElementById('caseAnyDeskConnectBtn');
|
const connectBtn = document.getElementById('caseAnyDeskConnectBtn');
|
||||||
const openBtn = document.getElementById('caseAnyDeskOpenBtn');
|
const openBtn = document.getElementById('caseAnyDeskOpenBtn');
|
||||||
if (connectBtn) {
|
if (connectBtn) {
|
||||||
connectBtn.disabled = true;
|
connectBtn.classList.add('disabled');
|
||||||
connectBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Registrerer...';
|
connectBtn.setAttribute('aria-disabled', 'true');
|
||||||
|
connectBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Forbinder…';
|
||||||
}
|
}
|
||||||
|
setCaseAnyDeskStatus(`Opretter session til ${formatAnyDeskIdClient(anydeskId)}…`);
|
||||||
if (openBtn) {
|
if (openBtn) {
|
||||||
openBtn.disabled = true;
|
openBtn.disabled = true;
|
||||||
}
|
}
|
||||||
@ -13617,22 +13764,17 @@
|
|||||||
caseAnyDeskModal.hide();
|
caseAnyDeskModal.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result?.deep_link) {
|
|
||||||
window.location.href = result.deep_link;
|
|
||||||
}
|
|
||||||
|
|
||||||
alert(`AnyDesk forbindelse startet (session: ${result?.session?.id || '-'})`);
|
|
||||||
if (typeof loadComments === 'function') {
|
if (typeof loadComments === 'function') {
|
||||||
loadComments();
|
loadComments();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Fejl ved AnyDesk quick connect: ' + (e.message || 'Ukendt fejl'));
|
setCaseAnyDeskStatus(`Forbindelsen kunne ikke startes: ${e.message || 'Ukendt fejl'}`, 'danger');
|
||||||
} finally {
|
} finally {
|
||||||
caseAnyDeskConnectInFlight = false;
|
caseAnyDeskConnectInFlight = false;
|
||||||
if (connectBtn) {
|
if (connectBtn) {
|
||||||
connectBtn.disabled = false;
|
connectBtn.innerHTML = '<i class="bi bi-plug-fill me-1"></i>Forbind';
|
||||||
connectBtn.innerHTML = '<i class="bi bi-plug me-1"></i>Forbind og gem';
|
|
||||||
}
|
}
|
||||||
|
onCaseAnyDeskIdInputChange();
|
||||||
if (openBtn) {
|
if (openBtn) {
|
||||||
openBtn.disabled = false;
|
openBtn.disabled = false;
|
||||||
}
|
}
|
||||||
@ -13644,6 +13786,7 @@
|
|||||||
window.registerCaseAnyDeskSession = registerCaseAnyDeskSession;
|
window.registerCaseAnyDeskSession = registerCaseAnyDeskSession;
|
||||||
window.onCaseAnyDeskIdInputChange = onCaseAnyDeskIdInputChange;
|
window.onCaseAnyDeskIdInputChange = onCaseAnyDeskIdInputChange;
|
||||||
window.setCaseAnyDeskInputFromSaved = setCaseAnyDeskInputFromSaved;
|
window.setCaseAnyDeskInputFromSaved = setCaseAnyDeskInputFromSaved;
|
||||||
|
window.connectSavedCaseAnyDesk = connectSavedCaseAnyDesk;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const adjustCaseTopbarOffset = () => {
|
const adjustCaseTopbarOffset = () => {
|
||||||
@ -13674,8 +13817,10 @@
|
|||||||
if (connectBtn) {
|
if (connectBtn) {
|
||||||
connectBtn.removeAttribute('onclick');
|
connectBtn.removeAttribute('onclick');
|
||||||
connectBtn.addEventListener('click', (event) => {
|
connectBtn.addEventListener('click', (event) => {
|
||||||
event.preventDefault();
|
if (connectBtn.getAttribute('aria-disabled') === 'true') {
|
||||||
event.stopPropagation();
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
registerCaseAnyDeskSession();
|
registerCaseAnyDeskSession();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -15377,15 +15522,6 @@
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtmlForInput(value) {
|
|
||||||
return String(value || '')
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
let rewriteReviewState = null;
|
let rewriteReviewState = null;
|
||||||
|
|
||||||
function extractRewriteBody(rawText, context) {
|
function extractRewriteBody(rawText, context) {
|
||||||
@ -15624,7 +15760,7 @@
|
|||||||
|
|
||||||
if (subjectInput && !subjectInput.value.trim()) {
|
if (subjectInput && !subjectInput.value.trim()) {
|
||||||
const title = (currentCaseTitle || '').trim() || 'EMNE PÅ SAGEN';
|
const title = (currentCaseTitle || '').trim() || 'EMNE PÅ SAGEN';
|
||||||
subjectInput.value = escapeHtmlForInput(`(Sag:${caseIds}) - "${title}"`);
|
subjectInput.value = `(Sag:${caseIds}) - "${title}"`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -15670,7 +15806,7 @@
|
|||||||
const replySubject = /^re:\s*/i.test(originalSubject)
|
const replySubject = /^re:\s*/i.test(originalSubject)
|
||||||
? originalSubject
|
? originalSubject
|
||||||
: `Re: ${originalSubject || `Sag #${caseIds}`}`;
|
: `Re: ${originalSubject || `Sag #${caseIds}`}`;
|
||||||
subjectInput.value = escapeHtmlForInput(replySubject);
|
subjectInput.value = replySubject;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bodyInput && !bodyInput.value.trim()) {
|
if (bodyInput && !bodyInput.value.trim()) {
|
||||||
|
|||||||
@ -363,9 +363,10 @@ async def register_manual_session(data: dict):
|
|||||||
|
|
||||||
@router.get("/anydesk/cases/{sag_id}/ids", tags=["Remote Support"])
|
@router.get("/anydesk/cases/{sag_id}/ids", tags=["Remote Support"])
|
||||||
async def list_case_anydesk_ids(sag_id: int):
|
async def list_case_anydesk_ids(sag_id: int):
|
||||||
"""List saved AnyDesk IDs for a case (multi-ID support)."""
|
"""List all useful AnyDesk targets for a case, deduplicated by ID."""
|
||||||
try:
|
try:
|
||||||
_ensure_case_exists(sag_id)
|
case_row = _ensure_case_exists(sag_id)
|
||||||
|
customer_id = case_row.get("customer_id")
|
||||||
|
|
||||||
rows = execute_query(
|
rows = execute_query(
|
||||||
"""
|
"""
|
||||||
@ -400,8 +401,126 @@ async def list_case_anydesk_ids(sag_id: int):
|
|||||||
if serial:
|
if serial:
|
||||||
fragments.append(f"SN: {serial}")
|
fragments.append(f"SN: {serial}")
|
||||||
row["hardware_label"] = " - ".join(fragments) if fragments else None
|
row["hardware_label"] = " - ".join(fragments) if fragments else None
|
||||||
|
row["source"] = "case"
|
||||||
|
|
||||||
return {"ids": rows}
|
history_rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(TRIM(s.device_info->>'customer_machine_id'), ''),
|
||||||
|
NULLIF(TRIM(s.device_info->>'to_id'), '')
|
||||||
|
) AS anydesk_id,
|
||||||
|
s.hardware_asset_id,
|
||||||
|
MAX(s.started_at) AS last_used_at,
|
||||||
|
MAX(
|
||||||
|
NULLIF(
|
||||||
|
TRIM(CONCAT_WS(' ', c.first_name, c.last_name)),
|
||||||
|
''
|
||||||
|
)
|
||||||
|
) AS contact_name,
|
||||||
|
MAX(
|
||||||
|
NULLIF(
|
||||||
|
TRIM(CONCAT_WS(' ', h.brand, h.model)),
|
||||||
|
''
|
||||||
|
)
|
||||||
|
) AS hardware_label
|
||||||
|
FROM anydesk_sessions s
|
||||||
|
LEFT JOIN contacts c ON c.id = s.contact_id
|
||||||
|
LEFT JOIN hardware_assets h ON h.id = s.hardware_asset_id
|
||||||
|
WHERE (
|
||||||
|
s.sag_id = %s
|
||||||
|
OR (%s IS NOT NULL AND s.customer_id = %s)
|
||||||
|
OR s.contact_id IN (
|
||||||
|
SELECT sk.contact_id
|
||||||
|
FROM sag_kontakter sk
|
||||||
|
WHERE sk.sag_id = %s
|
||||||
|
AND sk.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AND COALESCE(
|
||||||
|
NULLIF(TRIM(s.device_info->>'customer_machine_id'), ''),
|
||||||
|
NULLIF(TRIM(s.device_info->>'to_id'), '')
|
||||||
|
) IS NOT NULL
|
||||||
|
GROUP BY
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(TRIM(s.device_info->>'customer_machine_id'), ''),
|
||||||
|
NULLIF(TRIM(s.device_info->>'to_id'), '')
|
||||||
|
),
|
||||||
|
s.hardware_asset_id
|
||||||
|
ORDER BY MAX(s.started_at) DESC NULLS LAST
|
||||||
|
""",
|
||||||
|
(sag_id, customer_id, customer_id, sag_id),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
hardware_rows = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT
|
||||||
|
h.id AS hardware_asset_id,
|
||||||
|
h.anydesk_id,
|
||||||
|
NULLIF(
|
||||||
|
TRIM(CONCAT_WS(
|
||||||
|
' - ',
|
||||||
|
NULLIF(TRIM(CONCAT_WS(' ', h.brand, h.model)), ''),
|
||||||
|
CASE
|
||||||
|
WHEN NULLIF(TRIM(h.serial_number), '') IS NOT NULL
|
||||||
|
THEN 'SN: ' || TRIM(h.serial_number)
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
)),
|
||||||
|
''
|
||||||
|
) AS hardware_label
|
||||||
|
FROM hardware_assets h
|
||||||
|
LEFT JOIN sag_hardware sh
|
||||||
|
ON sh.hardware_id = h.id
|
||||||
|
AND sh.sag_id = %s
|
||||||
|
AND sh.deleted_at IS NULL
|
||||||
|
WHERE h.deleted_at IS NULL
|
||||||
|
AND NULLIF(TRIM(h.anydesk_id), '') IS NOT NULL
|
||||||
|
AND (
|
||||||
|
sh.id IS NOT NULL
|
||||||
|
OR (%s IS NOT NULL AND h.current_owner_customer_id = %s)
|
||||||
|
)
|
||||||
|
ORDER BY hardware_label NULLS LAST
|
||||||
|
""",
|
||||||
|
(sag_id, customer_id, customer_id),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
merged = {}
|
||||||
|
|
||||||
|
def add_target(target: dict, source: str) -> None:
|
||||||
|
anydesk_id = _normalize_anydesk_id(target.get("anydesk_id"))
|
||||||
|
if not anydesk_id:
|
||||||
|
return
|
||||||
|
target = dict(target)
|
||||||
|
target["anydesk_id"] = anydesk_id
|
||||||
|
target["source"] = source
|
||||||
|
existing = merged.get(anydesk_id)
|
||||||
|
if not existing:
|
||||||
|
merged[anydesk_id] = target
|
||||||
|
return
|
||||||
|
|
||||||
|
# A manually saved case target remains authoritative, but enrich it
|
||||||
|
# with contact/hardware/history information from other sources.
|
||||||
|
for key in ("hardware_asset_id", "hardware_label", "contact_name", "last_used_at"):
|
||||||
|
if not existing.get(key) and target.get(key):
|
||||||
|
existing[key] = target[key]
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
add_target(row, "case")
|
||||||
|
for row in hardware_rows:
|
||||||
|
add_target(row, "hardware")
|
||||||
|
for row in history_rows:
|
||||||
|
add_target(row, "contact" if row.get("contact_name") else "history")
|
||||||
|
|
||||||
|
targets = list(merged.values())
|
||||||
|
targets.sort(
|
||||||
|
key=lambda item: (
|
||||||
|
0 if item.get("is_primary") else 1,
|
||||||
|
0 if item.get("source") == "case" else 1,
|
||||||
|
str(item.get("hardware_label") or item.get("contact_name") or item.get("anydesk_id")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {"ids": targets}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -1411,4 +1530,3 @@ async def anydesk_hardware_list():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -159,12 +159,20 @@ class EmailProcessorService:
|
|||||||
email_id,
|
email_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if require_manual_approval and not has_helpdesk_hint:
|
# Bankruptcy notices are a safety-critical system workflow and run
|
||||||
|
# automatically, but only act when an explicit CVR matches exactly.
|
||||||
|
is_bankruptcy = classification == 'bankruptcy'
|
||||||
|
|
||||||
|
if require_manual_approval and not has_helpdesk_hint and not is_bankruptcy:
|
||||||
await self._set_awaiting_user_action(email_id, reason='manual_approval_required')
|
await self._set_awaiting_user_action(email_id, reason='manual_approval_required')
|
||||||
stats['awaiting_user_action'] = True
|
stats['awaiting_user_action'] = True
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
if (not classification or confidence < settings.EMAIL_AI_CONFIDENCE_THRESHOLD) and not has_helpdesk_hint:
|
if (
|
||||||
|
(not classification or confidence < settings.EMAIL_AI_CONFIDENCE_THRESHOLD)
|
||||||
|
and not has_helpdesk_hint
|
||||||
|
and not is_bankruptcy
|
||||||
|
):
|
||||||
await self._set_awaiting_user_action(email_id, reason='low_confidence')
|
await self._set_awaiting_user_action(email_id, reason='low_confidence')
|
||||||
stats['awaiting_user_action'] = True
|
stats['awaiting_user_action'] = True
|
||||||
return stats
|
return stats
|
||||||
|
|||||||
@ -182,9 +182,10 @@ class EmailWorkflowService:
|
|||||||
# Safe parameterized query for variable list length
|
# Safe parameterized query for variable list length
|
||||||
format_strings = ','.join(['%s'] * len(unique_cvrs))
|
format_strings = ','.join(['%s'] * len(unique_cvrs))
|
||||||
query = f"""
|
query = f"""
|
||||||
SELECT id, name, cvr_number
|
SELECT id, name, cvr_number
|
||||||
FROM customers
|
FROM customers
|
||||||
WHERE cvr_number IN ({format_strings})
|
WHERE REGEXP_REPLACE(COALESCE(cvr_number, ''), '[^0-9]', '', 'g') IN ({format_strings})
|
||||||
|
AND is_active = true
|
||||||
"""
|
"""
|
||||||
|
|
||||||
matching_customers = execute_query(query, tuple(unique_cvrs))
|
matching_customers = execute_query(query, tuple(unique_cvrs))
|
||||||
@ -200,28 +201,112 @@ class EmailWorkflowService:
|
|||||||
)
|
)
|
||||||
return {'status': 'completed', 'action': 'marked_processed_no_match'}
|
return {'status': 'completed', 'action': 'marked_processed_no_match'}
|
||||||
|
|
||||||
logger.warning(f"⚠️ FOUND BANKRUPTCY MATCHES: {[c['name'] for c in matching_customers]}")
|
logger.warning("⚠️ FOUND BANKRUPTCY MATCHES: %s", [c['name'] for c in matching_customers])
|
||||||
|
email_id = int(email_data["id"])
|
||||||
# Link to the first customer found (limitation of 1:1 schema)
|
subject = str(email_data.get("subject") or "Konkursmeddelelse").strip()
|
||||||
|
body_text = self._extract_primary_email_body(email_data)
|
||||||
|
created_cases = []
|
||||||
|
alert_ids = []
|
||||||
|
|
||||||
|
for customer in matching_customers:
|
||||||
|
customer_id = int(customer["id"])
|
||||||
|
existing_case = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT s.id, s.titel
|
||||||
|
FROM sag_sager s
|
||||||
|
JOIN sag_emails se ON se.sag_id = s.id
|
||||||
|
WHERE se.email_id = %s
|
||||||
|
AND s.customer_id = %s
|
||||||
|
AND s.deleted_at IS NULL
|
||||||
|
ORDER BY s.id ASC LIMIT 1
|
||||||
|
""",
|
||||||
|
(email_id, customer_id),
|
||||||
|
)
|
||||||
|
if existing_case:
|
||||||
|
case = existing_case[0]
|
||||||
|
else:
|
||||||
|
case_rows = execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_sager (
|
||||||
|
titel, beskrivelse, template_key, status, customer_id, created_by_user_id
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, 'ticket', 'åben', %s, 1)
|
||||||
|
RETURNING id, titel, customer_id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
f"KONKURSVARSEL – {customer['name']}: {subject}"[:255],
|
||||||
|
(
|
||||||
|
"Automatisk oprettet fra eksakt CVR-match i konkursmail.\n"
|
||||||
|
f"Kunde: {customer['name']}\n"
|
||||||
|
f"CVR: {customer['cvr_number']}\n"
|
||||||
|
f"Email-ID: {email_id}\n\n{body_text}"
|
||||||
|
),
|
||||||
|
customer_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not case_rows:
|
||||||
|
raise ValueError(f"Kunne ikke oprette konkurssag for kunde {customer_id}")
|
||||||
|
case = case_rows[0]
|
||||||
|
self._add_helpdesk_comment(int(case["id"]), email_data)
|
||||||
|
self._link_email_to_sag(int(case["id"]), email_id)
|
||||||
|
created_cases.append(int(case["id"]))
|
||||||
|
|
||||||
|
alert_marker = f"Konkursmail Email-ID: {email_id}"
|
||||||
|
existing_alert = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT id FROM alert_notes
|
||||||
|
WHERE entity_type = 'customer'
|
||||||
|
AND entity_id = %s
|
||||||
|
AND active = true
|
||||||
|
AND message LIKE %s
|
||||||
|
ORDER BY id ASC LIMIT 1
|
||||||
|
""",
|
||||||
|
(customer_id, f"%{alert_marker}%"),
|
||||||
|
)
|
||||||
|
if existing_alert:
|
||||||
|
alert_id = int(existing_alert[0]["id"])
|
||||||
|
else:
|
||||||
|
alert_rows = execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO alert_notes (
|
||||||
|
entity_type, entity_id, title, message, severity,
|
||||||
|
requires_acknowledgement, active, created_by_user_id
|
||||||
|
)
|
||||||
|
VALUES ('customer', %s, %s, %s, 'critical', true, true, 1)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
customer_id,
|
||||||
|
"KONKURSVARSEL – kræver handling",
|
||||||
|
(
|
||||||
|
f"{alert_marker}\n"
|
||||||
|
f"Der er modtaget en konkursmeddelelse med eksakt CVR-match.\n"
|
||||||
|
f"CVR: {customer['cvr_number']}\n"
|
||||||
|
f"Sag: #{case['id']} – {case['titel']}"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
alert_id = int(alert_rows[0]["id"])
|
||||||
|
alert_ids.append(alert_id)
|
||||||
|
|
||||||
first_match = matching_customers[0]
|
first_match = matching_customers[0]
|
||||||
|
|
||||||
execute_update(
|
execute_update(
|
||||||
"""UPDATE email_messages
|
"""
|
||||||
SET customer_id = %s, status = 'processed', folder = 'Processed',
|
UPDATE email_messages
|
||||||
processed_at = CURRENT_TIMESTAMP, auto_processed = true
|
SET customer_id = %s, linked_case_id = %s,
|
||||||
WHERE id = %s""",
|
status = 'processed', folder = 'Processed',
|
||||||
(first_match['id'], email_data['id'])
|
processed_at = CURRENT_TIMESTAMP, auto_processed = true
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(first_match["id"], created_cases[0], email_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"🔗 Linked bankruptcy email {email_data['id']} to customer {first_match['name']} ({first_match['id']}) and marked as processed")
|
|
||||||
|
|
||||||
if len(matching_customers) > 1:
|
|
||||||
logger.warning(f"❗ Email contained multiple customer matches! Only linked to first one.")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'completed',
|
"status": "completed",
|
||||||
'action': 'linked_customer',
|
"action": "created_bankruptcy_cases_and_alerts",
|
||||||
'customer_name': first_match['name']
|
"customer_ids": [int(c["id"]) for c in matching_customers],
|
||||||
|
"sag_ids": created_cases,
|
||||||
|
"alert_note_ids": alert_ids,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _extract_sender_domain(self, email_data: Dict) -> Optional[str]:
|
def _extract_sender_domain(self, email_data: Dict) -> Optional[str]:
|
||||||
|
|||||||
55
app/vendors/backend/router.py
vendored
55
app/vendors/backend/router.py
vendored
@ -55,6 +55,61 @@ class VendorCustomerLinkCreate(BaseModel):
|
|||||||
relationship_type: Optional[str] = "supplier"
|
relationship_type: Optional[str] = "supplier"
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vendors/{vendor_id}/email-domains", tags=["Vendors"])
|
||||||
|
async def list_vendor_email_domains(vendor_id: int):
|
||||||
|
return execute_query(
|
||||||
|
"SELECT domain, created_at FROM vendor_email_domains WHERE vendor_id = %s ORDER BY domain",
|
||||||
|
(vendor_id,),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vendors/{vendor_id}/email-domains", tags=["Vendors"])
|
||||||
|
async def add_vendor_email_domain(vendor_id: int, payload: EmailDomainCreate):
|
||||||
|
if not execute_query_single("SELECT id FROM vendors WHERE id = %s", (vendor_id,)):
|
||||||
|
raise HTTPException(status_code=404, detail="Leverandør ikke fundet")
|
||||||
|
domain = _normalize_email_domain(payload.domain)
|
||||||
|
existing = execute_query_single(
|
||||||
|
"SELECT vendor_id FROM vendor_email_domains WHERE domain = %s",
|
||||||
|
(domain,),
|
||||||
|
)
|
||||||
|
if existing and int(existing["vendor_id"]) != vendor_id:
|
||||||
|
raise HTTPException(status_code=409, detail="Domænet tilhører allerede en anden leverandør")
|
||||||
|
return execute_query_single(
|
||||||
|
"""
|
||||||
|
INSERT INTO vendor_email_domains (domain, vendor_id)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
ON CONFLICT (domain) DO UPDATE SET vendor_id = EXCLUDED.vendor_id
|
||||||
|
RETURNING domain, vendor_id, created_at
|
||||||
|
""",
|
||||||
|
(domain, vendor_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/vendors/{vendor_id}/email-domains/{domain}", tags=["Vendors"])
|
||||||
|
async def delete_vendor_email_domain(vendor_id: int, domain: str):
|
||||||
|
normalized = _normalize_email_domain(domain)
|
||||||
|
deleted = execute_update(
|
||||||
|
"DELETE FROM vendor_email_domains WHERE vendor_id = %s AND domain = %s",
|
||||||
|
(vendor_id, normalized),
|
||||||
|
)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Domænet blev ikke fundet")
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/vendors", response_model=List[Vendor], tags=["Vendors"])
|
@router.get("/vendors", response_model=List[Vendor], tags=["Vendors"])
|
||||||
async def list_vendors(
|
async def list_vendors(
|
||||||
search: Optional[str] = Query(None, description="Search by name, CVR, or domain"),
|
search: Optional[str] = Query(None, description="Search by name, CVR, or domain"),
|
||||||
|
|||||||
57
app/vendors/frontend/vendor_detail.html
vendored
57
app/vendors/frontend/vendor_detail.html
vendored
@ -184,6 +184,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-4 mt-4">
|
||||||
|
<h5 class="mb-2 fw-bold">Emaildomæner</h5>
|
||||||
|
<p class="small text-muted">Kun eksakte domæner bruges til automatisk match.</p>
|
||||||
|
<div class="input-group input-group-sm mb-3">
|
||||||
|
<input id="vendorEmailDomainInput" class="form-control" placeholder="fx cloudfactorygroup.com">
|
||||||
|
<button class="btn btn-primary" onclick="addVendorEmailDomain()">Tilføj</button>
|
||||||
|
</div>
|
||||||
|
<div id="vendorEmailDomains" class="d-flex flex-wrap gap-2"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -372,6 +382,7 @@ async function loadVendor() {
|
|||||||
}
|
}
|
||||||
const vendor = await response.json();
|
const vendor = await response.json();
|
||||||
displayVendor(vendor);
|
displayVendor(vendor);
|
||||||
|
await loadVendorEmailDomains();
|
||||||
await loadVendorCustomers();
|
await loadVendorCustomers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading vendor:', error);
|
console.error('Error loading vendor:', error);
|
||||||
@ -379,6 +390,52 @@ async function loadVendor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadVendorEmailDomains() {
|
||||||
|
const host = document.getElementById('vendorEmailDomains');
|
||||||
|
if (!host) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/vendors/${vendorId}/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 => `
|
||||||
|
<span class="badge bg-light text-dark border p-2">
|
||||||
|
${escapeHtml(row.domain)}
|
||||||
|
<button class="btn btn-link btn-sm text-danger p-0 ms-2" onclick="deleteVendorEmailDomain('${encodeURIComponent(row.domain)}')" title="Fjern">×</button>
|
||||||
|
</span>
|
||||||
|
`).join('') : '<span class="small text-muted">Ingen domæner registreret.</span>';
|
||||||
|
} catch (error) {
|
||||||
|
host.innerHTML = `<span class="small text-danger">${escapeHtml(error.message)}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addVendorEmailDomain() {
|
||||||
|
const input = document.getElementById('vendorEmailDomainInput');
|
||||||
|
const domain = input.value.trim();
|
||||||
|
if (!domain) return;
|
||||||
|
const response = await fetch(`/api/v1/vendors/${vendorId}/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 loadVendorEmailDomains();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteVendorEmailDomain(encodedDomain) {
|
||||||
|
const response = await fetch(`/api/v1/vendors/${vendorId}/email-domains/${encodedDomain}`, { method: 'DELETE' });
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({}));
|
||||||
|
alert(error.detail || 'Domænet kunne ikke fjernes');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadVendorEmailDomains();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadVendorCustomers() {
|
async function loadVendorCustomers() {
|
||||||
const listEl = document.getElementById('vendorCustomersList');
|
const listEl = document.getElementById('vendorCustomersList');
|
||||||
const emptyEl = document.getElementById('vendorCustomersEmpty');
|
const emptyEl = document.getElementById('vendorCustomersEmpty');
|
||||||
|
|||||||
26
migrations/1014_vendor_email_domains.sql
Normal file
26
migrations/1014_vendor_email_domains.sql
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
-- Multiple exact sender domains per vendor.
|
||||||
|
CREATE TABLE IF NOT EXISTS vendor_email_domains (
|
||||||
|
domain TEXT PRIMARY KEY,
|
||||||
|
vendor_id INTEGER NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vendor_email_domains_vendor_id
|
||||||
|
ON vendor_email_domains(vendor_id);
|
||||||
|
|
||||||
|
INSERT INTO vendor_email_domains (domain, vendor_id)
|
||||||
|
SELECT LOWER(TRIM(domain)), id
|
||||||
|
FROM vendors
|
||||||
|
WHERE NULLIF(TRIM(domain), '') IS NOT NULL
|
||||||
|
ON CONFLICT (domain) DO NOTHING;
|
||||||
|
|
||||||
|
-- Also promote the legacy single customer domain into the existing
|
||||||
|
-- multi-domain mapping table.
|
||||||
|
INSERT INTO email_domain_customer_mappings (domain, customer_id, source)
|
||||||
|
SELECT LOWER(TRIM(email_domain)), id, 'legacy_customer_domain'
|
||||||
|
FROM customers
|
||||||
|
WHERE NULLIF(TRIM(email_domain), '') IS NOT NULL
|
||||||
|
ON CONFLICT (domain) DO NOTHING;
|
||||||
|
|
||||||
|
COMMENT ON TABLE vendor_email_domains IS
|
||||||
|
'Exact trusted sender domains belonging to vendors; no fuzzy matching.';
|
||||||
9
migrations/1015_add_accounting_group.sql
Normal file
9
migrations/1015_add_accounting_group.sql
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
INSERT INTO groups (name, description)
|
||||||
|
SELECT
|
||||||
|
'Bogholderi',
|
||||||
|
'Kundehenvendelser og opgaver vedrørende bogholderi og økonomi'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM groups
|
||||||
|
WHERE LOWER(TRIM(name)) IN ('bogholderi', 'økonomi', 'okonomi', 'accounting')
|
||||||
|
);
|
||||||
66
tests/test_bankruptcy_workflow.py
Normal file
66
tests/test_bankruptcy_workflow.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
from app.services import email_workflow_service as workflow_module
|
||||||
|
|
||||||
|
|
||||||
|
def test_bankruptcy_exact_cvr_creates_case_alert_and_links_email(monkeypatch):
|
||||||
|
queries = []
|
||||||
|
updates = []
|
||||||
|
|
||||||
|
def fake_query(sql, params=()):
|
||||||
|
queries.append((sql, params))
|
||||||
|
if "FROM customers" in sql:
|
||||||
|
return [{"id": 42, "name": "Testkunde ApS", "cvr_number": "12345678"}]
|
||||||
|
if "FROM sag_sager s" in sql:
|
||||||
|
return []
|
||||||
|
if "INSERT INTO sag_sager" in sql:
|
||||||
|
return [{"id": 700, "titel": "KONKURSVARSEL", "customer_id": 42}]
|
||||||
|
if "SELECT id FROM alert_notes" in sql:
|
||||||
|
return []
|
||||||
|
if "INSERT INTO alert_notes" in sql:
|
||||||
|
return [{"id": 900}]
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(workflow_module, "execute_query", fake_query)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
workflow_module,
|
||||||
|
"execute_update",
|
||||||
|
lambda sql, params=(): updates.append((sql, params)) or 1,
|
||||||
|
)
|
||||||
|
service = workflow_module.EmailWorkflowService()
|
||||||
|
monkeypatch.setattr(service, "_add_helpdesk_comment", lambda *args: None)
|
||||||
|
monkeypatch.setattr(service, "_link_email_to_sag", lambda *args: None)
|
||||||
|
|
||||||
|
result = asyncio.run(service._handle_bankruptcy_analysis({
|
||||||
|
"id": 55,
|
||||||
|
"subject": "Konkursdekret",
|
||||||
|
"body_text": "Vedrørende CVR-nr.: 12345678",
|
||||||
|
"body_html": "",
|
||||||
|
"sender_email": "statstidende@example.dk",
|
||||||
|
}))
|
||||||
|
|
||||||
|
assert result["action"] == "created_bankruptcy_cases_and_alerts"
|
||||||
|
assert result["customer_ids"] == [42]
|
||||||
|
assert result["sag_ids"] == [700]
|
||||||
|
assert result["alert_note_ids"] == [900]
|
||||||
|
assert any("linked_case_id" in sql for sql, _ in updates)
|
||||||
|
assert any("INSERT INTO alert_notes" in sql for sql, _ in queries)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bankruptcy_without_explicit_cvr_does_nothing(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
workflow_module,
|
||||||
|
"execute_query",
|
||||||
|
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("DB må ikke kaldes")),
|
||||||
|
)
|
||||||
|
service = workflow_module.EmailWorkflowService()
|
||||||
|
result = asyncio.run(service._handle_bankruptcy_analysis({
|
||||||
|
"id": 56,
|
||||||
|
"subject": "Mulig konkurs",
|
||||||
|
"body_text": "Ingen CVR-oplysning i teksten",
|
||||||
|
"body_html": "",
|
||||||
|
}))
|
||||||
|
assert result == {"status": "skipped", "reason": "no_cvr_found"}
|
||||||
Loading…
Reference in New Issue
Block a user