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
|
||||
|
||||
|
||||
class EmailDomainCreate(BaseModel):
|
||||
domain: str
|
||||
|
||||
|
||||
def _normalize_email_domain(value: str) -> str:
|
||||
domain = str(value or "").strip().lower()
|
||||
domain = domain.removeprefix("https://").removeprefix("http://").removeprefix("www.")
|
||||
domain = domain.split("/", 1)[0].strip(". ")
|
||||
if not domain or "." not in domain or "@" in domain:
|
||||
raise HTTPException(status_code=400, detail="Ugyldigt emaildomæne")
|
||||
return domain
|
||||
|
||||
|
||||
class ContactCreate(BaseModel):
|
||||
first_name: str
|
||||
last_name: str
|
||||
@ -461,6 +474,59 @@ async def verify_customer_linking():
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/customers/{customer_id}/email-domains")
|
||||
async def list_customer_email_domains(customer_id: int):
|
||||
customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,))
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Kunde ikke fundet")
|
||||
rows = execute_query(
|
||||
"""
|
||||
SELECT domain, created_at
|
||||
FROM email_domain_customer_mappings
|
||||
WHERE customer_id = %s
|
||||
ORDER BY domain
|
||||
""",
|
||||
(customer_id,),
|
||||
) or []
|
||||
return rows
|
||||
|
||||
|
||||
@router.post("/customers/{customer_id}/email-domains")
|
||||
async def add_customer_email_domain(customer_id: int, payload: EmailDomainCreate):
|
||||
if not execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,)):
|
||||
raise HTTPException(status_code=404, detail="Kunde ikke fundet")
|
||||
domain = _normalize_email_domain(payload.domain)
|
||||
existing = execute_query_single(
|
||||
"SELECT customer_id FROM email_domain_customer_mappings WHERE domain = %s",
|
||||
(domain,),
|
||||
)
|
||||
if existing and int(existing["customer_id"]) != customer_id:
|
||||
raise HTTPException(status_code=409, detail="Domænet tilhører allerede en anden kunde")
|
||||
return execute_query_single(
|
||||
"""
|
||||
INSERT INTO email_domain_customer_mappings (domain, customer_id, source)
|
||||
VALUES (%s, %s, 'manual_customer')
|
||||
ON CONFLICT (domain) DO UPDATE SET
|
||||
customer_id = EXCLUDED.customer_id,
|
||||
source = EXCLUDED.source
|
||||
RETURNING domain, customer_id, created_at
|
||||
""",
|
||||
(domain, customer_id),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/customers/{customer_id}/email-domains/{domain}")
|
||||
async def delete_customer_email_domain(customer_id: int, domain: str):
|
||||
normalized = _normalize_email_domain(domain)
|
||||
deleted = execute_update(
|
||||
"DELETE FROM email_domain_customer_mappings WHERE customer_id = %s AND domain = %s",
|
||||
(customer_id, normalized),
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Domænet blev ikke fundet")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/customers/{customer_id}")
|
||||
async def get_customer(customer_id: int):
|
||||
"""Get single customer by ID with contact count and vTiger BMC Låst status"""
|
||||
|
||||
@ -1000,6 +1000,16 @@
|
||||
<span class="info-value" id="wikiLink">-</span>
|
||||
</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 class="col-lg-6">
|
||||
@ -2468,6 +2478,7 @@ async function loadCustomer() {
|
||||
|
||||
customerData = await response.json();
|
||||
displayCustomer(customerData);
|
||||
await loadCustomerEmailDomains();
|
||||
|
||||
await loadUtilityCompany();
|
||||
await loadCustomerTags();
|
||||
@ -2482,6 +2493,52 @@ async function loadCustomer() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomerEmailDomains() {
|
||||
const host = document.getElementById('customerEmailDomains');
|
||||
if (!host) return;
|
||||
try {
|
||||
const response = await fetch(`/api/v1/customers/${customerId}/email-domains`);
|
||||
if (!response.ok) throw new Error('Kunne ikke hente domæner');
|
||||
const rows = await response.json();
|
||||
host.innerHTML = rows.length ? rows.map(row => `
|
||||
<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() {
|
||||
const container = document.getElementById('customerVendorLinksContainer');
|
||||
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_workflow_service import email_workflow_service
|
||||
from app.services.ollama_service import ollama_service
|
||||
from app.services.simple_classifier import simple_classifier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -388,6 +389,11 @@ class CreateSagFromEmailRequest(BaseModel):
|
||||
relation_type: str = "mail"
|
||||
|
||||
|
||||
class EmailQuickActionRequest(BaseModel):
|
||||
action: str
|
||||
titel: Optional[str] = None
|
||||
|
||||
|
||||
class EmailReadStateUpdate(BaseModel):
|
||||
is_read: bool
|
||||
|
||||
@ -505,6 +511,11 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
steps = wf.get('workflow_steps')
|
||||
steps_total = len(steps) if isinstance(steps, list) else 0
|
||||
actions = [
|
||||
str(step.get("action") or "").strip()
|
||||
for step in (steps or [])
|
||||
if isinstance(step, dict) and str(step.get("action") or "").strip()
|
||||
]
|
||||
row = {
|
||||
'id': wf.get('id'),
|
||||
'name': wf.get('name'),
|
||||
@ -515,12 +526,17 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
'sender_pattern': sender_pattern,
|
||||
'subject_pattern': subject_pattern,
|
||||
'steps_total': steps_total,
|
||||
'actions': actions,
|
||||
'matches': bool(matches),
|
||||
'reasons': reasons,
|
||||
}
|
||||
candidates.append(row)
|
||||
if matches:
|
||||
matching.append(row)
|
||||
# Preview must mirror execution: a completed stop-on-match workflow
|
||||
# prevents lower-priority workflows from running.
|
||||
if row["stop_on_match"]:
|
||||
break
|
||||
|
||||
system_matches = []
|
||||
if classification == 'bankruptcy':
|
||||
@ -529,12 +545,16 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
'name': 'System: Bankruptcy Analysis',
|
||||
'matches': True,
|
||||
'reason': 'classification == bankruptcy',
|
||||
'effect': 'Ved eksakt CVR-match oprettes en sag og en kritisk Alert Note på kunden',
|
||||
'automatic': True,
|
||||
})
|
||||
|
||||
has_hint = email_workflow_service.has_helpdesk_routing_hint(email_data)
|
||||
supplier_document = classification in {'invoice', 'order_confirmation', 'freight_note'}
|
||||
hard_skip = {'newsletter', 'spam'}
|
||||
should_try_helpdesk = (
|
||||
classification not in hard_skip
|
||||
not supplier_document
|
||||
and classification not in hard_skip
|
||||
and (
|
||||
classification not in email_workflow_service.HELPDESK_SKIP_CLASSIFICATIONS
|
||||
or has_hint
|
||||
@ -545,6 +565,15 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
'name': 'System: Helpdesk SAG routing',
|
||||
'matches': bool(should_try_helpdesk),
|
||||
'reason': 'hint_or_allowed_classification' if should_try_helpdesk else 'classification_in_skip_list',
|
||||
'effect': (
|
||||
'Forsøger at finde en eksisterende sag eller oprette en sag for en kendt kunde'
|
||||
if should_try_helpdesk
|
||||
else (
|
||||
'Ingen supportsag oprettes – dokumentet behandles som leverandørbilag'
|
||||
if supplier_document
|
||||
else 'Ingen sag oprettes eller tilknyttes automatisk'
|
||||
)
|
||||
),
|
||||
})
|
||||
|
||||
return {
|
||||
@ -559,7 +588,60 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
'matching_workflows': matching,
|
||||
'workflow_candidates': candidates,
|
||||
'auto_run_enabled': bool(getattr(settings, 'EMAIL_WORKFLOW_AUTORUN_ENABLED', False)),
|
||||
'automatic_execution': classification == 'bankruptcy',
|
||||
}
|
||||
|
||||
|
||||
def _find_existing_vendor_for_email(email_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Match only trusted exact identifiers. Company-name guessing is forbidden."""
|
||||
extracted_cvr = str(email_data.get("extracted_vendor_cvr") or "").strip()
|
||||
sender_email = str(email_data.get("sender_email") or "").strip().lower()
|
||||
sender_domain = sender_email.rsplit("@", 1)[1] if "@" in sender_email else ""
|
||||
|
||||
if extracted_cvr:
|
||||
match = execute_query_single(
|
||||
"""
|
||||
SELECT id, name, cvr_number, domain, email, 100 AS match_score,
|
||||
'exact_cvr'::text AS source
|
||||
FROM vendors
|
||||
WHERE is_active = true
|
||||
AND TRIM(COALESCE(cvr_number, '')) = %s
|
||||
ORDER BY id ASC LIMIT 1
|
||||
""",
|
||||
(extracted_cvr,),
|
||||
)
|
||||
if match:
|
||||
return match
|
||||
|
||||
if sender_email:
|
||||
match = execute_query_single(
|
||||
"""
|
||||
SELECT id, name, cvr_number, domain, email, 100 AS match_score,
|
||||
'exact_email'::text AS source
|
||||
FROM vendors
|
||||
WHERE is_active = true
|
||||
AND LOWER(TRIM(COALESCE(email, ''))) = %s
|
||||
ORDER BY id ASC LIMIT 1
|
||||
""",
|
||||
(sender_email,),
|
||||
)
|
||||
if match:
|
||||
return match
|
||||
|
||||
if not sender_domain:
|
||||
return None
|
||||
|
||||
return execute_query_single(
|
||||
"""
|
||||
SELECT v.id, v.name, v.cvr_number, v.domain, v.email,
|
||||
100 AS match_score, 'exact_domain'::text AS source
|
||||
FROM vendor_email_domains d
|
||||
JOIN vendors v ON v.id = d.vendor_id
|
||||
WHERE v.is_active = true AND LOWER(TRIM(d.domain)) = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(sender_domain,),
|
||||
)
|
||||
context: Optional[str] = None
|
||||
|
||||
|
||||
@ -782,35 +864,6 @@ async def get_domain_customer_suggestion(email_id: int):
|
||||
},
|
||||
}
|
||||
|
||||
partial = execute_query_single(
|
||||
"""
|
||||
SELECT id, name, email_domain, cvr_number
|
||||
FROM customers
|
||||
WHERE is_active = true
|
||||
AND COALESCE(email_domain, '') ILIKE %s
|
||||
ORDER BY name ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(f"%{sender_domain}%",),
|
||||
)
|
||||
|
||||
if partial:
|
||||
return {
|
||||
"email_id": email_id,
|
||||
"domain": sender_domain,
|
||||
"has_customer": False,
|
||||
"ignored": False,
|
||||
"suggestion": {
|
||||
"customer_id": partial["id"],
|
||||
"customer_name": partial["name"],
|
||||
"email_domain": partial.get("email_domain"),
|
||||
"cvr_number": partial.get("cvr_number"),
|
||||
"confidence": "medium",
|
||||
"score": 70,
|
||||
"source": "partial_domain",
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
"email_id": email_id,
|
||||
"domain": sender_domain,
|
||||
@ -1252,15 +1305,22 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
|
||||
requested_case_type = _normalize_case_type(payload.case_type)
|
||||
|
||||
customer_id = payload.customer_id or email_data.get('customer_id')
|
||||
if not customer_id and _is_supplier_case_type(requested_case_type):
|
||||
customer_id = _ensure_customer_from_vendor(email_data.get('supplier_id'))
|
||||
# A vendor is not a customer. Supplier-originated cases are owned by
|
||||
# the internal procurement customer, while the email keeps only its
|
||||
# supplier_id relation.
|
||||
if not customer_id and email_data.get('supplier_id'):
|
||||
customer_id = _resolve_procurement_customer_id()
|
||||
if not customer_id and _is_supplier_case_type(requested_case_type):
|
||||
customer_id = _resolve_procurement_customer_id()
|
||||
|
||||
if not customer_id:
|
||||
raise HTTPException(status_code=400, detail="customer_id is required (missing on email and payload)")
|
||||
|
||||
if not email_data.get('customer_id') and customer_id:
|
||||
if (
|
||||
not _is_supplier_case_type(requested_case_type)
|
||||
and not email_data.get('customer_id')
|
||||
and customer_id
|
||||
):
|
||||
execute_update(
|
||||
"UPDATE email_messages SET customer_id = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||
(customer_id, email_id),
|
||||
@ -1268,7 +1328,7 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
|
||||
|
||||
sender_domain = _extract_sender_domain(email_data.get("sender_email"))
|
||||
if sender_domain and not _is_ignored_sender_domain(sender_domain):
|
||||
_upsert_domain_mapping(sender_domain, int(customer_id), "supplier_auto")
|
||||
_upsert_domain_mapping(sender_domain, int(customer_id), "case_creation")
|
||||
|
||||
titel = (payload.titel or email_data.get('subject') or f"E-mail fra {email_data.get('sender_email', 'ukendt afsender')}").strip()
|
||||
beskrivelse_raw = payload.beskrivelse or email_data.get('body_text') or email_data.get('body_html') or ''
|
||||
@ -1377,6 +1437,121 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/emails/{email_id}/quick-action")
|
||||
async def run_email_quick_action(email_id: int, payload: EmailQuickActionRequest):
|
||||
"""Run one explicit, identity-safe action from the email decision panel."""
|
||||
action = str(payload.action or "").strip().lower()
|
||||
allowed_actions = {
|
||||
"create_customer_case",
|
||||
"create_supplier_invoice",
|
||||
"create_accounting_case",
|
||||
"archive_entity_mail",
|
||||
}
|
||||
if action not in allowed_actions:
|
||||
raise HTTPException(status_code=400, detail="Ukendt emailhandling")
|
||||
|
||||
email_data = execute_query_single(
|
||||
"""
|
||||
SELECT id, subject, sender_email, body_text, body_html, classification,
|
||||
confidence_score, customer_id, supplier_id, linked_case_id
|
||||
FROM email_messages
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
""",
|
||||
(email_id,),
|
||||
)
|
||||
if not email_data:
|
||||
raise HTTPException(status_code=404, detail="Email blev ikke fundet")
|
||||
|
||||
customer_id = email_data.get("customer_id")
|
||||
supplier_id = email_data.get("supplier_id")
|
||||
title = str(payload.titel or email_data.get("subject") or "Sag fra email").strip()
|
||||
|
||||
if action in {"create_customer_case", "create_accounting_case"}:
|
||||
if not customer_id:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Fastslå og tilknyt kunden, før du opretter en kundesag",
|
||||
)
|
||||
|
||||
group_id = None
|
||||
case_type = "support"
|
||||
if action == "create_accounting_case":
|
||||
group = execute_query_single(
|
||||
"""
|
||||
SELECT id
|
||||
FROM groups
|
||||
WHERE LOWER(TRIM(name)) IN ('bogholderi', 'økonomi', 'okonomi', 'accounting')
|
||||
ORDER BY CASE WHEN LOWER(TRIM(name)) = 'bogholderi' THEN 0 ELSE 1 END, id
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
if not group:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Bogholderi-gruppen mangler i Indstillinger → Grupper",
|
||||
)
|
||||
group_id = int(group["id"])
|
||||
case_type = "bogholderi"
|
||||
|
||||
return await create_sag_from_email(
|
||||
email_id,
|
||||
CreateSagFromEmailRequest(
|
||||
titel=title,
|
||||
customer_id=int(customer_id),
|
||||
case_type=case_type,
|
||||
assigned_group_id=group_id,
|
||||
),
|
||||
)
|
||||
|
||||
if action == "create_supplier_invoice":
|
||||
if not supplier_id:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Fastslå og tilknyt leverandøren, før fakturaen behandles",
|
||||
)
|
||||
|
||||
# This button is an explicit user decision and therefore authoritative.
|
||||
email_data["classification"] = "invoice"
|
||||
email_data["confidence_score"] = 1.0
|
||||
execute_update(
|
||||
"""
|
||||
UPDATE email_messages
|
||||
SET classification = 'invoice', confidence_score = 1.0,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""",
|
||||
(email_id,),
|
||||
)
|
||||
|
||||
result = await email_workflow_service.execute_workflows(email_data)
|
||||
return {
|
||||
"success": True,
|
||||
"action": action,
|
||||
"result": result,
|
||||
"message": "Leverandørfaktura sendt til behandling",
|
||||
}
|
||||
|
||||
if not customer_id and not supplier_id:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Fastslå kunde eller leverandør, før mailen arkiveres",
|
||||
)
|
||||
|
||||
execute_update(
|
||||
"""
|
||||
UPDATE email_messages
|
||||
SET status = 'archived', folder = 'Archive', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""",
|
||||
(email_id,),
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"action": action,
|
||||
"message": "Mailen er arkiveret under den fastsatte kunde eller leverandør",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/emails/{email_id}/link-sag")
|
||||
async def link_email_to_sag(email_id: int, payload: LinkEmailToSagRequest):
|
||||
"""Link an email to an existing SAG and optionally append a system note."""
|
||||
@ -2240,12 +2415,29 @@ async def upload_emails(files: List[UploadFile] = File(...)):
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Classification failed for uploaded email: {e}")
|
||||
|
||||
# Execute workflows
|
||||
try:
|
||||
logger.info(f"⚙️ Executing workflows for email {email_id}...")
|
||||
await email_workflow_service.execute_workflows_for_email(email_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Workflow execution failed for uploaded email: {e}")
|
||||
# Manual uploads follow the same approval policy as mailbox imports:
|
||||
# classify and preview now; execute only after explicit user approval.
|
||||
if classification == "bankruptcy":
|
||||
try:
|
||||
logger.info("⚙️ Kører automatisk konkursflow for email %s", email_id)
|
||||
await email_workflow_service.execute_workflows_for_email(email_id)
|
||||
except Exception as e:
|
||||
logger.warning("⚠️ Automatisk konkursflow fejlede for email %s: %s", email_id, e)
|
||||
elif getattr(settings, "EMAIL_REQUIRE_MANUAL_APPROVAL", True):
|
||||
execute_update(
|
||||
"""
|
||||
UPDATE email_messages
|
||||
SET status = 'awaiting_user_action'
|
||||
WHERE id = %s
|
||||
""",
|
||||
(email_id,),
|
||||
)
|
||||
else:
|
||||
try:
|
||||
logger.info(f"⚙️ Executing workflows for email {email_id}...")
|
||||
await email_workflow_service.execute_workflows_for_email(email_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Workflow execution failed for uploaded email: {e}")
|
||||
|
||||
results.append({
|
||||
"filename": file.filename,
|
||||
@ -2779,7 +2971,7 @@ async def execute_workflows_for_email(email_id: int):
|
||||
query = """
|
||||
SELECT id, message_id, subject, sender_email, sender_name, body_text,
|
||||
body_html, in_reply_to, email_references, thread_key,
|
||||
classification, confidence_score, status
|
||||
classification, confidence_score, status, customer_id, supplier_id
|
||||
FROM email_messages
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
"""
|
||||
@ -2789,6 +2981,22 @@ async def execute_workflows_for_email(email_id: int):
|
||||
raise HTTPException(status_code=404, detail="Email not found")
|
||||
|
||||
email_data = email_result[0] # Get first row as dict
|
||||
if not str(email_data.get("classification") or "").strip():
|
||||
classified = simple_classifier.classify(email_data)
|
||||
email_data["classification"] = classified["classification"]
|
||||
email_data["confidence_score"] = classified["confidence"]
|
||||
execute_update(
|
||||
"""
|
||||
UPDATE email_messages
|
||||
SET classification = %s, confidence_score = %s, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
classified["classification"],
|
||||
classified["confidence"],
|
||||
email_id,
|
||||
),
|
||||
)
|
||||
|
||||
# Execute workflows
|
||||
result = await email_workflow_service.execute_workflows(email_data)
|
||||
@ -2807,11 +3015,16 @@ async def preview_workflows_for_email(email_id: int):
|
||||
"""Preview which workflows would match an email without executing them."""
|
||||
try:
|
||||
query = """
|
||||
SELECT id, message_id, subject, sender_email, sender_name, body_text,
|
||||
body_html, in_reply_to, email_references, thread_key,
|
||||
classification, confidence_score, status
|
||||
FROM email_messages
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
SELECT em.id, em.message_id, em.subject, em.sender_email, em.sender_name,
|
||||
em.body_text, em.body_html, em.in_reply_to, em.email_references,
|
||||
em.thread_key, em.classification, em.confidence_score, em.status,
|
||||
em.customer_id, em.supplier_id, em.extracted_vendor_name,
|
||||
em.extracted_vendor_cvr, c.name AS customer_name,
|
||||
v.name AS supplier_name, v.cvr_number AS supplier_cvr
|
||||
FROM email_messages em
|
||||
LEFT JOIN customers c ON c.id = em.customer_id
|
||||
LEFT JOIN vendors v ON v.id = em.supplier_id
|
||||
WHERE em.id = %s AND em.deleted_at IS NULL
|
||||
"""
|
||||
email_result = execute_query(query, (email_id,))
|
||||
|
||||
@ -2819,7 +3032,80 @@ async def preview_workflows_for_email(email_id: int):
|
||||
raise HTTPException(status_code=404, detail="Email not found")
|
||||
|
||||
email_data = email_result[0]
|
||||
return _compute_workflow_preview(email_data)
|
||||
if not str(email_data.get("classification") or "").strip():
|
||||
classified = simple_classifier.classify(email_data)
|
||||
email_data["classification"] = classified["classification"]
|
||||
email_data["confidence_score"] = classified["confidence"]
|
||||
execute_update(
|
||||
"""
|
||||
UPDATE email_messages
|
||||
SET classification = %s, confidence_score = %s, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
classified["classification"],
|
||||
classified["confidence"],
|
||||
email_id,
|
||||
),
|
||||
)
|
||||
preview = _compute_workflow_preview(email_data)
|
||||
|
||||
supplier_document = str(email_data.get("classification") or "").lower() in {
|
||||
"invoice",
|
||||
"order_confirmation",
|
||||
"freight_note",
|
||||
}
|
||||
customer_identity = (
|
||||
{
|
||||
"has_customer": False,
|
||||
"not_applicable": True,
|
||||
"reason": "supplier_document",
|
||||
"domain": _extract_sender_domain(email_data.get("sender_email")),
|
||||
"suggestion": None,
|
||||
}
|
||||
if supplier_document
|
||||
else await get_domain_customer_suggestion(email_id)
|
||||
)
|
||||
vendor_identity = {
|
||||
"has_vendor": bool(email_data.get("supplier_id")),
|
||||
"suggestion": None,
|
||||
}
|
||||
if email_data.get("supplier_id"):
|
||||
vendor_identity["suggestion"] = {
|
||||
"vendor_id": email_data.get("supplier_id"),
|
||||
"name": email_data.get("supplier_name"),
|
||||
"cvr_number": email_data.get("supplier_cvr"),
|
||||
"match_score": 100,
|
||||
"source": "linked_email",
|
||||
}
|
||||
else:
|
||||
extracted_cvr = str(email_data.get("extracted_vendor_cvr") or "").strip()
|
||||
extracted_name = str(email_data.get("extracted_vendor_name") or "").strip()
|
||||
matched_vendor = _find_existing_vendor_for_email(email_data)
|
||||
if matched_vendor:
|
||||
vendor_identity["suggestion"] = {
|
||||
"vendor_id": matched_vendor.get("id"),
|
||||
"name": matched_vendor.get("name"),
|
||||
"cvr_number": matched_vendor.get("cvr_number"),
|
||||
"domain": matched_vendor.get("domain"),
|
||||
"email": matched_vendor.get("email"),
|
||||
"match_score": matched_vendor.get("match_score"),
|
||||
"source": matched_vendor.get("source"),
|
||||
}
|
||||
elif extracted_name or extracted_cvr:
|
||||
vendor_identity["suggestion"] = {
|
||||
"vendor_id": None,
|
||||
"name": extracted_name or None,
|
||||
"cvr_number": extracted_cvr or None,
|
||||
"match_score": 0,
|
||||
"source": "extracted_email_data",
|
||||
}
|
||||
|
||||
preview["identity"] = {
|
||||
"customer": customer_identity,
|
||||
"vendor": vendor_identity,
|
||||
}
|
||||
return preview
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@ -77,6 +77,42 @@
|
||||
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
|
||||
}
|
||||
|
||||
.email-ai-decision {
|
||||
border: 2px solid color-mix(in srgb, var(--accent, #0f4c75) 42%, var(--border-color));
|
||||
border-radius: 12px;
|
||||
padding: 0.9rem;
|
||||
margin-bottom: 0.85rem;
|
||||
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
|
||||
}
|
||||
|
||||
.email-ai-type {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.email-ai-confidence {
|
||||
height: 7px;
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.email-ai-confidence > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.email-ai-actions {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.email-ai-actions li {
|
||||
margin-bottom: 0.32rem;
|
||||
}
|
||||
|
||||
.emails-shortcuts {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary);
|
||||
@ -200,6 +236,27 @@
|
||||
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
|
||||
}
|
||||
|
||||
.emails-v2-quick-toolbar {
|
||||
padding: 0.65rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.emails-v2-quick-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.emails-v2-quick-grid .btn {
|
||||
min-height: 38px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.emails-v2-mail-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
@ -428,6 +485,8 @@
|
||||
|
||||
<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="v2MailStatus" class="emails-v2-status">Ingen email valgt</div>
|
||||
@ -700,7 +759,9 @@
|
||||
const rows = await apiFetch(url);
|
||||
let emails = Array.isArray(rows) ? rows : [];
|
||||
|
||||
if (state.filter === 'active' && !state.query) {
|
||||
// Processed/archived emails must never reappear in the active
|
||||
// inbox, including when the user searches.
|
||||
if (state.filter === 'active') {
|
||||
emails = emails.filter((e) => ['new', 'awaiting_user_action'].includes((e.status || '').toLowerCase()));
|
||||
}
|
||||
|
||||
@ -713,6 +774,13 @@
|
||||
const stillExists = emails.some((e) => Number(e.id) === Number(state.selectedEmailId));
|
||||
if (stillExists) {
|
||||
await selectEmail(state.selectedEmailId, { silentListRefresh: true });
|
||||
} else {
|
||||
state.selectedEmailId = null;
|
||||
state.selectedEmail = null;
|
||||
renderDetail(null);
|
||||
if (emails.length) {
|
||||
await selectEmail(Number(emails[0].id), { silentListRefresh: true });
|
||||
}
|
||||
}
|
||||
} else if (emails.length) {
|
||||
await selectEmail(Number(emails[0].id), { silentListRefresh: true });
|
||||
@ -823,6 +891,15 @@
|
||||
try {
|
||||
const preview = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/workflow-preview`);
|
||||
state.workflowPreview = preview || null;
|
||||
if (preview?.identity) {
|
||||
state.domainCustomerSuggestion = preview.identity.customer || {
|
||||
domain: String(state.selectedEmail?.sender_email || '').split('@')[1] || null,
|
||||
suggestion: null,
|
||||
};
|
||||
state.vendorSuggestion = preview.identity.vendor?.suggestion || {};
|
||||
renderDomainCustomerSuggestion();
|
||||
renderVendorSuggestion();
|
||||
}
|
||||
renderWorkflowPreview();
|
||||
} catch (error) {
|
||||
state.workflowPreview = null;
|
||||
@ -845,18 +922,236 @@
|
||||
}
|
||||
}
|
||||
|
||||
const CLASSIFICATION_LABELS = {
|
||||
invoice: 'Leverandørfaktura',
|
||||
freight_note: 'Fragtbrev',
|
||||
order_confirmation: 'Ordrebekræftelse',
|
||||
time_confirmation: 'Tidsbekræftelse',
|
||||
case_notification: 'Sagsnotifikation',
|
||||
customer_email: 'Kundehenvendelse',
|
||||
bankruptcy: 'Konkursmeddelelse',
|
||||
general: 'Almindelig email',
|
||||
spam: 'Spam',
|
||||
newsletter: 'Nyhedsbrev',
|
||||
unknown: 'Ukendt type',
|
||||
};
|
||||
|
||||
const WORKFLOW_ACTION_LABELS = {
|
||||
create_ticket: 'Opretter en sag/opgave',
|
||||
link_email_to_ticket: 'Knytter emailen til en eksisterende sag',
|
||||
route_helpdesk_sag: 'Finder eller opretter den relevante sag',
|
||||
create_time_entry: 'Opretter en tidsregistrering',
|
||||
link_to_vendor: 'Knytter emailen til leverandøren',
|
||||
link_to_customer: 'Knytter emailen til kunden',
|
||||
extract_invoice_data: 'Sender PDF-bilaget til Leverandørfakturaer → Mangler behandling',
|
||||
extract_tracking_number: 'Udtrækker trackingnummer',
|
||||
regex_extract_and_link: 'Finder reference og opretter tilknytning',
|
||||
process_also_cloud_billing: 'Importerer ALSO-fakturafilen og opretter ordrekladder',
|
||||
send_slack_notification: 'Sender en Slack-notifikation',
|
||||
send_email_notification: 'Sender en emailnotifikation',
|
||||
mark_as_processed: 'Markerer emailen som behandlet',
|
||||
flag_for_review: 'Markerer emailen til manuel kontrol',
|
||||
};
|
||||
|
||||
function renderIdentityAssessment() {
|
||||
const email = state.selectedEmail || {};
|
||||
const customerData = state.domainCustomerSuggestion;
|
||||
const customerSuggestion = customerData?.suggestion;
|
||||
const vendorSuggestion = state.vendorSuggestion;
|
||||
const senderDomain = customerData?.domain
|
||||
|| String(email.sender_email || '').split('@')[1]
|
||||
|| '';
|
||||
|
||||
let customerHtml;
|
||||
if (customerData?.not_applicable) {
|
||||
customerHtml = `
|
||||
<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) {
|
||||
const host = document.getElementById('v2WorkflowPreview');
|
||||
if (!host) return;
|
||||
|
||||
if (errorMessage) {
|
||||
host.innerHTML = `<div class="small text-danger">${escapeHtml(errorMessage)}</div>`;
|
||||
renderClassificationDecision(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = state.workflowPreview;
|
||||
if (!preview) {
|
||||
host.innerHTML = '<div class="small text-muted">Ingen preview endnu</div>';
|
||||
renderClassificationDecision();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -898,6 +1193,7 @@
|
||||
${matchingHtml}
|
||||
</div>
|
||||
`;
|
||||
renderClassificationDecision();
|
||||
|
||||
const autoRunBtn = document.getElementById('v2AutoRun');
|
||||
if (autoRunBtn) {
|
||||
@ -931,6 +1227,7 @@
|
||||
|
||||
const caseTypeEl = document.getElementById('v2CaseType');
|
||||
const titelEl = document.getElementById('v2CaseTitle');
|
||||
const createButton = document.getElementById('v2CreateCase');
|
||||
const payload = {
|
||||
titel: String(titelEl?.value || state.selectedEmail.subject || '').trim(),
|
||||
case_type: String(caseTypeEl?.value || 'support'),
|
||||
@ -941,6 +1238,10 @@
|
||||
}
|
||||
|
||||
try {
|
||||
if (createButton) {
|
||||
createButton.disabled = true;
|
||||
createButton.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Opretter…';
|
||||
}
|
||||
const result = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/create-sag`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@ -952,6 +1253,49 @@
|
||||
await selectEmail(state.selectedEmailId);
|
||||
} catch (error) {
|
||||
setDetailStatus(`Kunne ikke oprette sag: ${error.message}`);
|
||||
window.alert(`Sagen kunne ikke oprettes:\n${error.message}`);
|
||||
} finally {
|
||||
if (createButton && document.body.contains(createButton)) {
|
||||
createButton.disabled = false;
|
||||
createButton.innerHTML = '<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;
|
||||
renderVendorSuggestion();
|
||||
renderClassificationDecision();
|
||||
setSupplierStatus('Forslag opdateret', 'success');
|
||||
} catch (error) {
|
||||
state.vendorSuggestion = {};
|
||||
renderClassificationDecision();
|
||||
setSupplierStatus(`Kunne ikke udtrække forslag: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
@ -1063,8 +1410,14 @@
|
||||
const suggestion = await apiFetch(`/api/v1/emails/${state.selectedEmailId}/domain-customer-suggestion`);
|
||||
state.domainCustomerSuggestion = suggestion || null;
|
||||
renderDomainCustomerSuggestion();
|
||||
renderClassificationDecision();
|
||||
setDomainStatus('Forslag opdateret', 'success');
|
||||
} catch (error) {
|
||||
state.domainCustomerSuggestion = {
|
||||
domain: String(state.selectedEmail?.sender_email || '').split('@')[1] || null,
|
||||
suggestion: null,
|
||||
};
|
||||
renderClassificationDecision();
|
||||
setDomainStatus(`Kunne ikke hente forslag: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
@ -1088,6 +1441,98 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function createCustomerFromSuggestion() {
|
||||
if (!state.selectedEmailId || !state.selectedEmail) return;
|
||||
const email = state.selectedEmail;
|
||||
const domain = state.domainCustomerSuggestion?.domain
|
||||
|| String(email.sender_email || '').split('@')[1]
|
||||
|| null;
|
||||
const name = String(email.sender_name || domain || '').trim();
|
||||
if (!name) {
|
||||
setDetailStatus('Kunden kan ikke oprettes uden et navn');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setDetailStatus(`Opretter kunden ${name}…`);
|
||||
const customer = await apiFetch('/api/v1/customers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
email: email.sender_email || null,
|
||||
email_domain: domain,
|
||||
is_active: true,
|
||||
country: 'DK',
|
||||
}),
|
||||
});
|
||||
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ customer_id: Number(customer.id) }),
|
||||
});
|
||||
setDetailStatus(`Kunden ${name} blev oprettet og tilknyttet`);
|
||||
await selectEmail(state.selectedEmailId);
|
||||
} catch (error) {
|
||||
setDetailStatus(`Kunne ikke oprette kunden: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function linkVendorSuggestion() {
|
||||
const vendorId = Number(state.vendorSuggestion?.vendor_id || 0);
|
||||
if (!state.selectedEmailId || !vendorId) return;
|
||||
try {
|
||||
setDetailStatus('Tilknytter leverandøren…');
|
||||
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ supplier_id: vendorId }),
|
||||
});
|
||||
setDetailStatus('Leverandøren blev tilknyttet');
|
||||
await selectEmail(state.selectedEmailId);
|
||||
} catch (error) {
|
||||
setDetailStatus(`Kunne ikke tilknytte leverandøren: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createVendorFromSuggestion() {
|
||||
if (!state.selectedEmailId || !state.selectedEmail) return;
|
||||
const email = state.selectedEmail;
|
||||
const suggestion = state.vendorSuggestion || {};
|
||||
const senderDomain = String(email.sender_email || '').split('@')[1] || null;
|
||||
const name = String(suggestion.name || email.sender_name || senderDomain || '').trim();
|
||||
if (!name) {
|
||||
setDetailStatus('Leverandøren kan ikke oprettes uden et navn');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setDetailStatus(`Opretter leverandøren ${name}…`);
|
||||
const vendor = await apiFetch('/api/v1/vendors', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
cvr_number: suggestion.cvr_number || null,
|
||||
email: suggestion.email || email.sender_email || null,
|
||||
phone: suggestion.phone || null,
|
||||
address: suggestion.address || null,
|
||||
domain: suggestion.domain || senderDomain,
|
||||
category: 'supplier',
|
||||
notes: `Oprettet fra email #${state.selectedEmailId}`,
|
||||
is_active: true,
|
||||
}),
|
||||
});
|
||||
await apiFetch(`/api/v1/emails/${state.selectedEmailId}/link`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ supplier_id: Number(vendor.id) }),
|
||||
});
|
||||
setDetailStatus(`Leverandøren ${name} blev oprettet og tilknyttet`);
|
||||
await selectEmail(state.selectedEmailId);
|
||||
} catch (error) {
|
||||
setDetailStatus(`Kunne ikke oprette leverandøren: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function searchSager(query) {
|
||||
const host = document.getElementById('v2SagResults');
|
||||
if (!host) return;
|
||||
@ -1126,12 +1571,14 @@
|
||||
|
||||
function renderDetail(email) {
|
||||
const mailHeader = document.getElementById('v2MailHeader');
|
||||
const quickActions = document.getElementById('v2QuickActions');
|
||||
const mailBody = document.getElementById('v2MailBody');
|
||||
const sideActions = document.getElementById('v2SideActions');
|
||||
if (!mailHeader || !mailBody || !sideActions) return;
|
||||
if (!mailHeader || !quickActions || !mailBody || !sideActions) return;
|
||||
|
||||
if (!email) {
|
||||
mailHeader.innerHTML = 'Vælg en email for at se info';
|
||||
quickActions.innerHTML = '';
|
||||
mailBody.className = 'emails-v2-detail-empty';
|
||||
mailBody.textContent = 'Vælg en email fra listen';
|
||||
sideActions.className = 'emails-v2-detail-empty';
|
||||
@ -1154,6 +1601,31 @@
|
||||
</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';
|
||||
const hasAttachments = Array.isArray(email.attachments) && email.attachments.length > 0;
|
||||
const rawHtml = String(email.body_html || '').trim();
|
||||
@ -1182,6 +1654,13 @@
|
||||
|
||||
sideActions.className = 'emails-v2-actions-pane';
|
||||
sideActions.innerHTML = `
|
||||
<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 ? `
|
||||
<div class="emails-v2-card emails-primary-action">
|
||||
<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">
|
||||
<div class="d-flex gap-2">
|
||||
<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="leverandor">Leverandør</option>
|
||||
<option value="leverandor" ${email.supplier_id ? 'selected' : ''}>Leverandør</option>
|
||||
<option value="helhedsopgave">Projekt/helhedsopgave</option>
|
||||
<option value="andet">Andet</option>
|
||||
</select>
|
||||
@ -1288,6 +1767,11 @@
|
||||
document.getElementById('v2CreateCase')?.addEventListener('click', createCaseFromCurrent);
|
||||
document.getElementById('v2ExtractVendor')?.addEventListener('click', extractVendorSuggestionCurrent);
|
||||
document.getElementById('v2DomainSuggestionBtn')?.addEventListener('click', loadDomainCustomerSuggestionCurrent);
|
||||
document.querySelectorAll('[data-v2-quick-action]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
runQuickAction(button.getAttribute('data-v2-quick-action'), button);
|
||||
});
|
||||
});
|
||||
|
||||
state.vendorSuggestion = null;
|
||||
state.domainCustomerSuggestion = null;
|
||||
@ -1296,6 +1780,14 @@
|
||||
renderDomainCustomerSuggestion();
|
||||
renderWorkflowPreview();
|
||||
loadWorkflowPreviewCurrent();
|
||||
const vendorClassifications = new Set(['invoice', 'order_confirmation', 'freight_note']);
|
||||
if (email.supplier_id || vendorClassifications.has(String(email.classification || '').toLowerCase())
|
||||
|| email.extracted_vendor_name || email.extracted_vendor_cvr) {
|
||||
extractVendorSuggestionCurrent();
|
||||
} else {
|
||||
state.vendorSuggestion = {};
|
||||
renderClassificationDecision();
|
||||
}
|
||||
setMailStatus(`Email #${email.id} vises`);
|
||||
|
||||
document.getElementById('v2SagSearch')?.addEventListener('input', (event) => {
|
||||
|
||||
@ -853,9 +853,124 @@ def _replace_switch_port_if_confirmed(
|
||||
)
|
||||
|
||||
|
||||
def _validate_outlet_port_references(
|
||||
*,
|
||||
location_id: int,
|
||||
cross_field_port_id: Optional[int],
|
||||
switch_hardware_id: Optional[int],
|
||||
switch_port: Optional[str],
|
||||
) -> None:
|
||||
"""Only allow active, explicitly created ports belonging to this location."""
|
||||
if cross_field_port_id is not None:
|
||||
cross_field_port = execute_query(
|
||||
'''SELECT p.id
|
||||
FROM locations_cross_field_ports p
|
||||
JOIN locations_cross_fields cf ON cf.id = p.cross_field_id
|
||||
WHERE p.id = %s
|
||||
AND p.is_active = TRUE
|
||||
AND cf.location_id = %s
|
||||
AND cf.is_active = TRUE
|
||||
AND cf.deleted_at IS NULL''',
|
||||
(cross_field_port_id, location_id),
|
||||
) or []
|
||||
if not cross_field_port:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail='Den valgte krydsfelt-port findes ikke eller er ikke aktiv på lokationen',
|
||||
)
|
||||
|
||||
if switch_hardware_id is None:
|
||||
return
|
||||
|
||||
hardware = execute_query(
|
||||
'''SELECT id, hardware_specs
|
||||
FROM hardware_assets
|
||||
WHERE id = %s
|
||||
AND current_location_id = %s
|
||||
AND deleted_at IS NULL
|
||||
AND LOWER(COALESCE(asset_type, '')) = 'netværk' ''',
|
||||
(switch_hardware_id, location_id),
|
||||
) or []
|
||||
if not hardware:
|
||||
raise HTTPException(status_code=400, detail='Den valgte switch findes ikke på lokationen')
|
||||
|
||||
if switch_port:
|
||||
specs = hardware[0].get('hardware_specs') or {}
|
||||
if isinstance(specs, str):
|
||||
try:
|
||||
specs = json.loads(specs)
|
||||
except (TypeError, ValueError):
|
||||
specs = {}
|
||||
port_count = int((specs or {}).get('port_count') or 0)
|
||||
valid_ports = {str(number) for number in range(1, port_count + 1)}
|
||||
if str(switch_port).strip() not in valid_ports:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail='Den valgte switch-port findes ikke blandt switchens oprettede porte',
|
||||
)
|
||||
|
||||
|
||||
def _validate_single_customer_wan(
|
||||
*,
|
||||
location_id: int,
|
||||
customer_id: Optional[int],
|
||||
is_wan: bool,
|
||||
exclude_outlet_id: Optional[int] = None,
|
||||
) -> None:
|
||||
if not is_wan:
|
||||
return
|
||||
|
||||
effective_customer = customer_id
|
||||
if effective_customer is None:
|
||||
location = execute_query(
|
||||
'''SELECT customer_id
|
||||
FROM locations_locations
|
||||
WHERE id = %s AND deleted_at IS NULL''',
|
||||
(location_id,),
|
||||
) or []
|
||||
effective_customer = location[0].get('customer_id') if location else None
|
||||
if effective_customer is None:
|
||||
raise HTTPException(status_code=400, detail='Vælg en kunde, før porten markeres som WAN')
|
||||
|
||||
params = [effective_customer]
|
||||
exclude_sql = ''
|
||||
if exclude_outlet_id is not None:
|
||||
exclude_sql = 'AND o.id <> %s'
|
||||
params.append(exclude_outlet_id)
|
||||
existing = execute_query(
|
||||
f'''SELECT o.id, o.outlet_number
|
||||
FROM locations_wall_outlets o
|
||||
JOIN locations_locations l ON l.id = o.location_id
|
||||
WHERE COALESCE(o.customer_id, l.customer_id) = %s
|
||||
AND o.is_wan = TRUE
|
||||
AND o.is_active = TRUE
|
||||
AND o.deleted_at IS NULL
|
||||
{exclude_sql}
|
||||
LIMIT 1''',
|
||||
tuple(params),
|
||||
) or []
|
||||
if existing:
|
||||
outlet_name = existing[0].get('outlet_number') or f"#{existing[0]['id']}"
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f'Kunden har allerede WAN-porten {outlet_name}. Der kan kun være én WAN-port pr. kunde',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/locations/outlets', response_model=WallOutlet, status_code=201)
|
||||
async def create_wall_outlet(data: WallOutletCreate):
|
||||
_outlet_location(data.location_id)
|
||||
_validate_outlet_port_references(
|
||||
location_id=data.location_id,
|
||||
cross_field_port_id=data.cross_field_port_id,
|
||||
switch_hardware_id=data.switch_hardware_id,
|
||||
switch_port=data.switch_port,
|
||||
)
|
||||
_validate_single_customer_wan(
|
||||
location_id=data.location_id,
|
||||
customer_id=data.customer_id,
|
||||
is_wan=data.is_wan,
|
||||
)
|
||||
_replace_switch_port_if_confirmed(
|
||||
switch_hardware_id=data.switch_hardware_id,
|
||||
switch_name=data.switch_name,
|
||||
@ -888,12 +1003,24 @@ async def update_wall_outlet(outlet_id: int, data: WallOutletUpdate):
|
||||
if 'outlet_number' in changes:
|
||||
changes['outlet_number'] = (changes['outlet_number'] or '').strip() or None
|
||||
current = execute_query(
|
||||
'''SELECT switch_hardware_id, switch_name, switch_port
|
||||
'''SELECT location_id, customer_id, cross_field_port_id, switch_hardware_id, switch_name, switch_port, is_wan
|
||||
FROM locations_wall_outlets WHERE id = %s AND deleted_at IS NULL''',
|
||||
(outlet_id,),
|
||||
) or []
|
||||
if not current:
|
||||
raise HTTPException(status_code=404, detail='Vægstik blev ikke fundet')
|
||||
_validate_outlet_port_references(
|
||||
location_id=changes.get('location_id', current[0]['location_id']),
|
||||
cross_field_port_id=changes.get('cross_field_port_id', current[0].get('cross_field_port_id')),
|
||||
switch_hardware_id=changes.get('switch_hardware_id', current[0].get('switch_hardware_id')),
|
||||
switch_port=changes.get('switch_port', current[0].get('switch_port')),
|
||||
)
|
||||
_validate_single_customer_wan(
|
||||
location_id=changes.get('location_id', current[0]['location_id']),
|
||||
customer_id=changes.get('customer_id', current[0].get('customer_id')),
|
||||
is_wan=changes.get('is_wan', current[0].get('is_wan', False)),
|
||||
exclude_outlet_id=outlet_id,
|
||||
)
|
||||
_replace_switch_port_if_confirmed(
|
||||
switch_hardware_id=changes.get('switch_hardware_id', current[0].get('switch_hardware_id')),
|
||||
switch_name=changes.get('switch_name', current[0].get('switch_name')),
|
||||
|
||||
@ -788,7 +788,7 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
||||
FROM locations_cross_field_ports p
|
||||
LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL
|
||||
LEFT JOIN locations_locations l ON l.id = o.location_id
|
||||
WHERE p.cross_field_id = %s ORDER BY p.port_order""",
|
||||
WHERE p.cross_field_id = %s AND p.is_active = TRUE ORDER BY p.port_order""",
|
||||
(cross_field["id"],),
|
||||
) or []
|
||||
for port in cross_field["ports"]:
|
||||
|
||||
@ -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="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-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">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">Fra krydsfelt-port</label><select class="form-select" id="bulkFromPort" required></select></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-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 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">Start switch-port</label><select class="form-select" id="bulkSwitchStart" required></select></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"><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>
|
||||
@ -1591,67 +1591,171 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const bulkFieldSelect = document.getElementById('bulkCrossField');
|
||||
const bulkSwitchSelect = document.getElementById('bulkSwitch');
|
||||
const bulkCustomerSelect = document.getElementById('bulkCustomer');
|
||||
let bulkCustomers = [];
|
||||
|
||||
function updateBulkCustomerOptions(query = '') {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase('da');
|
||||
const matches = normalizedQuery
|
||||
? bulkCustomers.filter(customer =>
|
||||
String(customer.name || customer.navn || '').toLocaleLowerCase('da').includes(normalizedQuery)
|
||||
).slice(0, 25)
|
||||
: [];
|
||||
bulkCustomerSelect.innerHTML = '<option value="">Ingen specifik kunde</option>' + matches
|
||||
.map(customer => `<option value="${customer.id}">${customer.name || customer.navn || `Kunde #${customer.id}`}</option>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function selectedBulkField() {
|
||||
return (locationCrossFields || []).find(field => Number(field.id) === Number(bulkFieldSelect.value));
|
||||
}
|
||||
|
||||
function selectableBulkFieldPorts(field) {
|
||||
return (field?.ports || []).filter(port => port.is_active !== false);
|
||||
}
|
||||
|
||||
function selectableSwitchPorts(hardware) {
|
||||
return (hardware?.switch_ports || []).filter(port => port && port.port_number !== null && port.port_number !== undefined);
|
||||
}
|
||||
|
||||
function populateBulkFieldPorts() {
|
||||
const ports = selectableBulkFieldPorts(selectedBulkField());
|
||||
document.getElementById('bulkFromPort').innerHTML = ports
|
||||
.map((port, index) => `<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() {
|
||||
const field = selectedBulkField();
|
||||
const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value));
|
||||
const fieldPorts = selectableBulkFieldPorts(field);
|
||||
const switchPorts = selectableSwitchPorts(hardware);
|
||||
const from = Number(document.getElementById('bulkFromPort').value);
|
||||
const to = Number(document.getElementById('bulkToPort').value);
|
||||
const switchStart = Number(document.getElementById('bulkSwitchStart').value);
|
||||
const count = Number.isInteger(from) && Number.isInteger(to) && to >= from ? to - from + 1 : 0;
|
||||
const firstPort = field?.ports?.[from - 1]?.port_number || '—';
|
||||
const lastPort = field?.ports?.[to - 1]?.port_number || '—';
|
||||
document.getElementById('bulkPatchPreview').innerHTML = count
|
||||
? `<strong>${count} forbindelser:</strong> ${field?.name || '—'} port ${firstPort}–${lastPort} → ${hardware ? switchDisplayName(hardware) : '—'} port ${switchStart}–${switchStart + count - 1}`
|
||||
const selectedSwitchPorts = switchPorts.slice(switchStart, switchStart + count);
|
||||
const firstPort = fieldPorts[from]?.port_number || '—';
|
||||
const lastPort = fieldPorts[to]?.port_number || '—';
|
||||
const firstSwitchPort = selectedSwitchPorts[0]?.port_number || '—';
|
||||
const lastSwitchPort = selectedSwitchPorts[selectedSwitchPorts.length - 1]?.port_number || '—';
|
||||
document.getElementById('bulkPatchPreview').innerHTML = count && selectedSwitchPorts.length === count
|
||||
? `<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.';
|
||||
}
|
||||
|
||||
async function openBulkPatch() {
|
||||
bulkFieldSelect.innerHTML = (locationCrossFields || []).map(field => `<option value="${field.id}">${field.name} · ${field.port_count} porte</option>`).join('');
|
||||
const switches = (locationHardware || []).filter(item => String(item.asset_type || '').toLowerCase() === 'netværk');
|
||||
bulkSwitchSelect.innerHTML = switches.map(item => `<option value="${item.id}">${switchDisplayName(item)} · ${switchPortCount(item)} porte</option>`).join('');
|
||||
const fields = (locationCrossFields || []).filter(field => selectableBulkFieldPorts(field).length);
|
||||
bulkFieldSelect.innerHTML = fields.map(field => {
|
||||
const ports = selectableBulkFieldPorts(field);
|
||||
const firstPort = ports[0]?.port_number || '—';
|
||||
const lastPort = ports[ports.length - 1]?.port_number || '—';
|
||||
return `<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 data = response.ok ? await response.json() : [];
|
||||
const customers = 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('');
|
||||
const firstField = selectedBulkField();
|
||||
document.getElementById('bulkToPort').value = Math.min(24, firstField?.ports?.length || 1);
|
||||
bulkCustomers = Array.isArray(data) ? data : (data.customers || []);
|
||||
document.getElementById('bulkCustomerSearch').value = '';
|
||||
updateBulkCustomerOptions();
|
||||
updateBulkPreview();
|
||||
bulkPatchModal?.show();
|
||||
}
|
||||
|
||||
document.getElementById('openBulkPatchBtn')?.addEventListener('click', openBulkPatch);
|
||||
['bulkCrossField', 'bulkSwitch', 'bulkFromPort', 'bulkToPort', 'bulkSwitchStart'].forEach(id => document.getElementById(id)?.addEventListener('input', updateBulkPreview));
|
||||
bulkFieldSelect?.addEventListener('change', () => { populateBulkFieldPorts(); populateBulkWanPorts(); updateBulkPreview(); });
|
||||
bulkSwitchSelect?.addEventListener('change', () => { populateBulkSwitchPorts(); updateBulkPreview(); });
|
||||
document.getElementById('bulkFromPort')?.addEventListener('change', () => {
|
||||
populateBulkEndPorts(Number(document.getElementById('bulkToPort').value));
|
||||
populateBulkWanPorts();
|
||||
updateBulkPreview();
|
||||
});
|
||||
document.getElementById('bulkToPort')?.addEventListener('change', () => {
|
||||
populateBulkWanPorts();
|
||||
updateBulkPreview();
|
||||
});
|
||||
document.getElementById('bulkSwitchStart')?.addEventListener('change', updateBulkPreview);
|
||||
document.getElementById('bulkCustomerSearch')?.addEventListener('input', event => {
|
||||
const query = event.target.value.trim().toLocaleLowerCase('da');
|
||||
Array.from(bulkCustomerSelect.options).forEach((option, index) => {
|
||||
option.hidden = index > 0 && Boolean(query) && !option.textContent.toLocaleLowerCase('da').includes(query);
|
||||
});
|
||||
const match = Array.from(bulkCustomerSelect.options).find((option, index) => index > 0 && !option.hidden);
|
||||
if (query && match) bulkCustomerSelect.value = match.value;
|
||||
updateBulkCustomerOptions(event.target.value);
|
||||
});
|
||||
|
||||
document.getElementById('bulkPatchForm')?.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const field = selectedBulkField();
|
||||
const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value));
|
||||
const fieldPorts = selectableBulkFieldPorts(field);
|
||||
const switchPorts = selectableSwitchPorts(hardware);
|
||||
const from = Number(document.getElementById('bulkFromPort').value);
|
||||
const to = Number(document.getElementById('bulkToPort').value);
|
||||
const switchStart = Number(document.getElementById('bulkSwitchStart').value);
|
||||
if (!field || !hardware || !Number.isInteger(from) || !Number.isInteger(to) || from < 1 || to < from || to > field.ports.length) {
|
||||
if (!field || !hardware || !Number.isInteger(from) || !Number.isInteger(to) || from < 0 || to < from || to >= fieldPorts.length) {
|
||||
alert('Vælg et gyldigt krydsfelt, en switch og et portområde.');
|
||||
return;
|
||||
}
|
||||
const ports = field.ports.slice(from - 1, to);
|
||||
if (switchStart < 1 || switchStart + ports.length - 1 > switchPortCount(hardware)) {
|
||||
alert('Portområdet går ud over switchens registrerede antal porte.');
|
||||
const ports = fieldPorts.slice(from, to + 1);
|
||||
const selectedSwitchPorts = switchPorts.slice(switchStart, switchStart + ports.length);
|
||||
if (switchStart < 0 || selectedSwitchPorts.length !== ports.length) {
|
||||
alert('Portområdet går ud over switchens oprettede porte.');
|
||||
return;
|
||||
}
|
||||
const conflicts = ports.map((port, index) => switchPortConflict(hardware, switchDisplayName(hardware), switchStart + index, port.outlet_id || null)).filter(Boolean);
|
||||
const conflicts = ports.map((port, index) => switchPortConflict(
|
||||
hardware,
|
||||
switchDisplayName(hardware),
|
||||
selectedSwitchPorts[index].port_number,
|
||||
port.outlet_id || null
|
||||
)).filter(Boolean);
|
||||
if (conflicts.length) {
|
||||
alert(`Massepatch blev stoppet: ${conflicts.length} switch-port(e) er allerede knyttet til andre vægstik.`);
|
||||
return;
|
||||
@ -1662,8 +1766,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
submit.disabled = true;
|
||||
submit.textContent = 'Gemmer…';
|
||||
const customerId = bulkCustomerSelect.value ? Number(bulkCustomerSelect.value) : null;
|
||||
const isWan = document.getElementById('bulkIsWan').checked;
|
||||
const results = await Promise.all(ports.map(async (port, index) => {
|
||||
const wanPortId = document.getElementById('bulkWanPort').value
|
||||
? Number(document.getElementById('bulkWanPort').value)
|
||||
: null;
|
||||
const orderedPorts = [
|
||||
...ports.map((port, index) => ({port, index})).filter(item => Number(item.port.id) !== wanPortId),
|
||||
...ports.map((port, index) => ({port, index})).filter(item => Number(item.port.id) === wanPortId)
|
||||
];
|
||||
const results = [];
|
||||
for (const {port, index} of orderedPorts) {
|
||||
const payload = {
|
||||
outlet_number: port.outlet_number || `${field.name}-${port.port_number}`,
|
||||
customer_id: customerId,
|
||||
@ -1673,8 +1784,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
cross_field_port_id: Number(port.id),
|
||||
switch_hardware_id: Number(hardware.id),
|
||||
switch_name: switchDisplayName(hardware),
|
||||
switch_port: String(switchStart + index),
|
||||
is_wan: isWan,
|
||||
switch_port: String(selectedSwitchPorts[index].port_number),
|
||||
is_wan: Number(port.id) === wanPortId,
|
||||
status: 'active',
|
||||
notes: port.outlet_notes || null
|
||||
};
|
||||
@ -1684,8 +1795,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
return response.ok;
|
||||
}));
|
||||
results.push(response.ok);
|
||||
}
|
||||
const failed = results.filter(ok => !ok).length;
|
||||
if (failed) {
|
||||
alert(`${results.length - failed} forbindelser blev gemt, men ${failed} fejlede. Siden genindlæses.`);
|
||||
|
||||
@ -660,7 +660,34 @@ async def sag_detaljer(request: Request, sag_id: int):
|
||||
customer = None
|
||||
hovedkontakt = None
|
||||
if sag.get('customer_id'):
|
||||
customer_query = "SELECT * FROM customers WHERE id = %s"
|
||||
customer_query = """
|
||||
SELECT
|
||||
c.*,
|
||||
(vendor_link.vendor_id IS NOT NULL) AS is_vendor,
|
||||
vendor_link.vendor_id,
|
||||
vendor_link.vendor_name,
|
||||
vendor_link.relationship_type AS vendor_relationship_type
|
||||
FROM customers c
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
cvl.vendor_id,
|
||||
v.name AS vendor_name,
|
||||
cvl.relationship_type
|
||||
FROM customer_vendor_links cvl
|
||||
JOIN vendors v ON v.id = cvl.vendor_id
|
||||
WHERE cvl.customer_id = c.id
|
||||
AND v.is_active IS NOT FALSE
|
||||
ORDER BY
|
||||
CASE cvl.relationship_type
|
||||
WHEN 'supplier' THEN 0
|
||||
WHEN 'reseller' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
cvl.id
|
||||
LIMIT 1
|
||||
) vendor_link ON TRUE
|
||||
WHERE c.id = %s
|
||||
"""
|
||||
customer_result = execute_query(customer_query, (sag['customer_id'],))
|
||||
if customer_result:
|
||||
customer = customer_result[0]
|
||||
|
||||
@ -2550,6 +2550,69 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.anydesk-connect-modal .modal-content {
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 70px rgba(15, 42, 65, 0.28);
|
||||
}
|
||||
|
||||
.anydesk-connect-modal .modal-header {
|
||||
color: #fff;
|
||||
border: 0;
|
||||
padding: 0.8rem 1rem;
|
||||
background: linear-gradient(135deg, #0f4c75, #1677a8);
|
||||
}
|
||||
|
||||
.anydesk-connect-modal .modal-header .btn-close {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.anydesk-id-entry {
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 80%, #1677a8);
|
||||
border-radius: 9px;
|
||||
background: color-mix(in srgb, var(--bg-card) 94%, #dff4ff);
|
||||
}
|
||||
|
||||
.anydesk-id-display {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.anydesk-saved-device {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
min-height: 42px;
|
||||
padding: 0.35rem 0.45rem 0.35rem 0.65rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.anydesk-saved-device:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.anydesk-saved-device .device-name {
|
||||
overflow: hidden;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anydesk-saved-list {
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.anydesk-saved-device .device-id {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .module-title {
|
||||
color: #e5edf5;
|
||||
}
|
||||
@ -3439,6 +3502,11 @@
|
||||
</div>
|
||||
<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>
|
||||
{% 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>
|
||||
<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>
|
||||
@ -4652,60 +4720,72 @@
|
||||
</div>
|
||||
|
||||
<!-- AnyDesk quick-connect modal -->
|
||||
<div class="modal fade" id="caseAnyDeskModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal fade anydesk-connect-modal" id="caseAnyDeskModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered" style="max-width:560px;">
|
||||
<div class="modal-content">
|
||||
<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>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">AnyDesk ID</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="caseAnydeskIdInput" placeholder="fx 123 456 789" oninput="onCaseAnyDeskIdInputChange()" />
|
||||
<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 class="modal-body p-3">
|
||||
<section class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<label class="form-label fw-semibold mb-0">Gemte enheder</label>
|
||||
<span class="badge text-bg-light border" id="caseAnyDeskSavedCount">–</span>
|
||||
</div>
|
||||
<div class="form-text">ID gemmes automatisk på sagen når du klikker forbind.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Gemte IDs på sagen</label>
|
||||
<div id="caseAnyDeskSavedIds" class="d-flex flex-wrap gap-2">
|
||||
<div id="caseAnyDeskSavedIds" class="anydesk-saved-list">
|
||||
<span class="text-muted small">Indlæser...</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Relatér til hardware (valgfri)</label>
|
||||
<select class="form-select" id="caseAnydeskHardwareSelect">
|
||||
<option value="">Ingen hardware valgt</option>
|
||||
</select>
|
||||
<div class="form-text">Sagens hardware vises først. Derefter hardware hos kunden.</div>
|
||||
</div>
|
||||
<section class="anydesk-id-entry p-2 mb-2">
|
||||
<label class="form-label small fw-semibold mb-1">Andet AnyDesk-ID</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-hash"></i></span>
|
||||
<input type="text" inputmode="numeric" autocomplete="off" class="form-control anydesk-id-display" id="caseAnydeskIdInput" placeholder="123 456 789" oninput="onCaseAnyDeskIdInputChange()" />
|
||||
<a id="caseAnyDeskConnectBtn" class="btn btn-primary px-4 disabled" href="#" role="button" aria-disabled="true" onclick="registerCaseAnyDeskSession()">
|
||||
<i class="bi bi-plug-fill me-1"></i>Forbind
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Kontakt (valgfri)</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="mb-1">
|
||||
<label class="form-label">Notat (valgfri)</label>
|
||||
<textarea class="form-control" id="caseAnydeskNoteInput" rows="3" placeholder="Kort notat om supporten"></textarea>
|
||||
</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>
|
||||
<div id="caseAnyDeskStatus" class="small text-muted mb-2" role="status"></div>
|
||||
|
||||
<details class="border rounded-3 p-2">
|
||||
<summary class="small fw-semibold" style="cursor:pointer;">
|
||||
<i class="bi bi-sliders me-1"></i>Hardware, kontakt og notat
|
||||
</summary>
|
||||
<div class="mt-3">
|
||||
<label class="form-label">Relatér til hardware</label>
|
||||
<select class="form-select" id="caseAnydeskHardwareSelect">
|
||||
<option value="">Ingen hardware valgt</option>
|
||||
</select>
|
||||
<div class="form-text">Sagens hardware vises først. Derefter kundens øvrige hardware.</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<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 class="modal-footer">
|
||||
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annuller</button>
|
||||
<button id="caseAnyDeskConnectBtn" type="button" class="btn btn-primary" onclick="registerCaseAnyDeskSession()"><i class="bi bi-plug me-1"></i>Forbind og gem</button>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<a href="/anydesk/sessions" class="btn btn-link text-decoration-none me-auto">
|
||||
<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>
|
||||
@ -9613,11 +9693,9 @@
|
||||
}
|
||||
|
||||
if (subjectInput && !subjectInput.value.trim()) {
|
||||
subjectInput.value = escapeHtmlForInput(
|
||||
/^re:\s*/i.test(header.emne || '')
|
||||
? (header.emne || `Sag #${caseIds}`)
|
||||
: `Re: ${header.emne || `Sag #${caseIds}`}`
|
||||
);
|
||||
subjectInput.value = /^re:\s*/i.test(header.emne || '')
|
||||
? (header.emne || `Sag #${caseIds}`)
|
||||
: `Re: ${header.emne || `Sag #${caseIds}`}`;
|
||||
}
|
||||
|
||||
if (bodyInput && !bodyInput.value.trim()) {
|
||||
@ -13414,21 +13492,42 @@
|
||||
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() {
|
||||
const input = document.getElementById('caseAnydeskIdInput');
|
||||
const linkBtn = document.getElementById('caseAnydeskOpenLinkBtn');
|
||||
if (!input || !linkBtn) return;
|
||||
const connectBtn = document.getElementById('caseAnyDeskConnectBtn');
|
||||
if (!input || !connectBtn) return;
|
||||
|
||||
const id = normalizeAnyDeskIdClient(input.value);
|
||||
if (!id) {
|
||||
linkBtn.style.display = 'none';
|
||||
linkBtn.setAttribute('href', '#');
|
||||
connectBtn.classList.add('disabled');
|
||||
connectBtn.setAttribute('aria-disabled', 'true');
|
||||
connectBtn.setAttribute('href', '#');
|
||||
setCaseAnyDeskStatus('Indtast et AnyDesk-ID eller vælg en gemt enhed.');
|
||||
return;
|
||||
}
|
||||
|
||||
input.value = id;
|
||||
linkBtn.style.display = '';
|
||||
linkBtn.setAttribute('href', `anydesk:${id}`);
|
||||
const valid = /^\d{6,12}$/.test(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) {
|
||||
@ -13440,21 +13539,63 @@
|
||||
|
||||
function renderCaseAnyDeskSavedIds(entries) {
|
||||
const container = document.getElementById('caseAnyDeskSavedIds');
|
||||
const count = document.getElementById('caseAnyDeskSavedCount');
|
||||
if (!container) return;
|
||||
if (count) count.textContent = String(entries?.length || 0);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
container.innerHTML = entries.map((entry) => {
|
||||
const primary = entry?.is_primary ? ' border-primary text-primary' : '';
|
||||
const hardware = entry?.hardware_label ? ` <span class="text-muted">(${entry.hardware_label})</span>` : '';
|
||||
const badge = entry?.is_primary ? ' <span class="badge bg-primary-subtle text-primary-emphasis border border-primary-subtle">Primær</span>' : '';
|
||||
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>`;
|
||||
const id = normalizeAnyDeskIdClient(entry?.anydesk_id);
|
||||
const hardwareId = Number(entry?.hardware_asset_id || 0);
|
||||
const sourceLabels = {
|
||||
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('');
|
||||
}
|
||||
|
||||
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) {
|
||||
const select = document.getElementById('caseAnydeskHardwareSelect');
|
||||
if (!select) return;
|
||||
@ -13519,6 +13660,7 @@
|
||||
|
||||
const noteInput = document.getElementById('caseAnydeskNoteInput');
|
||||
if (noteInput) noteInput.value = '';
|
||||
setCaseAnyDeskStatus('Henter gemte enheder…');
|
||||
|
||||
const saved = document.getElementById('caseAnyDeskSavedIds');
|
||||
if (saved) {
|
||||
@ -13538,8 +13680,14 @@
|
||||
|
||||
const connectBtn = document.getElementById('caseAnyDeskConnectBtn');
|
||||
if (connectBtn) {
|
||||
connectBtn.disabled = false;
|
||||
connectBtn.innerHTML = '<i class="bi bi-plug me-1"></i>Forbind og gem';
|
||||
connectBtn.classList.add('disabled');
|
||||
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 {
|
||||
@ -13549,10 +13697,7 @@
|
||||
if (saved) {
|
||||
saved.innerHTML = `<span class="text-danger small">${message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (caseAnyDeskModal && typeof caseAnyDeskModal.show === 'function') {
|
||||
caseAnyDeskModal.show();
|
||||
setCaseAnyDeskStatus(message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
@ -13567,13 +13712,13 @@
|
||||
const notes = (document.getElementById('caseAnydeskNoteInput')?.value || '').trim();
|
||||
|
||||
if (!anydeskId) {
|
||||
alert('Udfyld AnyDesk ID');
|
||||
setCaseAnyDeskStatus('Udfyld et AnyDesk-ID.', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
const customerId = {{ customer.id if customer else 'null' }};
|
||||
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;
|
||||
}
|
||||
|
||||
@ -13592,9 +13737,11 @@
|
||||
const connectBtn = document.getElementById('caseAnyDeskConnectBtn');
|
||||
const openBtn = document.getElementById('caseAnyDeskOpenBtn');
|
||||
if (connectBtn) {
|
||||
connectBtn.disabled = true;
|
||||
connectBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Registrerer...';
|
||||
connectBtn.classList.add('disabled');
|
||||
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) {
|
||||
openBtn.disabled = true;
|
||||
}
|
||||
@ -13617,22 +13764,17 @@
|
||||
caseAnyDeskModal.hide();
|
||||
}
|
||||
|
||||
if (result?.deep_link) {
|
||||
window.location.href = result.deep_link;
|
||||
}
|
||||
|
||||
alert(`AnyDesk forbindelse startet (session: ${result?.session?.id || '-'})`);
|
||||
if (typeof loadComments === 'function') {
|
||||
loadComments();
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Fejl ved AnyDesk quick connect: ' + (e.message || 'Ukendt fejl'));
|
||||
setCaseAnyDeskStatus(`Forbindelsen kunne ikke startes: ${e.message || 'Ukendt fejl'}`, 'danger');
|
||||
} finally {
|
||||
caseAnyDeskConnectInFlight = false;
|
||||
if (connectBtn) {
|
||||
connectBtn.disabled = false;
|
||||
connectBtn.innerHTML = '<i class="bi bi-plug me-1"></i>Forbind og gem';
|
||||
connectBtn.innerHTML = '<i class="bi bi-plug-fill me-1"></i>Forbind';
|
||||
}
|
||||
onCaseAnyDeskIdInputChange();
|
||||
if (openBtn) {
|
||||
openBtn.disabled = false;
|
||||
}
|
||||
@ -13644,6 +13786,7 @@
|
||||
window.registerCaseAnyDeskSession = registerCaseAnyDeskSession;
|
||||
window.onCaseAnyDeskIdInputChange = onCaseAnyDeskIdInputChange;
|
||||
window.setCaseAnyDeskInputFromSaved = setCaseAnyDeskInputFromSaved;
|
||||
window.connectSavedCaseAnyDesk = connectSavedCaseAnyDesk;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const adjustCaseTopbarOffset = () => {
|
||||
@ -13674,8 +13817,10 @@
|
||||
if (connectBtn) {
|
||||
connectBtn.removeAttribute('onclick');
|
||||
connectBtn.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (connectBtn.getAttribute('aria-disabled') === 'true') {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
registerCaseAnyDeskSession();
|
||||
});
|
||||
}
|
||||
@ -15377,15 +15522,6 @@
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function escapeHtmlForInput(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
let rewriteReviewState = null;
|
||||
|
||||
function extractRewriteBody(rawText, context) {
|
||||
@ -15624,7 +15760,7 @@
|
||||
|
||||
if (subjectInput && !subjectInput.value.trim()) {
|
||||
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)
|
||||
? originalSubject
|
||||
: `Re: ${originalSubject || `Sag #${caseIds}`}`;
|
||||
subjectInput.value = escapeHtmlForInput(replySubject);
|
||||
subjectInput.value = replySubject;
|
||||
}
|
||||
|
||||
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"])
|
||||
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:
|
||||
_ensure_case_exists(sag_id)
|
||||
case_row = _ensure_case_exists(sag_id)
|
||||
customer_id = case_row.get("customer_id")
|
||||
|
||||
rows = execute_query(
|
||||
"""
|
||||
@ -400,8 +401,126 @@ async def list_case_anydesk_ids(sag_id: int):
|
||||
if serial:
|
||||
fragments.append(f"SN: {serial}")
|
||||
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:
|
||||
raise
|
||||
except Exception as e:
|
||||
@ -1411,4 +1530,3 @@ async def anydesk_hardware_list():
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
|
||||
@ -159,12 +159,20 @@ class EmailProcessorService:
|
||||
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')
|
||||
stats['awaiting_user_action'] = True
|
||||
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')
|
||||
stats['awaiting_user_action'] = True
|
||||
return stats
|
||||
|
||||
@ -184,7 +184,8 @@ class EmailWorkflowService:
|
||||
query = f"""
|
||||
SELECT id, name, cvr_number
|
||||
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))
|
||||
@ -200,28 +201,112 @@ class EmailWorkflowService:
|
||||
)
|
||||
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"])
|
||||
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)
|
||||
|
||||
# Link to the first customer found (limitation of 1:1 schema)
|
||||
first_match = matching_customers[0]
|
||||
|
||||
execute_update(
|
||||
"""UPDATE email_messages
|
||||
SET customer_id = %s, status = 'processed', folder = 'Processed',
|
||||
processed_at = CURRENT_TIMESTAMP, auto_processed = true
|
||||
WHERE id = %s""",
|
||||
(first_match['id'], email_data['id'])
|
||||
"""
|
||||
UPDATE email_messages
|
||||
SET customer_id = %s, linked_case_id = %s,
|
||||
status = 'processed', folder = 'Processed',
|
||||
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 {
|
||||
'status': 'completed',
|
||||
'action': 'linked_customer',
|
||||
'customer_name': first_match['name']
|
||||
"status": "completed",
|
||||
"action": "created_bankruptcy_cases_and_alerts",
|
||||
"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]:
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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"])
|
||||
async def list_vendors(
|
||||
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 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>
|
||||
@ -372,6 +382,7 @@ async function loadVendor() {
|
||||
}
|
||||
const vendor = await response.json();
|
||||
displayVendor(vendor);
|
||||
await loadVendorEmailDomains();
|
||||
await loadVendorCustomers();
|
||||
} catch (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() {
|
||||
const listEl = document.getElementById('vendorCustomersList');
|
||||
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