chore(release): bump version to 2.3.23

This commit is contained in:
Christian 2026-07-03 20:20:22 +02:00
parent f3684afad3
commit 8e99453dea
33 changed files with 7843 additions and 690 deletions

View File

@ -1 +1 @@
2.3.22
2.3.23

View File

@ -5,7 +5,7 @@ Handles contact CRUD operations with multi-company support
from fastapi import APIRouter, HTTPException, Query
from typing import Optional, List
from app.core.database import execute_query, execute_insert, execute_update
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.core.contact_utils import get_contact_customer_ids, get_primary_customer_id
from app.customers.backend.router import (
get_customer_subscriptions,
@ -119,36 +119,55 @@ async def get_contacts(
where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
# Count total
# Count total matching contacts before pagination.
count_query = f"""
SELECT COUNT(DISTINCT c.id)
SELECT COUNT(DISTINCT c.id) AS count
FROM contacts c
{where_sql}
"""
count_result = execute_query_single(count_query, tuple(params))
total = count_result['count'] if count_result else 0
# Get contacts with company count
query = f"""
SELECT
total = int((count_result or {}).get('count') or 0)
# Fetch the page of contacts first, then enrich with aggregated company data.
# This avoids grouped-pagination mismatches across PostgreSQL plans/versions.
page_query = f"""
SELECT
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
c.title, c.department, c.is_active, c.vtiger_id,
c.created_at, c.updated_at,
COUNT(DISTINCT cc.customer_id) as company_count,
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) as company_names
c.created_at, c.updated_at
FROM contacts c
LEFT JOIN contact_companies cc ON c.id = cc.contact_id
LEFT JOIN customers cu ON cc.customer_id = cu.id
{where_sql}
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
c.title, c.department, c.is_active, c.vtiger_id, c.created_at, c.updated_at
ORDER BY c.last_name, c.first_name
ORDER BY c.last_name, c.first_name, c.id
LIMIT %s OFFSET %s
"""
params.extend([limit, offset])
contacts = execute_query(query, tuple(params)) # Returns all rows
page_params = list(params)
page_params.extend([limit, offset])
contacts = execute_query(page_query, tuple(page_params)) or []
if contacts:
contact_ids = [row["id"] for row in contacts if row.get("id") is not None]
placeholders = ",".join(["%s"] * len(contact_ids))
company_rows = execute_query(
f"""
SELECT
cc.contact_id,
COUNT(DISTINCT cc.customer_id) AS company_count,
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name)
FILTER (WHERE cu.name IS NOT NULL) AS company_names
FROM contact_companies cc
LEFT JOIN customers cu ON cc.customer_id = cu.id
WHERE cc.contact_id IN ({placeholders})
GROUP BY cc.contact_id
""",
tuple(contact_ids),
) or []
company_map = {row["contact_id"]: row for row in company_rows}
for contact in contacts:
company_info = company_map.get(contact["id"]) or {}
contact["company_count"] = int(company_info.get("company_count") or 0)
contact["company_names"] = company_info.get("company_names") or []
return {
"contacts": contacts or [],
"total": total,

View File

@ -199,6 +199,10 @@ class Settings(BaseSettings):
SIMPLYCRM_TICKET_EMAIL_MODULE: str = "Emails"
SIMPLYCRM_TICKET_EMAIL_RELATION_FIELD: str = "parent_id"
SIMPLYCRM_TICKET_EMAIL_FALLBACK_RELATION_FIELD: str = "related_to"
ARCHIVED_VTIGER_SYNC_ENABLED: bool = True
ARCHIVED_VTIGER_SYNC_INTERVAL_MINUTES: int = 30
ARCHIVED_VTIGER_SYNC_LIMIT: int = 5000
ARCHIVED_VTIGER_SYNC_INCLUDE_MESSAGES: bool = False
# Backup System Configuration
BACKUP_ENABLED: bool = True

View File

@ -59,6 +59,42 @@ def _ensure_customer_supplier_tag(customer_id: int) -> None:
logger.warning("⚠️ Could not ensure supplier tag for customer %s: %s", customer_id, tag_error)
def _ensure_customer_tag(customer_id: int, tag_name: str, description: str = "") -> None:
try:
tag = execute_query_single(
"SELECT id FROM tags WHERE LOWER(name) = LOWER(%s) AND type = 'category' LIMIT 1",
(tag_name,),
)
if tag and tag.get("id") is not None:
tag_id = int(tag["id"])
else:
created = execute_query_single(
"""
INSERT INTO tags (name, type, description, color, is_active)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (name, type)
DO UPDATE SET is_active = TRUE, updated_at = CURRENT_TIMESTAMP
RETURNING id
""",
(tag_name, "category", description or tag_name, "#198754", True),
)
tag_id = int(created["id"]) if created and created.get("id") is not None else None
if not tag_id:
return
execute_query(
"""
INSERT INTO entity_tags (entity_type, entity_id, tag_id)
VALUES (%s, %s, %s)
ON CONFLICT (entity_type, entity_id, tag_id) DO NOTHING
""",
("customer", customer_id, tag_id),
)
except Exception as tag_error:
logger.warning("⚠️ Could not ensure tag %s for customer %s: %s", tag_name, customer_id, tag_error)
# Pydantic Models
class CustomerBase(BaseModel):
name: str
@ -111,6 +147,11 @@ class ContactCreate(BaseModel):
role: Optional[str] = None
class ContactSyncRequest(BaseModel):
selected_match_keys: List[str]
add_sync_ok_tag: Optional[bool] = False
@router.get("/customers")
async def list_customers(
limit: int = Query(default=50, ge=1, le=1000),
@ -747,12 +788,21 @@ async def check_customer_data_consistency(customer_id: int):
1 for field_data in discrepancies.values()
if field_data['discrepancy']
)
contact_discrepancies = consistency_service.compare_contacts(all_data)
actionable_contact_discrepancies = [
item for item in contact_discrepancies
if item.get("action") != "matched"
]
return {
"enabled": True,
"customer_id": customer_id,
"discrepancy_count": discrepancy_count,
"discrepancy_count": discrepancy_count + len(actionable_contact_discrepancies),
"field_discrepancy_count": discrepancy_count,
"contact_discrepancy_count": len(actionable_contact_discrepancies),
"contact_total_count": len(contact_discrepancies),
"discrepancies": discrepancies,
"contact_discrepancies": contact_discrepancies,
"systems_available": {
"hub": True,
"vtiger": all_data.get('vtiger') is not None,
@ -813,6 +863,70 @@ async def sync_customer_field(
raise HTTPException(status_code=500, detail=str(e))
@router.post("/customers/{customer_id}/sync-contacts")
async def sync_customer_contacts(customer_id: int, request: ContactSyncRequest):
"""Sync selected vTiger contacts into Hub and optionally tag the customer."""
try:
consistency_service = CustomerConsistencyService()
stats = await consistency_service.sync_vtiger_contacts_to_hub(
customer_id=customer_id,
selected_match_keys=request.selected_match_keys or [],
)
if request.add_sync_ok_tag and stats.get("selected", 0) > 0 and stats.get("skipped", 0) == 0:
_ensure_customer_tag(customer_id, "Sync OK", "Kundedata og kontakter er manuelt verificeret")
return {
"success": True,
"customer_id": customer_id,
"stats": stats,
"sync_ok_tag_added": bool(request.add_sync_ok_tag and stats.get("selected", 0) > 0 and stats.get("skipped", 0) == 0),
}
except Exception as e:
logger.error(f"❌ Failed to sync contacts for customer {customer_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/customers/{customer_id}/mark-sync-ok")
async def mark_customer_sync_ok(customer_id: int):
"""Ensure the customer has the Sync OK tag."""
try:
_ensure_customer_tag(customer_id, "Sync OK", "Kundedata og kontakter er manuelt verificeret")
return {"success": True, "customer_id": customer_id, "tag": "Sync OK"}
except Exception as e:
logger.error(f"❌ Failed to add Sync OK tag for customer {customer_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/customers/{customer_id}/data-consistency-debug")
async def debug_customer_data_consistency(customer_id: int):
"""Debug payload for customer consistency, including contact sync source data."""
try:
consistency_service = CustomerConsistencyService()
all_data = await consistency_service.fetch_all_data(customer_id)
contact_discrepancies = consistency_service.compare_contacts(all_data)
return {
"customer_id": customer_id,
"hub_customer": {
"id": all_data.get("hub", {}).get("id"),
"name": all_data.get("hub", {}).get("name"),
"vtiger_id": all_data.get("hub", {}).get("vtiger_id"),
},
"counts": {
"hub_contacts": len(all_data.get("hub_contacts") or []),
"vtiger_contacts": len(all_data.get("vtiger_contacts") or []),
"contact_rows": len(contact_discrepancies),
},
"hub_contacts": all_data.get("hub_contacts") or [],
"vtiger_contacts": all_data.get("vtiger_contacts") or [],
"contact_discrepancies": contact_discrepancies,
}
except Exception as e:
logger.error(f"❌ Failed to debug consistency for customer {customer_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/customers/sync-economic-from-simplycrm")
async def sync_economic_numbers_from_simplycrm():
"""
@ -1850,26 +1964,108 @@ async def get_customer_acmp_overview(
products = execute_query(
"""
WITH base AS (
SELECT
COALESCE(material_number, 'N/A') AS material_number,
COALESCE(vendor, 'N/A') AS vendor,
COALESCE(product_name, 'Ukendt produkt') AS product_name,
COALESCE(billable_parameters, 1) AS qty,
COALESCE(total_price, sales_price, 0) AS revenue,
COALESCE(cost_amount, 0) AS cost,
billing_start,
created_at
FROM also_import_lines
WHERE matched_customer_id = %s
)
SELECT
COALESCE(material_number, 'N/A') AS material_number,
COALESCE(vendor, 'N/A') AS vendor,
COALESCE(product_name, 'Ukendt produkt') AS product_name,
material_number,
vendor,
product_name,
COUNT(*)::INTEGER AS line_count,
COALESCE(SUM(COALESCE(billable_parameters, 1)), 0) AS quantity_total,
COALESCE(SUM(COALESCE(total_price, sales_price, 0)), 0) AS revenue_total,
COALESCE(SUM(COALESCE(cost_amount, 0)), 0) AS cost_total,
COALESCE(SUM(COALESCE(total_price, sales_price, 0) - COALESCE(cost_amount, 0)), 0) AS margin_total,
COALESCE(SUM(qty), 0) AS quantity_total,
COALESCE(SUM(revenue), 0) AS revenue_total,
COALESCE(SUM(cost), 0) AS cost_total,
COALESCE(SUM(revenue - cost), 0) AS margin_total,
COALESCE(SUM(qty) FILTER (
WHERE date_trunc('month', COALESCE(billing_start::timestamp, created_at)) = date_trunc('month', CURRENT_DATE)
), 0) AS current_month_qty,
COALESCE(SUM(qty) FILTER (
WHERE date_trunc('month', COALESCE(billing_start::timestamp, created_at)) = date_trunc('month', CURRENT_DATE - INTERVAL '1 month')
), 0) AS previous_month_qty,
MAX(billing_start) AS last_billing_start,
MAX(created_at) AS last_seen_at
FROM also_import_lines
WHERE matched_customer_id = %s
GROUP BY COALESCE(material_number, 'N/A'), COALESCE(vendor, 'N/A'), COALESCE(product_name, 'Ukendt produkt')
FROM base
GROUP BY material_number, vendor, product_name
ORDER BY revenue_total DESC, quantity_total DESC
LIMIT 200
""",
(customer_id,),
) or []
changes = execute_query(
"""
WITH current_month AS (
SELECT
COALESCE(material_number, 'N/A') AS material_number,
COALESCE(vendor, 'N/A') AS vendor,
COALESCE(product_name, 'Ukendt produkt') AS product_name,
SUM(COALESCE(billable_parameters, 1)) AS qty
FROM also_import_lines
WHERE matched_customer_id = %s
AND date_trunc('month', COALESCE(billing_start::timestamp, created_at)) = date_trunc('month', CURRENT_DATE)
GROUP BY 1, 2, 3
),
previous_month AS (
SELECT
COALESCE(material_number, 'N/A') AS material_number,
COALESCE(vendor, 'N/A') AS vendor,
COALESCE(product_name, 'Ukendt produkt') AS product_name,
SUM(COALESCE(billable_parameters, 1)) AS qty
FROM also_import_lines
WHERE matched_customer_id = %s
AND date_trunc('month', COALESCE(billing_start::timestamp, created_at)) = date_trunc('month', CURRENT_DATE - INTERVAL '1 month')
GROUP BY 1, 2, 3
)
SELECT
COALESCE(c.material_number, p.material_number) AS material_number,
COALESCE(c.vendor, p.vendor) AS vendor,
COALESCE(c.product_name, p.product_name) AS product_name,
COALESCE(p.qty, 0) AS previous_month_qty,
COALESCE(c.qty, 0) AS current_month_qty,
COALESCE(c.qty, 0) - COALESCE(p.qty, 0) AS quantity_delta
FROM current_month c
FULL OUTER JOIN previous_month p
ON c.material_number = p.material_number
AND c.vendor = p.vendor
AND c.product_name = p.product_name
WHERE COALESCE(c.qty, 0) <> COALESCE(p.qty, 0)
ORDER BY ABS(COALESCE(c.qty, 0) - COALESCE(p.qty, 0)) DESC, COALESCE(c.qty, 0) DESC
LIMIT 100
""",
(customer_id, customer_id),
) or []
recent_lines = execute_query(
"""
SELECT
COALESCE(product_name, 'Ukendt produkt') AS product_name,
COALESCE(material_number, 'N/A') AS material_number,
COALESCE(vendor, 'N/A') AS vendor,
COALESCE(billable_parameters, 1) AS quantity,
COALESCE(total_price, sales_price, 0) AS revenue,
COALESCE(total_price, sales_price, 0) - COALESCE(cost_amount, 0) AS margin,
queue_status,
order_draft_id,
billing_start,
created_at
FROM also_import_lines
WHERE matched_customer_id = %s
ORDER BY COALESCE(billing_start::timestamp, created_at) DESC, id DESC
LIMIT 50
""",
(customer_id,),
) or []
monthly = execute_query(
"""
SELECT
@ -1907,6 +2103,8 @@ async def get_customer_acmp_overview(
"status_breakdown": status_breakdown,
"products": products,
"monthly": monthly,
"changes": changes,
"recent_lines": recent_lines,
}
return response
@ -1915,4 +2113,3 @@ async def get_customer_acmp_overview(
except Exception as e:
logger.error("❌ Error fetching ACMP overview for customer %s: %s", customer_id, e)
raise HTTPException(status_code=500, detail=str(e))

View File

@ -345,6 +345,80 @@
font-weight: 700;
}
.consistency-contact-comparison {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin-top: 0.75rem;
}
.consistency-contact-box {
border: 1px solid rgba(15, 76, 117, 0.14);
border-radius: 10px;
padding: 0.75rem;
background: rgba(15, 76, 117, 0.03);
}
.consistency-contact-box--new {
border-color: rgba(25, 135, 84, 0.24);
background: rgba(25, 135, 84, 0.05);
}
.consistency-contact-box-title {
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 0.45rem;
}
.consistency-contact-line {
font-size: 0.9rem;
margin-bottom: 0.3rem;
}
.consistency-contact-line:last-child {
margin-bottom: 0;
}
.consistency-contact-line-label {
color: var(--text-secondary);
font-weight: 600;
margin-right: 0.35rem;
}
.consistency-diff-list {
margin-top: 0.6rem;
padding-top: 0.6rem;
border-top: 1px dashed rgba(15, 76, 117, 0.14);
}
.consistency-diff-row {
display: grid;
grid-template-columns: 140px 1fr 1fr;
gap: 0.6rem;
font-size: 0.86rem;
margin-bottom: 0.35rem;
}
.consistency-diff-row:last-child {
margin-bottom: 0;
}
.consistency-diff-field {
font-weight: 600;
color: var(--text-secondary);
}
.consistency-diff-old {
color: #8b1e3f;
}
.consistency-diff-new {
color: #146c43;
}
@media (max-width: 992px) {
.contacts-toolbar {
padding: 0.7rem;
@ -359,6 +433,11 @@
#contactsContainer {
min-width: 920px;
}
.consistency-contact-comparison,
.consistency-diff-row {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
@ -1029,6 +1108,9 @@
<th>Produkt</th>
<th>Materiale</th>
<th>Vendor</th>
<th class="text-end">Nu</th>
<th class="text-end">Sidste md.</th>
<th class="text-end">Ændring</th>
<th class="text-end">Antal</th>
<th class="text-end">Omsætning</th>
<th class="text-end">DB</th>
@ -1038,6 +1120,52 @@
</table>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-lg-6">
<div class="info-card h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="fw-bold mb-0">Ændringer siden sidste måned</h6>
<small class="text-muted">Pr. produkt</small>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Produkt</th>
<th class="text-end">Sidste md.</th>
<th class="text-end">Nu</th>
<th class="text-end">Delta</th>
</tr>
</thead>
<tbody id="acmpChangesRows"></tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="info-card h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="fw-bold mb-0">Seneste ACMP-linjer</h6>
<small class="text-muted">Seneste 50 linjer</small>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Dato</th>
<th>Produkt</th>
<th class="text-end">Antal</th>
<th class="text-end">Oms.</th>
<th>Status</th>
</tr>
</thead>
<tbody id="acmpRecentRows"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
@ -2934,6 +3062,20 @@ function getContactMobileValue(contact) {
return normalizePhoneValue(contact.mobile || contact.mobile_phone);
}
function renderContactActionButtons(number, displayName, contactId) {
const safeNumber = escapeHtml(number);
const safeDisplayName = escapeHtml(displayName || '');
const safeContactId = contactId || 'null';
return `
<div class="d-flex align-items-center gap-2 flex-wrap">
<button type="button" class="btn btn-sm btn-outline-success btn-voip js-contact-voip" data-number="${safeNumber}">
<i class="bi bi-telephone-outbound me-1"></i>VOIP
</button>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt('${safeNumber}', '${safeDisplayName}', ${safeContactId})">SMS</button>
</div>
`;
}
function buildContactsRows(contacts) {
return contacts.map(contact => {
const displayName = getContactDisplayName(contact);
@ -2947,10 +3089,10 @@ function buildContactsRows(contacts) {
const email = contact.email ? `<a href="mailto:${contact.email}">${escapeHtml(contact.email)}</a>` : '—';
const phone = phoneValue
? `<div class="contact-phone-wrap"><span class="contact-number"><a href="tel:${phoneValue}">${escapeHtml(phoneValue)}</a></span><button type="button" class="btn btn-sm btn-outline-success btn-voip js-contact-voip" data-number="${escapeHtml(phoneValue)}"><i class="bi bi-telephone-outbound me-1"></i>VOIP</button></div>`
? `<div class="contact-phone-wrap"><span class="contact-number"><a href="tel:${phoneValue}">${escapeHtml(phoneValue)}</a></span>${renderContactActionButtons(phoneValue, displayName, contactId)}</div>`
: '—';
const mobile = mobileValue
? `<div class="contact-mobile-wrap"><span class="contact-number"><a href="tel:${mobileValue}">${escapeHtml(mobileValue)}</a></span><button type="button" class="btn btn-sm btn-outline-success btn-voip js-contact-voip" data-number="${escapeHtml(mobileValue)}"><i class="bi bi-telephone-outbound me-1"></i>VOIP</button><button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt('${escapeHtml(mobileValue)}', '${escapeHtml(displayName)}', ${contact.id || 'null'})">SMS</button></div>`
? `<div class="contact-mobile-wrap"><span class="contact-number"><a href="tel:${mobileValue}">${escapeHtml(mobileValue)}</a></span>${renderContactActionButtons(mobileValue, displayName, contactId)}</div>`
: '—';
const title = titleValue ? escapeHtml(titleValue) : '—';
const primaryBadge = contact.is_primary ? '<span class="badge bg-primary primary-pill">Primær</span>' : '—';
@ -4570,6 +4712,96 @@ async function saveCustomerEdit() {
// Data Consistency Functions
let consistencyData = null;
function escapeAttribute(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function renderConsistencyValue(value) {
if (value === null || value === undefined || String(value).trim() === '') {
return '<em class="text-muted">Tom</em>';
}
return escapeHtml(String(value));
}
function getContactFieldLabel(field) {
const labels = {
first_name: 'Fornavn',
last_name: 'Efternavn',
email: 'Email',
phone: 'Telefon',
mobile: 'Mobil',
title: 'Titel',
department: 'Afdeling'
};
return labels[field] || field;
}
function buildContactComparisonLines(contact) {
return [
['Navn', contact?.display_name],
['Email', contact?.email],
['Telefon', contact?.phone],
['Mobil', contact?.mobile],
['Titel', contact?.title],
['Afdeling', contact?.department]
].map(([label, value]) => `
<div class="consistency-contact-line">
<span class="consistency-contact-line-label">${escapeHtml(label)}:</span>
<span>${renderConsistencyValue(value)}</span>
</div>
`).join('');
}
function buildChangedFieldsDetails(hub, vtiger, changedFields) {
if (!Array.isArray(changedFields) || !changedFields.length) {
return '';
}
return `
<div class="consistency-diff-list">
${changedFields.map((field) => `
<div class="consistency-diff-row">
<div class="consistency-diff-field">${escapeHtml(getContactFieldLabel(field))}</div>
<div class="consistency-diff-old"><strong>Hub nu:</strong> ${renderConsistencyValue(hub?.[field])}</div>
<div class="consistency-diff-new"><strong>Ny fra vTiger:</strong> ${renderConsistencyValue(vtiger?.[field])}</div>
</div>
`).join('')}
</div>
`;
}
function buildFieldDifferenceSummary(fieldData) {
const comparisons = [];
const hubValue = String(fieldData?.hub ?? '').trim();
const vtigerValue = String(fieldData?.vtiger ?? '').trim();
const economicValue = String(fieldData?.economic ?? '').trim();
if (fieldData && fieldData.vtiger !== undefined && hubValue !== vtigerValue) {
comparisons.push('Hub/vTiger');
}
if (fieldData && fieldData.economic !== undefined && hubValue !== economicValue) {
comparisons.push('Hub/e-conomic');
}
if (
fieldData &&
fieldData.vtiger !== undefined &&
fieldData.economic !== undefined &&
vtigerValue !== economicValue
) {
comparisons.push('vTiger/e-conomic');
}
if (!comparisons.length) {
return '<span class="text-muted">Ingen</span>';
}
return comparisons.map(item => `<span class="badge text-bg-warning me-1">${escapeHtml(item)}</span>`).join('');
}
async function checkDataConsistency() {
try {
const response = await fetch(`/api/v1/customers/${customerId}/data-consistency`);
@ -4602,7 +4834,16 @@ function showConsistencyModal() {
}
const tbody = document.getElementById('consistencyTableBody');
const contactsContainer = document.getElementById('consistencyContactsContainer');
const contactsSection = document.getElementById('consistencyContactsSection');
const syncOkCheckbox = document.getElementById('consistencyAddSyncOkTag');
tbody.innerHTML = '';
if (contactsContainer) {
contactsContainer.innerHTML = '';
}
if (syncOkCheckbox) {
syncOkCheckbox.checked = false;
}
// Field labels in Danish
const fieldLabels = {
@ -4628,7 +4869,10 @@ function showConsistencyModal() {
// Field name
const fieldCell = document.createElement('td');
fieldCell.innerHTML = `<strong>${fieldLabels[fieldName] || fieldName}</strong>`;
fieldCell.innerHTML = `
<strong>${fieldLabels[fieldName] || fieldName}</strong>
<div class="small text-muted mt-1">Forskellig værdi fundet på dette felt</div>
`;
row.appendChild(fieldCell);
// Hub value
@ -4636,9 +4880,9 @@ function showConsistencyModal() {
hubCell.innerHTML = `
<div class="form-check">
<input class="form-check-input" type="radio" name="field_${fieldName}"
id="hub_${fieldName}" value="hub" data-value="${fieldData.hub || ''}">
id="hub_${fieldName}" value="hub" data-value="${escapeAttribute(fieldData.hub || '')}">
<label class="form-check-label" for="hub_${fieldName}">
${fieldData.hub || '<em class="text-muted">Tom</em>'}
${fieldData.hub ? escapeHtml(fieldData.hub) : '<em class="text-muted">Tom</em>'}
</label>
</div>
`;
@ -4650,9 +4894,9 @@ function showConsistencyModal() {
vtigerCell.innerHTML = `
<div class="form-check">
<input class="form-check-input" type="radio" name="field_${fieldName}"
id="vtiger_${fieldName}" value="vtiger" data-value="${fieldData.vtiger || ''}">
id="vtiger_${fieldName}" value="vtiger" data-value="${escapeAttribute(fieldData.vtiger || '')}">
<label class="form-check-label" for="vtiger_${fieldName}">
${fieldData.vtiger || '<em class="text-muted">Tom</em>'}
${fieldData.vtiger ? escapeHtml(fieldData.vtiger) : '<em class="text-muted">Tom</em>'}
</label>
</div>
`;
@ -4667,9 +4911,9 @@ function showConsistencyModal() {
economicCell.innerHTML = `
<div class="form-check">
<input class="form-check-input" type="radio" name="field_${fieldName}"
id="economic_${fieldName}" value="economic" data-value="${fieldData.economic || ''}">
id="economic_${fieldName}" value="economic" data-value="${escapeAttribute(fieldData.economic || '')}">
<label class="form-check-label" for="economic_${fieldName}">
${fieldData.economic || '<em class="text-muted">Tom</em>'}
${fieldData.economic ? escapeHtml(fieldData.economic) : '<em class="text-muted">Tom</em>'}
</label>
</div>
`;
@ -4680,11 +4924,69 @@ function showConsistencyModal() {
// Action cell (which system to use)
const actionCell = document.createElement('td');
actionCell.innerHTML = '<span class="text-muted">← Vælg</span>';
actionCell.innerHTML = `
<div class="small mb-2"><strong>Forskelle:</strong> ${buildFieldDifferenceSummary(fieldData)}</div>
<span class="text-muted">Vælg hvilken værdi der skal bruges som ny korrekt værdi</span>
`;
row.appendChild(actionCell);
tbody.appendChild(row);
}
const contactDiscrepancies = Array.isArray(consistencyData.contact_discrepancies)
? consistencyData.contact_discrepancies
: [];
if (contactsSection) {
contactsSection.classList.toggle('d-none', contactDiscrepancies.length === 0);
}
if (contactsContainer && contactDiscrepancies.length > 0) {
contactsContainer.innerHTML = contactDiscrepancies.map((item, index) => {
const vtiger = item.vtiger || {};
const hub = item.hub || null;
const selectable = item.selectable !== false;
const checkedAttr = selectable ? '' : 'disabled';
const statusClass = item.action === 'matched' ? 'bg-success-subtle text-success border-success-subtle' : 'bg-light text-dark border';
const changedFields = buildChangedFieldsDetails(hub, vtiger, item.changed_fields || []);
const hubSummary = hub
? `
<div class="consistency-contact-box">
<div class="consistency-contact-box-title">Nuværende i Hub</div>
${buildContactComparisonLines(hub)}
</div>
`
: `
<div class="consistency-contact-box">
<div class="consistency-contact-box-title">Nuværende i Hub</div>
<div class="small text-muted">Ingen kontakt tilknyttet denne kunde endnu</div>
</div>
`;
const vtigerSummary = `
<div class="consistency-contact-box consistency-contact-box--new">
<div class="consistency-contact-box-title">Ny værdi fra vTiger</div>
${buildContactComparisonLines(vtiger)}
</div>
`;
return `
<label class="list-group-item list-group-item-action ${selectable ? '' : 'opacity-75'}">
<div class="form-check">
<input class="form-check-input me-2 consistency-contact-check" type="checkbox"
value="${escapeAttribute(item.match_key || '')}" id="consistency_contact_${index}" ${checkedAttr}>
<span class="fw-semibold">${escapeHtml(vtiger.display_name || 'Ukendt kontakt')}</span>
<span class="badge ${statusClass} ms-2">${escapeHtml(item.action || 'sync')}</span>
</div>
<div class="small mt-1">${escapeHtml(item.reason || '')}</div>
<div class="consistency-contact-comparison">
${hubSummary}
${vtigerSummary}
</div>
${changedFields}
</label>
`;
}).join('');
}
const modal = new bootstrap.Modal(document.getElementById('consistencyModal'));
modal.show();
@ -4692,12 +4994,15 @@ function showConsistencyModal() {
async function syncSelectedFields() {
const selections = [];
const selectedContactKeys = [];
// Gather all selected values
const radioButtons = document.querySelectorAll('#consistencyTableBody input[type="radio"]:checked');
const contactCheckboxes = document.querySelectorAll('.consistency-contact-check:checked');
const addSyncOkTag = Boolean(document.getElementById('consistencyAddSyncOkTag')?.checked);
if (radioButtons.length === 0) {
alert('Vælg venligst mindst ét felt at synkronisere');
if (radioButtons.length === 0 && contactCheckboxes.length === 0) {
alert('Vælg venligst mindst ét felt eller én kontakt at synkronisere');
return;
}
@ -4712,15 +5017,21 @@ async function syncSelectedFields() {
source_value: sourceValue
});
});
contactCheckboxes.forEach((checkbox) => {
if (checkbox.value) {
selectedContactKeys.push(checkbox.value);
}
});
// Confirm action
if (!confirm(`Du er ved at synkronisere ${selections.length} felt(er) på tværs af alle systemer. Fortsæt?`)) {
if (!confirm(`Du er ved at synkronisere ${selections.length} felt(er) og ${selectedContactKeys.length} kontakt(er). Fortsæt?`)) {
return;
}
// Sync each field
let successCount = 0;
let fieldSuccessCount = 0;
let failCount = 0;
let contactSummary = null;
for (const selection of selections) {
try {
@ -4731,7 +5042,7 @@ async function syncSelectedFields() {
);
if (response.ok) {
successCount++;
fieldSuccessCount++;
} else {
failCount++;
console.error(`Failed to sync ${selection.field_name}`);
@ -4741,6 +5052,39 @@ async function syncSelectedFields() {
console.error(`Error syncing ${selection.field_name}:`, error);
}
}
if (selectedContactKeys.length > 0) {
try {
const response = await fetch(`/api/v1/customers/${customerId}/sync-contacts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
selected_match_keys: selectedContactKeys,
add_sync_ok_tag: false
})
});
if (response.ok) {
const payload = await response.json();
contactSummary = payload.stats || null;
} else {
failCount++;
console.error('Failed to sync selected contacts');
}
} catch (error) {
failCount++;
console.error('Error syncing selected contacts:', error);
}
}
if (addSyncOkTag && failCount === 0 && (fieldSuccessCount > 0 || (contactSummary && contactSummary.selected > 0))) {
try {
await addSyncOkTagToCustomer();
} catch (error) {
failCount++;
console.error('Failed to add Sync OK tag:', error);
}
}
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('consistencyModal'));
@ -4748,9 +5092,19 @@ async function syncSelectedFields() {
// Show result
if (failCount === 0) {
alert(`✓ ${successCount} felt(er) synkroniseret succesfuldt!`);
const parts = [];
if (fieldSuccessCount > 0) {
parts.push(`${fieldSuccessCount} felt`);
}
if (contactSummary && contactSummary.selected > 0) {
parts.push(`${contactSummary.selected} kontakt(er)`);
}
if (addSyncOkTag) {
parts.push('tag "Sync OK"');
}
alert(`✓ Synkroniseret: ${parts.join(', ')}`);
} else {
alert(`⚠️ ${successCount} felt(er) synkroniseret, ${failCount} fejlede`);
alert(`⚠️ ${fieldSuccessCount} felt synkroniseret, ${selectedContactKeys.length} kontakt(er) forsøgt, ${failCount} fejl`);
}
// Reload customer data and recheck consistency
@ -4758,6 +5112,17 @@ async function syncSelectedFields() {
await checkDataConsistency();
}
async function addSyncOkTagToCustomer() {
const response = await fetch(`/api/v1/customers/${customerId}/mark-sync-ok`, {
method: 'POST'
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || 'Kunne ikke tilføje Sync OK tag');
}
}
function showAddContactModal() {
// TODO: Open add contact modal
console.log('Add contact for customer:', customerId);
@ -5220,12 +5585,61 @@ function renderAcmpOverview(payload) {
<td>${escapeHtml(row.product_name || '-')}</td>
<td>${escapeHtml(row.material_number || '-')}</td>
<td>${escapeHtml(row.vendor || '-')}</td>
<td class="text-end">${Number(row.current_month_qty || 0).toLocaleString('da-DK')}</td>
<td class="text-end">${Number(row.previous_month_qty || 0).toLocaleString('da-DK')}</td>
<td class="text-end">${formatSignedNumber(Number(row.current_month_qty || 0) - Number(row.previous_month_qty || 0))}</td>
<td class="text-end">${Number(row.quantity_total || 0).toLocaleString('da-DK')}</td>
<td class="text-end">${formatDKK(Number(row.revenue_total || 0))}</td>
<td class="text-end">${formatDKK(Number(row.margin_total || 0))}</td>
</tr>
`).join('')
: '<tr><td colspan="6" class="text-center text-muted">Ingen produkter fundet</td></tr>';
: '<tr><td colspan="9" class="text-center text-muted">Ingen produkter fundet</td></tr>';
const changesRows = document.getElementById('acmpChangesRows');
const changes = payload.changes || [];
changesRows.innerHTML = changes.length > 0
? changes.map(row => `
<tr>
<td>
<div>${escapeHtml(row.product_name || '-')}</div>
<div class="small text-muted">${escapeHtml(row.material_number || '-')}</div>
</td>
<td class="text-end">${Number(row.previous_month_qty || 0).toLocaleString('da-DK')}</td>
<td class="text-end">${Number(row.current_month_qty || 0).toLocaleString('da-DK')}</td>
<td class="text-end">${formatSignedNumber(Number(row.quantity_delta || 0))}</td>
</tr>
`).join('')
: '<tr><td colspan="4" class="text-center text-muted">Ingen ændringer fundet</td></tr>';
const recentRows = document.getElementById('acmpRecentRows');
const recent = payload.recent_lines || [];
recentRows.innerHTML = recent.length > 0
? recent.map(row => `
<tr>
<td>${formatShortDate(row.billing_start || row.created_at)}</td>
<td>
<div>${escapeHtml(row.product_name || '-')}</div>
<div class="small text-muted">${escapeHtml(row.material_number || '-')}</div>
</td>
<td class="text-end">${Number(row.quantity || 0).toLocaleString('da-DK')}</td>
<td class="text-end">${formatDKK(Number(row.revenue || 0))}</td>
<td>${escapeHtml(row.queue_status || '-')}</td>
</tr>
`).join('')
: '<tr><td colspan="5" class="text-center text-muted">Ingen linjer fundet</td></tr>';
}
function formatSignedNumber(value) {
const number = Number(value || 0);
const prefix = number > 0 ? '+' : '';
return `${prefix}${number.toLocaleString('da-DK')}`;
}
function formatShortDate(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return escapeHtml(String(value));
return date.toLocaleDateString('da-DK');
}
function editInternalComment() {
@ -5470,7 +5884,7 @@ document.addEventListener('DOMContentLoaded', () => {
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
<strong>Vejledning:</strong> Vælg den korrekte værdi for hvert felt med uoverensstemmelser.
Når du klikker "Synkroniser Valgte", vil de valgte værdier blive opdateret i alle systemer.
vTiger bruges kun som læsekilde. Synkronisering skriver kun til BMC Hub og evt. e-conomic.
</div>
<div class="table-responsive">
@ -5489,6 +5903,23 @@ document.addEventListener('DOMContentLoaded', () => {
</tbody>
</table>
</div>
<div id="consistencyContactsSection" class="mt-4 d-none">
<h6 class="mb-3">
<i class="bi bi-people me-2"></i>Kontaktpersoner fra vTiger
</h6>
<div class="small text-muted mb-2">
Alle fundne vTiger-kontakter vises her. Marker kun de kontakter der skal oprettes, opdateres eller linkes til denne kunde i Hub.
</div>
<div id="consistencyContactsContainer" class="list-group"></div>
</div>
<div class="form-check mt-4">
<input class="form-check-input" type="checkbox" id="consistencyAddSyncOkTag">
<label class="form-check-label" for="consistencyAddSyncOkTag">
Tilføj tag "Sync OK" hvis alt lykkes
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">

File diff suppressed because it is too large Load Diff

View File

@ -12,3 +12,11 @@ async def economy_time_queue_page(request: Request):
"economy/frontend/time_queue.html",
{"request": request, "title": "Economy Time Queue"},
)
@router.get("/economy/also-cloud", response_class=HTMLResponse)
async def economy_also_cloud_page(request: Request):
return templates.TemplateResponse(
"economy/frontend/also_cloud.html",
{"request": request, "title": "ALSO Cloud Marketplace"},
)

View File

@ -550,7 +550,7 @@ def _compute_workflow_preview(email_data: Dict[str, Any]) -> Dict[str, Any]:
reasons = []
matches = True
if trigger != classification:
if trigger not in {classification, 'any'}:
matches = False
reasons.append(f"classification_mismatch ({trigger} != {classification or 'none'})")

View File

@ -4870,6 +4870,23 @@ const WORKFLOW_TEMPLATES = {
workflow_steps: [
{ action: 'flag_for_review', params: { reason: 'low_confidence' } }
]
},
'also_cloud_billing': {
name: 'ALSO Cloud Billing Import',
description: 'Downloader ALSO Cloud Marketplace ZIP, importerer ACMP-linjer og opretter ordrekladder',
classification_trigger: 'any',
confidence_threshold: 0.00,
priority: 15,
workflow_steps: [
{
action: 'process_also_cloud_billing',
params: {
sender_pattern: 'marketplace\\.also\\.',
require_sender_match: true,
source_label: 'ALSO Cloud Marketplace email'
}
}
]
}
};

View File

@ -0,0 +1,29 @@
"""
Scheduled archived vTiger sync job.
"""
import logging
from app.core.config import settings
from app.ticket.backend.router import _run_vtiger_archived_import
logger = logging.getLogger(__name__)
async def run_archived_vtiger_sync() -> None:
"""Run incremental archived vTiger sync in the background scheduler."""
if not settings.ARCHIVED_VTIGER_SYNC_ENABLED:
logger.info("⏭️ Archived vTiger sync skipped (ARCHIVED_VTIGER_SYNC_ENABLED=false)")
return
logger.info("🔄 Archived vTiger sync job started")
try:
result = await _run_vtiger_archived_import(
limit=settings.ARCHIVED_VTIGER_SYNC_LIMIT,
include_messages=settings.ARCHIVED_VTIGER_SYNC_INCLUDE_MESSAGES,
force=False,
incremental=True,
)
logger.info("✅ Archived vTiger sync job completed: %s", result)
except Exception as exc:
logger.error("❌ Archived vTiger sync job failed: %s", exc, exc_info=True)

View File

@ -1,6 +1,6 @@
from typing import Optional
from fastapi import APIRouter, Query, Request
from fastapi import APIRouter, File, Query, Request, UploadFile
from app.modules.also.backend.service import also_service
from app.modules.also.models.schemas import (
@ -11,6 +11,8 @@ from app.modules.also.models.schemas import (
AlsoImportJobCreate,
AlsoImportJobResponse,
AlsoImportLinesRequest,
AlsoManualCompanyMapRequest,
AlsoManualProductMapRequest,
AlsoProductMappingUpsert,
AlsoQueueApproveRequest,
AlsoQueueLineResponse,
@ -49,16 +51,80 @@ async def list_import_jobs(
return also_service.list_import_jobs(status=status, limit=limit)
@router.get("/also/import-jobs/{job_id}", response_model=AlsoImportJobResponse)
@router.get("/also/import-jobs/{job_id}")
async def get_import_job(job_id: int):
return also_service.get_import_job(job_id)
@router.delete("/also/import-jobs/{job_id}")
async def delete_import_job(
job_id: int,
delete_order_drafts: bool = Query(default=False),
):
return also_service.delete_import_job(job_id=job_id, delete_order_drafts=delete_order_drafts)
@router.post("/also/import-jobs/{job_id}/auto-map")
async def auto_map_import_job(job_id: int):
return also_service.auto_map_import_job(job_id=job_id)
@router.post("/also/import-lines/{line_id}/map-company")
async def manual_map_company_for_line(
line_id: int,
payload: AlsoManualCompanyMapRequest,
request: Request,
):
return also_service.manual_map_company_for_line(
line_id=line_id,
customer_id=payload.customer_id,
notes=payload.notes,
updated_by_user_id=_user_id_from_request(request),
)
@router.post("/also/import-lines/{line_id}/map-product")
async def manual_map_product_for_line(
line_id: int,
payload: AlsoManualProductMapRequest,
request: Request,
):
return also_service.manual_map_product_for_line(
line_id=line_id,
product_id=payload.product_id,
notes=payload.notes,
updated_by_user_id=_user_id_from_request(request),
)
@router.post("/also/import-upload")
async def upload_import_file(
request: Request,
file: UploadFile = File(...),
):
payload = await file.read()
return also_service.import_billing_upload(
file_name=file.filename or "also-billing.xlsx",
file_bytes=payload,
imported_by_user_id=_user_id_from_request(request),
source_label="Manual upload",
)
@router.post("/also/import-jobs/{job_id}/lines")
async def import_lines(job_id: int, payload: AlsoImportLinesRequest):
return also_service.import_lines(job_id=job_id, payload=payload)
@router.get("/also/import-jobs/{job_id}/lines")
async def get_import_job_lines(
job_id: int,
status: Optional[str] = Query(default=None),
limit: int = Query(default=500, ge=1, le=2000),
):
return also_service.get_import_job_lines(job_id=job_id, status=status, limit=limit)
@router.get("/also/queue", response_model=list[AlsoQueueLineResponse])
async def get_queue(
status: Optional[str] = Query(default=None),
@ -77,6 +143,16 @@ async def get_dashboard_differences(limit: int = Query(default=50, ge=1, le=200)
return also_service.get_monthly_differences(limit=limit)
@router.get("/also/dashboard/status-breakdown")
async def get_status_breakdown():
return also_service.get_status_breakdown()
@router.get("/also/dashboard/monthly-history")
async def get_monthly_history(months: int = Query(default=6, ge=1, le=24)):
return also_service.get_monthly_history(months=months)
@router.post("/also/queue/run-matching", response_model=AlsoQueueProcessResult)
async def run_matching(payload: AlsoQueueProcessRequest):
return also_service.run_matching(

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@ from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field
ALSO_SOURCE_TYPES = Literal["api", "xml", "json_export", "xml_export", "csv"]
ALSO_SOURCE_TYPES = Literal["api", "xml", "json_export", "xml_export", "csv", "xlsx_export"]
ALSO_QUEUE_STATUSES = Literal[
"new",
"matching_products",
@ -97,6 +97,16 @@ class AlsoProductMappingUpsert(BaseModel):
product_name_snapshot: Optional[str] = None
class AlsoManualCompanyMapRequest(BaseModel):
customer_id: int = Field(gt=0)
notes: Optional[str] = None
class AlsoManualProductMapRequest(BaseModel):
product_id: int = Field(gt=0)
notes: Optional[str] = None
class AlsoImportJobResponse(BaseModel):
id: int
source_type: str
@ -119,6 +129,9 @@ class AlsoQueueLineResponse(BaseModel):
product_name: Optional[str] = None
vendor: Optional[str] = None
total_price: Optional[Decimal] = None
cost_amount: Optional[Decimal] = None
effective_cost_amount: Optional[Decimal] = None
effective_margin_amount: Optional[Decimal] = None
currency: Optional[str] = None
matched_customer_id: Optional[int] = None
matched_product_id: Optional[int] = None

View File

@ -6,7 +6,7 @@ from typing import Optional
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
from app.core.auth_service import AuthService
from .service import get_active_timer, get_dashboard_status, get_notifications
from .service import get_active_timer, get_dashboard_status, get_notifications, get_user_messages_summary
logger = logging.getLogger(__name__)
@ -78,11 +78,19 @@ async def bottom_bar_ws(websocket: WebSocket):
initial_status = get_dashboard_status()
initial_notifications = get_notifications(user_id, limit=20)
initial_messages = get_user_messages_summary(user_id, limit=20)
await websocket.send_json({"event": "status_delta", "data": initial_status})
await websocket.send_json({"event": "notification_delta", "data": initial_notifications})
await websocket.send_json({
"event": "notification_delta",
"data": {
"notifications": initial_notifications,
"messages": initial_messages,
},
})
last_status_json = json.dumps(initial_status, sort_keys=True, default=str)
last_notifications_json = json.dumps(initial_notifications, sort_keys=True, default=str)
last_messages_json = json.dumps(initial_messages, sort_keys=True, default=str)
last_timer_elapsed = -1
status_tick = 0
@ -98,6 +106,7 @@ async def bottom_bar_ws(websocket: WebSocket):
if status_tick >= 5:
status = get_dashboard_status()
notifications = get_notifications(user_id, limit=20)
messages = get_user_messages_summary(user_id, limit=20)
status_json = json.dumps(status, sort_keys=True, default=str)
if status_json != last_status_json:
@ -105,9 +114,17 @@ async def bottom_bar_ws(websocket: WebSocket):
last_status_json = status_json
notifications_json = json.dumps(notifications, sort_keys=True, default=str)
if notifications_json != last_notifications_json:
await websocket.send_json({"event": "notification_delta", "data": notifications})
messages_json = json.dumps(messages, sort_keys=True, default=str)
if notifications_json != last_notifications_json or messages_json != last_messages_json:
await websocket.send_json({
"event": "notification_delta",
"data": {
"notifications": notifications,
"messages": messages,
},
})
last_notifications_json = notifications_json
last_messages_json = messages_json
status_tick = 0

View File

@ -8,7 +8,14 @@ from app.core.auth_service import AuthService
from app.core.auth_dependencies import get_current_user
from app.core.database import execute_query, execute_query_single, execute_update
from .service import build_bottom_bar_state, get_own_timer_snapshot, get_unassigned_open_cases
from .service import (
acknowledge_message,
build_bottom_bar_state,
ensure_bottom_bar_messages_schema,
get_own_timer_snapshot,
get_unassigned_open_cases,
mark_user_messages_read,
)
router = APIRouter()
logger = logging.getLogger(__name__)
@ -58,6 +65,20 @@ class NoteToCustomerPayload(BaseModel):
mode: str = "append"
class BottomBarMessageCreatePayload(BaseModel):
message: str
recipient_user_id: Optional[int] = None
requires_manual_ack: bool = False
class BottomBarMessageReadPayload(BaseModel):
partner_user_id: Optional[int] = None
class BottomBarMessageAcknowledgePayload(BaseModel):
message_id: int
def _ensure_user_notes_schema() -> None:
global _USER_NOTES_SCHEMA_READY
if _USER_NOTES_SCHEMA_READY:
@ -521,6 +542,88 @@ async def get_own_timers(
return get_own_timer_snapshot(int(current_user_id), paused_limit=paused_limit)
@router.post("/messages")
async def send_bottom_bar_message(
payload: BottomBarMessageCreatePayload,
current_user: dict = Depends(get_current_user),
):
current_user_id = current_user.get("id")
if current_user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
ensure_bottom_bar_messages_schema()
message_text = str(payload.message or "").strip()
if not message_text:
raise HTTPException(status_code=400, detail="Besked må ikke være tom")
if len(message_text) > 2000:
raise HTTPException(status_code=400, detail="Besked er for lang")
recipient_user_id = payload.recipient_user_id
if recipient_user_id is not None:
recipient_user_id = int(recipient_user_id)
_ensure_user_exists(recipient_user_id)
if recipient_user_id == int(current_user_id):
raise HTTPException(status_code=400, detail="Du kan ikke sende en besked til dig selv")
row = execute_query_single(
"""
INSERT INTO bottom_bar_messages (sender_user_id, recipient_user_id, message_text, requires_manual_ack)
VALUES (%s, %s, %s, %s)
RETURNING id, sender_user_id, recipient_user_id, message_text, requires_manual_ack, created_at
""",
(int(current_user_id), recipient_user_id, message_text, bool(payload.requires_manual_ack)),
) or {}
return {
"message": "Besked sendt",
"item": {
"id": row.get("id"),
"from": _resolve_current_user_display_name(current_user),
"to": "Alle på vagt" if recipient_user_id is None else f"Bruger #{recipient_user_id}",
"text": row.get("message_text") or message_text,
"requires_manual_ack": bool(row.get("requires_manual_ack")),
"created_at": row.get("created_at"),
"is_own": True,
"is_unread": False,
"is_acknowledged": False,
},
}
@router.post("/messages/read")
async def mark_bottom_bar_messages_read(
payload: Optional[BottomBarMessageReadPayload] = None,
current_user: dict = Depends(get_current_user),
):
current_user_id = current_user.get("id")
if current_user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
return {
"ok": True,
"updated": mark_user_messages_read(
int(current_user_id),
partner_user_id=payload.partner_user_id if payload else None,
),
}
@router.post("/messages/acknowledge")
async def acknowledge_bottom_bar_message(
payload: BottomBarMessageAcknowledgePayload,
current_user: dict = Depends(get_current_user),
):
current_user_id = current_user.get("id")
if current_user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
if not acknowledge_message(int(current_user_id), int(payload.message_id)):
raise HTTPException(status_code=404, detail="Besked ikke fundet eller allerede bekræftet")
return {"ok": True, "message_id": int(payload.message_id)}
@router.get("/boss/unassigned-cases")
async def list_unassigned_open_cases(
limit: int = Query(default=25, ge=1, le=100),

View File

@ -9,6 +9,7 @@ logger = logging.getLogger(__name__)
CLOSED_CASE_STATUSES = ("lukket", "løst", "closed", "resolved")
URGENT_PRIORITIES = ("urgent", "high", "kritisk", "critical")
_BOTTOM_BAR_MESSAGES_SCHEMA_READY = False
def _safe_count(row: Optional[dict], key: str = "count") -> int:
@ -65,6 +66,203 @@ def _table_columns(table_name: str) -> List[str]:
return [str(r.get("column_name") or "").strip().lower() for r in rows if r.get("column_name")]
def ensure_bottom_bar_messages_schema() -> None:
global _BOTTOM_BAR_MESSAGES_SCHEMA_READY
if _BOTTOM_BAR_MESSAGES_SCHEMA_READY:
return
exists = execute_query_single("SELECT to_regclass('public.bottom_bar_messages') AS table_name") or {}
table_exists = bool(exists.get("table_name"))
if not table_exists:
execute_query(
"""
CREATE TABLE IF NOT EXISTS bottom_bar_messages (
id SERIAL PRIMARY KEY,
sender_user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
recipient_user_id INTEGER NULL REFERENCES users(user_id) ON DELETE CASCADE,
message_text TEXT NOT NULL,
requires_manual_ack BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
read_at TIMESTAMP NULL
)
"""
)
logger.warning("⚠️ bottom_bar_messages table was missing and has been created automatically")
else:
columns = set(_table_columns("bottom_bar_messages"))
if "requires_manual_ack" not in columns:
execute_query(
"""
ALTER TABLE bottom_bar_messages
ADD COLUMN requires_manual_ack BOOLEAN NOT NULL DEFAULT FALSE
"""
)
logger.warning("⚠️ Added requires_manual_ack to bottom_bar_messages")
execute_query(
"""
CREATE INDEX IF NOT EXISTS idx_bottom_bar_messages_recipient_created
ON bottom_bar_messages (recipient_user_id, created_at DESC)
"""
)
execute_query(
"""
CREATE INDEX IF NOT EXISTS idx_bottom_bar_messages_sender_created
ON bottom_bar_messages (sender_user_id, created_at DESC)
"""
)
execute_query(
"""
CREATE INDEX IF NOT EXISTS idx_bottom_bar_messages_unread
ON bottom_bar_messages (recipient_user_id, read_at, created_at DESC)
"""
)
execute_query(
"""
CREATE INDEX IF NOT EXISTS idx_bottom_bar_messages_manual_ack
ON bottom_bar_messages (recipient_user_id, requires_manual_ack, read_at, created_at DESC)
"""
)
_BOTTOM_BAR_MESSAGES_SCHEMA_READY = True
def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[str, Any]:
if user_id is None:
return {"count": 0, "list": []}
ensure_bottom_bar_messages_schema()
safe_limit = max(1, min(int(limit or 20), 100))
rows = execute_query(
"""
SELECT
m.id,
m.sender_user_id,
m.recipient_user_id,
m.message_text,
m.requires_manual_ack,
m.created_at,
m.read_at,
COALESCE(NULLIF(sender.full_name, ''), sender.username, ('Bruger #' || sender.user_id::text)) AS sender_name,
COALESCE(NULLIF(recipient.full_name, ''), recipient.username, ('Bruger #' || recipient.user_id::text)) AS recipient_name
FROM bottom_bar_messages m
JOIN users sender ON sender.user_id = m.sender_user_id
LEFT JOIN users recipient ON recipient.user_id = m.recipient_user_id
WHERE m.sender_user_id = %s
OR m.recipient_user_id = %s
OR (m.recipient_user_id IS NULL AND EXISTS (
SELECT 1
FROM users u
WHERE u.user_id = %s
AND COALESCE(u.is_active, TRUE) = TRUE
))
ORDER BY m.created_at DESC, m.id DESC
LIMIT %s
""",
(int(user_id), int(user_id), int(user_id), safe_limit),
) or []
unread_row = execute_query_single(
"""
SELECT COUNT(*) AS count
FROM bottom_bar_messages
WHERE read_at IS NULL
AND sender_user_id <> %s
AND (
recipient_user_id = %s
OR recipient_user_id IS NULL
)
""",
(int(user_id), int(user_id)),
) or {}
unread_count = _safe_count(unread_row)
items = []
for row in reversed(rows):
recipient_user_id = row.get("recipient_user_id")
recipient_name = "Alle på vagt" if recipient_user_id is None else (row.get("recipient_name") or f"Bruger #{recipient_user_id}")
items.append(
{
"id": row.get("id"),
"sender_user_id": row.get("sender_user_id"),
"recipient_user_id": row.get("recipient_user_id"),
"from": row.get("sender_name") or "Ukendt",
"to": recipient_name,
"text": row.get("message_text") or "",
"requires_manual_ack": bool(row.get("requires_manual_ack")),
"created_at": row.get("created_at").isoformat() if row.get("created_at") else None,
"is_own": int(row.get("sender_user_id") or 0) == int(user_id),
"is_unread": row.get("read_at") is None and int(row.get("sender_user_id") or 0) != int(user_id),
"is_acknowledged": row.get("read_at") is not None,
}
)
return {
"count": unread_count,
"list": items,
}
def mark_user_messages_read(user_id: Optional[int], partner_user_id: Optional[int] = None) -> int:
if user_id is None:
return 0
ensure_bottom_bar_messages_schema()
params: List[Any] = [int(user_id)]
if partner_user_id is not None and int(partner_user_id) > 0:
where_clause = """
recipient_user_id = %s
AND read_at IS NULL
AND COALESCE(requires_manual_ack, FALSE) = FALSE
AND sender_user_id <> %s
"""
params.append(int(user_id))
where_clause += " AND sender_user_id = %s"
params.append(int(partner_user_id))
else:
where_clause = """
(recipient_user_id = %s OR recipient_user_id IS NULL)
AND read_at IS NULL
AND COALESCE(requires_manual_ack, FALSE) = FALSE
AND sender_user_id <> %s
"""
params.append(int(user_id))
row = execute_query_single(
f"""
UPDATE bottom_bar_messages
SET read_at = CURRENT_TIMESTAMP
WHERE {where_clause}
RETURNING COUNT(*) OVER() AS affected_count
""",
tuple(params),
) or {}
return int(row.get("affected_count") or 0)
def acknowledge_message(user_id: Optional[int], message_id: int) -> bool:
if user_id is None or int(message_id or 0) <= 0:
return False
ensure_bottom_bar_messages_schema()
row = execute_query_single(
"""
UPDATE bottom_bar_messages
SET read_at = CURRENT_TIMESTAMP
WHERE id = %s
AND recipient_user_id = %s
AND read_at IS NULL
RETURNING id
""",
(int(message_id), int(user_id)),
) or {}
return bool(row.get("id"))
def get_drift_status(limit: int = 5) -> Dict[str, Any]:
rel_row = execute_query_single(
"""
@ -731,6 +929,7 @@ def build_bottom_bar_state(
timer = get_active_timer(user_id)
own_timers = get_own_timer_snapshot(user_id, paused_limit=10)
notifications = get_notifications(user_id, limit=10)
messages_summary = get_user_messages_summary(user_id, limit=20)
drift_status = get_drift_status(limit=5)
unassigned_open_cases = get_unassigned_open_cases(limit=8)
recent_cases = _get_recent_cases(user_id, limit=10)
@ -771,13 +970,6 @@ def build_bottom_bar_state(
}
)
messages = [
{
"from": "System",
"text": f"{notifications.get('count', 0)} aktive notifikationer",
}
]
tasks = []
for n in (notifications.get("items") or [])[:5]:
tasks.append(
@ -956,8 +1148,8 @@ def build_bottom_bar_state(
"list": [],
},
"messages": {
"count": len(messages),
"list": messages,
"count": int(messages_summary.get("count") or 0),
"list": messages_summary.get("list") or [],
},
"tasks": {
"count": len(tasks),

View File

@ -32,6 +32,7 @@ class DriftCustomerMappingPayload(BaseModel):
monitor_key: Optional[str] = None
monitor_name: Optional[str] = None
customer_id: int
source: Optional[str] = None
class DriftBlacklistPayload(BaseModel):
@ -163,10 +164,19 @@ def _row_to_event(row: Dict[str, Any]) -> Dict[str, Any]:
started = row.get("started_at")
updated = row.get("updated_at")
resolved = row.get("resolved_at")
raw = row.get("raw_json") if isinstance(row.get("raw_json"), dict) else {}
raw_payload = row.get("raw_json")
if isinstance(raw_payload, str):
try:
raw_payload = json.loads(raw_payload)
except (TypeError, ValueError):
raw_payload = {}
raw = raw_payload if isinstance(raw_payload, dict) else {}
raw_item = raw.get("raw_item") if isinstance(raw.get("raw_item"), dict) else {}
raw_overview = raw_item.get("overview") if isinstance(raw_item.get("overview"), dict) else {}
nested_overview = raw.get("overview") if isinstance(raw.get("overview"), dict) else {}
identification = raw_item.get("identification") if isinstance(raw_item.get("identification"), dict) else {}
if not isinstance(identification, dict):
identification = raw.get("identification") if isinstance(raw.get("identification"), dict) else {}
last_seen = (
raw.get("last_seen")
or raw_overview.get("lastSeen")
@ -185,6 +195,83 @@ def _row_to_event(row: Dict[str, Any]) -> Dict[str, Any]:
source_event_id = row.get("source_event_id") or ""
monitor_key = source_event_id[5:] if source_event_id.startswith("kuma-") else source_event_id
ip_value = None
for candidate in (
raw.get("ip"),
raw.get("ip_address"),
raw.get("ipAddress"),
raw_item.get("ip"),
raw_item.get("ip_address"),
raw_item.get("ipAddress"),
raw_item.get("overview", {}).get("ip") if isinstance(raw_item.get("overview"), dict) else None,
raw_item.get("overview", {}).get("ip_address") if isinstance(raw_item.get("overview"), dict) else None,
raw_item.get("overview", {}).get("ipAddress") if isinstance(raw_item.get("overview"), dict) else None,
identification.get("ip"),
identification.get("ip_address"),
identification.get("ipAddress"),
identification.get("address"),
raw_item.get("address"),
raw_item.get("addresses"),
raw_item.get("network"),
):
if candidate is None:
continue
value = str(candidate).strip()
if value:
ip_value = value
break
site_client_value = None
def _normalize_text_value(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, dict):
for key in ("name", "title", "client_name", "clientName", "site_name", "siteName", "value"):
nested = value.get(key)
if isinstance(nested, str) and nested.strip():
return nested.strip()
return None
if isinstance(value, list):
for item in value:
normalized = _normalize_text_value(item)
if normalized:
return normalized
return None
if isinstance(value, (str, int, float, bool)):
text = str(value).strip()
return text or None
return None
for candidate in (
raw.get("site_client_name"),
raw.get("client_name"),
raw.get("clientName"),
raw.get("client"),
raw_item.get("client_name"),
raw_item.get("clientName"),
raw_item.get("client"),
raw_item.get("site_client_name"),
raw_item.get("site_name"),
raw_item.get("site"),
raw_item.get("siteName"),
raw.get("site"),
raw.get("site_name"),
raw.get("siteName"),
identification.get("site_client_name"),
identification.get("client_name"),
identification.get("clientName"),
identification.get("client"),
identification.get("site"),
identification.get("site_name"),
identification.get("siteName"),
row.get("site_name"),
row.get("customer_name"),
):
normalized = _normalize_text_value(candidate)
if normalized:
site_client_value = normalized
break
raw_source_link = raw.get("source_link") or raw.get("device_link")
if not raw_source_link and isinstance(raw.get("raw_item"), dict):
raw_source_link = _extract_device_link(raw.get("raw_item"), None, source_event_id[5:] if source_event_id.startswith(("uisp-", "kuma-")) else None)
@ -199,8 +286,10 @@ def _row_to_event(row: Dict[str, Any]) -> Dict[str, Any]:
"customer": row.get("customer_name"),
"customer_id": raw.get("customer_id"),
"site": row.get("site_name"),
"site_client_name": site_client_value or row.get("site_name") or row.get("customer_name"),
"device": row.get("device_name"),
"service": row.get("service_name"),
"ip": ip_value,
"source_link": raw_source_link,
"device_link": raw_source_link,
"message": row.get("message"),
@ -1007,6 +1096,47 @@ def _build_events_from_uisp_payload(payload: Any, source_id: Optional[int], base
or str(external_id)
)
site = site_obj.get("name") or item.get("site_name") or item.get("site") or item.get("siteName") or "UISP"
ip_value = None
for candidate in (
item.get("ip"),
item.get("ip_address"),
item.get("ipAddress"),
identification.get("ip"),
identification.get("ip_address"),
identification.get("ipAddress"),
overview.get("ip"),
overview.get("ip_address"),
overview.get("ipAddress"),
item.get("overview", {}).get("ip") if isinstance(item.get("overview"), dict) else None,
item.get("overview", {}).get("ip_address") if isinstance(item.get("overview"), dict) else None,
item.get("overview", {}).get("ipAddress") if isinstance(item.get("overview"), dict) else None,
):
if candidate is None:
continue
value = str(candidate).strip()
if value:
ip_value = value
break
site_client_name = None
for candidate in (
item.get("site_client_name"),
item.get("client_name"),
item.get("clientName"),
item.get("client"),
identification.get("site_client_name"),
identification.get("client_name"),
identification.get("clientName"),
identification.get("client"),
site_obj.get("name"),
site,
):
if candidate is None:
continue
value = str(candidate).strip()
if value:
site_client_name = value
break
state_value = (
overview.get("status")
or identification.get("status")
@ -1061,6 +1191,9 @@ def _build_events_from_uisp_payload(payload: Any, source_id: Optional[int], base
"overview_status": overview.get("status"),
"last_seen": last_seen,
"site": site,
"site_client_name": site_client_name or site,
"ip": ip_value,
"ip_address": ip_value,
"raw_item": item,
},
}
@ -1505,14 +1638,21 @@ async def list_customer_drift_events(customer_id: int, limit: int = Query(defaul
@router.put("/drift/customer-mappings")
async def upsert_customer_mapping(payload: DriftCustomerMappingPayload):
_ensure_schema()
source = _ensure_source()
monitor_key = str(payload.monitor_key or "").strip()
monitor_name = str(payload.monitor_name or "").strip()
requested_source = str(payload.source or "").strip().lower()
if requested_source in {"uisp", "uptime-kuma", "uptime_kuma", "kuma"}:
connector_type = "uisp" if requested_source == "uisp" else "uptime-kuma"
elif monitor_key.startswith("uisp-") or monitor_name.startswith("uisp-"):
connector_type = "uisp"
else:
connector_type = "uptime-kuma"
source = _ensure_source(connector_type)
source_id = source.get("id")
if not source_id:
raise HTTPException(status_code=500, detail="Drift source kunne ikke initialiseres")
monitor_key = str(payload.monitor_key or "").strip()
monitor_name = str(payload.monitor_name or "").strip()
if not monitor_key and not monitor_name:
raise HTTPException(status_code=400, detail="monitor_key eller monitor_name er paakraevet")

View File

@ -98,6 +98,7 @@
<select class="form-select form-select-sm w-auto" id="drift-status-filter">
<option value="">Status: Alle</option>
<option value="active">Aktiv</option>
<option value="acknowledged">Godkendt</option>
<option value="resolved">Løst</option>
</select>
<select class="form-select form-select-sm w-auto" id="drift-severity-filter">
@ -124,6 +125,8 @@
<th>Kilde</th>
<th>Kunde</th>
<th>Device</th>
<th>IP</th>
<th>Site / Client</th>
<th>Start</th>
<th>Last seen</th>
<th>Varighed</th>
@ -131,7 +134,7 @@
</tr>
</thead>
<tbody id="drift-events-body">
<tr><td colspan="9" class="text-muted">Indlæser...</td></tr>
<tr><td colspan="11" class="text-muted">Indlæser...</td></tr>
</tbody>
</table>
</div>
@ -157,6 +160,7 @@
<div class="small text-muted mb-2" id="driftMapModalMonitorText">Monitor</div>
<input type="hidden" id="driftMapMonitorKey">
<input type="hidden" id="driftMapMonitorName">
<input type="hidden" id="driftMapSource" value="uptime-kuma">
<label class="form-label">Vælg kunde</label>
<select class="form-select" id="driftMapCustomerSelect"></select>
</div>
@ -353,7 +357,7 @@ async function loadDriftEvents() {
const visibleEvents = (Array.isArray(events) ? events : []).filter(event => !isDriftEventBlacklisted(event, blacklist));
syncBottomDriftCountFromVisibleEvents(visibleEvents);
if (!visibleEvents.length) {
body.innerHTML = '<tr><td colspan="9" class="text-muted">Ingen hændelser fundet.</td></tr>';
body.innerHTML = '<tr><td colspan="11" class="text-muted">Ingen hændelser fundet.</td></tr>';
return;
}
body.innerHTML = visibleEvents.map(event => `
@ -369,7 +373,7 @@ async function loadDriftEvents() {
<td>
<div class="d-flex align-items-center gap-2">
<span>${event.customer || '-'}</span>
${event.monitor_key ? `<button class="btn btn-sm btn-outline-primary" onclick='openMapCustomerModal(${JSON.stringify(event.monitor_key || '')}, ${JSON.stringify(event.device || '')})' title="Knyt monitor til kunde">Knyt</button>` : ''}
${event.monitor_key ? `<button class="btn btn-sm btn-outline-primary" onclick='openMapCustomerModal(${JSON.stringify(event.monitor_key || '')}, ${JSON.stringify(event.device || '')}, ${JSON.stringify(String(event.source_event_id || '').startsWith('uisp-') ? 'uisp' : 'uptime-kuma')})' title="Knyt monitor til kunde">Knyt</button>` : ''}
</div>
</td>
<td>
@ -378,6 +382,8 @@ async function loadDriftEvents() {
${(event.device_link || event.source_link) ? `<a class="btn btn-sm btn-outline-secondary" href="${event.device_link || event.source_link}" target="_blank" rel="noopener noreferrer" title="Åbn enhed"><i class="bi bi-box-arrow-up-right"></i></a>` : ''}
</div>
</td>
<td>${event.ip || '-'}</td>
<td>${event.site_client_name || event.site || event.customer || '-'}</td>
<td>${event.started ? new Date(event.started).toLocaleString('da-DK') : '-'}</td>
<td>${event.last_seen ? new Date(event.last_seen).toLocaleString('da-DK') : '-'}</td>
<td>${event.duration_minutes !== null && event.duration_minutes !== undefined ? `${event.duration_minutes} min` : '-'}</td>
@ -395,7 +401,7 @@ async function loadDriftEvents() {
} catch (e) {
console.error(e);
syncBottomDriftCountFromVisibleEvents([]);
body.innerHTML = '<tr><td colspan="9" class="text-muted">Kunne ikke hente drift-data.</td></tr>';
body.innerHTML = '<tr><td colspan="11" class="text-muted">Kunne ikke hente drift-data.</td></tr>';
}
}
@ -422,6 +428,11 @@ async function acknowledgeDriftEvent(eventId) {
return;
}
const statusFilter = document.getElementById('drift-status-filter');
if (statusFilter && (statusFilter.value === '' || statusFilter.value === 'active')) {
statusFilter.value = 'acknowledged';
}
await loadDriftSummary();
await loadDriftEvents();
}
@ -451,6 +462,7 @@ async function blacklistDriftEvent(eventId, deviceName = '') {
async function mapDriftCustomer(monitorKey, monitorName) {
const customerId = Number(document.getElementById('driftMapCustomerSelect')?.value || 0);
const source = document.getElementById('driftMapSource')?.value || 'uptime-kuma';
if (!Number.isInteger(customerId) || customerId <= 0) {
alert('Vælg en kunde');
return false;
@ -460,7 +472,7 @@ async function mapDriftCustomer(monitorKey, monitorName) {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ monitor_key: monitorKey, monitor_name: monitorName, customer_id: customerId })
body: JSON.stringify({ monitor_key: monitorKey, monitor_name: monitorName, customer_id: customerId, source })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
@ -473,10 +485,11 @@ async function mapDriftCustomer(monitorKey, monitorName) {
return true;
}
async function openMapCustomerModal(monitorKey, monitorName) {
async function openMapCustomerModal(monitorKey, monitorName, source = 'uptime-kuma') {
await loadDriftCustomers();
document.getElementById('driftMapMonitorKey').value = monitorKey || '';
document.getElementById('driftMapMonitorName').value = monitorName || '';
document.getElementById('driftMapSource').value = source || 'uptime-kuma';
document.getElementById('driftMapModalMonitorText').textContent = `Monitor: ${monitorName || monitorKey}`;
renderCustomerSelectOptions(document.getElementById('driftMapCustomerSelect'));
const modalEl = document.getElementById('driftMapModal');
@ -555,7 +568,7 @@ async function saveBulkMapping(monitorKey, monitorName, selectId) {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ monitor_key: monitorKey, monitor_name: monitorName, customer_id: customerId })
body: JSON.stringify({ monitor_key: monitorKey, monitor_name: monitorName, customer_id: customerId, source: 'uptime-kuma' })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {

View File

@ -110,3 +110,32 @@ async def search_locations(q: str = Query(..., min_length=2)):
term = f"%{q}%"
results = execute_query(sql, (term, term, term))
return results
@router.get("/search/products")
async def search_products(q: str = Query(..., min_length=2)):
"""
Autocomplete search for products.
Returns list of {id, name, sku_internal, supplier_sku, manufacturer}
"""
sql = """
SELECT
id,
name,
sku_internal,
supplier_sku,
manufacturer
FROM products
WHERE (
name ILIKE %s
OR COALESCE(sku_internal, '') ILIKE %s
OR COALESCE(supplier_sku, '') ILIKE %s
OR COALESCE(manufacturer, '') ILIKE %s
)
AND deleted_at IS NULL
ORDER BY name ASC
LIMIT 20
"""
term = f"%{q}%"
results = execute_query(sql, (term, term, term, term))
return results

View File

@ -5,7 +5,7 @@ Compares customer data across BMC Hub, vTiger Cloud, and e-conomic
import logging
import asyncio
from typing import Dict, List, Optional, Tuple, Any
from app.core.database import execute_query_single, execute_update
from app.core.database import execute_query, execute_query_single, execute_update, execute_insert
from app.services.vtiger_service import VTigerService
from app.services.economic_service import EconomicService
from app.core.config import settings
@ -34,6 +34,111 @@ class CustomerConsistencyService:
def __init__(self):
self.vtiger = VTigerService()
self.economic = EconomicService()
@staticmethod
def _clean_contact_value(value: Any) -> Optional[str]:
if value is None:
return None
text = str(value).strip()
return text or None
@classmethod
def _normalize_contact(cls, source: str, row: Dict[str, Any]) -> Dict[str, Any]:
first_name = cls._clean_contact_value(
row.get('first_name') if source == 'hub' else row.get('firstname')
)
last_name = cls._clean_contact_value(
row.get('last_name') if source == 'hub' else row.get('lastname')
)
email = cls._clean_contact_value(row.get('email'))
phone = cls._clean_contact_value(row.get('phone'))
mobile = cls._clean_contact_value(row.get('mobile'))
title = cls._clean_contact_value(row.get('title'))
department = cls._clean_contact_value(row.get('department'))
vtiger_id = cls._clean_contact_value(
row.get('vtiger_id') if source == 'hub' else row.get('id')
)
display_name = " ".join(part for part in [first_name, last_name] if part).strip()
if not display_name:
display_name = email or phone or mobile or vtiger_id or 'Ukendt kontakt'
return {
'id': row.get('id'),
'vtiger_id': vtiger_id,
'first_name': first_name,
'last_name': last_name,
'email': email,
'phone': phone,
'mobile': mobile,
'title': title,
'department': department,
'display_name': display_name,
'is_primary': bool(row.get('is_primary')) if source == 'hub' else False,
'role': cls._clean_contact_value(row.get('role')) if source == 'hub' else None,
'customer_count': row.get('customer_count', 0) or 0,
}
@staticmethod
def _contact_match_key(contact: Dict[str, Any]) -> str:
vtiger_id = str(contact.get('vtiger_id') or '').strip().lower()
if vtiger_id:
return f"vtiger:{vtiger_id}"
email = str(contact.get('email') or '').strip().lower()
if email:
return f"email:{email}"
first_name = str(contact.get('first_name') or '').strip().lower()
last_name = str(contact.get('last_name') or '').strip().lower()
phone = str(contact.get('phone') or '').strip().lower()
mobile = str(contact.get('mobile') or '').strip().lower()
return f"name:{first_name}|{last_name}|{phone}|{mobile}"
async def fetch_hub_contacts(self, customer_id: int) -> List[Dict[str, Any]]:
query = """
SELECT
c.*,
cc.is_primary,
cc.role,
(
SELECT COUNT(*)
FROM contact_companies cc2
WHERE cc2.contact_id = c.id
) AS customer_count
FROM contacts c
JOIN contact_companies cc ON cc.contact_id = c.id
WHERE cc.customer_id = %s
AND c.is_active = TRUE
ORDER BY cc.is_primary DESC, c.first_name, c.last_name, c.id
"""
rows = await asyncio.to_thread(execute_query, query, (customer_id,))
return [self._normalize_contact('hub', row) for row in (rows or [])]
async def fetch_vtiger_contacts(self, vtiger_customer_id: Optional[str]) -> List[Dict[str, Any]]:
if not vtiger_customer_id or not settings.VTIGER_URL:
return []
safe_customer_id = self.vtiger._sanitize_vtiger_id(vtiger_customer_id)
if not safe_customer_id:
return []
rows: List[Dict[str, Any]] = []
seen_ids = set()
# vTiger installations are inconsistent about which relation field is exposed on Contacts.
relation_fields = ("account_id", "accountid", "parent_id", "account")
for field_name in relation_fields:
query = f"SELECT * FROM Contacts WHERE {field_name}='{safe_customer_id}';"
result = await self.vtiger.query(query)
for row in (result or []):
row_id = str(row.get("id") or "").strip()
dedupe_key = row_id or f"{row.get('email')}|{row.get('firstname')}|{row.get('lastname')}"
if dedupe_key in seen_ids:
continue
seen_ids.add(dedupe_key)
rows.append(row)
return [self._normalize_contact('vtiger', row) for row in rows]
@staticmethod
def normalize_value(value: Any) -> Optional[str]:
@ -57,7 +162,7 @@ class CustomerConsistencyService:
# Lowercase for case-insensitive comparison
return str_value.lower()
async def fetch_all_data(self, customer_id: int) -> Dict[str, Optional[Dict[str, Any]]]:
async def fetch_all_data(self, customer_id: int) -> Dict[str, Any]:
"""
Fetch customer data from all three systems in parallel
@ -81,10 +186,13 @@ class CustomerConsistencyService:
# Prepare async tasks for vTiger and e-conomic
vtiger_task = None
economic_task = None
hub_contacts_task = self.fetch_hub_contacts(customer_id)
vtiger_contacts_task = None
# Fetch vTiger data if we have an ID and vTiger is configured
if hub_data.get('vtiger_id') and settings.VTIGER_URL:
vtiger_task = self.vtiger.get_account_by_id(hub_data['vtiger_id'])
vtiger_contacts_task = self.fetch_vtiger_contacts(hub_data['vtiger_id'])
# Fetch e-conomic data if we have a customer number and e-conomic is configured
if hub_data.get('economic_customer_number') and settings.ECONOMIC_APP_SECRET_TOKEN:
@ -96,6 +204,9 @@ class CustomerConsistencyService:
tasks['vtiger'] = vtiger_task
if economic_task:
tasks['economic'] = economic_task
tasks['hub_contacts'] = hub_contacts_task
if vtiger_contacts_task:
tasks['vtiger_contacts'] = vtiger_contacts_task
results = {}
if tasks:
@ -115,7 +226,9 @@ class CustomerConsistencyService:
return {
'hub': hub_data,
'vtiger': results.get('vtiger'),
'economic': results.get('economic')
'economic': results.get('economic'),
'hub_contacts': results.get('hub_contacts') or [],
'vtiger_contacts': results.get('vtiger_contacts') or [],
}
@classmethod
@ -173,6 +286,56 @@ class CustomerConsistencyService:
}
return discrepancies
@classmethod
def compare_contacts(cls, all_data: Dict[str, Any]) -> List[Dict[str, Any]]:
hub_contacts = all_data.get('hub_contacts') or []
vtiger_contacts = all_data.get('vtiger_contacts') or []
hub_by_key = {cls._contact_match_key(contact): contact for contact in hub_contacts}
discrepancies: List[Dict[str, Any]] = []
for vtiger_contact in vtiger_contacts:
match_key = cls._contact_match_key(vtiger_contact)
hub_contact = hub_by_key.get(match_key)
if not hub_contact:
discrepancies.append({
'match_key': match_key,
'action': 'create_or_link',
'reason': 'Kontakten findes i vTiger men ikke på denne kunde i Hub',
'hub': None,
'vtiger': vtiger_contact,
'selectable': True,
})
continue
changed_fields = []
for field in ('first_name', 'last_name', 'email', 'phone', 'mobile', 'title', 'department'):
if cls.normalize_value(hub_contact.get(field)) != cls.normalize_value(vtiger_contact.get(field)):
changed_fields.append(field)
if changed_fields:
discrepancies.append({
'match_key': match_key,
'action': 'update_hub',
'reason': 'Kontakt findes begge steder men har feltforskelle',
'hub': hub_contact,
'vtiger': vtiger_contact,
'changed_fields': changed_fields,
'selectable': True,
})
else:
discrepancies.append({
'match_key': match_key,
'action': 'matched',
'reason': 'Kontakt findes allerede i Hub og matcher vTiger',
'hub': hub_contact,
'vtiger': vtiger_contact,
'changed_fields': [],
'selectable': False,
})
return discrepancies
async def sync_field(
self,
@ -198,7 +361,7 @@ class CustomerConsistencyService:
if field_name not in self.FIELD_MAP:
raise ValueError(f"Unknown field: {field_name}")
vtiger_field, economic_field = self.FIELD_MAP[field_name]
_, economic_field = self.FIELD_MAP[field_name]
# Fetch Hub data to get mapping IDs
hub_query = "SELECT * FROM customers WHERE id = %s"
@ -222,22 +385,8 @@ class CustomerConsistencyService:
else:
results['hub'] = True # Already correct
# Update vTiger if enabled and not the source
if settings.VTIGER_SYNC_ENABLED and source_system != 'vtiger' and hub_data.get('vtiger_id'):
try:
update_data = {vtiger_field: source_value}
success = await self.vtiger.update_account(hub_data['vtiger_id'], update_data)
if success:
results['vtiger'] = True
logger.info(f"✅ vTiger {vtiger_field} updated")
else:
results['vtiger'] = False
logger.error(f"❌ vTiger update failed - API returned False")
except Exception as e:
logger.error(f"❌ Failed to update vTiger: {e}")
results['vtiger'] = False
else:
results['vtiger'] = True # Not applicable or already correct
# vTiger is read-only for this workflow.
results['vtiger'] = True
# Update e-conomic if enabled and not the source
if settings.ECONOMIC_SYNC_ENABLED and source_system != 'economic' and hub_data.get('economic_customer_number'):
@ -258,5 +407,130 @@ class CustomerConsistencyService:
results['economic'] = False
else:
results['economic'] = True # Not applicable or already correct
return results
async def sync_vtiger_contacts_to_hub(
self,
customer_id: int,
selected_match_keys: List[str],
) -> Dict[str, Any]:
if not selected_match_keys:
return {"selected": 0, "created": 0, "updated": 0, "linked": 0, "skipped": 0}
all_data = await self.fetch_all_data(customer_id)
discrepancies = self.compare_contacts(all_data)
selected = {str(item or '').strip() for item in selected_match_keys if str(item or '').strip()}
selected_rows = [row for row in discrepancies if row.get('match_key') in selected]
stats = {"selected": len(selected_rows), "created": 0, "updated": 0, "linked": 0, "skipped": 0}
for row in selected_rows:
vtiger_contact = row.get('vtiger') or {}
hub_contact = row.get('hub')
if not vtiger_contact:
stats["skipped"] += 1
continue
contact_id = hub_contact.get('id') if hub_contact else None
if not contact_id:
vtiger_id = vtiger_contact.get("vtiger_id")
email = vtiger_contact.get("email")
if vtiger_id:
existing_global = await asyncio.to_thread(
execute_query_single,
"SELECT id FROM contacts WHERE vtiger_id = %s LIMIT 1",
(vtiger_id,),
)
contact_id = existing_global.get("id") if existing_global else None
if not contact_id and email:
existing_global = await asyncio.to_thread(
execute_query_single,
"SELECT id FROM contacts WHERE LOWER(COALESCE(email, '')) = %s LIMIT 1",
(str(email).strip().lower(),),
)
contact_id = existing_global.get("id") if existing_global else None
if contact_id:
update_fields = {
"first_name": vtiger_contact.get("first_name"),
"last_name": vtiger_contact.get("last_name"),
"email": vtiger_contact.get("email"),
"phone": vtiger_contact.get("phone"),
"mobile": vtiger_contact.get("mobile"),
"title": vtiger_contact.get("title"),
"department": vtiger_contact.get("department"),
"vtiger_id": vtiger_contact.get("vtiger_id"),
}
await asyncio.to_thread(
execute_update,
"""
UPDATE contacts
SET first_name = %s,
last_name = %s,
email = %s,
phone = %s,
mobile = %s,
title = %s,
department = %s,
vtiger_id = COALESCE(%s, vtiger_id)
WHERE id = %s
""",
(
update_fields["first_name"],
update_fields["last_name"],
update_fields["email"],
update_fields["phone"],
update_fields["mobile"],
update_fields["title"],
update_fields["department"],
update_fields["vtiger_id"],
contact_id,
),
)
stats["updated"] += 1
else:
contact_id = await asyncio.to_thread(
execute_insert,
"""
INSERT INTO contacts (
first_name, last_name, email, phone, mobile, title, department, vtiger_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
vtiger_contact.get("first_name"),
vtiger_contact.get("last_name"),
vtiger_contact.get("email"),
vtiger_contact.get("phone"),
vtiger_contact.get("mobile"),
vtiger_contact.get("title"),
vtiger_contact.get("department"),
vtiger_contact.get("vtiger_id"),
),
)
stats["created"] += 1
link_exists = await asyncio.to_thread(
execute_query_single,
"""
SELECT id
FROM contact_companies
WHERE contact_id = %s AND customer_id = %s
""",
(contact_id, customer_id),
)
if not link_exists:
await asyncio.to_thread(
execute_update,
"""
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
VALUES (%s, %s, %s, %s)
ON CONFLICT (contact_id, customer_id) DO NOTHING
""",
(contact_id, customer_id, False, None),
)
stats["linked"] += 1
return stats

View File

@ -19,6 +19,7 @@ from uuid import uuid4
from app.core.database import execute_query, execute_insert, execute_update, table_has_column
from app.core.config import settings
from app.modules.also.backend.service import also_service
from app.services.email_activity_logger import email_activity_logger
logger = logging.getLogger(__name__)
@ -1236,7 +1237,7 @@ class EmailWorkflowService:
confidence_threshold, workflow_steps, priority, stop_on_match
FROM email_workflows
WHERE enabled = true
AND classification_trigger = %s
AND LOWER(classification_trigger) IN (%s, 'any')
AND confidence_threshold <= %s
ORDER BY priority ASC
"""
@ -1412,6 +1413,7 @@ class EmailWorkflowService:
'extract_invoice_data': self._action_extract_invoice_data,
'extract_tracking_number': self._action_extract_tracking_number,
'regex_extract_and_link': self._action_regex_extract_and_link,
'process_also_cloud_billing': self._action_process_also_cloud_billing,
'send_slack_notification': self._action_send_slack_notification,
'send_email_notification': self._action_send_email_notification,
'mark_as_processed': self._action_mark_as_processed,
@ -1439,6 +1441,52 @@ class EmailWorkflowService:
'status': 'failed',
'error': str(e)
}
def _extract_also_billing_download_link(self, email_data: Dict) -> Optional[str]:
candidates = [
email_data.get('body_html') or '',
email_data.get('body_text') or '',
email_data.get('subject') or '',
]
pattern = re.compile(r'https://marketplace\.also\.[^"\s<]+?/download/[^"\s<]+', re.IGNORECASE)
for value in candidates:
match = pattern.search(str(value))
if match:
return html.unescape(match.group(0))
return None
async def _action_process_also_cloud_billing(self, params: Dict, email_data: Dict) -> Dict:
"""Download ALSO Cloud Marketplace billing ZIP, import lines, and create ordre drafts."""
download_url = params.get('download_url') or self._extract_also_billing_download_link(email_data)
if not download_url:
return {
'action': 'process_also_cloud_billing',
'success': False,
'reason': 'download_link_not_found',
}
sender_email = str(email_data.get('sender_email') or '')
if params.get('require_sender_match', True):
sender_pattern = params.get('sender_pattern') or r'also\.[a-z]{2,}$'
if sender_email and not re.search(sender_pattern, sender_email, re.IGNORECASE):
return {
'action': 'process_also_cloud_billing',
'success': False,
'reason': 'sender_pattern_no_match',
'sender_email': sender_email,
}
result = await also_service.import_billing_zip_from_url(
download_url=download_url,
imported_by_user_id=params.get('imported_by_user_id'),
email_id=email_data.get('id'),
source_label=params.get('source_label') or 'ALSO Cloud Marketplace email',
)
return {
'action': 'process_also_cloud_billing',
'success': True,
**result,
}
# Action Handlers

View File

@ -202,7 +202,7 @@ class SimplyCRMService:
Returns:
List of ticket records
"""
module_name = getattr(settings, "SIMPLYCRM_TICKET_MODULE", "Tickets")
module_name = getattr(settings, "SIMPLYCRM_TICKET_MODULE", "HelpDesk")
all_records: List[Dict] = []
offset = 0
batch_size = 200

View File

@ -5,6 +5,7 @@ Settings and User Management API Router
from fastapi import APIRouter, HTTPException, Request
from typing import List, Optional, Dict
from pydantic import BaseModel
from datetime import datetime
from app.core.database import execute_query
from app.core.config import settings
import httpx
@ -57,8 +58,8 @@ class User(BaseModel):
email: Optional[str]
full_name: Optional[str]
is_active: bool
last_login: Optional[str]
created_at: str
last_login: Optional[datetime]
created_at: datetime
class UserCreate(BaseModel):
@ -992,4 +993,3 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest, http_request: Req
err = str(e) or e.__class__.__name__
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}")

View File

@ -1047,7 +1047,7 @@
<div class="small text-muted mb-3">Beskeder lokalt: <span id="archivedVtigerMessagesCount">-</span></div>
<div class="d-grid">
<button class="btn btn-outline-primary btn-sm" onclick="syncArchivedVtiger()" id="btnSyncArchivedVtiger">
<i class="bi bi-cloud-download me-2"></i>Sync vTiger Archived
<i class="bi bi-cloud-download me-2"></i>Sync resten fra vTiger
</button>
</div>
</div>
@ -5145,9 +5145,9 @@ async function syncArchivedVtiger() {
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Synkroniserer...';
try {
addSyncLogEntry('vTiger Archived Sync Startet', 'Importerer archived tickets fra vTiger Cases...', 'info');
addSyncLogEntry('vTiger Archived Sync Startet', 'Importerer nye eller ændrede archived tickets fra vTiger Cases...', 'info');
const response = await fetch('/api/v1/ticket/archived/vtiger/import?limit=5000&include_messages=true&force=false', {
const response = await fetch('/api/v1/ticket/archived/vtiger/import?limit=5000&include_messages=true&force=false&incremental=true', {
method: 'POST'
});
@ -5158,6 +5158,8 @@ async function syncArchivedVtiger() {
const result = await response.json();
const details = [
`Mode: ${result.mode || 'ukendt'}`,
`Hentet remote: ${result.fetched_remote || 0}`,
`Importeret: ${result.imported || 0}`,
`Opdateret: ${result.updated || 0}`,
`Sprunget over: ${result.skipped || 0}`,
@ -5173,7 +5175,7 @@ async function syncArchivedVtiger() {
showNotification('Fejl: ' + error.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-cloud-download me-2"></i>Sync vTiger Archived';
btn.innerHTML = '<i class="bi bi-cloud-download me-2"></i>Sync resten fra vTiger';
}
}
@ -5891,6 +5893,7 @@ const MENU_VISIBILITY_GROUPS = [
{ key: 'menu-salg-products', label: 'Produkter' },
{ key: 'menu-salg-webshop', label: 'Webshop Administration' },
{ key: 'menu-okonomi-time-queue', label: 'Time Queue' },
{ key: 'menu-okonomi-also-cloud', label: 'ALSO Cloud Marketplace' },
{ key: 'menu-okonomi-supplier-invoices', label: 'Leverandør fakturaer' },
{ key: 'menu-okonomi-prepaid', label: 'Prepaid Cards' },
{ key: 'menu-okonomi-fixed-price', label: 'Fastpris Aftaler' },

View File

@ -391,10 +391,14 @@
grid-template-columns: 160px minmax(0, 1fr);
min-height: 240px;
max-height: min(52vh, 420px);
height: min(52vh, 420px);
overflow: hidden;
box-shadow: inset 0 2px 10px rgba(0,0,0,0.02);
margin-top: 0.5rem;
}
.global-bottom-bar .bb-sheet-inner > * {
min-height: 0;
}
.global-bottom-bar .bb-side-tabs {
border-right: 1px solid rgba(var(--text-primary-rgb), 0.08);
@ -403,6 +407,8 @@
display: grid;
gap: 0.4rem;
align-content: start;
min-height: 0;
overflow-y: auto;
}
.global-bottom-bar .bb-tab-btn {
@ -424,6 +430,23 @@
font-size: 1rem;
opacity: 0.7;
}
.global-bottom-bar .bb-tab-btn .bb-tab-badge {
display: none;
align-items: center;
justify-content: center;
min-width: 1.2rem;
height: 1.2rem;
border-radius: 999px;
padding: 0 0.35rem;
margin-left: auto;
font-size: 0.7rem;
font-weight: 700;
background: rgba(220, 53, 69, 0.14);
color: #b02a37;
}
.global-bottom-bar .bb-tab-btn.has-unread .bb-tab-badge {
display: inline-flex;
}
.global-bottom-bar .bb-tab-btn:hover {
background: rgba(var(--text-primary-rgb), 0.05);
color: var(--text-primary);
@ -439,20 +462,29 @@
opacity: 1;
color: var(--accent);
}
[data-bs-theme="dark"] .global-bottom-bar .bb-tab-btn .bb-tab-badge {
background: rgba(255, 138, 148, 0.18);
color: #ffb3ba;
}
.global-bottom-bar .bb-tab-content {
padding: 1.2rem;
overflow: auto;
padding: 0.75rem 0.9rem 0.9rem;
overflow: hidden;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.global-bottom-bar .bb-tab-title {
font-size: 1.1rem;
font-size: 1rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 1rem;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
line-height: 1.15;
}
.global-bottom-bar .bb-tab-list {
@ -467,7 +499,7 @@
border-left: 4px solid var(--accent);
background: var(--accent-light);
border-radius: 6px 8px 8px 6px;
padding: 0.75rem 1rem;
padding: 0.65rem 0.85rem;
font-size: 0.88rem;
line-height: 1.4;
color: var(--text-primary);
@ -478,6 +510,105 @@
transform: translateX(2px);
box-shadow: 0 4px 14px rgba(0,0,0,0.08);
}
.global-bottom-bar .bb-messages-layout {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 0;
}
.global-bottom-bar .bb-message-threads {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.1rem;
scrollbar-width: none;
}
.global-bottom-bar .bb-message-threads::-webkit-scrollbar {
display: none;
}
.global-bottom-bar .bb-message-thread {
border: 1px solid rgba(var(--text-primary-rgb), 0.1);
background: var(--bg-card);
color: var(--text-primary);
border-radius: 999px;
padding: 0.42rem 0.75rem;
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
}
.global-bottom-bar .bb-message-thread.is-active {
background: var(--accent);
color: #fff;
border-color: transparent;
box-shadow: 0 8px 20px rgba(15, 76, 117, 0.18);
}
.global-bottom-bar .bb-message-thread-count {
min-width: 1.2rem;
height: 1.2rem;
border-radius: 999px;
background: rgba(220, 53, 69, 0.14);
color: #b42318;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
padding: 0 0.35rem;
}
.global-bottom-bar .bb-message-thread.is-active .bb-message-thread-count {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
.global-bottom-bar .bb-messages-list {
max-height: min(26vh, 240px);
overflow-y: auto;
padding-right: 0.2rem;
}
.global-bottom-bar #bbTabInnerContent {
flex: 1 1 auto;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.global-bottom-bar .bb-notes-layout {
display: flex;
flex-direction: column;
gap: 0.75rem;
height: 100%;
min-height: 0;
overflow: hidden;
}
.global-bottom-bar .bb-notes-editor {
flex: 0 0 auto;
}
.global-bottom-bar .bb-notes-list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-right: 0.2rem;
}
.global-bottom-bar .bb-messages-composer {
border-top: 1px solid rgba(var(--text-primary-rgb), 0.08);
padding-top: 0.55rem;
margin-top: 0.15rem;
}
.global-bottom-bar .bb-messages-layout {
height: 100%;
overflow: hidden;
}
.global-bottom-bar .bb-detail-line {
min-height: 28px;
padding: 0.2rem 0.2rem 0;
font-size: 0.8rem;
line-height: 1.2;
}
.global-bottom-bar.is-expanded .bb-detail-line {
padding-bottom: 0.1rem;
}
#bbSwitchCaseModal .modal-content {
border: 1px solid rgba(var(--text-primary-rgb), 0.12);
@ -777,7 +908,7 @@
{% set _xff = request.headers.get('x-forwarded-for') if request and request.headers else '' %}
{% set _xff_first = _xff.split(',')[0].strip() if _xff else '' %}
{% set _client_ip = (request.headers.get('cf-connecting-ip') if request and request.headers else '') or (request.headers.get('true-client-ip') if request and request.headers else '') or _xff_first or (request.headers.get('x-real-ip') if request and request.headers else '') or (request.client.host if request and request.client else '') %}
{% set _can_click_to_call = _client_ip.startswith('172.16.31.') %}
{% set _can_click_to_call = true %}
<body>
<nav class="navbar navbar-expand-lg fixed-top">
@ -862,6 +993,7 @@
<ul class="dropdown-menu mt-2">
<li><h6 class="dropdown-header">Fakturering</h6></li>
<li data-menu-key="menu-okonomi-time-queue"><a class="dropdown-item py-2" href="/economy/time-queue"><i class="bi bi-clock-history me-2"></i>Time Queue</a></li>
<li data-menu-key="menu-okonomi-also-cloud"><a class="dropdown-item py-2" href="/economy/also-cloud"><i class="bi bi-cloud-arrow-up me-2"></i>ALSO Cloud Marketplace</a></li>
<li data-menu-key="menu-okonomi-supplier-invoices"><a class="dropdown-item py-2" href="/billing/supplier-invoices"><i class="bi bi-receipt me-2"></i>Leverandør fakturaer</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Aftaler</h6></li>
@ -907,8 +1039,8 @@
</button>
<div class="dropdown">
<a href="#" class="d-flex align-items-center text-decoration-none text-dark dropdown-toggle" data-bs-toggle="dropdown">
<img src="https://ui-avatars.com/api/?name=CT&background=0f4c75&color=fff" class="rounded-circle me-2" width="32">
<span class="small fw-bold" style="color: var(--text-primary)">Christian</span>
<img id="currentUserAvatar" src="https://ui-avatars.com/api/?name=Bruger&background=0f4c75&color=fff" class="rounded-circle me-2" width="32">
<span id="currentUserDisplayName" class="small fw-bold" style="color: var(--text-primary)">Bruger</span>
</a>
<ul class="dropdown-menu dropdown-menu-end mt-2">
<li><a class="dropdown-item py-2" href="#" data-bs-toggle="modal" data-bs-target="#profileModal">Profil</a></li>
@ -1297,61 +1429,10 @@ if (bmcOriginalFetch) {
<script src="/static/js/telefoni.js?v=2.4"></script>
<script src="/static/js/sms.js?v=1.0"></script>
<script src="/static/js/bug-report.js?v=1.4"></script>
<script src="/static/js/bottom-bar.js?v=2.32"></script>
<script src="/static/js/bottom-bar.js?v=2.43"></script>
<script>
// Dark Mode Toggle Logic
window.BMC_CAN_CLICK_TO_CALL = {{ 'true' if _can_click_to_call else 'false' }};
if (!window.BMC_CAN_CLICK_TO_CALL) {
const RING_OP_TEXT = /\bring\s*op\b/i;
const callSelector = [
'button[onclick*="ViaYealink"]',
'a[onclick*="ViaYealink"]',
'button[onclick*="testTelefoniCall"]',
'#telefoniTestBtn',
'[data-call-action]'
].join(',');
const isRingCallButton = (el) => {
if (!el || !(el instanceof Element)) return false;
if (el.matches(callSelector)) return true;
const text = (el.textContent || '').trim();
if (RING_OP_TEXT.test(text)) return true;
const onclick = (el.getAttribute('onclick') || '').toLowerCase();
return onclick.includes('click-to-call') || onclick.includes('viayealink') || onclick.includes('testtelefonicall');
};
const hideCallButtons = (root) => {
const scope = root && root.querySelectorAll ? root : document;
scope.querySelectorAll('button, a, [role="button"]').forEach((el) => {
if (!isRingCallButton(el)) return;
if (el.dataset.callHiddenByIp === '1') return;
el.dataset.callHiddenByIp = '1';
el.style.display = 'none';
});
};
document.addEventListener('DOMContentLoaded', () => {
hideCallButtons(document);
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (!(node instanceof Element)) return;
if (isRingCallButton(node)) {
node.dataset.callHiddenByIp = '1';
node.style.display = 'none';
}
hideCallButtons(node);
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
});
}
window.BMC_CAN_CLICK_TO_CALL = true;
const darkModeToggle = document.getElementById('darkModeToggle');
const htmlElement = document.documentElement;
@ -2347,6 +2428,7 @@ if (bmcOriginalFetch) {
{ key: 'menu-salg-products', label: 'Salg: Produkter' },
{ key: 'menu-salg-webshop', label: 'Salg: Webshop Administration' },
{ key: 'menu-okonomi-time-queue', label: 'Økonomi: Time Queue' },
{ key: 'menu-okonomi-also-cloud', label: 'Økonomi: ALSO Cloud Marketplace' },
{ key: 'menu-okonomi-supplier-invoices', label: 'Økonomi: Leverandør fakturaer' },
{ key: 'menu-okonomi-prepaid', label: 'Økonomi: Prepaid Cards' },
{ key: 'menu-okonomi-fixed-price', label: 'Økonomi: Fastpris Aftaler' },
@ -2589,6 +2671,38 @@ if (bmcOriginalFetch) {
loadAnyDeskChips();
}
function buildInitials(name) {
const clean = String(name || '').trim();
if (!clean) return 'BR';
const parts = clean.split(/\s+/).filter(Boolean).slice(0, 2);
if (!parts.length) return 'BR';
return parts.map((part) => part.charAt(0).toUpperCase()).join('');
}
async function loadCurrentUserMenuIdentity() {
try {
const res = await fetch('/api/v1/auth/me', { credentials: 'include' });
if (!res.ok) return;
const user = await res.json();
const displayName = String(user.full_name || user.username || user.email || 'Bruger').trim() || 'Bruger';
const avatarEl = document.getElementById('currentUserAvatar');
const nameEl = document.getElementById('currentUserDisplayName');
if (nameEl) {
nameEl.textContent = displayName;
}
if (avatarEl) {
const initials = buildInitials(displayName);
avatarEl.src = `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=0f4c75&color=fff`;
avatarEl.alt = displayName;
}
} catch (e) {
console.error('Failed to load current user identity', e);
}
}
async function loadAnyDeskChips() {
try {
const res = await fetch('/api/v1/auth/me/anydesk-ids', { credentials: 'include' });
@ -2652,6 +2766,7 @@ if (bmcOriginalFetch) {
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error((await res.json()).detail || 'Fejl');
loadCurrentUserMenuIdentity();
const statusEl = document.getElementById('prof-save-status');
statusEl.style.display = '';
setTimeout(() => { statusEl.style.display = 'none'; }, 3000);
@ -2659,6 +2774,8 @@ if (bmcOriginalFetch) {
}
document.addEventListener('DOMContentLoaded', () => {
loadCurrentUserMenuIdentity();
const saveMenuBtn = document.getElementById('profMenuSaveBtn');
if (saveMenuBtn) saveMenuBtn.addEventListener('click', saveProfileMenuPreferences);
const showAllBtn = document.getElementById('profMenuShowAllBtn');
@ -2810,4 +2927,4 @@ if (bmcOriginalFetch) {
{% block scripts %}{% endblock %}
{% block extra_js %}{% endblock %}
</body>
</html>
</html>

View File

@ -10,7 +10,7 @@ import hashlib
import json
import re
import asyncio
from typing import List, Optional
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import JSONResponse
@ -56,7 +56,7 @@ from app.ticket.backend.models import (
)
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.core.auth_dependencies import require_any_permission
from datetime import date, datetime
from datetime import date, datetime, timedelta
logger = logging.getLogger(__name__)
@ -208,6 +208,149 @@ async def _vtiger_query_with_retry(vtiger, query_string: str, retries: int = 5,
return []
def _normalize_source_key(source_key: str) -> str:
normalized = (source_key or "").strip().lower()
if normalized not in {"simplycrm", "vtiger"}:
raise HTTPException(status_code=400, detail="source must be 'simplycrm' or 'vtiger'")
return normalized
def _source_module_name(source_key: str) -> str:
return getattr(settings, "SIMPLYCRM_TICKET_MODULE", "HelpDesk") if source_key == "simplycrm" else "Cases"
def _record_digest(record: dict) -> Dict[str, Any]:
return {
"external_id": _get_first_value(record, ["id", "ticketid", "ticket_id"]),
"ticket_number": _get_first_value(record, ["ticket_no", "ticketnumber", "ticket_number", "case_no", "casenumber"]),
"title": _get_first_value(record, ["title", "subject", "ticket_title", "tickettitle", "summary"]),
"source_created_at": _get_first_value(record, ["createdtime", "created_at", "createdon", "created_time"]),
"source_updated_at": _get_first_value(record, ["modifiedtime", "updated_at", "modified_time", "updatedtime"]),
}
def _format_remote_datetime(value: datetime) -> str:
return value.strftime("%Y-%m-%d %H:%M:%S")
async def _fetch_remote_ticket_records(source_key: str, limit: int) -> List[dict]:
source_key = _normalize_source_key(source_key)
if source_key == "simplycrm":
async with SimplyCRMService() as service:
return await service.fetch_tickets(limit=limit)
vtiger = get_vtiger_service()
records: List[dict] = []
offset = 0
batch_size = 200
while len(records) < limit:
query = f"SELECT * FROM Cases LIMIT {offset}, {batch_size};"
batch = await _vtiger_query_with_retry(vtiger, query)
if not batch:
break
records.extend(batch)
offset += batch_size
if len(batch) < batch_size:
break
return records[:limit]
async def _fetch_remote_ticket_total(source_key: str) -> tuple[Optional[int], Optional[str]]:
source_key = _normalize_source_key(source_key)
if source_key == "simplycrm":
try:
async with SimplyCRMService() as service:
module_name = _source_module_name(source_key)
rows = await service.query(f"SELECT count(*) FROM {module_name};")
total = _extract_count_value(rows)
error = None if total is not None else ((service.last_query_error or {}).get("message") if service.last_query_error else None)
return total, error
except Exception as exc:
return None, str(exc)
try:
vtiger = get_vtiger_service()
rows = await _vtiger_query_with_retry(vtiger, "SELECT count(*) FROM Cases;")
total = _extract_count_value(rows)
error = None if total is not None else ((vtiger.last_query_error or {}).get("message") if getattr(vtiger, "last_query_error", None) else None)
return total, error
except Exception as exc:
return None, str(exc)
def _local_archived_summary(source_key: str) -> Dict[str, Any]:
row = execute_query_single(
"""
SELECT
COUNT(*) AS total_tickets,
COUNT(*) FILTER (WHERE COALESCE(ticket_number, '') <> '') AS with_ticket_number,
COUNT(*) FILTER (WHERE COALESCE(contact_name, '') <> '') AS with_contact_name,
COUNT(*) FILTER (WHERE COALESCE(organization_name, '') <> '') AS with_organization_name,
COUNT(*) FILTER (WHERE COALESCE(description, '') <> '') AS with_description,
COUNT(*) FILTER (WHERE COALESCE(solution, '') <> '') AS with_solution,
MIN(source_created_at) AS oldest_source_created_at,
MAX(source_created_at) AS newest_source_created_at,
MAX(last_synced_at) AS last_synced_at
FROM tticket_archived_tickets
WHERE source_system = %s
""",
(source_key,)
) or {}
message_row = execute_query_single(
"""
SELECT
COUNT(*) AS total_messages,
COUNT(*) FILTER (WHERE message_type = 'comment') AS total_comments,
COUNT(*) FILTER (WHERE message_type = 'email') AS total_emails,
COUNT(DISTINCT archived_ticket_id) AS tickets_with_messages
FROM tticket_archived_messages m
INNER JOIN tticket_archived_tickets t ON t.id = m.archived_ticket_id
WHERE t.source_system = %s
""",
(source_key,)
) or {}
def iso(value: Any) -> Optional[str]:
return value.isoformat() if isinstance(value, (datetime, date)) else None
total_tickets = int(row.get("total_tickets") or 0)
tickets_with_messages = int(message_row.get("tickets_with_messages") or 0)
return {
"local_total_tickets": total_tickets,
"local_total_messages": int(message_row.get("total_messages") or 0),
"local_total_comments": int(message_row.get("total_comments") or 0),
"local_total_emails": int(message_row.get("total_emails") or 0),
"tickets_with_messages": tickets_with_messages,
"tickets_without_messages": max(0, total_tickets - tickets_with_messages),
"with_ticket_number": int(row.get("with_ticket_number") or 0),
"with_contact_name": int(row.get("with_contact_name") or 0),
"with_organization_name": int(row.get("with_organization_name") or 0),
"with_description": int(row.get("with_description") or 0),
"with_solution": int(row.get("with_solution") or 0),
"oldest_source_created_at": iso(row.get("oldest_source_created_at")),
"newest_source_created_at": iso(row.get("newest_source_created_at")),
"last_synced_at": iso(row.get("last_synced_at")),
}
def _get_vtiger_archived_incremental_watermark(overlap_minutes: int = 10) -> Optional[datetime]:
row = execute_query_single(
"""
SELECT COALESCE(MAX(source_updated_at), MAX(source_created_at)) AS watermark
FROM tticket_archived_tickets
WHERE source_system = 'vtiger'
"""
) or {}
watermark = row.get("watermark")
if not isinstance(watermark, datetime):
return None
return watermark - timedelta(minutes=max(0, overlap_minutes))
# ============================================================================
# TICKET ENDPOINTS
# ============================================================================
@ -2255,23 +2398,29 @@ async def import_simply_archived_tickets(
raise HTTPException(status_code=500, detail=str(e))
@router.post("/archived/vtiger/import", tags=["Archived Tickets"])
async def import_vtiger_archived_tickets(
limit: int = Query(5000, ge=1, le=50000, description="Maximum tickets to import"),
include_messages: bool = Query(True, description="Include comments and emails"),
ticket_number: Optional[str] = Query(None, description="Import a single ticket by number"),
force: bool = Query(False, description="Update even if sync hash matches"),
current_user: dict = Depends(sync_admin_access)
async def _run_vtiger_archived_import(
limit: int = 5000,
include_messages: bool = True,
ticket_number: Optional[str] = None,
force: bool = False,
incremental: bool = True,
):
"""
One-time import of archived tickets from vTiger (Cases module).
"""
stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0, "messages_imported": 0}
stats = {
"imported": 0,
"updated": 0,
"skipped": 0,
"errors": 0,
"messages_imported": 0,
"mode": "full",
"watermark_used": None,
"fetched_remote": 0,
}
try:
vtiger = get_vtiger_service()
if ticket_number:
stats["mode"] = "single_ticket"
sanitized = _escape_simply_value(ticket_number)
tickets = []
for field in ("ticket_no", "ticketnumber", "ticket_number"):
@ -2279,12 +2428,28 @@ async def import_vtiger_archived_tickets(
tickets = await _vtiger_query_with_retry(vtiger, query)
if tickets:
break
stats["fetched_remote"] = len(tickets)
else:
tickets = []
offset = 0
batch_size = 200
watermark = _get_vtiger_archived_incremental_watermark() if incremental and not force else None
if watermark:
stats["mode"] = "incremental"
stats["watermark_used"] = watermark.isoformat()
else:
stats["mode"] = "full"
while len(tickets) < limit:
query = f"SELECT * FROM Cases LIMIT {offset}, {batch_size};"
if watermark:
watermark_str = _escape_simply_value(_format_remote_datetime(watermark))
query = (
"SELECT * FROM Cases "
f"WHERE modifiedtime >= '{watermark_str}' "
f"LIMIT {offset}, {batch_size};"
)
else:
query = f"SELECT * FROM Cases LIMIT {offset}, {batch_size};"
batch = await _vtiger_query_with_retry(vtiger, query)
if not batch:
break
@ -2294,6 +2459,7 @@ async def import_vtiger_archived_tickets(
break
tickets = tickets[:limit]
stats["fetched_remote"] = len(tickets)
logger.info(f"🔍 Importing {len(tickets)} archived tickets from vTiger")
@ -2594,6 +2760,30 @@ async def import_vtiger_archived_tickets(
except Exception as e:
logger.error(f"❌ vTiger archived ticket import failed: {e}")
raise
@router.post("/archived/vtiger/import", tags=["Archived Tickets"])
async def import_vtiger_archived_tickets(
limit: int = Query(5000, ge=1, le=50000, description="Maximum tickets to import"),
include_messages: bool = Query(True, description="Include comments and emails"),
ticket_number: Optional[str] = Query(None, description="Import a single ticket by number"),
force: bool = Query(False, description="Update even if sync hash matches"),
incremental: bool = Query(True, description="Fetch only new/changed archived vTiger tickets since latest local watermark"),
current_user: dict = Depends(sync_admin_access)
):
"""
One-time import of archived tickets from vTiger (Cases module).
"""
try:
return await _run_vtiger_archived_import(
limit=limit,
include_messages=include_messages,
ticket_number=ticket_number,
force=force,
incremental=incremental,
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@ -2606,72 +2796,25 @@ async def get_archived_sync_status(current_user: dict = Depends(sync_admin_acces
sources: dict[str, dict] = {}
for source_key in source_keys:
local_ticket_row = execute_query_single(
"""
SELECT COUNT(*) AS total_tickets,
MAX(last_synced_at) AS last_synced_at
FROM tticket_archived_tickets
WHERE source_system = %s
""",
(source_key,)
) or {}
local_message_row = execute_query_single(
"""
SELECT COUNT(*) AS total_messages
FROM tticket_archived_messages m
INNER JOIN tticket_archived_tickets t ON t.id = m.archived_ticket_id
WHERE t.source_system = %s
""",
(source_key,)
) or {}
local_tickets = int(local_ticket_row.get("total_tickets") or 0)
local_messages = int(local_message_row.get("total_messages") or 0)
last_synced_value = local_ticket_row.get("last_synced_at")
if isinstance(last_synced_value, (datetime, date)):
last_synced_at_iso = last_synced_value.isoformat()
else:
last_synced_at_iso = None
sources[source_key] = {
"source_label": "Simply-CRM" if source_key == "simplycrm" else "vTiger",
"source_module": _source_module_name(source_key),
**_local_archived_summary(source_key),
"remote_total_tickets": None,
"local_total_tickets": local_tickets,
"local_total_messages": local_messages,
"last_synced_at": last_synced_at_iso,
"diff": None,
"is_synced": False,
"error": None,
}
try:
async with SimplyCRMService() as service:
module_name = getattr(settings, "SIMPLYCRM_TICKET_MODULE", "Tickets")
simply_rows = await service.query(f"SELECT count(*) AS total_count FROM {module_name};")
simply_remote_count = _extract_count_value(simply_rows)
sources["simplycrm"]["remote_total_tickets"] = simply_remote_count
if simply_remote_count is not None:
sources["simplycrm"]["diff"] = simply_remote_count - sources["simplycrm"]["local_total_tickets"]
sources["simplycrm"]["is_synced"] = sources["simplycrm"]["diff"] == 0
elif service.last_query_error:
sources["simplycrm"]["error"] = service.last_query_error.get("message") or str(service.last_query_error)
except Exception as e:
logger.warning("⚠️ Simply-CRM archived status check failed: %s", e)
sources["simplycrm"]["error"] = str(e)
try:
vtiger = get_vtiger_service()
vtiger_rows = await _vtiger_query_with_retry(vtiger, "SELECT count(*) AS total_count FROM Cases;")
vtiger_remote_count = _extract_count_value(vtiger_rows)
sources["vtiger"]["remote_total_tickets"] = vtiger_remote_count
if vtiger_remote_count is not None:
sources["vtiger"]["diff"] = vtiger_remote_count - sources["vtiger"]["local_total_tickets"]
sources["vtiger"]["is_synced"] = sources["vtiger"]["diff"] == 0
elif vtiger.last_query_error:
sources["vtiger"]["error"] = vtiger.last_query_error.get("message") or str(vtiger.last_query_error)
except Exception as e:
logger.warning("⚠️ vTiger archived status check failed: %s", e)
sources["vtiger"]["error"] = str(e)
for source_key in source_keys:
remote_total, error = await _fetch_remote_ticket_total(source_key)
sources[source_key]["remote_total_tickets"] = remote_total
if remote_total is not None:
sources[source_key]["diff"] = remote_total - sources[source_key]["local_total_tickets"]
sources[source_key]["is_synced"] = sources[source_key]["diff"] == 0
if error:
logger.warning("⚠️ %s archived status check failed: %s", source_key, error)
sources[source_key]["error"] = error
overall_synced = all(sources[key].get("is_synced") is True for key in source_keys)
@ -2682,6 +2825,84 @@ async def get_archived_sync_status(current_user: dict = Depends(sync_admin_acces
}
@router.get("/archived/reconciliation/{source_key}", tags=["Archived Tickets"])
async def get_archived_reconciliation(
source_key: str,
limit: int = Query(50000, ge=1, le=100000, description="Maximum remote tickets to inspect"),
sample_size: int = Query(25, ge=1, le=200, description="How many mismatch examples to return"),
current_user: dict = Depends(sync_admin_access)
):
"""
Deep reconciliation for archived tickets by source.
Compares remote external IDs against local archived imports and returns mismatch examples.
"""
normalized_source = _normalize_source_key(source_key)
remote_records = await _fetch_remote_ticket_records(normalized_source, limit=limit)
local_rows = execute_query(
"""
SELECT external_id, ticket_number, title, source_created_at, source_updated_at, last_synced_at
FROM tticket_archived_tickets
WHERE source_system = %s
ORDER BY source_created_at DESC NULLS LAST, id DESC
""",
(normalized_source,)
) or []
remote_by_id: Dict[str, Dict[str, Any]] = {}
remote_duplicates: List[str] = []
for record in remote_records:
digest = _record_digest(record)
external_id = digest.get("external_id")
if not external_id:
continue
if external_id in remote_by_id:
remote_duplicates.append(external_id)
continue
remote_by_id[external_id] = digest
local_by_id: Dict[str, Dict[str, Any]] = {}
for row in local_rows:
external_id = row.get("external_id")
if not external_id or external_id in local_by_id:
continue
local_by_id[external_id] = {
"external_id": external_id,
"ticket_number": row.get("ticket_number"),
"title": row.get("title"),
"source_created_at": row.get("source_created_at").isoformat() if isinstance(row.get("source_created_at"), (datetime, date)) else None,
"source_updated_at": row.get("source_updated_at").isoformat() if isinstance(row.get("source_updated_at"), (datetime, date)) else None,
"last_synced_at": row.get("last_synced_at").isoformat() if isinstance(row.get("last_synced_at"), (datetime, date)) else None,
}
remote_ids = set(remote_by_id.keys())
local_ids = set(local_by_id.keys())
missing_local_ids = sorted(remote_ids - local_ids)
orphaned_local_ids = sorted(local_ids - remote_ids)
coverage_pct = round((len(remote_ids & local_ids) / len(remote_ids) * 100), 2) if remote_ids else None
return {
"source": normalized_source,
"source_label": "Simply-CRM" if normalized_source == "simplycrm" else "vTiger",
"module_name": _source_module_name(normalized_source),
"limit_checked": limit,
"remote_checked_records": len(remote_records),
"remote_unique_external_ids": len(remote_ids),
"local_total_records": len(local_rows),
"local_unique_external_ids": len(local_ids),
"matched_external_ids": len(remote_ids & local_ids),
"coverage_pct": coverage_pct,
"missing_locally_count": len(missing_local_ids),
"orphaned_locally_count": len(orphaned_local_ids),
"remote_duplicate_external_ids_count": len(remote_duplicates),
"missing_locally_examples": [remote_by_id[item] for item in missing_local_ids[:sample_size]],
"orphaned_locally_examples": [local_by_id[item] for item in orphaned_local_ids[:sample_size]],
"remote_duplicate_external_ids_examples": remote_duplicates[:sample_size],
"local_summary": _local_archived_summary(normalized_source),
}
@router.get("/archived/simply/modules", tags=["Archived Tickets"])
async def list_simply_modules(current_user: dict = Depends(sync_admin_access)):
"""

View File

@ -4,223 +4,725 @@
{% block extra_css %}
<style>
.detail-card {
background: var(--bg-card);
border-radius: var(--border-radius);
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
padding: 1.5rem;
margin-bottom: 1.5rem;
.archived-detail-page {
max-width: 1480px;
margin: 0 auto;
padding-bottom: 3rem;
}
.archive-hero {
position: relative;
overflow: hidden;
background:
radial-gradient(circle at top right, rgba(15, 76, 117, 0.18), transparent 28rem),
linear-gradient(145deg, #ffffff 0%, #f4f8fb 58%, #eaf1f6 100%);
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 28px;
box-shadow: 0 24px 60px rgba(15, 76, 117, 0.12);
padding: 2rem;
margin-bottom: 1.5rem;
}
.archive-hero::after {
content: "";
position: absolute;
inset: auto -5rem -5rem auto;
width: 18rem;
height: 18rem;
border-radius: 999px;
background: rgba(15, 76, 117, 0.07);
filter: blur(20px);
pointer-events: none;
}
.archive-hero-top {
display: flex;
justify-content: space-between;
gap: 1.5rem;
align-items: flex-start;
margin-bottom: 1.5rem;
}
.archive-kicker {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.85rem;
border-radius: 999px;
background: rgba(15, 76, 117, 0.08);
color: var(--accent);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.archive-title {
max-width: 60rem;
font-size: clamp(2rem, 3.4vw, 3.15rem);
line-height: 1.05;
font-weight: 800;
margin: 1rem 0 0.85rem;
letter-spacing: -0.03em;
color: #14344a;
}
.archive-subtitle {
max-width: 52rem;
margin: 0;
color: #587086;
font-size: 1rem;
line-height: 1.7;
}
.archive-back-btn {
display: inline-flex;
align-items: center;
gap: 0.55rem;
padding: 0.9rem 1.15rem;
border-radius: 14px;
border: 1px solid rgba(15, 76, 117, 0.14);
background: rgba(255, 255, 255, 0.78);
color: var(--accent);
text-decoration: none;
font-weight: 700;
box-shadow: 0 12px 30px rgba(15, 76, 117, 0.08);
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.archive-back-btn:hover {
transform: translateY(-1px);
box-shadow: 0 18px 32px rgba(15, 76, 117, 0.12);
border-color: rgba(15, 76, 117, 0.25);
color: var(--accent);
}
.archive-meta-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
}
.archive-stat {
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(15, 76, 117, 0.1);
border-radius: 18px;
padding: 1rem 1.1rem;
backdrop-filter: blur(10px);
}
.archive-stat-label {
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #75899b;
margin-bottom: 0.45rem;
}
.archive-stat-value {
color: #16384d;
font-size: 1rem;
font-weight: 700;
line-height: 1.4;
}
.archive-layout {
display: grid;
grid-template-columns: minmax(0, 1.8fr) minmax(320px, 0.95fr);
gap: 1.5rem;
align-items: start;
}
.surface-card {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 251, 253, 0.98));
border: 1px solid rgba(15, 76, 117, 0.1);
border-radius: 24px;
box-shadow: 0 20px 48px rgba(15, 76, 117, 0.08);
}
.content-card {
padding: 1.65rem 1.75rem;
margin-bottom: 1.5rem;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
}
.section-title {
display: inline-flex;
align-items: center;
gap: 0.7rem;
margin: 0;
color: #16384d;
font-size: 1.18rem;
font-weight: 800;
letter-spacing: -0.01em;
}
.section-title i {
width: 2rem;
height: 2rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(15, 76, 117, 0.08);
color: var(--accent);
}
.section-hint {
color: #7890a1;
font-size: 0.92rem;
}
.ticket-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.85rem;
border-radius: 999px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.86rem;
font-weight: 700;
color: var(--accent);
background: rgba(15, 76, 117, 0.1);
border: 1px solid rgba(15, 76, 117, 0.1);
}
.status-badges {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: flex-end;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.55rem 0.9rem;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
border: 1px solid transparent;
}
.status-pill.status-open,
.status-pill.status-new,
.status-pill.status-created {
background: rgba(25, 135, 84, 0.12);
border-color: rgba(25, 135, 84, 0.15);
color: #146c43;
}
.status-pill.status-pending,
.status-pill.status-waiting,
.status-pill.status-pending_customer {
background: rgba(255, 193, 7, 0.16);
border-color: rgba(255, 193, 7, 0.18);
color: #8a6510;
}
.status-pill.status-closed,
.status-pill.status-resolved,
.status-pill.status-done {
background: rgba(13, 110, 253, 0.12);
border-color: rgba(13, 110, 253, 0.16);
color: #0b5ed7;
}
.status-pill.status-priority {
background: rgba(220, 53, 69, 0.1);
border-color: rgba(220, 53, 69, 0.14);
color: #b02a37;
}
.rich-copy {
color: #334a5e;
font-size: 1rem;
line-height: 1.82;
}
.rich-copy p:last-child {
margin-bottom: 0;
}
.empty-copy {
padding: 1rem 1.1rem;
border-radius: 16px;
background: rgba(15, 76, 117, 0.05);
color: #7890a1;
font-style: italic;
}
.sidebar-card {
padding: 1.4rem;
position: sticky;
top: 100px;
}
.sidebar-title {
margin: 0 0 1rem;
color: #16384d;
font-size: 1rem;
font-weight: 800;
letter-spacing: -0.01em;
}
.meta-list {
display: grid;
gap: 0.9rem;
}
.meta-row {
display: grid;
gap: 0.3rem;
padding-bottom: 0.9rem;
border-bottom: 1px solid rgba(15, 76, 117, 0.08);
}
.meta-row:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.meta-label {
font-size: 0.73rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #7d92a2;
}
.meta-value {
color: #17364a;
font-size: 0.98rem;
font-weight: 700;
line-height: 1.45;
}
.meta-value.is-muted {
color: #7c91a2;
font-weight: 600;
}
.message-timeline {
position: relative;
display: grid;
gap: 1rem;
}
.message-timeline::before {
content: "";
position: absolute;
top: 0.75rem;
bottom: 0.75rem;
left: 1rem;
width: 2px;
background: linear-gradient(180deg, rgba(15, 76, 117, 0.24), rgba(15, 76, 117, 0.08));
}
.message-entry {
position: relative;
margin-left: 2.6rem;
padding: 1.2rem 1.2rem 1.15rem;
border-radius: 20px;
border: 1px solid rgba(15, 76, 117, 0.08);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 14px 30px rgba(15, 76, 117, 0.06);
}
.message-entry::before {
content: "";
position: absolute;
left: -1.95rem;
top: 1.2rem;
width: 0.9rem;
height: 0.9rem;
border-radius: 999px;
background: #ffffff;
border: 3px solid var(--accent);
box-shadow: 0 0 0 6px rgba(15, 76, 117, 0.08);
}
.message-entry.message-email::before {
border-color: #0d6efd;
}
.message-entry.message-comment::before {
border-color: #198754;
}
.message-entry.message-note::before {
border-color: #fd7e14;
}
.message-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
margin-bottom: 0.85rem;
}
.message-type {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.75rem;
border-radius: 999px;
background: rgba(15, 76, 117, 0.09);
color: var(--accent);
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.message-subject {
display: block;
margin-top: 0.7rem;
font-size: 1.08rem;
line-height: 1.35;
color: #193d53;
}
.message-meta {
color: #7991a2;
font-size: 0.84rem;
line-height: 1.5;
text-align: right;
white-space: nowrap;
}
.message-author {
margin-bottom: 0.95rem;
color: #5d7488;
font-size: 0.92rem;
}
.message-body {
color: #2f4659;
font-size: 0.97rem;
line-height: 1.78;
}
.message-count {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.5rem 0.8rem;
border-radius: 999px;
background: rgba(15, 76, 117, 0.06);
color: #617b8f;
font-size: 0.8rem;
font-weight: 700;
}
@media (max-width: 1199.98px) {
.archive-layout {
grid-template-columns: 1fr;
}
.meta-label {
font-size: 0.8rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
.sidebar-card {
position: static;
}
}
@media (max-width: 991.98px) {
.archive-meta-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.meta-value {
font-weight: 600;
color: var(--text-primary);
.archive-hero-top,
.message-head {
flex-direction: column;
}
.ticket-number {
font-family: 'Monaco', 'Courier New', monospace;
background: var(--accent-light);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
color: var(--accent);
font-weight: 600;
}
.message-card {
border: 1px solid var(--accent-light);
border-radius: 10px;
padding: 1rem;
margin-bottom: 1rem;
background: var(--bg-body);
.status-badges {
justify-content: flex-start;
}
.message-meta {
font-size: 0.85rem;
color: var(--text-secondary);
text-align: left;
white-space: normal;
}
}
@media (max-width: 767.98px) {
.archived-detail-page {
padding-bottom: 2rem;
}
.message-type {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.4px;
background: var(--accent-light);
color: var(--accent);
padding: 0.2rem 0.5rem;
border-radius: 6px;
.archive-hero,
.content-card,
.sidebar-card {
border-radius: 20px;
}
.long-text {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
font-size: 1rem;
.archive-hero,
.content-card {
padding: 1.3rem;
}
.long-text p {
margin: 0 0 0.75rem;
.archive-title {
font-size: 1.75rem;
}
.long-text ul {
margin: 0.5rem 0 0.75rem 1.25rem;
.archive-meta-strip {
grid-template-columns: 1fr;
}
.long-text li {
margin-bottom: 0.35rem;
.message-entry {
margin-left: 2rem;
padding: 1rem;
}
.message-timeline::before {
left: 0.7rem;
}
.message-entry::before {
left: -1.55rem;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="mb-2">
<i class="bi bi-archive"></i> Arkiveret Ticket
</h1>
<p class="text-muted">Detaljer fra Simply-CRM import</p>
<div class="container-fluid px-4 archived-detail-page">
<section class="archive-hero">
<div class="archive-hero-top">
<div>
<span class="archive-kicker">
<i class="bi bi-archive-fill"></i>
Arkiveret ticket
</span>
<h1 class="archive-title">{{ ticket.title or 'Ingen titel' }}</h1>
<p class="archive-subtitle">
Historisk ticket fra Simply-CRM med samlet overblik over kunde, status, tidsforbrug og hele beskedtråden.
</p>
</div>
<a href="/ticket/archived" class="archive-back-btn">
<i class="bi bi-arrow-left"></i>
Tilbage til liste
</a>
</div>
<a href="/ticket/archived" class="btn btn-outline-primary">
<i class="bi bi-arrow-left"></i> Tilbage til liste
</a>
</div>
<div class="detail-card">
<div class="row g-3">
<div class="col-md-8">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div class="d-flex flex-wrap gap-2">
{% if ticket.ticket_number %}
<span class="ticket-number">{{ ticket.ticket_number }}</span>
<span class="ticket-chip">
<i class="bi bi-ticket-perforated"></i>
{{ ticket.ticket_number }}
</span>
{% endif %}
<h2 class="mt-2">{{ ticket.title or 'Ingen titel' }}</h2>
</div>
<div class="col-md-4 text-md-end">
<div class="status-badges">
{% if ticket.status %}
<span class="message-type">{{ ticket.status.replace('_', ' ').title() }}</span>
<span class="status-pill status-{{ ticket.status|lower|replace(' ', '_') }}">
<i class="bi bi-circle-fill"></i>
{{ ticket.status.replace('_', ' ').title() }}
</span>
{% endif %}
{% if ticket.priority %}
<span class="status-pill status-priority">
<i class="bi bi-flag-fill"></i>
{{ ticket.priority.replace('_', ' ').title() }}
</span>
{% endif %}
</div>
</div>
<div class="row g-4 mt-3">
<div class="col-md-3">
<div class="meta-label">Organisation</div>
<div class="meta-value">{{ ticket.organization_name or '-' }}</div>
<div class="archive-meta-strip">
<div class="archive-stat">
<div class="archive-stat-label">Organisation</div>
<div class="archive-stat-value">{{ ticket.organization_name or 'Ikke registreret' }}</div>
</div>
<div class="col-md-3">
<div class="meta-label">Kontakt</div>
<div class="meta-value">{{ ticket.contact_name or '-' }}</div>
<div class="archive-stat">
<div class="archive-stat-label">Kontakt</div>
<div class="archive-stat-value">{{ ticket.contact_name or 'Ukendt kontakt' }}</div>
</div>
<div class="col-md-3">
<div class="meta-label">Email From</div>
<div class="meta-value">{{ ticket.email_from or '-' }}</div>
</div>
<div class="col-md-3">
<div class="meta-label">Tid brugt</div>
<div class="meta-value">
<div class="archive-stat">
<div class="archive-stat-label">Tidsforbrug</div>
<div class="archive-stat-value">
{% if ticket.time_spent_hours is not none %}
{{ '%.2f'|format(ticket.time_spent_hours) }} t
{{ '%.2f'|format(ticket.time_spent_hours) }} timer
{% else %}
-
Ikke angivet
{% endif %}
</div>
</div>
<div class="col-md-3">
<div class="meta-label">Prioritet</div>
<div class="meta-value">{{ ticket.priority or '-' }}</div>
</div>
<div class="col-md-3">
<div class="meta-label">Oprettet</div>
<div class="meta-value">
{% if ticket.source_created_at %}
{{ ticket.source_created_at.strftime('%Y-%m-%d %H:%M') }}
{% else %}
-
{% endif %}
</div>
</div>
<div class="col-md-3">
<div class="meta-label">Opdateret</div>
<div class="meta-value">
{% if ticket.source_updated_at %}
{{ ticket.source_updated_at.strftime('%Y-%m-%d %H:%M') }}
{% else %}
-
{% endif %}
</div>
<div class="archive-stat">
<div class="archive-stat-label">Beskeder</div>
<div class="archive-stat-value">{{ messages|length }} registreret{% if messages|length != 1 %}e{% endif %}</div>
</div>
</div>
</div>
</section>
<div class="detail-card">
<h5>Beskrivelse</h5>
{% if description_html %}
<div class="text-muted long-text">{{ description_html | safe }}</div>
{% elif ticket.description %}
<div class="text-muted long-text">{{ ticket.description | e | replace('\n', '<br>') | safe }}</div>
{% else %}
<div class="text-muted long-text">Ingen beskrivelse</div>
{% endif %}
</div>
<div class="archive-layout">
<div>
<section class="surface-card content-card">
<div class="section-heading">
<h2 class="section-title">
<i class="bi bi-card-text"></i>
Beskrivelse
</h2>
</div>
<div class="detail-card">
<h5>Løsning</h5>
{% if solution_html %}
<div class="text-muted long-text">{{ solution_html | safe }}</div>
{% elif ticket.solution %}
<div class="text-muted long-text">{{ ticket.solution | e | replace('\n', '<br>') | safe }}</div>
{% else %}
<div class="text-muted long-text">Ingen løsning angivet</div>
{% endif %}
</div>
{% if description_html %}
<div class="rich-copy">{{ description_html | safe }}</div>
{% elif ticket.description %}
<div class="rich-copy">{{ ticket.description | e | replace('\n', '<br>') | safe }}</div>
{% else %}
<div class="empty-copy">Ingen beskrivelse gemt på den arkiverede ticket.</div>
{% endif %}
</section>
<div class="detail-card">
<h5>Kommentarer og Emails</h5>
{% if messages %}
{% for message in messages %}
<div class="message-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="message-type">{{ message.message_type }}</span>
{% if message.subject %}
<strong class="ms-2">{{ message.subject }}</strong>
{% endif %}
</div>
<div class="message-meta">
{% if message.source_created_at %}
{{ message.source_created_at.strftime('%Y-%m-%d %H:%M') }}
{% else %}
-
{% endif %}
</div>
<section class="surface-card content-card">
<div class="section-heading">
<h2 class="section-title">
<i class="bi bi-check2-circle"></i>
Løsning
</h2>
</div>
{% if solution_html %}
<div class="rich-copy">{{ solution_html | safe }}</div>
{% elif ticket.solution %}
<div class="rich-copy">{{ ticket.solution | e | replace('\n', '<br>') | safe }}</div>
{% else %}
<div class="empty-copy">Ingen løsning er registreret på sagen.</div>
{% endif %}
</section>
<section class="surface-card content-card">
<div class="section-heading">
<h2 class="section-title">
<i class="bi bi-chat-left-text"></i>
Kommentarer og emails
</h2>
<span class="message-count">
<i class="bi bi-collection"></i>
{{ messages|length }} element{% if messages|length != 1 %}er{% endif %}
</span>
</div>
{% if messages %}
<div class="message-timeline">
{% for message in messages %}
{% set message_type = (message.message_type or 'message')|lower|replace(' ', '_') %}
<article class="message-entry message-{{ message_type }}">
<div class="message-head">
<div>
<span class="message-type">
{% if message_type == 'comment' %}
<i class="bi bi-chat-left-text-fill"></i>
{% elif message_type == 'email' %}
<i class="bi bi-envelope-fill"></i>
{% else %}
<i class="bi bi-journal-text"></i>
{% endif %}
{{ message.message_type or 'Besked' }}
</span>
{% if message.subject %}
<strong class="message-subject">{{ message.subject }}</strong>
{% endif %}
</div>
<div class="message-meta">
{% if message.source_created_at %}
{{ message.source_created_at.strftime('%d/%m/%Y %H:%M') }}
{% else %}
Tidspunkt ukendt
{% endif %}
</div>
</div>
{% if message.author_name or message.author_email %}
<div class="message-author">
{{ message.author_name or 'Ukendt afsender' }}
{% if message.author_email %}
({{ message.author_email }})
{% endif %}
</div>
{% endif %}
<div class="message-body rich-copy">
{% if message.body_html %}
{{ message.body_html | safe }}
{% elif message.body %}
{{ message.body | e | replace('\n', '<br>') | safe }}
{% else %}
<div class="empty-copy">Ingen tekst gemt i denne besked.</div>
{% endif %}
</div>
</article>
{% endfor %}
</div>
<div class="message-meta mt-2">
{% if message.author_name %}
{{ message.author_name }}
{% endif %}
{% if message.author_email %}
({{ message.author_email }})
{% endif %}
</div>
<div class="mt-3">
{% if message.body_html %}
<div class="mb-0 long-text">{{ message.body_html | safe }}</div>
{% elif message.body %}
<div class="mb-0 long-text">{{ message.body | e | replace('\n', '<br>') | safe }}</div>
{% else %}
<div class="empty-copy">Ingen kommentarer eller emails blev fundet for den arkiverede ticket.</div>
{% endif %}
</section>
</div>
<aside class="surface-card sidebar-card">
<h3 class="sidebar-title">Sagsmetadata</h3>
<div class="meta-list">
<div class="meta-row">
<div class="meta-label">Organisation</div>
<div class="meta-value">{{ ticket.organization_name or '-' }}</div>
</div>
<div class="meta-row">
<div class="meta-label">Kontaktperson</div>
<div class="meta-value">{{ ticket.contact_name or '-' }}</div>
</div>
<div class="meta-row">
<div class="meta-label">Afsender-email</div>
<div class="meta-value {% if not ticket.email_from %}is-muted{% endif %}">{{ ticket.email_from or 'Ikke registreret' }}</div>
</div>
<div class="meta-row">
<div class="meta-label">Oprettet</div>
<div class="meta-value {% if not ticket.source_created_at %}is-muted{% endif %}">
{% if ticket.source_created_at %}
{{ ticket.source_created_at.strftime('%d/%m/%Y %H:%M') }}
{% else %}
<div class="mb-0 long-text">Ingen tekst</div>
Ikke registreret
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted">Ingen kommentarer eller emails fundet.</p>
{% endif %}
<div class="meta-row">
<div class="meta-label">Opdateret</div>
<div class="meta-value {% if not ticket.source_updated_at %}is-muted{% endif %}">
{% if ticket.source_updated_at %}
{{ ticket.source_updated_at.strftime('%d/%m/%Y %H:%M') }}
{% else %}
Ikke registreret
{% endif %}
</div>
</div>
<div class="meta-row">
<div class="meta-label">Prioritet</div>
<div class="meta-value {% if not ticket.priority %}is-muted{% endif %}">
{{ ticket.priority.replace('_', ' ').title() if ticket.priority else 'Ikke angivet' }}
</div>
</div>
<div class="meta-row">
<div class="meta-label">Status</div>
<div class="meta-value {% if not ticket.status %}is-muted{% endif %}">
{{ ticket.status.replace('_', ' ').title() if ticket.status else 'Ikke angivet' }}
</div>
</div>
</div>
</aside>
</div>
</div>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ HTML template routes for ticket management UI
"""
import logging
import html
from fastapi import APIRouter, Request, HTTPException, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates

16
main.py
View File

@ -255,6 +255,22 @@ async def lifespan(app: FastAPI):
)
logger.info("✅ Links health job scheduled (every %d minutes)", settings.LINKS_DEAD_LINK_CHECK_INTERVAL_MINUTES)
if settings.ARCHIVED_VTIGER_SYNC_ENABLED:
from app.jobs.archived_vtiger_sync import run_archived_vtiger_sync
backup_scheduler.scheduler.add_job(
func=run_archived_vtiger_sync,
trigger=IntervalTrigger(minutes=settings.ARCHIVED_VTIGER_SYNC_INTERVAL_MINUTES),
id='archived_vtiger_sync',
name='Archived vTiger Sync',
max_instances=1,
replace_existing=True
)
logger.info(
"✅ Archived vTiger sync job scheduled (every %d minutes)",
settings.ARCHIVED_VTIGER_SYNC_INTERVAL_MINUTES,
)
backup_scheduler.scheduler.add_job(
func=run_uptime_kuma_sync,
trigger=IntervalTrigger(seconds=120),

View File

@ -0,0 +1,71 @@
-- ALSO Cloud Marketplace email workflow action and default workflow
INSERT INTO email_workflow_actions (
action_code,
name,
description,
category,
parameter_schema,
example_config
)
VALUES (
'process_also_cloud_billing',
'Process ALSO Cloud Billing ZIP',
'Downloader ALSO Cloud Marketplace billing ZIP fra email, importerer linjer og opretter ordrekladder',
'billing',
'{
"type": "object",
"properties": {
"sender_pattern": {"type": "string"},
"require_sender_match": {"type": "boolean"},
"source_label": {"type": "string"},
"imported_by_user_id": {"type": "integer"}
}
}'::jsonb,
'{
"sender_pattern": "no-reply@marketplace\\\\.also\\\\.dk",
"require_sender_match": true,
"source_label": "ALSO Cloud Marketplace email"
}'::jsonb
)
ON CONFLICT (action_code) DO NOTHING;
INSERT INTO email_workflows (
name,
description,
classification_trigger,
sender_pattern,
subject_pattern,
confidence_threshold,
workflow_steps,
priority,
enabled,
stop_on_match,
created_by_user_id
)
SELECT
'ALSO Cloud Billing Import',
'Matcher ALSO Cloud Marketplace billing-mails, downloader ZIP og opretter ordrekladder automatisk',
'any',
'marketplace\\.also\\.',
'YOUR BILLING ZIP FILE IS READY TO BE DOWNLOADED|BILLING ZIP FILE',
0.00,
'[
{
"action": "process_also_cloud_billing",
"params": {
"sender_pattern": "marketplace\\.also\\.",
"require_sender_match": true,
"source_label": "ALSO Cloud Marketplace email"
}
}
]'::jsonb,
15,
true,
true,
1
WHERE NOT EXISTS (
SELECT 1
FROM email_workflows
WHERE LOWER(name) = LOWER('ALSO Cloud Billing Import')
);

View File

@ -31,9 +31,23 @@
target: 'case',
noteId: 0
};
let chatComposerState = {
draft: '',
recipient: 'all',
requiresManualAck: false,
users: [],
loading: false,
loaded: false,
error: '',
replyToMessageId: 0,
replyToName: '',
replyToUserId: null,
activeThreadKey: ''
};
const LOCAL_NOTES_KEY = 'bmc_bottom_bar_notes_v1';
let notesApiUnavailable = false;
let driftSummaryRefreshTimer = null;
let markMessagesReadPromise = null;
function byId(id) {
return document.getElementById(id);
@ -48,6 +62,408 @@
.replace(/'/g, '&#39;');
}
function isMessagesComposerFocused() {
const focusedId = document.activeElement && document.activeElement.id;
return activeKey === 'messages' && (focusedId === 'chatInputQuick' || focusedId === 'chatRecipient' || focusedId === 'chatRequiresAck');
}
function renderChatRecipientOptions(sel) {
if (!sel) return;
sel.innerHTML = '';
const baseOption = document.createElement('option');
baseOption.value = 'all';
baseOption.textContent = 'Alle på vagt';
sel.appendChild(baseOption);
if (chatComposerState.loading && !chatComposerState.users.length) {
const loadingOption = document.createElement('option');
loadingOption.value = '';
loadingOption.textContent = 'Indlæser brugere...';
loadingOption.disabled = true;
sel.appendChild(loadingOption);
}
if (chatComposerState.error && !chatComposerState.users.length) {
const errorOption = document.createElement('option');
errorOption.value = '';
errorOption.textContent = 'Kunne ikke hente brugere';
errorOption.disabled = true;
sel.appendChild(errorOption);
}
if (chatComposerState.loaded && !chatComposerState.users.length && !chatComposerState.error) {
const emptyOption = document.createElement('option');
emptyOption.value = '';
emptyOption.textContent = 'Ingen aktive brugere fundet';
emptyOption.disabled = true;
sel.appendChild(emptyOption);
}
chatComposerState.users.forEach(function (u) {
const option = document.createElement('option');
option.value = String(u.id);
option.textContent = u.full_name || u.username || u.email || ('Bruger #' + u.id);
sel.appendChild(option);
});
const wantedValue = String(chatComposerState.recipient || 'all');
const hasWantedValue = Array.from(sel.options).some(function (option) {
return option.value === wantedValue;
});
if (!hasWantedValue && chatComposerState.replyToUserId && wantedValue === String(chatComposerState.replyToUserId)) {
const replyOption = document.createElement('option');
replyOption.value = wantedValue;
replyOption.textContent = chatComposerState.replyToName
? ('Svar til: ' + chatComposerState.replyToName)
: ('Bruger #' + wantedValue);
sel.appendChild(replyOption);
}
const hasWantedValueAfterReplyFallback = Array.from(sel.options).some(function (option) {
return option.value === wantedValue;
});
sel.value = hasWantedValueAfterReplyFallback ? wantedValue : 'all';
chatComposerState.recipient = sel.value || 'all';
}
function fetchChatUsers() {
if (chatComposerState.loading || chatComposerState.loaded) {
return;
}
chatComposerState.loading = true;
chatComposerState.error = '';
fetch('/api/v1/users?is_active=true', { credentials: 'include' })
.then(async function (r) {
if (!r.ok) {
let detail = 'Kunne ikke hente brugere';
try {
const payload = await r.json();
detail = payload.detail || payload.message || detail;
} catch (_) {
// Ignore parse failures
}
throw new Error(detail);
}
return r.json();
})
.then(function (payload) {
const users = Array.isArray(payload) ? payload : ((payload && payload.data && Array.isArray(payload.data)) ? payload.data : []);
chatComposerState.users = users;
chatComposerState.loaded = true;
chatComposerState.error = '';
const sel = document.getElementById('chatRecipient');
if (sel) {
renderChatRecipientOptions(sel);
}
})
.catch(function (e) {
console.error('Error fetching users for chat:', e);
chatComposerState.error = e && e.message ? e.message : 'Kunne ikke hente brugere';
chatComposerState.users = [];
chatComposerState.loaded = false;
const sel = document.getElementById('chatRecipient');
if (sel) {
renderChatRecipientOptions(sel);
}
})
.finally(function () {
chatComposerState.loading = false;
});
}
function updateMessagesTabBadge() {
const btn = document.querySelector('.bb-tab-btn[data-bb-tab="messages"]');
if (!btn) return;
let badge = btn.querySelector('.bb-tab-badge');
if (!badge) {
badge = document.createElement('span');
badge.className = 'bb-tab-badge';
btn.appendChild(badge);
}
const unread = Number((((latestSections || {}).messages || {}).count) || 0);
badge.textContent = String(unread);
btn.classList.toggle('has-unread', unread > 0);
badge.setAttribute('aria-hidden', unread > 0 ? 'false' : 'true');
}
function getMessageThreads() {
const messageItems = Array.isArray((((latestSections || {}).messages || {}).list)) ? (((latestSections || {}).messages || {}).list) : [];
const threadsByKey = new Map();
messageItems.forEach(function (item) {
const own = !!item.is_own;
const partnerId = own ? Number(item.recipient_user_id || 0) : Number(item.sender_user_id || 0);
const key = partnerId > 0 ? ('user:' + partnerId) : 'broadcast';
const label = partnerId > 0
? String(own ? (item.to || ('Bruger #' + partnerId)) : (item.from || ('Bruger #' + partnerId)))
: 'Alle på vagt';
const existing = threadsByKey.get(key) || {
key: key,
partnerUserId: partnerId > 0 ? partnerId : null,
label: label,
items: [],
unread: 0,
lastCreatedAt: ''
};
existing.items.push(item);
if (item.is_unread) {
existing.unread += 1;
}
existing.lastCreatedAt = String(item.created_at || existing.lastCreatedAt || '');
threadsByKey.set(key, existing);
});
return Array.from(threadsByKey.values()).sort(function (a, b) {
return String(b.lastCreatedAt || '').localeCompare(String(a.lastCreatedAt || ''));
});
}
function getChatUserDisplayName(userId) {
const normalizedId = Number(userId || 0);
if (!(normalizedId > 0)) {
return 'Alle på vagt';
}
const matchedUser = (chatComposerState.users || []).find(function (user) {
return Number(user.id || 0) === normalizedId;
});
if (matchedUser) {
return matchedUser.full_name || matchedUser.username || matchedUser.email || ('Bruger #' + normalizedId);
}
const messageItems = Array.isArray((((latestSections || {}).messages || {}).list)) ? (((latestSections || {}).messages || {}).list) : [];
const matchedMessage = messageItems.find(function (item) {
return Number(item.sender_user_id || 0) === normalizedId || Number(item.recipient_user_id || 0) === normalizedId;
});
if (matchedMessage) {
if (Number(matchedMessage.sender_user_id || 0) === normalizedId) {
return matchedMessage.from || ('Bruger #' + normalizedId);
}
return matchedMessage.to || ('Bruger #' + normalizedId);
}
return 'Bruger #' + normalizedId;
}
function ensureActiveMessageThread() {
const threads = getMessageThreads();
const currentKey = String(chatComposerState.activeThreadKey || '').trim();
const matched = threads.find(function (thread) { return thread.key === currentKey; });
if (matched) {
return matched;
}
if (currentKey.indexOf('user:') === 0) {
const partnerUserId = Number(currentKey.split(':')[1] || 0);
if (partnerUserId > 0) {
return {
key: currentKey,
partnerUserId: partnerUserId,
label: chatComposerState.replyToName || getChatUserDisplayName(partnerUserId),
items: [],
unread: 0,
lastCreatedAt: ''
};
}
}
if (currentKey === 'broadcast') {
return {
key: 'broadcast',
partnerUserId: null,
label: 'Alle på vagt',
items: [],
unread: 0,
lastCreatedAt: ''
};
}
if (!threads.length) {
if (String(chatComposerState.recipient || 'all') !== 'all') {
const partnerUserId = Number(chatComposerState.recipient || 0);
if (partnerUserId > 0) {
const syntheticKey = 'user:' + partnerUserId;
chatComposerState.activeThreadKey = syntheticKey;
return {
key: syntheticKey,
partnerUserId: partnerUserId,
label: chatComposerState.replyToName || getChatUserDisplayName(partnerUserId),
items: [],
unread: 0,
lastCreatedAt: ''
};
}
}
chatComposerState.activeThreadKey = '';
return null;
}
chatComposerState.activeThreadKey = threads[0].key;
return threads[0];
}
function getRenderableMessageThreads(activeThread) {
const seen = new Set();
const out = [];
if (activeThread && activeThread.key) {
out.push(activeThread);
seen.add(activeThread.key);
}
getMessageThreads().forEach(function (thread) {
if (seen.has(thread.key)) return;
seen.add(thread.key);
out.push(thread);
});
return out;
}
function syncChatRecipientToActiveThread(activeThread) {
if (chatComposerState.replyToUserId) {
chatComposerState.recipient = String(chatComposerState.replyToUserId);
return;
}
if (!activeThread) {
if (!chatComposerState.recipient) {
chatComposerState.recipient = 'all';
}
return;
}
chatComposerState.recipient = activeThread.partnerUserId ? String(activeThread.partnerUserId) : 'all';
}
function markActiveMessageThreadRead(activeThread) {
if (!activeThread || markMessagesReadPromise) {
return;
}
const unreadCount = Number((((latestSections || {}).messages || {}).count) || 0);
if (unreadCount <= 0) {
return;
}
const hasUnreadInThread = (((latestSections || {}).messages || {}).list || []).some(function (item) {
if (item.is_own || !item.is_unread || item.requires_manual_ack) return false;
if (activeThread.partnerUserId) {
return Number(item.sender_user_id || 0) === Number(activeThread.partnerUserId);
}
return item.recipient_user_id == null;
});
if (!hasUnreadInThread) {
return;
}
const payload = activeThread.partnerUserId ? { partner_user_id: Number(activeThread.partnerUserId) } : {};
markMessagesReadPromise = fetch('/api/v1/bottom-bar/messages/read', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(payload)
})
.then(function (response) {
if (!response.ok) {
throw new Error('Kunne ikke markere beskeder som læst');
}
return response.json().catch(function () { return {}; });
})
.then(function () {
const messages = ((latestSections || {}).messages || {});
const list = Array.isArray(messages.list) ? messages.list : [];
messages.list = list.map(function (item) {
const belongsToThread = activeThread.partnerUserId
? Number(item.sender_user_id || 0) === Number(activeThread.partnerUserId)
: item.recipient_user_id == null;
if (!item.is_own && belongsToThread && !item.requires_manual_ack) {
return Object.assign({}, item, { is_unread: false });
}
return item;
});
messages.count = messages.list.filter(function (item) {
return !item.is_own && item.is_unread;
}).length;
latestSections.messages = messages;
updateMessagesTabBadge();
updateActivityZone();
})
.catch(function (err) {
console.warn('Failed marking messages read', err);
})
.finally(function () {
markMessagesReadPromise = null;
});
}
function clearChatReplyState() {
chatComposerState.replyToMessageId = 0;
chatComposerState.replyToName = '';
chatComposerState.replyToUserId = null;
}
function setChatReplyState(message) {
if (!message) return;
const replyUserId = message.is_own ? Number(message.recipient_user_id || 0) : Number(message.sender_user_id || 0);
if (!replyUserId) return;
chatComposerState.replyToMessageId = Number(message.id || 0);
chatComposerState.replyToName = String(message.is_own ? (message.to || 'Bruger') : (message.from || 'Bruger'));
chatComposerState.replyToUserId = replyUserId;
chatComposerState.recipient = String(replyUserId);
chatComposerState.activeThreadKey = 'user:' + replyUserId;
}
function acknowledgeMessage(messageId) {
const normalizedId = Number(messageId || 0);
if (!(normalizedId > 0)) {
return Promise.reject(new Error('Ugyldigt besked-id'));
}
return fetch('/api/v1/bottom-bar/messages/acknowledge', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ message_id: normalizedId })
})
.then(async function (response) {
if (!response.ok) {
let detail = 'Kunne ikke bekræfte besked';
try {
const payload = await response.json();
detail = payload.detail || payload.message || detail;
} catch (_) {
// Ignore parse failure
}
throw new Error(detail);
}
return response.json().catch(function () { return {}; });
})
.then(function () {
const messages = ((latestSections || {}).messages || {});
const list = Array.isArray(messages.list) ? messages.list : [];
messages.list = list.map(function (item) {
if (Number(item.id || 0) === normalizedId) {
return Object.assign({}, item, {
is_unread: false,
is_acknowledged: true
});
}
return item;
});
messages.count = messages.list.filter(function (item) {
return !item.is_own && item.is_unread;
}).length;
latestSections.messages = messages;
updateMessagesTabBadge();
updateActivityZone();
});
}
function loadLocalNotes() {
try {
const raw = window.localStorage.getItem(LOCAL_NOTES_KEY);
@ -152,12 +568,14 @@
latestNotifications = (((data || {}).notifications || {}).items || []);
syncBossTabVisibility();
updateBar(latestSections);
updateMessagesTabBadge();
if (!onDriftPage) {
refreshDriftFromSummary();
}
updateActivityZone();
const focusedId = document.activeElement && document.activeElement.id;
const keepCurrentRender = activeKey === 'notes' && (focusedId === 'bbNoteTitleInput' || focusedId === 'bbNoteContentInput');
const keepCurrentRender = (activeKey === 'notes' && (focusedId === 'bbNoteTitleInput' || focusedId === 'bbNoteContentInput'))
|| isMessagesComposerFocused();
if (!keepCurrentRender) {
renderTabPanel();
}
@ -471,10 +889,42 @@
}
if (key === 'messages') {
if (messages.count > 0) {
return (messages.list || []).map(m => '<div><strong class="' + (m.from === 'System' ? 'text-primary' : 'text-accent') + '">' + esc(m.from) + ':</strong> ' + esc(m.text) + '</div>');
const activeThread = ensureActiveMessageThread();
syncChatRecipientToActiveThread(activeThread);
const messageItems = activeThread && Array.isArray(activeThread.items) ? activeThread.items : [];
if (messageItems.length > 0) {
return messageItems.map(function (m) {
const own = !!m.is_own;
const unreadBadge = m.is_unread ? ' <span class="badge text-bg-warning ms-1">Ny</span>' : '';
const importantBadge = m.requires_manual_ack ? ' <span class="badge text-bg-danger ms-1">Vigtig</span>' : '';
const ackBadge = own && m.requires_manual_ack
? (m.is_acknowledged
? ' <span class="badge text-bg-success ms-1">Bekræftet læst</span>'
: ' <span class="badge text-bg-secondary ms-1">Afventer læst-bekræftelse</span>')
: '';
const targetMeta = own && m.to ? '<div class="small text-muted mb-1">Til: ' + esc(m.to) + '</div>' : '';
const replyTargetId = own ? Number(m.recipient_user_id || 0) : Number(m.sender_user_id || 0);
const replyBtn = replyTargetId > 0
? '<div class="mt-2"><button type="button" class="btn btn-sm ' + (own ? 'btn-light' : 'btn-outline-secondary') + '" data-bb-reply-message="' + Number(m.id || 0) + '"><i class="bi bi-reply me-1"></i>Svar</button></div>'
: '';
const ackBtn = (!own && m.requires_manual_ack && m.is_unread)
? '<div class="mt-2"><button type="button" class="btn btn-sm btn-danger" data-bb-ack-message="' + Number(m.id || 0) + '"><i class="bi bi-check2-circle me-1"></i>Bekræft læst</button></div>'
: '';
return ''
+ '<div class="' + (own ? 'text-end' : '') + '">'
+ targetMeta
+ '<div class="d-inline-block ' + (own ? 'bg-primary text-white' : (m.requires_manual_ack ? 'bg-warning-subtle border border-warning-subtle' : 'bg-light')) + ' p-2 rounded-3 text-start shadow-sm" style="max-width: 85%;">'
+ '<strong class="' + (own ? 'text-white' : 'text-accent') + '">' + esc(m.from) + ':</strong> '
+ esc(m.text)
+ unreadBadge
+ importantBadge
+ ackBadge
+ replyBtn
+ ackBtn
+ '</div></div>';
});
}
return ['Ingen nye beskeder.'];
return ['Ingen beskeder i denne tråd endnu.'];
}
if (key === 'tasks') {
@ -702,7 +1152,8 @@
}
if (notifCount) {
const computed = Number(latestNotificationCount || ((latestSections.messages || {}).count || 0));
const unreadMessages = Number((((latestSections || {}).messages || {}).count) || 0);
const computed = Number(latestNotificationCount || 0) + unreadMessages;
notifCount.textContent = String(computed);
}
}
@ -713,6 +1164,19 @@
if (!titleContainer || !innerContent) {
return;
}
let messageFocusState = null;
if (activeKey === 'messages') {
const activeEl = document.activeElement;
const activeId = activeEl && activeEl.id ? activeEl.id : '';
if (activeId === 'chatInputQuick' || activeId === 'chatRecipient' || activeId === 'chatRequiresAck') {
messageFocusState = {
id: activeId,
selectionStart: typeof activeEl.selectionStart === 'number' ? activeEl.selectionStart : null,
selectionEnd: typeof activeEl.selectionEnd === 'number' ? activeEl.selectionEnd : null
};
}
}
const titleText = titleContainer.querySelector('.bb-tab-title-text');
@ -769,22 +1233,56 @@
}
if (activeKey === 'messages') {
const chatContainer = document.createElement('div');
chatContainer.className = 'd-flex flex-column h-100';
ul.classList.add('flex-grow-1', 'mb-3');
chatContainer.className = 'bb-messages-layout';
ul.classList.add('bb-messages-list');
const activeThread = ensureActiveMessageThread();
const threadItems = getRenderableMessageThreads(activeThread);
syncChatRecipientToActiveThread(activeThread);
if (threadItems.length > 0) {
const threadList = document.createElement('div');
threadList.className = 'bb-message-threads';
threadItems.forEach(function (thread) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'bb-message-thread' + (activeThread && thread.key === activeThread.key ? ' is-active' : '');
button.setAttribute('data-bb-thread-key', thread.key);
button.innerHTML = ''
+ '<span class="bb-message-thread-label">' + escapeHtml(thread.label || 'Samtale') + '</span>'
+ (thread.unread > 0 ? '<span class="bb-message-thread-count">' + Number(thread.unread || 0) + '</span>' : '');
threadList.appendChild(button);
});
chatContainer.appendChild(threadList);
}
const replyBox = document.createElement('div');
replyBox.className = 'mt-2 border-top pt-2 border-primary-subtle';
replyBox.className = 'bb-messages-composer';
const threadMeta = activeThread
? '<div class="small text-muted mb-2"><i class="bi bi-chat-square-text me-1"></i>'
+ (activeThread.partnerUserId ? ('Samtale med ' + escapeHtml(activeThread.label || 'Bruger')) : 'Besked til alle på vagt')
+ '</div>'
: '';
const replyBanner = chatComposerState.replyToUserId
? `<div class="small d-flex justify-content-between align-items-center mb-2">
<span><i class="bi bi-reply me-1"></i>Svarer til ${escapeHtml(chatComposerState.replyToName || 'Bruger')}</span>
<button type="button" class="btn btn-sm btn-link p-0 text-decoration-none" id="bbCancelReplyBtn">Annuller</button>
</div>`
: '';
replyBox.innerHTML = `
${threadMeta}
${replyBanner}
<div class="input-group input-group-sm mb-1">
<span class="input-group-text bg-light text-muted border-0"><i class="bi bi-person"></i></span>
<select id="chatRecipient" class="form-select border-0 bg-light">
<option value="all">Indlæser brugere...</option>
<option value="all">Alle vagt</option>
</select>
</div>
<label class="form-check form-switch small mb-2">
<input class="form-check-input" type="checkbox" id="chatRequiresAck" ${chatComposerState.requiresManualAck ? 'checked' : ''}>
<span class="form-check-label">Kræv manuel læst-bekræftelse</span>
</label>
<div class="input-group">
<input type="text" id="chatInputQuick" class="form-control form-control-sm" placeholder="Skriv en besked...">
<input type="text" id="chatInputQuick" class="form-control form-control-sm" placeholder="Skriv en besked..." value="${escapeHtml(chatComposerState.draft || '')}">
<button class="btn btn-outline-primary btn-sm" id="btnSendMsg"><i class="bi bi-send"></i></button>
</div>
`;
@ -792,23 +1290,91 @@
chatContainer.appendChild(ul);
chatContainer.appendChild(replyBox);
innerContent.appendChild(chatContainer);
// Fetch users dynamically
fetch('/api/v1/users?is_active=true', { credentials: 'include' })
.then(r => r.json())
.then(payload => {
const users = Array.isArray(payload) ? payload : ((payload && payload.data && Array.isArray(payload.data)) ? payload.data : []);
const sel = document.getElementById('chatRecipient');
if (sel) {
sel.innerHTML = '<option value="all">Alle på vagt</option><option value="system">System (Bot)</option>';
users.forEach(u => {
sel.innerHTML += `<option value="${u.id}">${u.full_name || u.username || u.email || ('Bruger #' + u.id)}</option>`;
});
}
})
.catch(e => console.error("Error fetching users for chat:", e));
const recipientSelect = document.getElementById('chatRecipient');
const input = document.getElementById('chatInputQuick');
const ackToggle = document.getElementById('chatRequiresAck');
const cancelReplyBtn = document.getElementById('bbCancelReplyBtn');
renderChatRecipientOptions(recipientSelect);
if (recipientSelect) {
recipientSelect.disabled = !!chatComposerState.replyToUserId;
}
if (ackToggle) {
ackToggle.checked = !!chatComposerState.requiresManualAck;
ackToggle.addEventListener('change', function () {
chatComposerState.requiresManualAck = !!ackToggle.checked;
});
}
if (input) {
input.addEventListener('input', function () {
chatComposerState.draft = input.value || '';
});
}
if (recipientSelect) {
recipientSelect.addEventListener('change', function () {
chatComposerState.recipient = recipientSelect.value || 'all';
if (!chatComposerState.replyToUserId) {
chatComposerState.activeThreadKey = chatComposerState.recipient === 'all'
? 'broadcast'
: ('user:' + chatComposerState.recipient);
renderTabPanel();
window.requestAnimationFrame(function () {
const nextInput = document.getElementById('chatInputQuick');
if (nextInput) {
nextInput.focus();
}
});
}
});
}
if (cancelReplyBtn) {
cancelReplyBtn.addEventListener('click', function () {
clearChatReplyState();
renderTabPanel();
});
}
fetchChatUsers();
markActiveMessageThreadRead(activeThread);
if (messageFocusState) {
window.requestAnimationFrame(function () {
const focusEl = document.getElementById(messageFocusState.id);
if (!focusEl) return;
focusEl.focus();
if (
messageFocusState.id === 'chatInputQuick'
&& typeof messageFocusState.selectionStart === 'number'
&& typeof focusEl.setSelectionRange === 'function'
) {
focusEl.setSelectionRange(messageFocusState.selectionStart, messageFocusState.selectionEnd ?? messageFocusState.selectionStart);
}
});
}
} else {
innerContent.appendChild(ul);
if (activeKey === 'notes') {
const notesContainer = document.createElement('div');
notesContainer.className = 'bb-notes-layout';
const noteItems = Array.from(ul.children);
const editorItem = noteItems.shift() || null;
if (editorItem) {
const editorWrap = document.createElement('div');
editorWrap.className = 'bb-notes-editor';
editorWrap.appendChild(editorItem);
notesContainer.appendChild(editorWrap);
}
const notesList = document.createElement('ul');
notesList.className = 'bb-tab-list bb-notes-list';
noteItems.forEach(function (item) {
notesList.appendChild(item);
});
notesContainer.appendChild(notesList);
innerContent.appendChild(notesContainer);
} else {
innerContent.appendChild(ul);
}
}
}
@ -959,18 +1525,19 @@
}
if (payload.event === 'notification_delta') {
const notifications = payload.data || {};
const rawData = payload.data || {};
const notifications = rawData.notifications || rawData;
const messages = rawData.messages || null;
const items = Array.isArray(notifications.items) ? notifications.items : [];
latestSections.messages = latestSections.messages || {};
latestSections.tasks = latestSections.tasks || {};
latestSections.messages.count = items.length;
latestSections.messages.list = items.slice(0, 5).map(function (item) {
return {
from: (item.type || 'System').toString(),
text: (item.title || item.message || 'Notifikation').toString()
if (messages) {
latestSections.messages = {
count: Number(messages.count || 0),
list: Array.isArray(messages.list) ? messages.list : []
};
});
} else {
latestSections.messages = latestSections.messages || {};
}
latestSections.tasks.count = items.length;
latestSections.tasks.list = items.slice(0, 5).map(function (item) {
@ -985,11 +1552,12 @@
syncBossTabVisibility();
updateBar(latestSections);
updateMessagesTabBadge();
updateActivityZone();
const focusedId = document.activeElement && document.activeElement.id;
const quickNoteFocused = activeKey === 'overview' && focusedId === 'bbQuickNoteInput';
const noteEditorFocused = activeKey === 'notes' && (focusedId === 'bbNoteTitleInput' || focusedId === 'bbNoteContentInput');
if (!quickNoteFocused && !noteEditorFocused) {
if (!quickNoteFocused && !noteEditorFocused && !isMessagesComposerFocused()) {
renderTabPanel();
}
}
@ -1933,6 +2501,65 @@
return;
}
const threadKey = String(btn.getAttribute('data-bb-thread-key') || '').trim();
if (threadKey) {
clearChatReplyState();
chatComposerState.activeThreadKey = threadKey;
if (threadKey === 'broadcast') {
chatComposerState.recipient = 'all';
} else if (threadKey.indexOf('user:') === 0) {
chatComposerState.recipient = String(Number(threadKey.split(':')[1] || 0) || 'all');
}
activeKey = 'messages';
renderTabPanel();
window.requestAnimationFrame(function () {
const input = document.getElementById('chatInputQuick');
if (input) {
input.focus();
}
});
return;
}
const replyMessageId = Number(btn.getAttribute('data-bb-reply-message') || 0);
if (replyMessageId > 0) {
const message = ((((latestSections || {}).messages || {}).list) || []).find(function (item) {
return Number(item.id || 0) === replyMessageId;
});
if (!message) {
return;
}
setChatReplyState(message);
activeKey = 'messages';
renderTabPanel();
window.requestAnimationFrame(function () {
const input = document.getElementById('chatInputQuick');
if (input) {
input.focus();
}
});
return;
}
const acknowledgeMessageId = Number(btn.getAttribute('data-bb-ack-message') || 0);
if (acknowledgeMessageId > 0) {
btn.disabled = true;
acknowledgeMessage(acknowledgeMessageId)
.then(function () {
if (activeKey === 'messages') {
renderTabPanel();
}
})
.catch(function (err) {
console.warn('Failed acknowledging message', err);
alert(err.message || 'Kunne ikke bekræfte besked');
})
.finally(function () {
btn.disabled = false;
});
return;
}
if (btn.id === 'bbNoteTargetSubmitBtn') {
const target = String(btn.dataset.target || 'case');
const noteId = Number(btn.dataset.noteId || 0);
@ -2239,32 +2866,67 @@
const recipientObj = document.getElementById('chatRecipient');
if (input && input.value.trim() !== '') {
const recipient = recipientObj ? recipientObj.options[recipientObj.selectedIndex].text : 'Alle';
console.log("-> Sender besked til", recipient, ":", input.value);
const msgVal = input.value;
input.value = '';
const msgContainer = document.createElement('div');
msgContainer.className = 'mb-2 text-end';
msgContainer.innerHTML = '<div class="small text-muted mb-1 me-1" style="font-size:0.7rem;">Til: ' + escapeHtml(recipient) + '</div><div class="d-inline-block bg-primary text-white p-2 rounded-3 text-start shadow-sm" style="max-width: 85%;"><strong>Mig:</strong> ' + escapeHtml(msgVal) + '</div>';
if (recipientObj && recipientObj.selectedOptions && recipientObj.selectedOptions[0] && recipientObj.selectedOptions[0].disabled) {
alert('Brugerlisten kunne ikke indlæses endnu.');
return;
}
const recipientValue = recipientObj ? recipientObj.value : 'all';
const msgVal = input.value.trim();
chatComposerState.draft = msgVal;
chatComposerState.recipient = recipientValue || 'all';
chatComposerState.activeThreadKey = chatComposerState.recipient === 'all'
? 'broadcast'
: ('user:' + chatComposerState.recipient);
const requiresManualAck = !!chatComposerState.requiresManualAck;
btn.disabled = true;
const chatContainer = document.querySelector('#bbTabInnerContent .d-flex.flex-column.h-100');
if (!chatContainer) {
return;
}
const listUl = chatContainer.querySelector('ul.bb-tab-list');
if (!listUl) {
return;
}
listUl.appendChild(msgContainer);
// Simple hacky scroll down
const tabInner = document.getElementById('bbTabInnerContent');
if(tabInner) {
tabInner.scrollTop = tabInner.scrollHeight + 500;
}
fetch('/api/v1/bottom-bar/messages', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({
message: msgVal,
recipient_user_id: recipientValue && recipientValue !== 'all' ? Number(recipientValue) : null,
requires_manual_ack: requiresManualAck
})
})
.then(async r => {
if (!r.ok) {
let detail = 'Kunne ikke sende besked';
try {
const payload = await r.json();
detail = payload.detail || payload.message || detail;
} catch (_) {
// Ignore parse failure
}
throw new Error(detail);
}
return r.json();
})
.then(() => {
input.value = '';
chatComposerState.draft = '';
chatComposerState.requiresManualAck = false;
clearChatReplyState();
return fetchBottomBarState();
})
.then((data) => {
applyState(data);
if (activeKey === 'messages') {
renderTabPanel();
}
const tabInner = document.getElementById('bbTabInnerContent');
if (tabInner) {
tabInner.scrollTop = tabInner.scrollHeight + 500;
}
})
.catch(err => {
console.error('Fejl ved afsendelse af bundmenu-besked:', err);
alert(err.message || 'Kunne ikke sende besked');
})
.finally(() => {
btn.disabled = false;
});
}
}
});

View File

@ -6,6 +6,11 @@ from unittest.mock import patch
drift_router = importlib.import_module("app.modules.drift.backend.router")
class DummyPayload(dict):
def __getattr__(self, item):
return self.get(item)
def test_get_uisp_connector_config_reads_settings():
with patch.object(drift_router, "execute_query_single", side_effect=[
{"value": "https://uisp.example.com"},
@ -83,3 +88,45 @@ def test_row_to_event_extracts_device_link_from_existing_raw_payload():
assert event["device_link"] == "https://uisp.example.com/nms/devices/abc-123"
assert event["source_link"] == "https://uisp.example.com/nms/devices/abc-123"
def test_row_to_event_exposes_ip_and_site_client_metadata():
event = drift_router._row_to_event(
{
"id": 2,
"source_event_id": "uisp-abc-123",
"site_name": "Site A",
"customer_name": "Customer X",
"raw_json": {
"ip": "192.0.2.10",
"site_client_name": "Client Alpha",
"raw_item": {
"site_name": "Site A",
"client_name": "Client Alpha",
},
},
}
)
assert event["ip"] == "192.0.2.10"
assert event["site_client_name"] == "Client Alpha"
def test_row_to_event_extracts_ip_from_nested_identification_payload():
event = drift_router._row_to_event(
{
"id": 3,
"source_event_id": "uisp-abc-456",
"raw_json": {
"raw_item": {
"identification": {
"ip": "203.0.113.9",
"site": {"name": "North Site"},
}
}
},
}
)
assert event["ip"] == "203.0.113.9"
assert event["site_client_name"] == "North Site"