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 fastapi import APIRouter, HTTPException, Query
|
||||||
from typing import Optional, List
|
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.core.contact_utils import get_contact_customer_ids, get_primary_customer_id
|
||||||
from app.customers.backend.router import (
|
from app.customers.backend.router import (
|
||||||
get_customer_subscriptions,
|
get_customer_subscriptions,
|
||||||
@ -119,35 +119,54 @@ async def get_contacts(
|
|||||||
|
|
||||||
where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
|
where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
|
||||||
|
|
||||||
# Count total
|
# Count total matching contacts before pagination.
|
||||||
count_query = f"""
|
count_query = f"""
|
||||||
SELECT COUNT(DISTINCT c.id)
|
SELECT COUNT(DISTINCT c.id) AS count
|
||||||
FROM contacts c
|
FROM contacts c
|
||||||
{where_sql}
|
{where_sql}
|
||||||
"""
|
"""
|
||||||
count_result = execute_query_single(count_query, tuple(params))
|
count_result = execute_query_single(count_query, tuple(params))
|
||||||
total = count_result['count'] if count_result else 0
|
total = int((count_result or {}).get('count') or 0)
|
||||||
|
|
||||||
# Get contacts with company count
|
# Fetch the page of contacts first, then enrich with aggregated company data.
|
||||||
query = f"""
|
# This avoids grouped-pagination mismatches across PostgreSQL plans/versions.
|
||||||
|
page_query = f"""
|
||||||
SELECT
|
SELECT
|
||||||
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
|
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.title, c.department, c.is_active, c.vtiger_id,
|
||||||
c.created_at, c.updated_at,
|
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
|
|
||||||
FROM contacts c
|
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}
|
{where_sql}
|
||||||
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
|
ORDER BY c.last_name, c.first_name, c.id
|
||||||
c.title, c.department, c.is_active, c.vtiger_id, c.created_at, c.updated_at
|
|
||||||
ORDER BY c.last_name, c.first_name
|
|
||||||
LIMIT %s OFFSET %s
|
LIMIT %s OFFSET %s
|
||||||
"""
|
"""
|
||||||
params.extend([limit, offset])
|
page_params = list(params)
|
||||||
|
page_params.extend([limit, offset])
|
||||||
|
contacts = execute_query(page_query, tuple(page_params)) or []
|
||||||
|
|
||||||
contacts = execute_query(query, tuple(params)) # Returns all rows
|
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 {
|
return {
|
||||||
"contacts": contacts or [],
|
"contacts": contacts or [],
|
||||||
|
|||||||
@ -199,6 +199,10 @@ class Settings(BaseSettings):
|
|||||||
SIMPLYCRM_TICKET_EMAIL_MODULE: str = "Emails"
|
SIMPLYCRM_TICKET_EMAIL_MODULE: str = "Emails"
|
||||||
SIMPLYCRM_TICKET_EMAIL_RELATION_FIELD: str = "parent_id"
|
SIMPLYCRM_TICKET_EMAIL_RELATION_FIELD: str = "parent_id"
|
||||||
SIMPLYCRM_TICKET_EMAIL_FALLBACK_RELATION_FIELD: str = "related_to"
|
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 System Configuration
|
||||||
BACKUP_ENABLED: bool = True
|
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)
|
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
|
# Pydantic Models
|
||||||
class CustomerBase(BaseModel):
|
class CustomerBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
@ -111,6 +147,11 @@ class ContactCreate(BaseModel):
|
|||||||
role: Optional[str] = None
|
role: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSyncRequest(BaseModel):
|
||||||
|
selected_match_keys: List[str]
|
||||||
|
add_sync_ok_tag: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
@router.get("/customers")
|
@router.get("/customers")
|
||||||
async def list_customers(
|
async def list_customers(
|
||||||
limit: int = Query(default=50, ge=1, le=1000),
|
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()
|
1 for field_data in discrepancies.values()
|
||||||
if field_data['discrepancy']
|
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 {
|
return {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"customer_id": customer_id,
|
"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,
|
"discrepancies": discrepancies,
|
||||||
|
"contact_discrepancies": contact_discrepancies,
|
||||||
"systems_available": {
|
"systems_available": {
|
||||||
"hub": True,
|
"hub": True,
|
||||||
"vtiger": all_data.get('vtiger') is not None,
|
"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))
|
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")
|
@router.post("/customers/sync-economic-from-simplycrm")
|
||||||
async def sync_economic_numbers_from_simplycrm():
|
async def sync_economic_numbers_from_simplycrm():
|
||||||
"""
|
"""
|
||||||
@ -1850,26 +1964,108 @@ async def get_customer_acmp_overview(
|
|||||||
|
|
||||||
products = execute_query(
|
products = execute_query(
|
||||||
"""
|
"""
|
||||||
|
WITH base AS (
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(material_number, 'N/A') AS material_number,
|
COALESCE(material_number, 'N/A') AS material_number,
|
||||||
COALESCE(vendor, 'N/A') AS vendor,
|
COALESCE(vendor, 'N/A') AS vendor,
|
||||||
COALESCE(product_name, 'Ukendt produkt') AS product_name,
|
COALESCE(product_name, 'Ukendt produkt') AS product_name,
|
||||||
COUNT(*)::INTEGER AS line_count,
|
COALESCE(billable_parameters, 1) AS qty,
|
||||||
COALESCE(SUM(COALESCE(billable_parameters, 1)), 0) AS quantity_total,
|
COALESCE(total_price, sales_price, 0) AS revenue,
|
||||||
COALESCE(SUM(COALESCE(total_price, sales_price, 0)), 0) AS revenue_total,
|
COALESCE(cost_amount, 0) AS cost,
|
||||||
COALESCE(SUM(COALESCE(cost_amount, 0)), 0) AS cost_total,
|
billing_start,
|
||||||
COALESCE(SUM(COALESCE(total_price, sales_price, 0) - COALESCE(cost_amount, 0)), 0) AS margin_total,
|
created_at
|
||||||
MAX(billing_start) AS last_billing_start,
|
|
||||||
MAX(created_at) AS last_seen_at
|
|
||||||
FROM also_import_lines
|
FROM also_import_lines
|
||||||
WHERE matched_customer_id = %s
|
WHERE matched_customer_id = %s
|
||||||
GROUP BY COALESCE(material_number, 'N/A'), COALESCE(vendor, 'N/A'), COALESCE(product_name, 'Ukendt produkt')
|
)
|
||||||
|
SELECT
|
||||||
|
material_number,
|
||||||
|
vendor,
|
||||||
|
product_name,
|
||||||
|
COUNT(*)::INTEGER AS line_count,
|
||||||
|
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 base
|
||||||
|
GROUP BY material_number, vendor, product_name
|
||||||
ORDER BY revenue_total DESC, quantity_total DESC
|
ORDER BY revenue_total DESC, quantity_total DESC
|
||||||
LIMIT 200
|
LIMIT 200
|
||||||
""",
|
""",
|
||||||
(customer_id,),
|
(customer_id,),
|
||||||
) or []
|
) 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(
|
monthly = execute_query(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
@ -1907,6 +2103,8 @@ async def get_customer_acmp_overview(
|
|||||||
"status_breakdown": status_breakdown,
|
"status_breakdown": status_breakdown,
|
||||||
"products": products,
|
"products": products,
|
||||||
"monthly": monthly,
|
"monthly": monthly,
|
||||||
|
"changes": changes,
|
||||||
|
"recent_lines": recent_lines,
|
||||||
}
|
}
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@ -1915,4 +2113,3 @@ async def get_customer_acmp_overview(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("❌ Error fetching ACMP overview for customer %s: %s", customer_id, e)
|
logger.error("❌ Error fetching ACMP overview for customer %s: %s", customer_id, e)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|||||||
@ -345,6 +345,80 @@
|
|||||||
font-weight: 700;
|
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) {
|
@media (max-width: 992px) {
|
||||||
.contacts-toolbar {
|
.contacts-toolbar {
|
||||||
padding: 0.7rem;
|
padding: 0.7rem;
|
||||||
@ -359,6 +433,11 @@
|
|||||||
#contactsContainer {
|
#contactsContainer {
|
||||||
min-width: 920px;
|
min-width: 920px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.consistency-contact-comparison,
|
||||||
|
.consistency-diff-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -1029,6 +1108,9 @@
|
|||||||
<th>Produkt</th>
|
<th>Produkt</th>
|
||||||
<th>Materiale</th>
|
<th>Materiale</th>
|
||||||
<th>Vendor</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">Antal</th>
|
||||||
<th class="text-end">Omsætning</th>
|
<th class="text-end">Omsætning</th>
|
||||||
<th class="text-end">DB</th>
|
<th class="text-end">DB</th>
|
||||||
@ -1038,6 +1120,52 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -2934,6 +3062,20 @@ function getContactMobileValue(contact) {
|
|||||||
return normalizePhoneValue(contact.mobile || contact.mobile_phone);
|
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) {
|
function buildContactsRows(contacts) {
|
||||||
return contacts.map(contact => {
|
return contacts.map(contact => {
|
||||||
const displayName = getContactDisplayName(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 email = contact.email ? `<a href="mailto:${contact.email}">${escapeHtml(contact.email)}</a>` : '—';
|
||||||
const phone = phoneValue
|
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
|
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 title = titleValue ? escapeHtml(titleValue) : '—';
|
||||||
const primaryBadge = contact.is_primary ? '<span class="badge bg-primary primary-pill">Primær</span>' : '—';
|
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
|
// Data Consistency Functions
|
||||||
let consistencyData = null;
|
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() {
|
async function checkDataConsistency() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/customers/${customerId}/data-consistency`);
|
const response = await fetch(`/api/v1/customers/${customerId}/data-consistency`);
|
||||||
@ -4602,7 +4834,16 @@ function showConsistencyModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tbody = document.getElementById('consistencyTableBody');
|
const tbody = document.getElementById('consistencyTableBody');
|
||||||
|
const contactsContainer = document.getElementById('consistencyContactsContainer');
|
||||||
|
const contactsSection = document.getElementById('consistencyContactsSection');
|
||||||
|
const syncOkCheckbox = document.getElementById('consistencyAddSyncOkTag');
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
|
if (contactsContainer) {
|
||||||
|
contactsContainer.innerHTML = '';
|
||||||
|
}
|
||||||
|
if (syncOkCheckbox) {
|
||||||
|
syncOkCheckbox.checked = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Field labels in Danish
|
// Field labels in Danish
|
||||||
const fieldLabels = {
|
const fieldLabels = {
|
||||||
@ -4628,7 +4869,10 @@ function showConsistencyModal() {
|
|||||||
|
|
||||||
// Field name
|
// Field name
|
||||||
const fieldCell = document.createElement('td');
|
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);
|
row.appendChild(fieldCell);
|
||||||
|
|
||||||
// Hub value
|
// Hub value
|
||||||
@ -4636,9 +4880,9 @@ function showConsistencyModal() {
|
|||||||
hubCell.innerHTML = `
|
hubCell.innerHTML = `
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="radio" name="field_${fieldName}"
|
<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}">
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -4650,9 +4894,9 @@ function showConsistencyModal() {
|
|||||||
vtigerCell.innerHTML = `
|
vtigerCell.innerHTML = `
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="radio" name="field_${fieldName}"
|
<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}">
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -4667,9 +4911,9 @@ function showConsistencyModal() {
|
|||||||
economicCell.innerHTML = `
|
economicCell.innerHTML = `
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="radio" name="field_${fieldName}"
|
<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}">
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -4680,24 +4924,85 @@ function showConsistencyModal() {
|
|||||||
|
|
||||||
// Action cell (which system to use)
|
// Action cell (which system to use)
|
||||||
const actionCell = document.createElement('td');
|
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);
|
row.appendChild(actionCell);
|
||||||
|
|
||||||
tbody.appendChild(row);
|
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'));
|
const modal = new bootstrap.Modal(document.getElementById('consistencyModal'));
|
||||||
modal.show();
|
modal.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncSelectedFields() {
|
async function syncSelectedFields() {
|
||||||
const selections = [];
|
const selections = [];
|
||||||
|
const selectedContactKeys = [];
|
||||||
|
|
||||||
// Gather all selected values
|
// Gather all selected values
|
||||||
const radioButtons = document.querySelectorAll('#consistencyTableBody input[type="radio"]:checked');
|
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) {
|
if (radioButtons.length === 0 && contactCheckboxes.length === 0) {
|
||||||
alert('Vælg venligst mindst ét felt at synkronisere');
|
alert('Vælg venligst mindst ét felt eller én kontakt at synkronisere');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4713,14 +5018,20 @@ async function syncSelectedFields() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
contactCheckboxes.forEach((checkbox) => {
|
||||||
|
if (checkbox.value) {
|
||||||
|
selectedContactKeys.push(checkbox.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Confirm action
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync each field
|
let fieldSuccessCount = 0;
|
||||||
let successCount = 0;
|
|
||||||
let failCount = 0;
|
let failCount = 0;
|
||||||
|
let contactSummary = null;
|
||||||
|
|
||||||
for (const selection of selections) {
|
for (const selection of selections) {
|
||||||
try {
|
try {
|
||||||
@ -4731,7 +5042,7 @@ async function syncSelectedFields() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
successCount++;
|
fieldSuccessCount++;
|
||||||
} else {
|
} else {
|
||||||
failCount++;
|
failCount++;
|
||||||
console.error(`Failed to sync ${selection.field_name}`);
|
console.error(`Failed to sync ${selection.field_name}`);
|
||||||
@ -4742,15 +5053,58 @@ async function syncSelectedFields() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Close modal
|
||||||
const modal = bootstrap.Modal.getInstance(document.getElementById('consistencyModal'));
|
const modal = bootstrap.Modal.getInstance(document.getElementById('consistencyModal'));
|
||||||
modal.hide();
|
modal.hide();
|
||||||
|
|
||||||
// Show result
|
// Show result
|
||||||
if (failCount === 0) {
|
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 {
|
} 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
|
// Reload customer data and recheck consistency
|
||||||
@ -4758,6 +5112,17 @@ async function syncSelectedFields() {
|
|||||||
await checkDataConsistency();
|
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() {
|
function showAddContactModal() {
|
||||||
// TODO: Open add contact modal
|
// TODO: Open add contact modal
|
||||||
console.log('Add contact for customer:', customerId);
|
console.log('Add contact for customer:', customerId);
|
||||||
@ -5220,12 +5585,61 @@ function renderAcmpOverview(payload) {
|
|||||||
<td>${escapeHtml(row.product_name || '-')}</td>
|
<td>${escapeHtml(row.product_name || '-')}</td>
|
||||||
<td>${escapeHtml(row.material_number || '-')}</td>
|
<td>${escapeHtml(row.material_number || '-')}</td>
|
||||||
<td>${escapeHtml(row.vendor || '-')}</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">${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.revenue_total || 0))}</td>
|
||||||
<td class="text-end">${formatDKK(Number(row.margin_total || 0))}</td>
|
<td class="text-end">${formatDKK(Number(row.margin_total || 0))}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('')
|
`).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() {
|
function editInternalComment() {
|
||||||
@ -5470,7 +5884,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
<strong>Vejledning:</strong> Vælg den korrekte værdi for hvert felt med uoverensstemmelser.
|
<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>
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@ -5489,6 +5903,23 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
<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",
|
"economy/frontend/time_queue.html",
|
||||||
{"request": request, "title": "Economy Time Queue"},
|
{"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 = []
|
reasons = []
|
||||||
matches = True
|
matches = True
|
||||||
|
|
||||||
if trigger != classification:
|
if trigger not in {classification, 'any'}:
|
||||||
matches = False
|
matches = False
|
||||||
reasons.append(f"classification_mismatch ({trigger} != {classification or 'none'})")
|
reasons.append(f"classification_mismatch ({trigger} != {classification or 'none'})")
|
||||||
|
|
||||||
|
|||||||
@ -4870,6 +4870,23 @@ const WORKFLOW_TEMPLATES = {
|
|||||||
workflow_steps: [
|
workflow_steps: [
|
||||||
{ action: 'flag_for_review', params: { reason: 'low_confidence' } }
|
{ 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 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.backend.service import also_service
|
||||||
from app.modules.also.models.schemas import (
|
from app.modules.also.models.schemas import (
|
||||||
@ -11,6 +11,8 @@ from app.modules.also.models.schemas import (
|
|||||||
AlsoImportJobCreate,
|
AlsoImportJobCreate,
|
||||||
AlsoImportJobResponse,
|
AlsoImportJobResponse,
|
||||||
AlsoImportLinesRequest,
|
AlsoImportLinesRequest,
|
||||||
|
AlsoManualCompanyMapRequest,
|
||||||
|
AlsoManualProductMapRequest,
|
||||||
AlsoProductMappingUpsert,
|
AlsoProductMappingUpsert,
|
||||||
AlsoQueueApproveRequest,
|
AlsoQueueApproveRequest,
|
||||||
AlsoQueueLineResponse,
|
AlsoQueueLineResponse,
|
||||||
@ -49,16 +51,80 @@ async def list_import_jobs(
|
|||||||
return also_service.list_import_jobs(status=status, limit=limit)
|
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):
|
async def get_import_job(job_id: int):
|
||||||
return also_service.get_import_job(job_id)
|
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")
|
@router.post("/also/import-jobs/{job_id}/lines")
|
||||||
async def import_lines(job_id: int, payload: AlsoImportLinesRequest):
|
async def import_lines(job_id: int, payload: AlsoImportLinesRequest):
|
||||||
return also_service.import_lines(job_id=job_id, payload=payload)
|
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])
|
@router.get("/also/queue", response_model=list[AlsoQueueLineResponse])
|
||||||
async def get_queue(
|
async def get_queue(
|
||||||
status: Optional[str] = Query(default=None),
|
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)
|
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)
|
@router.post("/also/queue/run-matching", response_model=AlsoQueueProcessResult)
|
||||||
async def run_matching(payload: AlsoQueueProcessRequest):
|
async def run_matching(payload: AlsoQueueProcessRequest):
|
||||||
return also_service.run_matching(
|
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
|
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[
|
ALSO_QUEUE_STATUSES = Literal[
|
||||||
"new",
|
"new",
|
||||||
"matching_products",
|
"matching_products",
|
||||||
@ -97,6 +97,16 @@ class AlsoProductMappingUpsert(BaseModel):
|
|||||||
product_name_snapshot: Optional[str] = None
|
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):
|
class AlsoImportJobResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
source_type: str
|
source_type: str
|
||||||
@ -119,6 +129,9 @@ class AlsoQueueLineResponse(BaseModel):
|
|||||||
product_name: Optional[str] = None
|
product_name: Optional[str] = None
|
||||||
vendor: Optional[str] = None
|
vendor: Optional[str] = None
|
||||||
total_price: Optional[Decimal] = 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
|
currency: Optional[str] = None
|
||||||
matched_customer_id: Optional[int] = None
|
matched_customer_id: Optional[int] = None
|
||||||
matched_product_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 fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
from app.core.auth_service import AuthService
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -78,11 +78,19 @@ async def bottom_bar_ws(websocket: WebSocket):
|
|||||||
|
|
||||||
initial_status = get_dashboard_status()
|
initial_status = get_dashboard_status()
|
||||||
initial_notifications = get_notifications(user_id, limit=20)
|
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": "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_status_json = json.dumps(initial_status, sort_keys=True, default=str)
|
||||||
last_notifications_json = json.dumps(initial_notifications, 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
|
last_timer_elapsed = -1
|
||||||
status_tick = 0
|
status_tick = 0
|
||||||
|
|
||||||
@ -98,6 +106,7 @@ async def bottom_bar_ws(websocket: WebSocket):
|
|||||||
if status_tick >= 5:
|
if status_tick >= 5:
|
||||||
status = get_dashboard_status()
|
status = get_dashboard_status()
|
||||||
notifications = get_notifications(user_id, limit=20)
|
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)
|
status_json = json.dumps(status, sort_keys=True, default=str)
|
||||||
if status_json != last_status_json:
|
if status_json != last_status_json:
|
||||||
@ -105,9 +114,17 @@ async def bottom_bar_ws(websocket: WebSocket):
|
|||||||
last_status_json = status_json
|
last_status_json = status_json
|
||||||
|
|
||||||
notifications_json = json.dumps(notifications, sort_keys=True, default=str)
|
notifications_json = json.dumps(notifications, sort_keys=True, default=str)
|
||||||
if notifications_json != last_notifications_json:
|
messages_json = json.dumps(messages, sort_keys=True, default=str)
|
||||||
await websocket.send_json({"event": "notification_delta", "data": notifications})
|
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_notifications_json = notifications_json
|
||||||
|
last_messages_json = messages_json
|
||||||
|
|
||||||
status_tick = 0
|
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.auth_dependencies import get_current_user
|
||||||
from app.core.database import execute_query, execute_query_single, execute_update
|
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()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -58,6 +65,20 @@ class NoteToCustomerPayload(BaseModel):
|
|||||||
mode: str = "append"
|
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:
|
def _ensure_user_notes_schema() -> None:
|
||||||
global _USER_NOTES_SCHEMA_READY
|
global _USER_NOTES_SCHEMA_READY
|
||||||
if _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)
|
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")
|
@router.get("/boss/unassigned-cases")
|
||||||
async def list_unassigned_open_cases(
|
async def list_unassigned_open_cases(
|
||||||
limit: int = Query(default=25, ge=1, le=100),
|
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")
|
CLOSED_CASE_STATUSES = ("lukket", "løst", "closed", "resolved")
|
||||||
URGENT_PRIORITIES = ("urgent", "high", "kritisk", "critical")
|
URGENT_PRIORITIES = ("urgent", "high", "kritisk", "critical")
|
||||||
|
_BOTTOM_BAR_MESSAGES_SCHEMA_READY = False
|
||||||
|
|
||||||
|
|
||||||
def _safe_count(row: Optional[dict], key: str = "count") -> int:
|
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")]
|
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]:
|
def get_drift_status(limit: int = 5) -> Dict[str, Any]:
|
||||||
rel_row = execute_query_single(
|
rel_row = execute_query_single(
|
||||||
"""
|
"""
|
||||||
@ -731,6 +929,7 @@ def build_bottom_bar_state(
|
|||||||
timer = get_active_timer(user_id)
|
timer = get_active_timer(user_id)
|
||||||
own_timers = get_own_timer_snapshot(user_id, paused_limit=10)
|
own_timers = get_own_timer_snapshot(user_id, paused_limit=10)
|
||||||
notifications = get_notifications(user_id, 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)
|
drift_status = get_drift_status(limit=5)
|
||||||
unassigned_open_cases = get_unassigned_open_cases(limit=8)
|
unassigned_open_cases = get_unassigned_open_cases(limit=8)
|
||||||
recent_cases = _get_recent_cases(user_id, limit=10)
|
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 = []
|
tasks = []
|
||||||
for n in (notifications.get("items") or [])[:5]:
|
for n in (notifications.get("items") or [])[:5]:
|
||||||
tasks.append(
|
tasks.append(
|
||||||
@ -956,8 +1148,8 @@ def build_bottom_bar_state(
|
|||||||
"list": [],
|
"list": [],
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"count": len(messages),
|
"count": int(messages_summary.get("count") or 0),
|
||||||
"list": messages,
|
"list": messages_summary.get("list") or [],
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"count": len(tasks),
|
"count": len(tasks),
|
||||||
|
|||||||
@ -32,6 +32,7 @@ class DriftCustomerMappingPayload(BaseModel):
|
|||||||
monitor_key: Optional[str] = None
|
monitor_key: Optional[str] = None
|
||||||
monitor_name: Optional[str] = None
|
monitor_name: Optional[str] = None
|
||||||
customer_id: int
|
customer_id: int
|
||||||
|
source: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class DriftBlacklistPayload(BaseModel):
|
class DriftBlacklistPayload(BaseModel):
|
||||||
@ -163,10 +164,19 @@ def _row_to_event(row: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
started = row.get("started_at")
|
started = row.get("started_at")
|
||||||
updated = row.get("updated_at")
|
updated = row.get("updated_at")
|
||||||
resolved = row.get("resolved_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_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 {}
|
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 {}
|
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 = (
|
last_seen = (
|
||||||
raw.get("last_seen")
|
raw.get("last_seen")
|
||||||
or raw_overview.get("lastSeen")
|
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 ""
|
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
|
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")
|
raw_source_link = raw.get("source_link") or raw.get("device_link")
|
||||||
if not raw_source_link and isinstance(raw.get("raw_item"), dict):
|
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)
|
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": row.get("customer_name"),
|
||||||
"customer_id": raw.get("customer_id"),
|
"customer_id": raw.get("customer_id"),
|
||||||
"site": row.get("site_name"),
|
"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"),
|
"device": row.get("device_name"),
|
||||||
"service": row.get("service_name"),
|
"service": row.get("service_name"),
|
||||||
|
"ip": ip_value,
|
||||||
"source_link": raw_source_link,
|
"source_link": raw_source_link,
|
||||||
"device_link": raw_source_link,
|
"device_link": raw_source_link,
|
||||||
"message": row.get("message"),
|
"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)
|
or str(external_id)
|
||||||
)
|
)
|
||||||
site = site_obj.get("name") or item.get("site_name") or item.get("site") or item.get("siteName") or "UISP"
|
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 = (
|
state_value = (
|
||||||
overview.get("status")
|
overview.get("status")
|
||||||
or identification.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"),
|
"overview_status": overview.get("status"),
|
||||||
"last_seen": last_seen,
|
"last_seen": last_seen,
|
||||||
"site": site,
|
"site": site,
|
||||||
|
"site_client_name": site_client_name or site,
|
||||||
|
"ip": ip_value,
|
||||||
|
"ip_address": ip_value,
|
||||||
"raw_item": item,
|
"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")
|
@router.put("/drift/customer-mappings")
|
||||||
async def upsert_customer_mapping(payload: DriftCustomerMappingPayload):
|
async def upsert_customer_mapping(payload: DriftCustomerMappingPayload):
|
||||||
_ensure_schema()
|
_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")
|
source_id = source.get("id")
|
||||||
|
|
||||||
if not source_id:
|
if not source_id:
|
||||||
raise HTTPException(status_code=500, detail="Drift source kunne ikke initialiseres")
|
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:
|
if not monitor_key and not monitor_name:
|
||||||
raise HTTPException(status_code=400, detail="monitor_key eller monitor_name er paakraevet")
|
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">
|
<select class="form-select form-select-sm w-auto" id="drift-status-filter">
|
||||||
<option value="">Status: Alle</option>
|
<option value="">Status: Alle</option>
|
||||||
<option value="active">Aktiv</option>
|
<option value="active">Aktiv</option>
|
||||||
|
<option value="acknowledged">Godkendt</option>
|
||||||
<option value="resolved">Løst</option>
|
<option value="resolved">Løst</option>
|
||||||
</select>
|
</select>
|
||||||
<select class="form-select form-select-sm w-auto" id="drift-severity-filter">
|
<select class="form-select form-select-sm w-auto" id="drift-severity-filter">
|
||||||
@ -124,6 +125,8 @@
|
|||||||
<th>Kilde</th>
|
<th>Kilde</th>
|
||||||
<th>Kunde</th>
|
<th>Kunde</th>
|
||||||
<th>Device</th>
|
<th>Device</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>Site / Client</th>
|
||||||
<th>Start</th>
|
<th>Start</th>
|
||||||
<th>Last seen</th>
|
<th>Last seen</th>
|
||||||
<th>Varighed</th>
|
<th>Varighed</th>
|
||||||
@ -131,7 +134,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="drift-events-body">
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -157,6 +160,7 @@
|
|||||||
<div class="small text-muted mb-2" id="driftMapModalMonitorText">Monitor</div>
|
<div class="small text-muted mb-2" id="driftMapModalMonitorText">Monitor</div>
|
||||||
<input type="hidden" id="driftMapMonitorKey">
|
<input type="hidden" id="driftMapMonitorKey">
|
||||||
<input type="hidden" id="driftMapMonitorName">
|
<input type="hidden" id="driftMapMonitorName">
|
||||||
|
<input type="hidden" id="driftMapSource" value="uptime-kuma">
|
||||||
<label class="form-label">Vælg kunde</label>
|
<label class="form-label">Vælg kunde</label>
|
||||||
<select class="form-select" id="driftMapCustomerSelect"></select>
|
<select class="form-select" id="driftMapCustomerSelect"></select>
|
||||||
</div>
|
</div>
|
||||||
@ -353,7 +357,7 @@ async function loadDriftEvents() {
|
|||||||
const visibleEvents = (Array.isArray(events) ? events : []).filter(event => !isDriftEventBlacklisted(event, blacklist));
|
const visibleEvents = (Array.isArray(events) ? events : []).filter(event => !isDriftEventBlacklisted(event, blacklist));
|
||||||
syncBottomDriftCountFromVisibleEvents(visibleEvents);
|
syncBottomDriftCountFromVisibleEvents(visibleEvents);
|
||||||
if (!visibleEvents.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
body.innerHTML = visibleEvents.map(event => `
|
body.innerHTML = visibleEvents.map(event => `
|
||||||
@ -369,7 +373,7 @@ async function loadDriftEvents() {
|
|||||||
<td>
|
<td>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<span>${event.customer || '-'}</span>
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<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>` : ''}
|
${(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>
|
</div>
|
||||||
</td>
|
</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.started ? new Date(event.started).toLocaleString('da-DK') : '-'}</td>
|
||||||
<td>${event.last_seen ? new Date(event.last_seen).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>
|
<td>${event.duration_minutes !== null && event.duration_minutes !== undefined ? `${event.duration_minutes} min` : '-'}</td>
|
||||||
@ -395,7 +401,7 @@ async function loadDriftEvents() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
syncBottomDriftCountFromVisibleEvents([]);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const statusFilter = document.getElementById('drift-status-filter');
|
||||||
|
if (statusFilter && (statusFilter.value === '' || statusFilter.value === 'active')) {
|
||||||
|
statusFilter.value = 'acknowledged';
|
||||||
|
}
|
||||||
|
|
||||||
await loadDriftSummary();
|
await loadDriftSummary();
|
||||||
await loadDriftEvents();
|
await loadDriftEvents();
|
||||||
}
|
}
|
||||||
@ -451,6 +462,7 @@ async function blacklistDriftEvent(eventId, deviceName = '') {
|
|||||||
|
|
||||||
async function mapDriftCustomer(monitorKey, monitorName) {
|
async function mapDriftCustomer(monitorKey, monitorName) {
|
||||||
const customerId = Number(document.getElementById('driftMapCustomerSelect')?.value || 0);
|
const customerId = Number(document.getElementById('driftMapCustomerSelect')?.value || 0);
|
||||||
|
const source = document.getElementById('driftMapSource')?.value || 'uptime-kuma';
|
||||||
if (!Number.isInteger(customerId) || customerId <= 0) {
|
if (!Number.isInteger(customerId) || customerId <= 0) {
|
||||||
alert('Vælg en kunde');
|
alert('Vælg en kunde');
|
||||||
return false;
|
return false;
|
||||||
@ -460,7 +472,7 @@ async function mapDriftCustomer(monitorKey, monitorName) {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -473,10 +485,11 @@ async function mapDriftCustomer(monitorKey, monitorName) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openMapCustomerModal(monitorKey, monitorName) {
|
async function openMapCustomerModal(monitorKey, monitorName, source = 'uptime-kuma') {
|
||||||
await loadDriftCustomers();
|
await loadDriftCustomers();
|
||||||
document.getElementById('driftMapMonitorKey').value = monitorKey || '';
|
document.getElementById('driftMapMonitorKey').value = monitorKey || '';
|
||||||
document.getElementById('driftMapMonitorName').value = monitorName || '';
|
document.getElementById('driftMapMonitorName').value = monitorName || '';
|
||||||
|
document.getElementById('driftMapSource').value = source || 'uptime-kuma';
|
||||||
document.getElementById('driftMapModalMonitorText').textContent = `Monitor: ${monitorName || monitorKey}`;
|
document.getElementById('driftMapModalMonitorText').textContent = `Monitor: ${monitorName || monitorKey}`;
|
||||||
renderCustomerSelectOptions(document.getElementById('driftMapCustomerSelect'));
|
renderCustomerSelectOptions(document.getElementById('driftMapCustomerSelect'));
|
||||||
const modalEl = document.getElementById('driftMapModal');
|
const modalEl = document.getElementById('driftMapModal');
|
||||||
@ -555,7 +568,7 @@ async function saveBulkMapping(monitorKey, monitorName, selectId) {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@ -110,3 +110,32 @@ async def search_locations(q: str = Query(..., min_length=2)):
|
|||||||
term = f"%{q}%"
|
term = f"%{q}%"
|
||||||
results = execute_query(sql, (term, term, term))
|
results = execute_query(sql, (term, term, term))
|
||||||
return results
|
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 logging
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Dict, List, Optional, Tuple, Any
|
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.vtiger_service import VTigerService
|
||||||
from app.services.economic_service import EconomicService
|
from app.services.economic_service import EconomicService
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@ -35,6 +35,111 @@ class CustomerConsistencyService:
|
|||||||
self.vtiger = VTigerService()
|
self.vtiger = VTigerService()
|
||||||
self.economic = EconomicService()
|
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
|
@staticmethod
|
||||||
def normalize_value(value: Any) -> Optional[str]:
|
def normalize_value(value: Any) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
@ -57,7 +162,7 @@ class CustomerConsistencyService:
|
|||||||
# Lowercase for case-insensitive comparison
|
# Lowercase for case-insensitive comparison
|
||||||
return str_value.lower()
|
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
|
Fetch customer data from all three systems in parallel
|
||||||
|
|
||||||
@ -81,10 +186,13 @@ class CustomerConsistencyService:
|
|||||||
# Prepare async tasks for vTiger and e-conomic
|
# Prepare async tasks for vTiger and e-conomic
|
||||||
vtiger_task = None
|
vtiger_task = None
|
||||||
economic_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
|
# Fetch vTiger data if we have an ID and vTiger is configured
|
||||||
if hub_data.get('vtiger_id') and settings.VTIGER_URL:
|
if hub_data.get('vtiger_id') and settings.VTIGER_URL:
|
||||||
vtiger_task = self.vtiger.get_account_by_id(hub_data['vtiger_id'])
|
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
|
# 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:
|
if hub_data.get('economic_customer_number') and settings.ECONOMIC_APP_SECRET_TOKEN:
|
||||||
@ -96,6 +204,9 @@ class CustomerConsistencyService:
|
|||||||
tasks['vtiger'] = vtiger_task
|
tasks['vtiger'] = vtiger_task
|
||||||
if economic_task:
|
if economic_task:
|
||||||
tasks['economic'] = economic_task
|
tasks['economic'] = economic_task
|
||||||
|
tasks['hub_contacts'] = hub_contacts_task
|
||||||
|
if vtiger_contacts_task:
|
||||||
|
tasks['vtiger_contacts'] = vtiger_contacts_task
|
||||||
|
|
||||||
results = {}
|
results = {}
|
||||||
if tasks:
|
if tasks:
|
||||||
@ -115,7 +226,9 @@ class CustomerConsistencyService:
|
|||||||
return {
|
return {
|
||||||
'hub': hub_data,
|
'hub': hub_data,
|
||||||
'vtiger': results.get('vtiger'),
|
'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
|
@classmethod
|
||||||
@ -174,6 +287,56 @@ class CustomerConsistencyService:
|
|||||||
|
|
||||||
return discrepancies
|
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(
|
async def sync_field(
|
||||||
self,
|
self,
|
||||||
customer_id: int,
|
customer_id: int,
|
||||||
@ -198,7 +361,7 @@ class CustomerConsistencyService:
|
|||||||
if field_name not in self.FIELD_MAP:
|
if field_name not in self.FIELD_MAP:
|
||||||
raise ValueError(f"Unknown field: {field_name}")
|
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
|
# Fetch Hub data to get mapping IDs
|
||||||
hub_query = "SELECT * FROM customers WHERE id = %s"
|
hub_query = "SELECT * FROM customers WHERE id = %s"
|
||||||
@ -222,22 +385,8 @@ class CustomerConsistencyService:
|
|||||||
else:
|
else:
|
||||||
results['hub'] = True # Already correct
|
results['hub'] = True # Already correct
|
||||||
|
|
||||||
# Update vTiger if enabled and not the source
|
# vTiger is read-only for this workflow.
|
||||||
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
|
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
|
|
||||||
|
|
||||||
# Update e-conomic if enabled and not the source
|
# 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'):
|
if settings.ECONOMIC_SYNC_ENABLED and source_system != 'economic' and hub_data.get('economic_customer_number'):
|
||||||
@ -260,3 +409,128 @@ class CustomerConsistencyService:
|
|||||||
results['economic'] = True # Not applicable or already correct
|
results['economic'] = True # Not applicable or already correct
|
||||||
|
|
||||||
return results
|
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.database import execute_query, execute_insert, execute_update, table_has_column
|
||||||
from app.core.config import settings
|
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
|
from app.services.email_activity_logger import email_activity_logger
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -1236,7 +1237,7 @@ class EmailWorkflowService:
|
|||||||
confidence_threshold, workflow_steps, priority, stop_on_match
|
confidence_threshold, workflow_steps, priority, stop_on_match
|
||||||
FROM email_workflows
|
FROM email_workflows
|
||||||
WHERE enabled = true
|
WHERE enabled = true
|
||||||
AND classification_trigger = %s
|
AND LOWER(classification_trigger) IN (%s, 'any')
|
||||||
AND confidence_threshold <= %s
|
AND confidence_threshold <= %s
|
||||||
ORDER BY priority ASC
|
ORDER BY priority ASC
|
||||||
"""
|
"""
|
||||||
@ -1412,6 +1413,7 @@ class EmailWorkflowService:
|
|||||||
'extract_invoice_data': self._action_extract_invoice_data,
|
'extract_invoice_data': self._action_extract_invoice_data,
|
||||||
'extract_tracking_number': self._action_extract_tracking_number,
|
'extract_tracking_number': self._action_extract_tracking_number,
|
||||||
'regex_extract_and_link': self._action_regex_extract_and_link,
|
'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_slack_notification': self._action_send_slack_notification,
|
||||||
'send_email_notification': self._action_send_email_notification,
|
'send_email_notification': self._action_send_email_notification,
|
||||||
'mark_as_processed': self._action_mark_as_processed,
|
'mark_as_processed': self._action_mark_as_processed,
|
||||||
@ -1440,6 +1442,52 @@ class EmailWorkflowService:
|
|||||||
'error': str(e)
|
'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
|
# Action Handlers
|
||||||
|
|
||||||
async def _action_regex_extract_and_link(self, params: Dict, email_data: Dict) -> Dict:
|
async def _action_regex_extract_and_link(self, params: Dict, email_data: Dict) -> Dict:
|
||||||
|
|||||||
@ -202,7 +202,7 @@ class SimplyCRMService:
|
|||||||
Returns:
|
Returns:
|
||||||
List of ticket records
|
List of ticket records
|
||||||
"""
|
"""
|
||||||
module_name = getattr(settings, "SIMPLYCRM_TICKET_MODULE", "Tickets")
|
module_name = getattr(settings, "SIMPLYCRM_TICKET_MODULE", "HelpDesk")
|
||||||
all_records: List[Dict] = []
|
all_records: List[Dict] = []
|
||||||
offset = 0
|
offset = 0
|
||||||
batch_size = 200
|
batch_size = 200
|
||||||
|
|||||||
@ -5,6 +5,7 @@ Settings and User Management API Router
|
|||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
from app.core.database import execute_query
|
from app.core.database import execute_query
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
import httpx
|
import httpx
|
||||||
@ -57,8 +58,8 @@ class User(BaseModel):
|
|||||||
email: Optional[str]
|
email: Optional[str]
|
||||||
full_name: Optional[str]
|
full_name: Optional[str]
|
||||||
is_active: bool
|
is_active: bool
|
||||||
last_login: Optional[str]
|
last_login: Optional[datetime]
|
||||||
created_at: str
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class UserCreate(BaseModel):
|
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__
|
err = str(e) or e.__class__.__name__
|
||||||
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}")
|
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="small text-muted mb-3">Beskeder lokalt: <span id="archivedVtigerMessagesCount">-</span></div>
|
||||||
<div class="d-grid">
|
<div class="d-grid">
|
||||||
<button class="btn btn-outline-primary btn-sm" onclick="syncArchivedVtiger()" id="btnSyncArchivedVtiger">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -5145,9 +5145,9 @@ async function syncArchivedVtiger() {
|
|||||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Synkroniserer...';
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Synkroniserer...';
|
||||||
|
|
||||||
try {
|
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'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -5158,6 +5158,8 @@ async function syncArchivedVtiger() {
|
|||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
const details = [
|
const details = [
|
||||||
|
`Mode: ${result.mode || 'ukendt'}`,
|
||||||
|
`Hentet remote: ${result.fetched_remote || 0}`,
|
||||||
`Importeret: ${result.imported || 0}`,
|
`Importeret: ${result.imported || 0}`,
|
||||||
`Opdateret: ${result.updated || 0}`,
|
`Opdateret: ${result.updated || 0}`,
|
||||||
`Sprunget over: ${result.skipped || 0}`,
|
`Sprunget over: ${result.skipped || 0}`,
|
||||||
@ -5173,7 +5175,7 @@ async function syncArchivedVtiger() {
|
|||||||
showNotification('Fejl: ' + error.message, 'error');
|
showNotification('Fejl: ' + error.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
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-products', label: 'Produkter' },
|
||||||
{ key: 'menu-salg-webshop', label: 'Webshop Administration' },
|
{ key: 'menu-salg-webshop', label: 'Webshop Administration' },
|
||||||
{ key: 'menu-okonomi-time-queue', label: 'Time Queue' },
|
{ 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-supplier-invoices', label: 'Leverandør fakturaer' },
|
||||||
{ key: 'menu-okonomi-prepaid', label: 'Prepaid Cards' },
|
{ key: 'menu-okonomi-prepaid', label: 'Prepaid Cards' },
|
||||||
{ key: 'menu-okonomi-fixed-price', label: 'Fastpris Aftaler' },
|
{ key: 'menu-okonomi-fixed-price', label: 'Fastpris Aftaler' },
|
||||||
|
|||||||
@ -391,10 +391,14 @@
|
|||||||
grid-template-columns: 160px minmax(0, 1fr);
|
grid-template-columns: 160px minmax(0, 1fr);
|
||||||
min-height: 240px;
|
min-height: 240px;
|
||||||
max-height: min(52vh, 420px);
|
max-height: min(52vh, 420px);
|
||||||
|
height: min(52vh, 420px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: inset 0 2px 10px rgba(0,0,0,0.02);
|
box-shadow: inset 0 2px 10px rgba(0,0,0,0.02);
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
.global-bottom-bar .bb-sheet-inner > * {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.global-bottom-bar .bb-side-tabs {
|
.global-bottom-bar .bb-side-tabs {
|
||||||
border-right: 1px solid rgba(var(--text-primary-rgb), 0.08);
|
border-right: 1px solid rgba(var(--text-primary-rgb), 0.08);
|
||||||
@ -403,6 +407,8 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.global-bottom-bar .bb-tab-btn {
|
.global-bottom-bar .bb-tab-btn {
|
||||||
@ -424,6 +430,23 @@
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
opacity: 0.7;
|
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 {
|
.global-bottom-bar .bb-tab-btn:hover {
|
||||||
background: rgba(var(--text-primary-rgb), 0.05);
|
background: rgba(var(--text-primary-rgb), 0.05);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@ -439,20 +462,29 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
color: var(--accent);
|
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 {
|
.global-bottom-bar .bb-tab-content {
|
||||||
padding: 1.2rem;
|
padding: 0.75rem 0.9rem 0.9rem;
|
||||||
overflow: auto;
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.global-bottom-bar .bb-tab-title {
|
.global-bottom-bar .bb-tab-title {
|
||||||
font-size: 1.1rem;
|
font-size: 1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
line-height: 1.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
.global-bottom-bar .bb-tab-list {
|
.global-bottom-bar .bb-tab-list {
|
||||||
@ -467,7 +499,7 @@
|
|||||||
border-left: 4px solid var(--accent);
|
border-left: 4px solid var(--accent);
|
||||||
background: var(--accent-light);
|
background: var(--accent-light);
|
||||||
border-radius: 6px 8px 8px 6px;
|
border-radius: 6px 8px 8px 6px;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.65rem 0.85rem;
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@ -478,6 +510,105 @@
|
|||||||
transform: translateX(2px);
|
transform: translateX(2px);
|
||||||
box-shadow: 0 4px 14px rgba(0,0,0,0.08);
|
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 {
|
#bbSwitchCaseModal .modal-content {
|
||||||
border: 1px solid rgba(var(--text-primary-rgb), 0.12);
|
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 = request.headers.get('x-forwarded-for') if request and request.headers else '' %}
|
||||||
{% set _xff_first = _xff.split(',')[0].strip() if _xff 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 _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>
|
<body>
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-lg fixed-top">
|
<nav class="navbar navbar-expand-lg fixed-top">
|
||||||
@ -862,6 +993,7 @@
|
|||||||
<ul class="dropdown-menu mt-2">
|
<ul class="dropdown-menu mt-2">
|
||||||
<li><h6 class="dropdown-header">Fakturering</h6></li>
|
<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-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 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><hr class="dropdown-divider"></li>
|
||||||
<li><h6 class="dropdown-header">Aftaler</h6></li>
|
<li><h6 class="dropdown-header">Aftaler</h6></li>
|
||||||
@ -907,8 +1039,8 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<a href="#" class="d-flex align-items-center text-decoration-none text-dark dropdown-toggle" data-bs-toggle="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">
|
<img id="currentUserAvatar" src="https://ui-avatars.com/api/?name=Bruger&background=0f4c75&color=fff" class="rounded-circle me-2" width="32">
|
||||||
<span class="small fw-bold" style="color: var(--text-primary)">Christian</span>
|
<span id="currentUserDisplayName" class="small fw-bold" style="color: var(--text-primary)">Bruger</span>
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu dropdown-menu-end mt-2">
|
<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>
|
<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/telefoni.js?v=2.4"></script>
|
||||||
<script src="/static/js/sms.js?v=1.0"></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/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>
|
<script>
|
||||||
// Dark Mode Toggle Logic
|
// Dark Mode Toggle Logic
|
||||||
window.BMC_CAN_CLICK_TO_CALL = {{ 'true' if _can_click_to_call else 'false' }};
|
window.BMC_CAN_CLICK_TO_CALL = true;
|
||||||
|
|
||||||
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 });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const darkModeToggle = document.getElementById('darkModeToggle');
|
const darkModeToggle = document.getElementById('darkModeToggle');
|
||||||
const htmlElement = document.documentElement;
|
const htmlElement = document.documentElement;
|
||||||
@ -2347,6 +2428,7 @@ if (bmcOriginalFetch) {
|
|||||||
{ key: 'menu-salg-products', label: 'Salg: Produkter' },
|
{ key: 'menu-salg-products', label: 'Salg: Produkter' },
|
||||||
{ key: 'menu-salg-webshop', label: 'Salg: Webshop Administration' },
|
{ key: 'menu-salg-webshop', label: 'Salg: Webshop Administration' },
|
||||||
{ key: 'menu-okonomi-time-queue', label: 'Økonomi: Time Queue' },
|
{ 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-supplier-invoices', label: 'Økonomi: Leverandør fakturaer' },
|
||||||
{ key: 'menu-okonomi-prepaid', label: 'Økonomi: Prepaid Cards' },
|
{ key: 'menu-okonomi-prepaid', label: 'Økonomi: Prepaid Cards' },
|
||||||
{ key: 'menu-okonomi-fixed-price', label: 'Økonomi: Fastpris Aftaler' },
|
{ key: 'menu-okonomi-fixed-price', label: 'Økonomi: Fastpris Aftaler' },
|
||||||
@ -2589,6 +2671,38 @@ if (bmcOriginalFetch) {
|
|||||||
loadAnyDeskChips();
|
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() {
|
async function loadAnyDeskChips() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/auth/me/anydesk-ids', { credentials: 'include' });
|
const res = await fetch('/api/v1/auth/me/anydesk-ids', { credentials: 'include' });
|
||||||
@ -2652,6 +2766,7 @@ if (bmcOriginalFetch) {
|
|||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error((await res.json()).detail || 'Fejl');
|
if (!res.ok) throw new Error((await res.json()).detail || 'Fejl');
|
||||||
|
loadCurrentUserMenuIdentity();
|
||||||
const statusEl = document.getElementById('prof-save-status');
|
const statusEl = document.getElementById('prof-save-status');
|
||||||
statusEl.style.display = '';
|
statusEl.style.display = '';
|
||||||
setTimeout(() => { statusEl.style.display = 'none'; }, 3000);
|
setTimeout(() => { statusEl.style.display = 'none'; }, 3000);
|
||||||
@ -2659,6 +2774,8 @@ if (bmcOriginalFetch) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadCurrentUserMenuIdentity();
|
||||||
|
|
||||||
const saveMenuBtn = document.getElementById('profMenuSaveBtn');
|
const saveMenuBtn = document.getElementById('profMenuSaveBtn');
|
||||||
if (saveMenuBtn) saveMenuBtn.addEventListener('click', saveProfileMenuPreferences);
|
if (saveMenuBtn) saveMenuBtn.addEventListener('click', saveProfileMenuPreferences);
|
||||||
const showAllBtn = document.getElementById('profMenuShowAllBtn');
|
const showAllBtn = document.getElementById('profMenuShowAllBtn');
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from fastapi.responses import JSONResponse
|
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.database import execute_query, execute_insert, execute_update, execute_query_single
|
||||||
from app.core.auth_dependencies import require_any_permission
|
from app.core.auth_dependencies import require_any_permission
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -208,6 +208,149 @@ async def _vtiger_query_with_retry(vtiger, query_string: str, retries: int = 5,
|
|||||||
return []
|
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
|
# TICKET ENDPOINTS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@ -2255,23 +2398,29 @@ async def import_simply_archived_tickets(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/archived/vtiger/import", tags=["Archived Tickets"])
|
async def _run_vtiger_archived_import(
|
||||||
async def import_vtiger_archived_tickets(
|
limit: int = 5000,
|
||||||
limit: int = Query(5000, ge=1, le=50000, description="Maximum tickets to import"),
|
include_messages: bool = True,
|
||||||
include_messages: bool = Query(True, description="Include comments and emails"),
|
ticket_number: Optional[str] = None,
|
||||||
ticket_number: Optional[str] = Query(None, description="Import a single ticket by number"),
|
force: bool = False,
|
||||||
force: bool = Query(False, description="Update even if sync hash matches"),
|
incremental: bool = True,
|
||||||
current_user: dict = Depends(sync_admin_access)
|
|
||||||
):
|
):
|
||||||
"""
|
stats = {
|
||||||
One-time import of archived tickets from vTiger (Cases module).
|
"imported": 0,
|
||||||
"""
|
"updated": 0,
|
||||||
stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0, "messages_imported": 0}
|
"skipped": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"messages_imported": 0,
|
||||||
|
"mode": "full",
|
||||||
|
"watermark_used": None,
|
||||||
|
"fetched_remote": 0,
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
vtiger = get_vtiger_service()
|
vtiger = get_vtiger_service()
|
||||||
|
|
||||||
if ticket_number:
|
if ticket_number:
|
||||||
|
stats["mode"] = "single_ticket"
|
||||||
sanitized = _escape_simply_value(ticket_number)
|
sanitized = _escape_simply_value(ticket_number)
|
||||||
tickets = []
|
tickets = []
|
||||||
for field in ("ticket_no", "ticketnumber", "ticket_number"):
|
for field in ("ticket_no", "ticketnumber", "ticket_number"):
|
||||||
@ -2279,11 +2428,27 @@ async def import_vtiger_archived_tickets(
|
|||||||
tickets = await _vtiger_query_with_retry(vtiger, query)
|
tickets = await _vtiger_query_with_retry(vtiger, query)
|
||||||
if tickets:
|
if tickets:
|
||||||
break
|
break
|
||||||
|
stats["fetched_remote"] = len(tickets)
|
||||||
else:
|
else:
|
||||||
tickets = []
|
tickets = []
|
||||||
offset = 0
|
offset = 0
|
||||||
batch_size = 200
|
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:
|
while len(tickets) < limit:
|
||||||
|
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};"
|
query = f"SELECT * FROM Cases LIMIT {offset}, {batch_size};"
|
||||||
batch = await _vtiger_query_with_retry(vtiger, query)
|
batch = await _vtiger_query_with_retry(vtiger, query)
|
||||||
if not batch:
|
if not batch:
|
||||||
@ -2294,6 +2459,7 @@ async def import_vtiger_archived_tickets(
|
|||||||
break
|
break
|
||||||
|
|
||||||
tickets = tickets[:limit]
|
tickets = tickets[:limit]
|
||||||
|
stats["fetched_remote"] = len(tickets)
|
||||||
|
|
||||||
logger.info(f"🔍 Importing {len(tickets)} archived tickets from vTiger")
|
logger.info(f"🔍 Importing {len(tickets)} archived tickets from vTiger")
|
||||||
|
|
||||||
@ -2594,6 +2760,30 @@ async def import_vtiger_archived_tickets(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ vTiger archived ticket import failed: {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))
|
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] = {}
|
sources: dict[str, dict] = {}
|
||||||
|
|
||||||
for source_key in source_keys:
|
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] = {
|
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,
|
"remote_total_tickets": None,
|
||||||
"local_total_tickets": local_tickets,
|
|
||||||
"local_total_messages": local_messages,
|
|
||||||
"last_synced_at": last_synced_at_iso,
|
|
||||||
"diff": None,
|
"diff": None,
|
||||||
"is_synced": False,
|
"is_synced": False,
|
||||||
"error": None,
|
"error": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
for source_key in source_keys:
|
||||||
async with SimplyCRMService() as service:
|
remote_total, error = await _fetch_remote_ticket_total(source_key)
|
||||||
module_name = getattr(settings, "SIMPLYCRM_TICKET_MODULE", "Tickets")
|
sources[source_key]["remote_total_tickets"] = remote_total
|
||||||
simply_rows = await service.query(f"SELECT count(*) AS total_count FROM {module_name};")
|
if remote_total is not None:
|
||||||
simply_remote_count = _extract_count_value(simply_rows)
|
sources[source_key]["diff"] = remote_total - sources[source_key]["local_total_tickets"]
|
||||||
sources["simplycrm"]["remote_total_tickets"] = simply_remote_count
|
sources[source_key]["is_synced"] = sources[source_key]["diff"] == 0
|
||||||
if simply_remote_count is not None:
|
if error:
|
||||||
sources["simplycrm"]["diff"] = simply_remote_count - sources["simplycrm"]["local_total_tickets"]
|
logger.warning("⚠️ %s archived status check failed: %s", source_key, error)
|
||||||
sources["simplycrm"]["is_synced"] = sources["simplycrm"]["diff"] == 0
|
sources[source_key]["error"] = error
|
||||||
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)
|
|
||||||
|
|
||||||
overall_synced = all(sources[key].get("is_synced") is True for key in source_keys)
|
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"])
|
@router.get("/archived/simply/modules", tags=["Archived Tickets"])
|
||||||
async def list_simply_modules(current_user: dict = Depends(sync_admin_access)):
|
async def list_simply_modules(current_user: dict = Depends(sync_admin_access)):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -4,223 +4,725 @@
|
|||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
.detail-card {
|
.archived-detail-page {
|
||||||
background: var(--bg-card);
|
max-width: 1480px;
|
||||||
border-radius: var(--border-radius);
|
margin: 0 auto;
|
||||||
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
|
padding-bottom: 3rem;
|
||||||
padding: 1.5rem;
|
}
|
||||||
|
|
||||||
|
.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;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-label {
|
.archive-hero::after {
|
||||||
font-size: 0.8rem;
|
content: "";
|
||||||
color: var(--text-secondary);
|
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;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
}
|
||||||
|
|
||||||
|
.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 {
|
.meta-value {
|
||||||
font-weight: 600;
|
color: #17364a;
|
||||||
color: var(--text-primary);
|
font-size: 0.98rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ticket-number {
|
.meta-value.is-muted {
|
||||||
font-family: 'Monaco', 'Courier New', monospace;
|
color: #7c91a2;
|
||||||
background: var(--accent-light);
|
|
||||||
padding: 0.2rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--accent);
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-card {
|
.message-timeline {
|
||||||
border: 1px solid var(--accent-light);
|
position: relative;
|
||||||
border-radius: 10px;
|
display: grid;
|
||||||
padding: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 1rem;
|
|
||||||
background: var(--bg-body);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-meta {
|
.message-timeline::before {
|
||||||
font-size: 0.85rem;
|
content: "";
|
||||||
color: var(--text-secondary);
|
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 {
|
.message-type {
|
||||||
font-size: 0.7rem;
|
display: inline-flex;
|
||||||
text-transform: uppercase;
|
align-items: center;
|
||||||
letter-spacing: 0.4px;
|
gap: 0.4rem;
|
||||||
background: var(--accent-light);
|
padding: 0.45rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 76, 117, 0.09);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
padding: 0.2rem 0.5rem;
|
font-size: 0.74rem;
|
||||||
border-radius: 6px;
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.long-text {
|
.message-subject {
|
||||||
white-space: pre-wrap;
|
display: block;
|
||||||
word-break: break-word;
|
margin-top: 0.7rem;
|
||||||
line-height: 1.6;
|
font-size: 1.08rem;
|
||||||
font-size: 1rem;
|
line-height: 1.35;
|
||||||
|
color: #193d53;
|
||||||
}
|
}
|
||||||
|
|
||||||
.long-text p {
|
.message-meta {
|
||||||
margin: 0 0 0.75rem;
|
color: #7991a2;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.long-text ul {
|
.message-author {
|
||||||
margin: 0.5rem 0 0.75rem 1.25rem;
|
margin-bottom: 0.95rem;
|
||||||
|
color: #5d7488;
|
||||||
|
font-size: 0.92rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.long-text li {
|
.message-body {
|
||||||
margin-bottom: 0.35rem;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-card {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.archive-meta-strip {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-hero-top,
|
||||||
|
.message-head {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badges {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-meta {
|
||||||
|
text-align: left;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.archived-detail-page {
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-hero,
|
||||||
|
.content-card,
|
||||||
|
.sidebar-card {
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-hero,
|
||||||
|
.content-card {
|
||||||
|
padding: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-title {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-meta-strip {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-entry {
|
||||||
|
margin-left: 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline::before {
|
||||||
|
left: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-entry::before {
|
||||||
|
left: -1.55rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid px-4">
|
<div class="container-fluid px-4 archived-detail-page">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<section class="archive-hero">
|
||||||
|
<div class="archive-hero-top">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="mb-2">
|
<span class="archive-kicker">
|
||||||
<i class="bi bi-archive"></i> Arkiveret Ticket
|
<i class="bi bi-archive-fill"></i>
|
||||||
</h1>
|
Arkiveret ticket
|
||||||
<p class="text-muted">Detaljer fra Simply-CRM import</p>
|
</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>
|
</div>
|
||||||
<a href="/ticket/archived" class="btn btn-outline-primary">
|
<a href="/ticket/archived" class="archive-back-btn">
|
||||||
<i class="bi bi-arrow-left"></i> Tilbage til liste
|
<i class="bi bi-arrow-left"></i>
|
||||||
|
Tilbage til liste
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-card">
|
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
|
||||||
<div class="row g-3">
|
<div class="d-flex flex-wrap gap-2">
|
||||||
<div class="col-md-8">
|
|
||||||
{% if ticket.ticket_number %}
|
{% 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 %}
|
{% endif %}
|
||||||
<h2 class="mt-2">{{ ticket.title or 'Ingen titel' }}</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 text-md-end">
|
|
||||||
|
<div class="status-badges">
|
||||||
{% if ticket.status %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-4 mt-3">
|
<div class="archive-meta-strip">
|
||||||
<div class="col-md-3">
|
<div class="archive-stat">
|
||||||
<div class="meta-label">Organisation</div>
|
<div class="archive-stat-label">Organisation</div>
|
||||||
<div class="meta-value">{{ ticket.organization_name or '-' }}</div>
|
<div class="archive-stat-value">{{ ticket.organization_name or 'Ikke registreret' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="archive-stat">
|
||||||
<div class="meta-label">Kontakt</div>
|
<div class="archive-stat-label">Kontakt</div>
|
||||||
<div class="meta-value">{{ ticket.contact_name or '-' }}</div>
|
<div class="archive-stat-value">{{ ticket.contact_name or 'Ukendt kontakt' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="archive-stat">
|
||||||
<div class="meta-label">Email From</div>
|
<div class="archive-stat-label">Tidsforbrug</div>
|
||||||
<div class="meta-value">{{ ticket.email_from or '-' }}</div>
|
<div class="archive-stat-value">
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="meta-label">Tid brugt</div>
|
|
||||||
<div class="meta-value">
|
|
||||||
{% if ticket.time_spent_hours is not none %}
|
{% if ticket.time_spent_hours is not none %}
|
||||||
{{ '%.2f'|format(ticket.time_spent_hours) }} t
|
{{ '%.2f'|format(ticket.time_spent_hours) }} timer
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="detail-card">
|
<div class="archive-layout">
|
||||||
<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="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>
|
|
||||||
|
|
||||||
<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>
|
<div>
|
||||||
<span class="message-type">{{ message.message_type }}</span>
|
<section class="surface-card content-card">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<i class="bi bi-card-text"></i>
|
||||||
|
Beskrivelse
|
||||||
|
</h2>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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 %}
|
{% if message.subject %}
|
||||||
<strong class="ms-2">{{ message.subject }}</strong>
|
<strong class="message-subject">{{ message.subject }}</strong>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="message-meta">
|
<div class="message-meta">
|
||||||
{% if message.source_created_at %}
|
{% if message.source_created_at %}
|
||||||
{{ message.source_created_at.strftime('%Y-%m-%d %H:%M') }}
|
{{ message.source_created_at.strftime('%d/%m/%Y %H:%M') }}
|
||||||
{% else %}
|
{% else %}
|
||||||
-
|
Tidspunkt ukendt
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-meta mt-2">
|
|
||||||
{% if message.author_name %}
|
{% if message.author_name or message.author_email %}
|
||||||
{{ message.author_name }}
|
<div class="message-author">
|
||||||
{% endif %}
|
{{ message.author_name or 'Ukendt afsender' }}
|
||||||
{% if message.author_email %}
|
{% if message.author_email %}
|
||||||
({{ message.author_email }})
|
({{ message.author_email }})
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3">
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="message-body rich-copy">
|
||||||
{% if message.body_html %}
|
{% if message.body_html %}
|
||||||
<div class="mb-0 long-text">{{ message.body_html | safe }}</div>
|
{{ message.body_html | safe }}
|
||||||
{% elif message.body %}
|
{% elif message.body %}
|
||||||
<div class="mb-0 long-text">{{ message.body | e | replace('\n', '<br>') | safe }}</div>
|
{{ message.body | e | replace('\n', '<br>') | safe }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="mb-0 long-text">Ingen tekst</div>
|
<div class="empty-copy">Ingen tekst gemt i denne besked.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted">Ingen kommentarer eller emails fundet.</p>
|
<div class="empty-copy">Ingen kommentarer eller emails blev fundet for den arkiverede ticket.</div>
|
||||||
{% endif %}
|
{% 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 %}
|
||||||
|
Ikke registreret
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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 logging
|
||||||
|
import html
|
||||||
from fastapi import APIRouter, Request, HTTPException, Form
|
from fastapi import APIRouter, Request, HTTPException, Form
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
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)
|
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(
|
backup_scheduler.scheduler.add_job(
|
||||||
func=run_uptime_kuma_sync,
|
func=run_uptime_kuma_sync,
|
||||||
trigger=IntervalTrigger(seconds=120),
|
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',
|
target: 'case',
|
||||||
noteId: 0
|
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';
|
const LOCAL_NOTES_KEY = 'bmc_bottom_bar_notes_v1';
|
||||||
let notesApiUnavailable = false;
|
let notesApiUnavailable = false;
|
||||||
let driftSummaryRefreshTimer = null;
|
let driftSummaryRefreshTimer = null;
|
||||||
|
let markMessagesReadPromise = null;
|
||||||
|
|
||||||
function byId(id) {
|
function byId(id) {
|
||||||
return document.getElementById(id);
|
return document.getElementById(id);
|
||||||
@ -48,6 +62,408 @@
|
|||||||
.replace(/'/g, ''');
|
.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() {
|
function loadLocalNotes() {
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(LOCAL_NOTES_KEY);
|
const raw = window.localStorage.getItem(LOCAL_NOTES_KEY);
|
||||||
@ -152,12 +568,14 @@
|
|||||||
latestNotifications = (((data || {}).notifications || {}).items || []);
|
latestNotifications = (((data || {}).notifications || {}).items || []);
|
||||||
syncBossTabVisibility();
|
syncBossTabVisibility();
|
||||||
updateBar(latestSections);
|
updateBar(latestSections);
|
||||||
|
updateMessagesTabBadge();
|
||||||
if (!onDriftPage) {
|
if (!onDriftPage) {
|
||||||
refreshDriftFromSummary();
|
refreshDriftFromSummary();
|
||||||
}
|
}
|
||||||
updateActivityZone();
|
updateActivityZone();
|
||||||
const focusedId = document.activeElement && document.activeElement.id;
|
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) {
|
if (!keepCurrentRender) {
|
||||||
renderTabPanel();
|
renderTabPanel();
|
||||||
}
|
}
|
||||||
@ -471,10 +889,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'messages') {
|
if (key === 'messages') {
|
||||||
if (messages.count > 0) {
|
const activeThread = ensureActiveMessageThread();
|
||||||
return (messages.list || []).map(m => '<div><strong class="' + (m.from === 'System' ? 'text-primary' : 'text-accent') + '">' + esc(m.from) + ':</strong> ' + esc(m.text) + '</div>');
|
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') {
|
if (key === 'tasks') {
|
||||||
@ -702,7 +1152,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (notifCount) {
|
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);
|
notifCount.textContent = String(computed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -714,6 +1165,19 @@
|
|||||||
return;
|
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');
|
const titleText = titleContainer.querySelector('.bb-tab-title-text');
|
||||||
|
|
||||||
const titleByKey = {
|
const titleByKey = {
|
||||||
@ -769,22 +1233,56 @@
|
|||||||
}
|
}
|
||||||
if (activeKey === 'messages') {
|
if (activeKey === 'messages') {
|
||||||
const chatContainer = document.createElement('div');
|
const chatContainer = document.createElement('div');
|
||||||
chatContainer.className = 'd-flex flex-column h-100';
|
chatContainer.className = 'bb-messages-layout';
|
||||||
ul.classList.add('flex-grow-1', 'mb-3');
|
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');
|
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 = `
|
replyBox.innerHTML = `
|
||||||
|
${threadMeta}
|
||||||
|
${replyBanner}
|
||||||
<div class="input-group input-group-sm mb-1">
|
<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>
|
<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">
|
<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>
|
</select>
|
||||||
</div>
|
</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">
|
<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>
|
<button class="btn btn-outline-primary btn-sm" id="btnSendMsg"><i class="bi bi-send"></i></button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -793,23 +1291,91 @@
|
|||||||
chatContainer.appendChild(replyBox);
|
chatContainer.appendChild(replyBox);
|
||||||
innerContent.appendChild(chatContainer);
|
innerContent.appendChild(chatContainer);
|
||||||
|
|
||||||
// Fetch users dynamically
|
const recipientSelect = document.getElementById('chatRecipient');
|
||||||
fetch('/api/v1/users?is_active=true', { credentials: 'include' })
|
const input = document.getElementById('chatInputQuick');
|
||||||
.then(r => r.json())
|
const ackToggle = document.getElementById('chatRequiresAck');
|
||||||
.then(payload => {
|
const cancelReplyBtn = document.getElementById('bbCancelReplyBtn');
|
||||||
const users = Array.isArray(payload) ? payload : ((payload && payload.data && Array.isArray(payload.data)) ? payload.data : []);
|
renderChatRecipientOptions(recipientSelect);
|
||||||
const sel = document.getElementById('chatRecipient');
|
if (recipientSelect) {
|
||||||
if (sel) {
|
recipientSelect.disabled = !!chatComposerState.replyToUserId;
|
||||||
sel.innerHTML = '<option value="all">Alle på vagt</option><option value="system">System (Bot)</option>';
|
}
|
||||||
users.forEach(u => {
|
if (ackToggle) {
|
||||||
sel.innerHTML += `<option value="${u.id}">${u.full_name || u.username || u.email || ('Bruger #' + u.id)}</option>`;
|
ackToggle.checked = !!chatComposerState.requiresManualAck;
|
||||||
|
ackToggle.addEventListener('change', function () {
|
||||||
|
chatComposerState.requiresManualAck = !!ackToggle.checked;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
if (input) {
|
||||||
.catch(e => console.error("Error fetching users for chat:", e));
|
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 {
|
||||||
|
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 {
|
} else {
|
||||||
innerContent.appendChild(ul);
|
innerContent.appendChild(ul);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -959,18 +1525,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload.event === 'notification_delta') {
|
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 : [];
|
const items = Array.isArray(notifications.items) ? notifications.items : [];
|
||||||
latestSections.messages = latestSections.messages || {};
|
|
||||||
latestSections.tasks = latestSections.tasks || {};
|
latestSections.tasks = latestSections.tasks || {};
|
||||||
|
if (messages) {
|
||||||
latestSections.messages.count = items.length;
|
latestSections.messages = {
|
||||||
latestSections.messages.list = items.slice(0, 5).map(function (item) {
|
count: Number(messages.count || 0),
|
||||||
return {
|
list: Array.isArray(messages.list) ? messages.list : []
|
||||||
from: (item.type || 'System').toString(),
|
|
||||||
text: (item.title || item.message || 'Notifikation').toString()
|
|
||||||
};
|
};
|
||||||
});
|
} else {
|
||||||
|
latestSections.messages = latestSections.messages || {};
|
||||||
|
}
|
||||||
|
|
||||||
latestSections.tasks.count = items.length;
|
latestSections.tasks.count = items.length;
|
||||||
latestSections.tasks.list = items.slice(0, 5).map(function (item) {
|
latestSections.tasks.list = items.slice(0, 5).map(function (item) {
|
||||||
@ -985,11 +1552,12 @@
|
|||||||
|
|
||||||
syncBossTabVisibility();
|
syncBossTabVisibility();
|
||||||
updateBar(latestSections);
|
updateBar(latestSections);
|
||||||
|
updateMessagesTabBadge();
|
||||||
updateActivityZone();
|
updateActivityZone();
|
||||||
const focusedId = document.activeElement && document.activeElement.id;
|
const focusedId = document.activeElement && document.activeElement.id;
|
||||||
const quickNoteFocused = activeKey === 'overview' && focusedId === 'bbQuickNoteInput';
|
const quickNoteFocused = activeKey === 'overview' && focusedId === 'bbQuickNoteInput';
|
||||||
const noteEditorFocused = activeKey === 'notes' && (focusedId === 'bbNoteTitleInput' || focusedId === 'bbNoteContentInput');
|
const noteEditorFocused = activeKey === 'notes' && (focusedId === 'bbNoteTitleInput' || focusedId === 'bbNoteContentInput');
|
||||||
if (!quickNoteFocused && !noteEditorFocused) {
|
if (!quickNoteFocused && !noteEditorFocused && !isMessagesComposerFocused()) {
|
||||||
renderTabPanel();
|
renderTabPanel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1933,6 +2501,65 @@
|
|||||||
return;
|
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') {
|
if (btn.id === 'bbNoteTargetSubmitBtn') {
|
||||||
const target = String(btn.dataset.target || 'case');
|
const target = String(btn.dataset.target || 'case');
|
||||||
const noteId = Number(btn.dataset.noteId || 0);
|
const noteId = Number(btn.dataset.noteId || 0);
|
||||||
@ -2239,32 +2866,67 @@
|
|||||||
const recipientObj = document.getElementById('chatRecipient');
|
const recipientObj = document.getElementById('chatRecipient');
|
||||||
|
|
||||||
if (input && input.value.trim() !== '') {
|
if (input && input.value.trim() !== '') {
|
||||||
const recipient = recipientObj ? recipientObj.options[recipientObj.selectedIndex].text : 'Alle';
|
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;
|
||||||
|
|
||||||
console.log("-> Sender besked til", recipient, ":", input.value);
|
fetch('/api/v1/bottom-bar/messages', {
|
||||||
|
method: 'POST',
|
||||||
const msgVal = input.value;
|
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 = '';
|
input.value = '';
|
||||||
|
chatComposerState.draft = '';
|
||||||
const msgContainer = document.createElement('div');
|
chatComposerState.requiresManualAck = false;
|
||||||
msgContainer.className = 'mb-2 text-end';
|
clearChatReplyState();
|
||||||
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>';
|
return fetchBottomBarState();
|
||||||
|
})
|
||||||
const chatContainer = document.querySelector('#bbTabInnerContent .d-flex.flex-column.h-100');
|
.then((data) => {
|
||||||
if (!chatContainer) {
|
applyState(data);
|
||||||
return;
|
if (activeKey === 'messages') {
|
||||||
|
renderTabPanel();
|
||||||
}
|
}
|
||||||
const listUl = chatContainer.querySelector('ul.bb-tab-list');
|
|
||||||
if (!listUl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
listUl.appendChild(msgContainer);
|
|
||||||
|
|
||||||
// Simple hacky scroll down
|
|
||||||
const tabInner = document.getElementById('bbTabInnerContent');
|
const tabInner = document.getElementById('bbTabInnerContent');
|
||||||
if (tabInner) {
|
if (tabInner) {
|
||||||
tabInner.scrollTop = tabInner.scrollHeight + 500;
|
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")
|
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():
|
def test_get_uisp_connector_config_reads_settings():
|
||||||
with patch.object(drift_router, "execute_query_single", side_effect=[
|
with patch.object(drift_router, "execute_query_single", side_effect=[
|
||||||
{"value": "https://uisp.example.com"},
|
{"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["device_link"] == "https://uisp.example.com/nms/devices/abc-123"
|
||||||
assert event["source_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