chore(release): bump version to 2.3.23
This commit is contained in:
parent
f3684afad3
commit
8e99453dea
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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">
|
||||
|
||||
1215
app/economy/frontend/also_cloud.html
Normal file
1215
app/economy/frontend/also_cloud.html
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"},
|
||||
)
|
||||
|
||||
@ -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'})")
|
||||
|
||||
|
||||
@ -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'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
29
app/jobs/archived_vtiger_sync.py
Normal file
29
app/jobs/archived_vtiger_sync.py
Normal 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)
|
||||
@ -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
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}")
|
||||
|
||||
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)):
|
||||
"""
|
||||
|
||||
@ -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
@ -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
16
main.py
@ -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),
|
||||
|
||||
71
migrations/194_also_cloud_email_workflow.sql
Normal file
71
migrations/194_also_cloud_email_workflow.sql
Normal 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')
|
||||
);
|
||||
@ -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, ''');
|
||||
}
|
||||
|
||||
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 på 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user