feat: add alert notes functionality with inline and modal display
- Implemented alert notes JavaScript module for loading and displaying alerts for customers and contacts. - Created HTML template for alert boxes to display alerts inline on detail pages. - Developed modal for creating and editing alert notes with form validation and user restrictions. - Added modal for displaying alerts with acknowledgment functionality. - Enhanced user experience with toast notifications for successful operations.
This commit is contained in:
parent
3cddb71cec
commit
e6b4d8fb47
1
app/alert_notes/__init__.py
Normal file
1
app/alert_notes/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Alert Notes Module"""
|
||||||
4
app/alert_notes/backend/__init__.py
Normal file
4
app/alert_notes/backend/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
"""Alert Notes Backend Module"""
|
||||||
|
from app.alert_notes.backend.router import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
515
app/alert_notes/backend/router.py
Normal file
515
app/alert_notes/backend/router.py
Normal file
@ -0,0 +1,515 @@
|
|||||||
|
"""
|
||||||
|
Alert Notes Router
|
||||||
|
API endpoints for contextual customer/contact alert system
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||||
|
from typing import List, Optional, Dict
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.core.database import execute_query, execute_update
|
||||||
|
from app.core.auth_dependencies import require_permission, get_current_user
|
||||||
|
from app.alert_notes.backend.schemas import (
|
||||||
|
AlertNoteCreate, AlertNoteUpdate, AlertNoteFull, AlertNoteCheck,
|
||||||
|
AlertNoteRestriction, AlertNoteAcknowledgement, EntityType, Severity
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _check_user_can_handle(alert_id: int, current_user: dict) -> bool:
|
||||||
|
"""
|
||||||
|
Check if current user is allowed to handle the entity based on restrictions.
|
||||||
|
Returns True if no restrictions exist OR user matches a restriction.
|
||||||
|
"""
|
||||||
|
# Superadmins bypass restrictions
|
||||||
|
if current_user.get("is_superadmin"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Get restrictions for this alert
|
||||||
|
restrictions = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT restriction_type, restriction_id
|
||||||
|
FROM alert_note_restrictions
|
||||||
|
WHERE alert_note_id = %s
|
||||||
|
""",
|
||||||
|
(alert_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
# No restrictions = everyone can handle
|
||||||
|
if not restrictions:
|
||||||
|
return True
|
||||||
|
|
||||||
|
user_id = current_user["id"]
|
||||||
|
|
||||||
|
# Get user's group IDs
|
||||||
|
user_groups = execute_query(
|
||||||
|
"SELECT group_id FROM user_groups WHERE user_id = %s",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
user_group_ids = [g["group_id"] for g in user_groups]
|
||||||
|
|
||||||
|
# Check if user matches any restriction
|
||||||
|
for restriction in restrictions:
|
||||||
|
if restriction["restriction_type"] == "user" and restriction["restriction_id"] == user_id:
|
||||||
|
return True
|
||||||
|
if restriction["restriction_type"] == "group" and restriction["restriction_id"] in user_group_ids:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_entity_name(entity_type: str, entity_id: int) -> Optional[str]:
|
||||||
|
"""Get the name of the entity (customer or contact)"""
|
||||||
|
if entity_type == "customer":
|
||||||
|
result = execute_query(
|
||||||
|
"SELECT name FROM customers WHERE id = %s",
|
||||||
|
(entity_id,)
|
||||||
|
)
|
||||||
|
return result[0]["name"] if result else None
|
||||||
|
elif entity_type == "contact":
|
||||||
|
result = execute_query(
|
||||||
|
"SELECT first_name, last_name FROM contacts WHERE id = %s",
|
||||||
|
(entity_id,)
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
return f"{result[0]['first_name']} {result[0]['last_name']}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_alert_with_relations(alert_id: int, current_user: dict) -> Optional[Dict]:
|
||||||
|
"""Get alert note with all its relations"""
|
||||||
|
# Get main alert
|
||||||
|
alerts = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT an.*, u.full_name as created_by_user_name
|
||||||
|
FROM alert_notes an
|
||||||
|
LEFT JOIN users u ON an.created_by_user_id = u.user_id
|
||||||
|
WHERE an.id = %s
|
||||||
|
""",
|
||||||
|
(alert_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not alerts:
|
||||||
|
return None
|
||||||
|
|
||||||
|
alert = dict(alerts[0])
|
||||||
|
|
||||||
|
# Get entity name
|
||||||
|
alert["entity_name"] = _get_entity_name(alert["entity_type"], alert["entity_id"])
|
||||||
|
|
||||||
|
# Get restrictions
|
||||||
|
restrictions = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT anr.*,
|
||||||
|
CASE
|
||||||
|
WHEN anr.restriction_type = 'group' THEN g.name
|
||||||
|
WHEN anr.restriction_type = 'user' THEN u.full_name
|
||||||
|
END as restriction_name
|
||||||
|
FROM alert_note_restrictions anr
|
||||||
|
LEFT JOIN groups g ON anr.restriction_type = 'group' AND anr.restriction_id = g.id
|
||||||
|
LEFT JOIN users u ON anr.restriction_type = 'user' AND anr.restriction_id = u.user_id
|
||||||
|
WHERE anr.alert_note_id = %s
|
||||||
|
""",
|
||||||
|
(alert_id,)
|
||||||
|
)
|
||||||
|
alert["restrictions"] = restrictions
|
||||||
|
|
||||||
|
# Get acknowledgements
|
||||||
|
acknowledgements = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT ana.*, u.full_name as user_name
|
||||||
|
FROM alert_note_acknowledgements ana
|
||||||
|
LEFT JOIN users u ON ana.user_id = u.user_id
|
||||||
|
WHERE ana.alert_note_id = %s
|
||||||
|
ORDER BY ana.acknowledged_at DESC
|
||||||
|
""",
|
||||||
|
(alert_id,)
|
||||||
|
)
|
||||||
|
alert["acknowledgements"] = acknowledgements
|
||||||
|
|
||||||
|
return alert
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/alert-notes/check", response_model=AlertNoteCheck)
|
||||||
|
async def check_alerts(
|
||||||
|
entity_type: EntityType = Query(..., description="Entity type (customer/contact)"),
|
||||||
|
entity_id: int = Query(..., description="Entity ID"),
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Check if there are active alert notes for a specific entity.
|
||||||
|
Returns alerts that the current user is allowed to see based on restrictions.
|
||||||
|
"""
|
||||||
|
# Get active alerts for this entity
|
||||||
|
alerts = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT an.*, u.full_name as created_by_user_name
|
||||||
|
FROM alert_notes an
|
||||||
|
LEFT JOIN users u ON an.created_by_user_id = u.user_id
|
||||||
|
WHERE an.entity_type = %s
|
||||||
|
AND an.entity_id = %s
|
||||||
|
AND an.active = TRUE
|
||||||
|
ORDER BY
|
||||||
|
CASE an.severity
|
||||||
|
WHEN 'critical' THEN 1
|
||||||
|
WHEN 'warning' THEN 2
|
||||||
|
WHEN 'info' THEN 3
|
||||||
|
END,
|
||||||
|
an.created_at DESC
|
||||||
|
""",
|
||||||
|
(entity_type.value, entity_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not alerts:
|
||||||
|
return AlertNoteCheck(
|
||||||
|
has_alerts=False,
|
||||||
|
alerts=[],
|
||||||
|
user_can_handle=True,
|
||||||
|
user_has_acknowledged=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enrich alerts with relations
|
||||||
|
enriched_alerts = []
|
||||||
|
for alert in alerts:
|
||||||
|
alert_dict = dict(alert)
|
||||||
|
alert_dict["entity_name"] = _get_entity_name(alert["entity_type"], alert["entity_id"])
|
||||||
|
|
||||||
|
# Get restrictions
|
||||||
|
restrictions = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT anr.*,
|
||||||
|
CASE
|
||||||
|
WHEN anr.restriction_type = 'group' THEN g.name
|
||||||
|
WHEN anr.restriction_type = 'user' THEN u.full_name
|
||||||
|
END as restriction_name
|
||||||
|
FROM alert_note_restrictions anr
|
||||||
|
LEFT JOIN groups g ON anr.restriction_type = 'group' AND anr.restriction_id = g.id
|
||||||
|
LEFT JOIN users u ON anr.restriction_type = 'user' AND anr.restriction_id = u.user_id
|
||||||
|
WHERE anr.alert_note_id = %s
|
||||||
|
""",
|
||||||
|
(alert["id"],)
|
||||||
|
)
|
||||||
|
alert_dict["restrictions"] = restrictions
|
||||||
|
|
||||||
|
# Get acknowledgements
|
||||||
|
acknowledgements = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT ana.*, u.full_name as user_name
|
||||||
|
FROM alert_note_acknowledgements ana
|
||||||
|
LEFT JOIN users u ON ana.user_id = u.user_id
|
||||||
|
WHERE ana.alert_note_id = %s
|
||||||
|
ORDER BY ana.acknowledged_at DESC
|
||||||
|
""",
|
||||||
|
(alert["id"],)
|
||||||
|
)
|
||||||
|
alert_dict["acknowledgements"] = acknowledgements
|
||||||
|
|
||||||
|
enriched_alerts.append(alert_dict)
|
||||||
|
|
||||||
|
# Check if user can handle based on restrictions
|
||||||
|
user_can_handle = all(_check_user_can_handle(a["id"], current_user) for a in alerts)
|
||||||
|
|
||||||
|
# Check if user has acknowledged all alerts that require it
|
||||||
|
user_id = current_user["id"]
|
||||||
|
user_has_acknowledged = True
|
||||||
|
for alert in alerts:
|
||||||
|
if alert["requires_acknowledgement"]:
|
||||||
|
ack = execute_query(
|
||||||
|
"SELECT id FROM alert_note_acknowledgements WHERE alert_note_id = %s AND user_id = %s",
|
||||||
|
(alert["id"], user_id)
|
||||||
|
)
|
||||||
|
if not ack:
|
||||||
|
user_has_acknowledged = False
|
||||||
|
break
|
||||||
|
|
||||||
|
return AlertNoteCheck(
|
||||||
|
has_alerts=True,
|
||||||
|
alerts=enriched_alerts,
|
||||||
|
user_can_handle=user_can_handle,
|
||||||
|
user_has_acknowledged=user_has_acknowledged
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/alert-notes/{alert_id}/acknowledge")
|
||||||
|
async def acknowledge_alert(
|
||||||
|
alert_id: int,
|
||||||
|
current_user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Mark an alert note as acknowledged by the current user.
|
||||||
|
"""
|
||||||
|
# Check if alert exists
|
||||||
|
alert = execute_query(
|
||||||
|
"SELECT id, active FROM alert_notes WHERE id = %s",
|
||||||
|
(alert_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not alert:
|
||||||
|
raise HTTPException(status_code=404, detail="Alert note not found")
|
||||||
|
|
||||||
|
if not alert[0]["active"]:
|
||||||
|
raise HTTPException(status_code=400, detail="Alert note is not active")
|
||||||
|
|
||||||
|
user_id = current_user["id"]
|
||||||
|
|
||||||
|
# Check if already acknowledged
|
||||||
|
existing = execute_query(
|
||||||
|
"SELECT id FROM alert_note_acknowledgements WHERE alert_note_id = %s AND user_id = %s",
|
||||||
|
(alert_id, user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return {"status": "already_acknowledged", "alert_id": alert_id}
|
||||||
|
|
||||||
|
# Insert acknowledgement
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
INSERT INTO alert_note_acknowledgements (alert_note_id, user_id)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
""",
|
||||||
|
(alert_id, user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Alert {alert_id} acknowledged by user {user_id}")
|
||||||
|
|
||||||
|
return {"status": "acknowledged", "alert_id": alert_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/alert-notes", response_model=List[AlertNoteFull])
|
||||||
|
async def list_alerts(
|
||||||
|
entity_type: Optional[EntityType] = Query(None),
|
||||||
|
entity_id: Optional[int] = Query(None),
|
||||||
|
severity: Optional[Severity] = Query(None),
|
||||||
|
active: Optional[bool] = Query(None),
|
||||||
|
limit: int = Query(default=50, ge=1, le=500),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
current_user: dict = Depends(require_permission("alert_notes.view"))
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List alert notes with filtering (admin endpoint).
|
||||||
|
Requires alert_notes.view permission.
|
||||||
|
"""
|
||||||
|
conditions = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if entity_type:
|
||||||
|
conditions.append("an.entity_type = %s")
|
||||||
|
params.append(entity_type.value)
|
||||||
|
|
||||||
|
if entity_id:
|
||||||
|
conditions.append("an.entity_id = %s")
|
||||||
|
params.append(entity_id)
|
||||||
|
|
||||||
|
if severity:
|
||||||
|
conditions.append("an.severity = %s")
|
||||||
|
params.append(severity.value)
|
||||||
|
|
||||||
|
if active is not None:
|
||||||
|
conditions.append("an.active = %s")
|
||||||
|
params.append(active)
|
||||||
|
|
||||||
|
where_clause = "WHERE " + " AND ".join(conditions) if conditions else ""
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
SELECT an.*, u.full_name as created_by_user_name
|
||||||
|
FROM alert_notes an
|
||||||
|
LEFT JOIN users u ON an.created_by_user_id = u.user_id
|
||||||
|
{where_clause}
|
||||||
|
ORDER BY an.created_at DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
"""
|
||||||
|
params.extend([limit, offset])
|
||||||
|
|
||||||
|
alerts = execute_query(query, tuple(params))
|
||||||
|
|
||||||
|
# Enrich with relations
|
||||||
|
enriched_alerts = []
|
||||||
|
for alert in alerts:
|
||||||
|
alert_full = _get_alert_with_relations(alert["id"], current_user)
|
||||||
|
if alert_full:
|
||||||
|
enriched_alerts.append(alert_full)
|
||||||
|
|
||||||
|
return enriched_alerts
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/alert-notes", response_model=AlertNoteFull)
|
||||||
|
async def create_alert(
|
||||||
|
alert: AlertNoteCreate,
|
||||||
|
current_user: dict = Depends(require_permission("alert_notes.create"))
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new alert note.
|
||||||
|
Requires alert_notes.create permission.
|
||||||
|
"""
|
||||||
|
# Verify entity exists
|
||||||
|
entity_name = _get_entity_name(alert.entity_type.value, alert.entity_id)
|
||||||
|
if not entity_name:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"{alert.entity_type.value.capitalize()} with ID {alert.entity_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert alert note
|
||||||
|
result = execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO alert_notes (
|
||||||
|
entity_type, entity_id, title, message, severity,
|
||||||
|
requires_acknowledgement, active, created_by_user_id
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
alert.entity_type.value, alert.entity_id, alert.title, alert.message,
|
||||||
|
alert.severity.value, alert.requires_acknowledgement, alert.active,
|
||||||
|
current_user["id"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result or len(result) == 0:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create alert note")
|
||||||
|
|
||||||
|
alert_id = result[0]["id"]
|
||||||
|
|
||||||
|
# Insert restrictions
|
||||||
|
for group_id in alert.restriction_group_ids:
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO alert_note_restrictions (alert_note_id, restriction_type, restriction_id)
|
||||||
|
VALUES (%s, 'group', %s)
|
||||||
|
""",
|
||||||
|
(alert_id, group_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
for user_id in alert.restriction_user_ids:
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO alert_note_restrictions (alert_note_id, restriction_type, restriction_id)
|
||||||
|
VALUES (%s, 'user', %s)
|
||||||
|
""",
|
||||||
|
(alert_id, user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Alert note {alert_id} created for {alert.entity_type.value} {alert.entity_id} by user {current_user['id']}")
|
||||||
|
|
||||||
|
# Return full alert with relations
|
||||||
|
alert_full = _get_alert_with_relations(alert_id, current_user)
|
||||||
|
return alert_full
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/alert-notes/{alert_id}", response_model=AlertNoteFull)
|
||||||
|
async def update_alert(
|
||||||
|
alert_id: int,
|
||||||
|
alert_update: AlertNoteUpdate,
|
||||||
|
current_user: dict = Depends(require_permission("alert_notes.edit"))
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update an existing alert note.
|
||||||
|
Requires alert_notes.edit permission.
|
||||||
|
"""
|
||||||
|
# Check if alert exists
|
||||||
|
existing = execute_query(
|
||||||
|
"SELECT id FROM alert_notes WHERE id = %s",
|
||||||
|
(alert_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404, detail="Alert note not found")
|
||||||
|
|
||||||
|
# Build update query
|
||||||
|
update_fields = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if alert_update.title is not None:
|
||||||
|
update_fields.append("title = %s")
|
||||||
|
params.append(alert_update.title)
|
||||||
|
|
||||||
|
if alert_update.message is not None:
|
||||||
|
update_fields.append("message = %s")
|
||||||
|
params.append(alert_update.message)
|
||||||
|
|
||||||
|
if alert_update.severity is not None:
|
||||||
|
update_fields.append("severity = %s")
|
||||||
|
params.append(alert_update.severity.value)
|
||||||
|
|
||||||
|
if alert_update.requires_acknowledgement is not None:
|
||||||
|
update_fields.append("requires_acknowledgement = %s")
|
||||||
|
params.append(alert_update.requires_acknowledgement)
|
||||||
|
|
||||||
|
if alert_update.active is not None:
|
||||||
|
update_fields.append("active = %s")
|
||||||
|
params.append(alert_update.active)
|
||||||
|
|
||||||
|
if update_fields:
|
||||||
|
query = f"UPDATE alert_notes SET {', '.join(update_fields)} WHERE id = %s"
|
||||||
|
params.append(alert_id)
|
||||||
|
execute_update(query, tuple(params))
|
||||||
|
|
||||||
|
# Update restrictions if provided
|
||||||
|
if alert_update.restriction_group_ids is not None or alert_update.restriction_user_ids is not None:
|
||||||
|
# Delete existing restrictions
|
||||||
|
execute_update(
|
||||||
|
"DELETE FROM alert_note_restrictions WHERE alert_note_id = %s",
|
||||||
|
(alert_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert new group restrictions
|
||||||
|
if alert_update.restriction_group_ids is not None:
|
||||||
|
for group_id in alert_update.restriction_group_ids:
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
INSERT INTO alert_note_restrictions (alert_note_id, restriction_type, restriction_id)
|
||||||
|
VALUES (%s, 'group', %s)
|
||||||
|
""",
|
||||||
|
(alert_id, group_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert new user restrictions
|
||||||
|
if alert_update.restriction_user_ids is not None:
|
||||||
|
for user_id in alert_update.restriction_user_ids:
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
INSERT INTO alert_note_restrictions (alert_note_id, restriction_type, restriction_id)
|
||||||
|
VALUES (%s, 'user', %s)
|
||||||
|
""",
|
||||||
|
(alert_id, user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Alert note {alert_id} updated by user {current_user['id']}")
|
||||||
|
|
||||||
|
# Return updated alert
|
||||||
|
alert_full = _get_alert_with_relations(alert_id, current_user)
|
||||||
|
return alert_full
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/alert-notes/{alert_id}")
|
||||||
|
async def delete_alert(
|
||||||
|
alert_id: int,
|
||||||
|
current_user: dict = Depends(require_permission("alert_notes.delete"))
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Soft delete an alert note (sets active = false).
|
||||||
|
Requires alert_notes.delete permission.
|
||||||
|
"""
|
||||||
|
# Check if alert exists
|
||||||
|
existing = execute_query(
|
||||||
|
"SELECT id, active FROM alert_notes WHERE id = %s",
|
||||||
|
(alert_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404, detail="Alert note not found")
|
||||||
|
|
||||||
|
# Soft delete
|
||||||
|
execute_update(
|
||||||
|
"UPDATE alert_notes SET active = FALSE WHERE id = %s",
|
||||||
|
(alert_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Alert note {alert_id} deactivated by user {current_user['id']}")
|
||||||
|
|
||||||
|
return {"status": "deleted", "alert_id": alert_id}
|
||||||
99
app/alert_notes/backend/schemas.py
Normal file
99
app/alert_notes/backend/schemas.py
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
Alert Notes Pydantic Schemas
|
||||||
|
Data models for contextual customer/contact alerts
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class EntityType(str, Enum):
|
||||||
|
"""Entity types that can have alert notes"""
|
||||||
|
customer = "customer"
|
||||||
|
contact = "contact"
|
||||||
|
|
||||||
|
|
||||||
|
class Severity(str, Enum):
|
||||||
|
"""Alert severity levels"""
|
||||||
|
info = "info"
|
||||||
|
warning = "warning"
|
||||||
|
critical = "critical"
|
||||||
|
|
||||||
|
|
||||||
|
class RestrictionType(str, Enum):
|
||||||
|
"""Types of restrictions for alert notes"""
|
||||||
|
group = "group"
|
||||||
|
user = "user"
|
||||||
|
|
||||||
|
|
||||||
|
class AlertNoteRestriction(BaseModel):
|
||||||
|
"""Alert note restriction (who can handle the customer/contact)"""
|
||||||
|
id: Optional[int] = None
|
||||||
|
alert_note_id: int
|
||||||
|
restriction_type: RestrictionType
|
||||||
|
restriction_id: int # References groups.id or users.user_id
|
||||||
|
restriction_name: Optional[str] = None # Filled by JOIN in query
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AlertNoteAcknowledgement(BaseModel):
|
||||||
|
"""Alert note acknowledgement record"""
|
||||||
|
id: Optional[int] = None
|
||||||
|
alert_note_id: int
|
||||||
|
user_id: int
|
||||||
|
user_name: Optional[str] = None # Filled by JOIN
|
||||||
|
acknowledged_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AlertNoteBase(BaseModel):
|
||||||
|
"""Base schema for alert notes"""
|
||||||
|
entity_type: EntityType
|
||||||
|
entity_id: int
|
||||||
|
title: str = Field(..., min_length=1, max_length=255)
|
||||||
|
message: str = Field(..., min_length=1)
|
||||||
|
severity: Severity = Severity.info
|
||||||
|
requires_acknowledgement: bool = True
|
||||||
|
active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class AlertNoteCreate(AlertNoteBase):
|
||||||
|
"""Schema for creating an alert note"""
|
||||||
|
restriction_group_ids: List[int] = [] # List of group IDs
|
||||||
|
restriction_user_ids: List[int] = [] # List of user IDs
|
||||||
|
|
||||||
|
|
||||||
|
class AlertNoteUpdate(BaseModel):
|
||||||
|
"""Schema for updating an alert note"""
|
||||||
|
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
message: Optional[str] = Field(None, min_length=1)
|
||||||
|
severity: Optional[Severity] = None
|
||||||
|
requires_acknowledgement: Optional[bool] = None
|
||||||
|
active: Optional[bool] = None
|
||||||
|
restriction_group_ids: Optional[List[int]] = None
|
||||||
|
restriction_user_ids: Optional[List[int]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AlertNoteFull(AlertNoteBase):
|
||||||
|
"""Full alert note schema with all relations"""
|
||||||
|
id: int
|
||||||
|
created_by_user_id: Optional[int] = None
|
||||||
|
created_by_user_name: Optional[str] = None # Filled by JOIN
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
# Related data
|
||||||
|
restrictions: List[AlertNoteRestriction] = []
|
||||||
|
acknowledgements: List[AlertNoteAcknowledgement] = []
|
||||||
|
|
||||||
|
# Entity info (filled by JOIN)
|
||||||
|
entity_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AlertNoteCheck(BaseModel):
|
||||||
|
"""Response for checking alerts on an entity"""
|
||||||
|
has_alerts: bool
|
||||||
|
alerts: List[AlertNoteFull]
|
||||||
|
user_can_handle: bool # Whether current user is allowed per restrictions
|
||||||
|
user_has_acknowledged: bool = False
|
||||||
199
app/alert_notes/frontend/alert_box.html
Normal file
199
app/alert_notes/frontend/alert_box.html
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
<!-- Alert Notes Box Component - For inline display on detail pages -->
|
||||||
|
<style>
|
||||||
|
.alert-note-box {
|
||||||
|
border-left: 5px solid;
|
||||||
|
padding: 15px 20px;
|
||||||
|
margin: 15px 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-box:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-info {
|
||||||
|
border-left-color: #0dcaf0;
|
||||||
|
background: #d1ecf1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .alert-note-info {
|
||||||
|
background: rgba(13, 202, 240, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-warning {
|
||||||
|
border-left-color: #ffc107;
|
||||||
|
background: #fff3cd;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .alert-note-warning {
|
||||||
|
background: rgba(255, 193, 7, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-critical {
|
||||||
|
border-left-color: #dc3545;
|
||||||
|
background: #f8d7da;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .alert-note-critical {
|
||||||
|
background: rgba(220, 53, 69, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-severity-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-severity-badge.info {
|
||||||
|
background: #0dcaf0;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-severity-badge.warning {
|
||||||
|
background: #ffc107;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-severity-badge.critical {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-message {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-restrictions {
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(0,0,0,0.05);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .alert-note-restrictions {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-restrictions strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-acknowledge-btn {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 4px 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Template structure (fill via JavaScript) -->
|
||||||
|
<div id="alert-notes-container"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function renderAlertBox(alert) {
|
||||||
|
const severityClass = `alert-note-${alert.severity}`;
|
||||||
|
const severityBadgeClass = alert.severity;
|
||||||
|
|
||||||
|
let restrictionsHtml = '';
|
||||||
|
if (alert.restrictions && alert.restrictions.length > 0) {
|
||||||
|
const restrictionNames = alert.restrictions.map(r => r.restriction_name).join(', ');
|
||||||
|
restrictionsHtml = `
|
||||||
|
<div class="alert-note-restrictions">
|
||||||
|
<strong><i class="bi bi-shield-lock"></i> Håndteres kun af:</strong>
|
||||||
|
${restrictionNames}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let acknowledgeBtn = '';
|
||||||
|
if (alert.requires_acknowledgement && !alert.user_has_acknowledged) {
|
||||||
|
acknowledgeBtn = `
|
||||||
|
<button class="btn btn-sm btn-outline-secondary alert-note-acknowledge-btn"
|
||||||
|
onclick="acknowledgeAlert(${alert.id}, this)">
|
||||||
|
<i class="bi bi-check-circle"></i> Forstået
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit button (always show for admins/creators)
|
||||||
|
const editBtn = `
|
||||||
|
<button class="btn btn-sm btn-outline-primary alert-note-acknowledge-btn"
|
||||||
|
onclick="openAlertNoteForm('${alert.entity_type}', ${alert.entity_id}, ${alert.id})"
|
||||||
|
title="Rediger alert note">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const createdBy = alert.created_by_user_name ? ` • Oprettet af ${alert.created_by_user_name}` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="alert-note-box ${severityClass}" data-alert-id="${alert.id}">
|
||||||
|
<div class="alert-note-title">
|
||||||
|
<span class="alert-note-severity-badge ${severityBadgeClass}">
|
||||||
|
${alert.severity === 'info' ? 'INFO' : alert.severity === 'warning' ? 'ADVARSEL' : 'KRITISK'}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
${editBtn}
|
||||||
|
${acknowledgeBtn}
|
||||||
|
</div>
|
||||||
|
${alert.title}
|
||||||
|
</div>
|
||||||
|
<div class="alert-note-message">${alert.message}</div>
|
||||||
|
${restrictionsHtml}
|
||||||
|
<div class="alert-note-footer">
|
||||||
|
<span class="text-muted">
|
||||||
|
<i class="bi bi-calendar"></i> ${new Date(alert.created_at).toLocaleDateString('da-DK')}${createdBy}
|
||||||
|
</span>
|
||||||
|
${acknowledgeBtn}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function acknowledgeAlert(alertId, buttonElement) {
|
||||||
|
fetch(`/api/v1/alert-notes/${alertId}/acknowledge`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'acknowledged' || data.status === 'already_acknowledged') {
|
||||||
|
// Remove the alert box with fade animation
|
||||||
|
const alertBox = buttonElement.closest('.alert-note-box');
|
||||||
|
alertBox.style.opacity = '0';
|
||||||
|
alertBox.style.transform = 'translateX(-20px)';
|
||||||
|
setTimeout(() => alertBox.remove(), 300);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error acknowledging alert:', error);
|
||||||
|
alert('Kunne ikke markere som læst. Prøv igen.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
131
app/alert_notes/frontend/alert_check.js
Normal file
131
app/alert_notes/frontend/alert_check.js
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Alert Notes JavaScript Module
|
||||||
|
* Handles loading and displaying alert notes for customers and contacts
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and display alerts for an entity
|
||||||
|
* @param {string} entityType - 'customer' or 'contact'
|
||||||
|
* @param {number} entityId - The entity ID
|
||||||
|
* @param {string} mode - 'inline' (show in page) or 'modal' (show popup)
|
||||||
|
* @param {string} containerId - Optional container ID for inline mode (default: 'alert-notes-container')
|
||||||
|
*/
|
||||||
|
async function loadAndDisplayAlerts(entityType, entityId, mode = 'inline', containerId = 'alert-notes-container') {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/alert-notes/check?entity_type=${entityType}&entity_id=${entityId}`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Failed to fetch alerts:', response.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.has_alerts) {
|
||||||
|
// No alerts - clear container if in inline mode
|
||||||
|
if (mode === 'inline') {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store for later use
|
||||||
|
window.currentAlertData = data;
|
||||||
|
|
||||||
|
if (mode === 'modal') {
|
||||||
|
// Show modal popup
|
||||||
|
showAlertModal(data.alerts);
|
||||||
|
} else {
|
||||||
|
// Show inline
|
||||||
|
displayAlertsInline(data.alerts, containerId, data.user_has_acknowledged);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading alerts:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display alerts inline in a container
|
||||||
|
* @param {Array} alerts - Array of alert objects
|
||||||
|
* @param {string} containerId - Container element ID
|
||||||
|
* @param {boolean} userHasAcknowledged - Whether user has acknowledged all
|
||||||
|
*/
|
||||||
|
function displayAlertsInline(alerts, containerId, userHasAcknowledged) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) {
|
||||||
|
console.error('Alert container not found:', containerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing content
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// Add each alert
|
||||||
|
alerts.forEach(alert => {
|
||||||
|
// Set user_has_acknowledged on individual alert if needed
|
||||||
|
alert.user_has_acknowledged = userHasAcknowledged;
|
||||||
|
|
||||||
|
// Render using the renderAlertBox function from alert_box.html
|
||||||
|
const alertHtml = renderAlertBox(alert);
|
||||||
|
container.innerHTML += alertHtml;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acknowledge a single alert
|
||||||
|
* @param {number} alertId - The alert ID
|
||||||
|
* @param {HTMLElement} buttonElement - The button that was clicked
|
||||||
|
*/
|
||||||
|
async function acknowledgeAlert(alertId, buttonElement) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/alert-notes/${alertId}/acknowledge`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'acknowledged' || data.status === 'already_acknowledged') {
|
||||||
|
// Remove the alert box with fade animation
|
||||||
|
const alertBox = buttonElement.closest('.alert-note-box');
|
||||||
|
if (alertBox) {
|
||||||
|
alertBox.style.opacity = '0';
|
||||||
|
alertBox.style.transform = 'translateX(-20px)';
|
||||||
|
alertBox.style.transition = 'all 0.3s';
|
||||||
|
setTimeout(() => alertBox.remove(), 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error acknowledging alert:', error);
|
||||||
|
alert('Kunne ikke markere som læst. Prøv igen.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize alert checking on page load
|
||||||
|
* Call this from your page's DOMContentLoaded or similar
|
||||||
|
* @param {string} entityType - 'customer' or 'contact'
|
||||||
|
* @param {number} entityId - The entity ID
|
||||||
|
* @param {Object} options - Optional settings {mode: 'inline'|'modal', containerId: 'element-id'}
|
||||||
|
*/
|
||||||
|
function initAlertNotes(entityType, entityId, options = {}) {
|
||||||
|
const mode = options.mode || 'inline';
|
||||||
|
const containerId = options.containerId || 'alert-notes-container';
|
||||||
|
|
||||||
|
loadAndDisplayAlerts(entityType, entityId, mode, containerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make functions globally available
|
||||||
|
window.loadAndDisplayAlerts = loadAndDisplayAlerts;
|
||||||
|
window.displayAlertsInline = displayAlertsInline;
|
||||||
|
window.acknowledgeAlert = acknowledgeAlert;
|
||||||
|
window.initAlertNotes = initAlertNotes;
|
||||||
551
app/alert_notes/frontend/alert_form_modal.html
Normal file
551
app/alert_notes/frontend/alert_form_modal.html
Normal file
@ -0,0 +1,551 @@
|
|||||||
|
<!-- Alert Note Create/Edit Modal -->
|
||||||
|
<div class="modal fade" id="alertNoteFormModal" tabindex="-1" aria-labelledby="alertNoteFormModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-warning bg-opacity-10 border-bottom border-warning">
|
||||||
|
<h5 class="modal-title d-flex align-items-center" id="alertNoteFormModalLabel">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill text-warning me-2" style="font-size: 1.3rem;"></i>
|
||||||
|
<span id="alertFormTitle" class="fw-bold">Opret Alert Note</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="max-height: 70vh; overflow-y: auto;">
|
||||||
|
<form id="alertNoteForm">
|
||||||
|
<input type="hidden" id="alertNoteId" value="">
|
||||||
|
<input type="hidden" id="alertEntityType" value="">
|
||||||
|
<input type="hidden" id="alertEntityId" value="">
|
||||||
|
|
||||||
|
<!-- Titel Section -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="alertTitle" class="form-label fw-semibold">
|
||||||
|
Titel <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
id="alertTitle"
|
||||||
|
required
|
||||||
|
maxlength="255"
|
||||||
|
placeholder="Kort beskrivende titel">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Besked Section -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="alertMessage" class="form-label fw-semibold">
|
||||||
|
Besked <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea class="form-control"
|
||||||
|
id="alertMessage"
|
||||||
|
rows="6"
|
||||||
|
required
|
||||||
|
placeholder="Detaljeret information der skal vises..."
|
||||||
|
style="font-family: inherit; line-height: 1.6;"></textarea>
|
||||||
|
<div class="form-text mt-2">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
Du kan bruge linjeskift for formatering
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alvorlighed Section -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="alertSeverity" class="form-label fw-semibold">
|
||||||
|
Alvorlighed <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<select class="form-select form-select-lg" id="alertSeverity" required>
|
||||||
|
<option value="info">ℹ️ Info - General kontekst</option>
|
||||||
|
<option value="warning" selected>⚠️ Advarsel - Særlige forhold</option>
|
||||||
|
<option value="critical">🚨 Kritisk - Følsomme forhold</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Checkboxes Section -->
|
||||||
|
<div class="mb-4 p-3 bg-light rounded">
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="alertRequiresAck"
|
||||||
|
checked>
|
||||||
|
<label class="form-check-label" for="alertRequiresAck">
|
||||||
|
<strong>Kræv bekræftelse</strong>
|
||||||
|
<div class="text-muted small mt-1">
|
||||||
|
Brugere skal klikke "Forstået" for at bekræfte at de har set advarslen
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="alertActive"
|
||||||
|
checked>
|
||||||
|
<label class="form-check-label" for="alertActive">
|
||||||
|
<strong>Aktiv</strong>
|
||||||
|
<div class="text-muted small mt-1">
|
||||||
|
Alert noten vises på kunde/kontakt siden
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<!-- Restrictions Section -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold d-flex align-items-center mb-3">
|
||||||
|
<i class="bi bi-shield-lock me-2 text-primary"></i>
|
||||||
|
Begrænsninger (Valgfri)
|
||||||
|
</label>
|
||||||
|
<div class="alert alert-info d-flex align-items-start mb-4">
|
||||||
|
<i class="bi bi-info-circle-fill me-2 mt-1"></i>
|
||||||
|
<div>
|
||||||
|
<strong>Hvad er begrænsninger?</strong>
|
||||||
|
<p class="mb-0 mt-1 small">
|
||||||
|
Angiv hvilke grupper eller brugere der må håndtere denne kunde/kontakt.
|
||||||
|
Lad felterne stå tomme hvis alle må håndtere kunden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="alertGroups" class="form-label fw-semibold">
|
||||||
|
<i class="bi bi-people-fill me-1"></i>
|
||||||
|
Godkendte Grupper
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="alertGroups" multiple size="5">
|
||||||
|
<!-- Populated via JavaScript -->
|
||||||
|
</select>
|
||||||
|
<div class="form-text mt-2">
|
||||||
|
<i class="bi bi-hand-index me-1"></i>
|
||||||
|
Hold Ctrl/Cmd for at vælge flere
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="alertUsers" class="form-label fw-semibold">
|
||||||
|
<i class="bi bi-person-fill me-1"></i>
|
||||||
|
Godkendte Brugere
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="alertUsers" multiple size="5">
|
||||||
|
<!-- Populated via JavaScript -->
|
||||||
|
</select>
|
||||||
|
<div class="form-text mt-2">
|
||||||
|
<i class="bi bi-hand-index me-1"></i>
|
||||||
|
Hold Ctrl/Cmd for at vælge flere
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer bg-light">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||||
|
<i class="bi bi-x-circle me-2"></i>
|
||||||
|
Annuller
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-primary btn-lg" id="saveAlertNoteBtn" onclick="saveAlertNote()">
|
||||||
|
<i class="bi bi-save me-2"></i>
|
||||||
|
Gem Alert Note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Modal Header Styling */
|
||||||
|
#alertNoteFormModal .modal-header {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteFormModal .modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteFormModal .modal-footer {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Labels */
|
||||||
|
#alertNoteFormModal .form-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input Fields */
|
||||||
|
#alertNoteFormModal .form-control,
|
||||||
|
#alertNoteFormModal .form-select {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteFormModal .form-control:focus,
|
||||||
|
#alertNoteFormModal .form-select:focus {
|
||||||
|
border-color: var(--bs-primary);
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Textarea specific */
|
||||||
|
#alertNoteFormModal textarea.form-control {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Multiselect Styling */
|
||||||
|
#alertNoteFormModal select[multiple] {
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteFormModal select[multiple]:focus {
|
||||||
|
border-color: var(--bs-primary);
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.15);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteFormModal select[multiple] option {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteFormModal select[multiple] option:hover {
|
||||||
|
background: rgba(13, 110, 253, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteFormModal select[multiple] option:checked {
|
||||||
|
background: var(--bs-primary);
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox Container */
|
||||||
|
#alertNoteFormModal .form-check {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteFormModal .form-check:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] #alertNoteFormModal .form-check:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteFormModal .form-check-input {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteFormModal .form-check-label {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert Info Box */
|
||||||
|
#alertNoteFormModal .alert-info {
|
||||||
|
border-left: 4px solid var(--bs-info);
|
||||||
|
background: rgba(13, 202, 240, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] #alertNoteFormModal .alert-info {
|
||||||
|
background: rgba(13, 202, 240, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background Color Theme Support */
|
||||||
|
[data-bs-theme="dark"] #alertNoteFormModal .bg-light {
|
||||||
|
background: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] #alertNoteFormModal .modal-header {
|
||||||
|
background: rgba(255, 193, 7, 0.1) !important;
|
||||||
|
border-bottom-color: rgba(255, 193, 7, 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Text Helpers */
|
||||||
|
#alertNoteFormModal .form-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Divider */
|
||||||
|
#alertNoteFormModal hr {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#alertNoteFormModal .row > .col-md-6 {
|
||||||
|
margin-bottom: 1rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let alertFormModal = null;
|
||||||
|
let currentAlertEntityType = null;
|
||||||
|
let currentAlertEntityId = null;
|
||||||
|
|
||||||
|
async function openAlertNoteForm(entityType, entityId, alertId = null) {
|
||||||
|
currentAlertEntityType = entityType;
|
||||||
|
currentAlertEntityId = entityId;
|
||||||
|
|
||||||
|
// Load groups and users for restrictions
|
||||||
|
await loadGroupsAndUsers();
|
||||||
|
|
||||||
|
if (alertId) {
|
||||||
|
// Edit mode
|
||||||
|
await loadAlertForEdit(alertId);
|
||||||
|
document.getElementById('alertFormTitle').textContent = 'Rediger Alert Note';
|
||||||
|
} else {
|
||||||
|
// Create mode
|
||||||
|
document.getElementById('alertFormTitle').textContent = 'Opret Alert Note';
|
||||||
|
document.getElementById('alertNoteForm').reset();
|
||||||
|
document.getElementById('alertNoteId').value = '';
|
||||||
|
document.getElementById('alertRequiresAck').checked = true;
|
||||||
|
document.getElementById('alertActive').checked = true;
|
||||||
|
document.getElementById('alertSeverity').value = 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('alertEntityType').value = entityType;
|
||||||
|
document.getElementById('alertEntityId').value = entityId;
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
const modalEl = document.getElementById('alertNoteFormModal');
|
||||||
|
alertFormModal = new bootstrap.Modal(modalEl);
|
||||||
|
alertFormModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGroupsAndUsers() {
|
||||||
|
try {
|
||||||
|
// Load groups
|
||||||
|
const groupsResponse = await fetch('/api/v1/admin/groups', {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (groupsResponse.ok) {
|
||||||
|
const groups = await groupsResponse.json();
|
||||||
|
const groupsSelect = document.getElementById('alertGroups');
|
||||||
|
groupsSelect.innerHTML = groups.map(g =>
|
||||||
|
`<option value="${g.id}">${g.name}</option>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load users
|
||||||
|
const usersResponse = await fetch('/api/v1/admin/users', {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (usersResponse.ok) {
|
||||||
|
const users = await usersResponse.json();
|
||||||
|
const usersSelect = document.getElementById('alertUsers');
|
||||||
|
usersSelect.innerHTML = users.map(u =>
|
||||||
|
`<option value="${u.user_id}">${u.full_name || u.username} (${u.username})</option>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading groups/users:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAlertForEdit(alertId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/alert-notes?entity_type=${currentAlertEntityType}&entity_id=${currentAlertEntityId}`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to load alert');
|
||||||
|
|
||||||
|
const alerts = await response.json();
|
||||||
|
const alert = alerts.find(a => a.id === alertId);
|
||||||
|
|
||||||
|
if (!alert) throw new Error('Alert not found');
|
||||||
|
|
||||||
|
document.getElementById('alertNoteId').value = alert.id;
|
||||||
|
document.getElementById('alertTitle').value = alert.title;
|
||||||
|
document.getElementById('alertMessage').value = alert.message;
|
||||||
|
document.getElementById('alertSeverity').value = alert.severity;
|
||||||
|
document.getElementById('alertRequiresAck').checked = alert.requires_acknowledgement;
|
||||||
|
document.getElementById('alertActive').checked = alert.active;
|
||||||
|
|
||||||
|
// Set restrictions
|
||||||
|
if (alert.restrictions && alert.restrictions.length > 0) {
|
||||||
|
const groupIds = alert.restrictions
|
||||||
|
.filter(r => r.restriction_type === 'group')
|
||||||
|
.map(r => r.restriction_id);
|
||||||
|
const userIds = alert.restrictions
|
||||||
|
.filter(r => r.restriction_type === 'user')
|
||||||
|
.map(r => r.restriction_id);
|
||||||
|
|
||||||
|
// Select options
|
||||||
|
Array.from(document.getElementById('alertGroups').options).forEach(opt => {
|
||||||
|
opt.selected = groupIds.includes(parseInt(opt.value));
|
||||||
|
});
|
||||||
|
Array.from(document.getElementById('alertUsers').options).forEach(opt => {
|
||||||
|
opt.selected = userIds.includes(parseInt(opt.value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading alert for edit:', error);
|
||||||
|
alert('Kunne ikke indlæse alert. Prøv igen.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAlertNote() {
|
||||||
|
const form = document.getElementById('alertNoteForm');
|
||||||
|
if (!form.checkValidity()) {
|
||||||
|
form.reportValidity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alertId = document.getElementById('alertNoteId').value;
|
||||||
|
const isEdit = !!alertId;
|
||||||
|
|
||||||
|
// Get selected groups and users
|
||||||
|
const selectedGroups = Array.from(document.getElementById('alertGroups').selectedOptions)
|
||||||
|
.map(opt => parseInt(opt.value));
|
||||||
|
const selectedUsers = Array.from(document.getElementById('alertUsers').selectedOptions)
|
||||||
|
.map(opt => parseInt(opt.value));
|
||||||
|
|
||||||
|
// Build data object - different structure for create vs update
|
||||||
|
let data;
|
||||||
|
if (isEdit) {
|
||||||
|
// PATCH: Only send fields to update (no entity_type, entity_id)
|
||||||
|
data = {
|
||||||
|
title: document.getElementById('alertTitle').value,
|
||||||
|
message: document.getElementById('alertMessage').value,
|
||||||
|
severity: document.getElementById('alertSeverity').value,
|
||||||
|
requires_acknowledgement: document.getElementById('alertRequiresAck').checked,
|
||||||
|
active: document.getElementById('alertActive').checked,
|
||||||
|
restriction_group_ids: selectedGroups,
|
||||||
|
restriction_user_ids: selectedUsers
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// POST: Include entity_type and entity_id for creation
|
||||||
|
data = {
|
||||||
|
entity_type: document.getElementById('alertEntityType').value,
|
||||||
|
entity_id: parseInt(document.getElementById('alertEntityId').value),
|
||||||
|
title: document.getElementById('alertTitle').value,
|
||||||
|
message: document.getElementById('alertMessage').value,
|
||||||
|
severity: document.getElementById('alertSeverity').value,
|
||||||
|
requires_acknowledgement: document.getElementById('alertRequiresAck').checked,
|
||||||
|
active: document.getElementById('alertActive').checked,
|
||||||
|
restriction_group_ids: selectedGroups,
|
||||||
|
restriction_user_ids: selectedUsers
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saveBtn = document.getElementById('saveAlertNoteBtn');
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Gemmer...';
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log('Saving alert note:', { isEdit, alertId, data });
|
||||||
|
|
||||||
|
let response;
|
||||||
|
if (isEdit) {
|
||||||
|
// Update existing
|
||||||
|
response = await fetch(`/api/v1/alert-notes/${alertId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
response = await fetch('/api/v1/alert-notes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMsg = 'Failed to save alert note';
|
||||||
|
try {
|
||||||
|
const error = await response.json();
|
||||||
|
console.error('API Error Response:', error);
|
||||||
|
|
||||||
|
// Handle Pydantic validation errors
|
||||||
|
if (error.detail && Array.isArray(error.detail)) {
|
||||||
|
errorMsg = error.detail.map(e => `${e.loc.join('.')}: ${e.msg}`).join('\n');
|
||||||
|
} else if (error.detail) {
|
||||||
|
errorMsg = error.detail;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg = `HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
|
alertFormModal.hide();
|
||||||
|
|
||||||
|
// Reload alerts on page
|
||||||
|
loadAndDisplayAlerts(
|
||||||
|
currentAlertEntityType,
|
||||||
|
currentAlertEntityId,
|
||||||
|
'inline',
|
||||||
|
'alert-notes-container'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
showSuccessToast(isEdit ? 'Alert note opdateret!' : 'Alert note oprettet!');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving alert note:', error);
|
||||||
|
|
||||||
|
// Show detailed error message
|
||||||
|
const errorDiv = document.createElement('div');
|
||||||
|
errorDiv.className = 'alert alert-danger alert-dismissible fade show mt-3';
|
||||||
|
errorDiv.innerHTML = `
|
||||||
|
<strong>Kunne ikke gemme alert note:</strong><br>
|
||||||
|
<pre style="white-space: pre-wrap; margin-top: 10px; font-size: 0.9em;">${error.message}</pre>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Insert error before form
|
||||||
|
const modalBody = document.querySelector('#alertNoteFormModal .modal-body');
|
||||||
|
modalBody.insertBefore(errorDiv, modalBody.firstChild);
|
||||||
|
|
||||||
|
// Auto-remove after 10 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (errorDiv.parentNode) {
|
||||||
|
errorDiv.remove();
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
} finally {
|
||||||
|
const saveBtn = document.getElementById('saveAlertNoteBtn');
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.innerHTML = '<i class="bi bi-save me-2"></i>Gem Alert Note';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSuccessToast(message) {
|
||||||
|
// Simple toast notification
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'alert alert-success position-fixed bottom-0 end-0 m-3';
|
||||||
|
toast.style.zIndex = '9999';
|
||||||
|
toast.innerHTML = `<i class="bi bi-check-circle me-2"></i>${message}`;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('fade');
|
||||||
|
setTimeout(() => toast.remove(), 150);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make functions globally available
|
||||||
|
window.openAlertNoteForm = openAlertNoteForm;
|
||||||
|
window.saveAlertNote = saveAlertNote;
|
||||||
|
</script>
|
||||||
198
app/alert_notes/frontend/alert_modal.html
Normal file
198
app/alert_notes/frontend/alert_modal.html
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
<!-- Alert Notes Modal Component - For popup display -->
|
||||||
|
<div class="modal fade" id="alertNoteModal" tabindex="-1" aria-labelledby="alertNoteModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header" id="alertModalHeader">
|
||||||
|
<h5 class="modal-title" id="alertNoteModalLabel">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill"></i> Vigtig information
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="alertModalBody">
|
||||||
|
<!-- Alert content will be inserted here -->
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" id="alertModalFooter">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="alertModalAcknowledgeBtn" style="display: none;">
|
||||||
|
<i class="bi bi-check-circle"></i> Forstået
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#alertNoteModal .modal-header.severity-info {
|
||||||
|
background: linear-gradient(135deg, #0dcaf0 0%, #00b4d8 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteModal .modal-header.severity-warning {
|
||||||
|
background: linear-gradient(135deg, #ffc107 0%, #ffb703 100%);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteModal .modal-header.severity-critical {
|
||||||
|
background: linear-gradient(135deg, #dc3545 0%, #bb2d3b 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-modal-content {
|
||||||
|
padding: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-modal-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 2px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-modal-message {
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-modal-restrictions {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid #0f4c75;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .alert-modal-restrictions {
|
||||||
|
background: #2c3034;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-modal-restrictions strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-modal-restrictions ul {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-modal-meta {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 15px;
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentAlertModal = null;
|
||||||
|
let currentAlerts = [];
|
||||||
|
|
||||||
|
function showAlertModal(alerts) {
|
||||||
|
if (!alerts || alerts.length === 0) return;
|
||||||
|
|
||||||
|
currentAlerts = alerts;
|
||||||
|
const modal = document.getElementById('alertNoteModal');
|
||||||
|
const modalHeader = document.getElementById('alertModalHeader');
|
||||||
|
const modalBody = document.getElementById('alertModalBody');
|
||||||
|
const modalAckBtn = document.getElementById('alertModalAcknowledgeBtn');
|
||||||
|
|
||||||
|
// Set severity styling (use highest severity)
|
||||||
|
const highestSeverity = alerts.find(a => a.severity === 'critical') ? 'critical' :
|
||||||
|
alerts.find(a => a.severity === 'warning') ? 'warning' : 'info';
|
||||||
|
|
||||||
|
modalHeader.className = `modal-header severity-${highestSeverity}`;
|
||||||
|
|
||||||
|
// Build content
|
||||||
|
let contentHtml = '';
|
||||||
|
|
||||||
|
alerts.forEach((alert, index) => {
|
||||||
|
const severityText = alert.severity === 'info' ? 'INFO' :
|
||||||
|
alert.severity === 'warning' ? 'ADVARSEL' : 'KRITISK';
|
||||||
|
|
||||||
|
let restrictionsHtml = '';
|
||||||
|
if (alert.restrictions && alert.restrictions.length > 0) {
|
||||||
|
const restrictionsList = alert.restrictions
|
||||||
|
.map(r => `<li>${r.restriction_name}</li>`)
|
||||||
|
.join('');
|
||||||
|
restrictionsHtml = `
|
||||||
|
<div class="alert-modal-restrictions">
|
||||||
|
<strong><i class="bi bi-shield-lock"></i> Kun følgende må håndtere denne ${alert.entity_type === 'customer' ? 'kunde' : 'kontakt'}:</strong>
|
||||||
|
<ul>${restrictionsList}</ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdBy = alert.created_by_user_name ? ` • Oprettet af ${alert.created_by_user_name}` : '';
|
||||||
|
|
||||||
|
contentHtml += `
|
||||||
|
<div class="alert-modal-content" data-alert-id="${alert.id}">
|
||||||
|
${index > 0 ? '<hr>' : ''}
|
||||||
|
<div class="alert-modal-title">
|
||||||
|
<span class="badge bg-${alert.severity === 'critical' ? 'danger' : alert.severity === 'warning' ? 'warning' : 'info'}">
|
||||||
|
${severityText}
|
||||||
|
</span>
|
||||||
|
${alert.title}
|
||||||
|
</div>
|
||||||
|
<div class="alert-modal-message">${alert.message}</div>
|
||||||
|
${restrictionsHtml}
|
||||||
|
<div class="alert-modal-meta">
|
||||||
|
<i class="bi bi-calendar"></i> ${new Date(alert.created_at).toLocaleDateString('da-DK', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})}${createdBy}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
modalBody.innerHTML = contentHtml;
|
||||||
|
|
||||||
|
// Show acknowledge button if any alert requires it and user hasn't acknowledged
|
||||||
|
const requiresAck = alerts.some(a => a.requires_acknowledgement && !a.user_has_acknowledged);
|
||||||
|
if (requiresAck) {
|
||||||
|
modalAckBtn.style.display = 'inline-block';
|
||||||
|
modalAckBtn.onclick = function() {
|
||||||
|
acknowledgeAllAlerts();
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
modalAckBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
currentAlertModal = new bootstrap.Modal(modal);
|
||||||
|
currentAlertModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function acknowledgeAllAlerts() {
|
||||||
|
const promises = currentAlerts
|
||||||
|
.filter(a => a.requires_acknowledgement && !a.user_has_acknowledged)
|
||||||
|
.map(alert => {
|
||||||
|
return fetch(`/api/v1/alert-notes/${alert.id}/acknowledge`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all(promises)
|
||||||
|
.then(() => {
|
||||||
|
if (currentAlertModal) {
|
||||||
|
currentAlertModal.hide();
|
||||||
|
}
|
||||||
|
// Reload alerts on the page if in inline view
|
||||||
|
if (typeof loadAlerts === 'function') {
|
||||||
|
loadAlerts();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error acknowledging alerts:', error);
|
||||||
|
alert('Kunne ikke markere som læst. Prøv igen.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -134,6 +134,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-warning btn-sm" onclick="openAlertNoteForm('contact', contactId)" title="Opret vigtig information/advarsel om denne kontakt">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>Alert Note
|
||||||
|
</button>
|
||||||
<button class="btn btn-light btn-sm" onclick="editContact()">
|
<button class="btn btn-light btn-sm" onclick="editContact()">
|
||||||
<i class="bi bi-pencil me-2"></i>Rediger
|
<i class="bi bi-pencil me-2"></i>Rediger
|
||||||
</button>
|
</button>
|
||||||
@ -144,6 +147,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Alert Notes Container -->
|
||||||
|
<div id="alert-notes-container"></div>
|
||||||
|
|
||||||
<!-- Content Layout with Sidebar Navigation -->
|
<!-- Content Layout with Sidebar Navigation -->
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-3 col-md-4">
|
<div class="col-lg-3 col-md-4">
|
||||||
@ -784,6 +790,12 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
<!-- Alert Notes Components -->
|
||||||
|
<script src="/static/alert_notes/alert_check.js"></script>
|
||||||
|
{% include "alert_notes/frontend/alert_box.html" %}
|
||||||
|
{% include "alert_notes/frontend/alert_modal.html" %}
|
||||||
|
{% include "alert_notes/frontend/alert_form_modal.html" %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const contactId = parseInt(window.location.pathname.split('/').pop());
|
const contactId = parseInt(window.location.pathname.split('/').pop());
|
||||||
let contactData = null;
|
let contactData = null;
|
||||||
@ -931,6 +943,9 @@ function displayContact(contact) {
|
|||||||
if (document.querySelector('a[href="#companies"]').classList.contains('active')) {
|
if (document.querySelector('a[href="#companies"]').classList.contains('active')) {
|
||||||
displayCompanies(contact.companies);
|
displayCompanies(contact.companies);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load Alert Notes for this contact
|
||||||
|
loadAndDisplayAlerts('contact', contact.id, 'inline', 'alert-notes-container');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderNumberActions(number, allowSms = false, contact = null) {
|
function renderNumberActions(number, allowSms = false, contact = null) {
|
||||||
|
|||||||
@ -245,6 +245,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-warning btn-sm" onclick="openAlertNoteForm('customer', customerId)" title="Opret vigtig information/advarsel om denne kunde">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>Alert Note
|
||||||
|
</button>
|
||||||
<button class="btn btn-edit-customer" onclick="editCustomer()">
|
<button class="btn btn-edit-customer" onclick="editCustomer()">
|
||||||
<i class="bi bi-pencil-square me-2"></i>Rediger Kunde
|
<i class="bi bi-pencil-square me-2"></i>Rediger Kunde
|
||||||
</button>
|
</button>
|
||||||
@ -255,6 +258,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Alert Notes Container -->
|
||||||
|
<div id="alert-notes-container"></div>
|
||||||
|
|
||||||
<!-- Bankruptcy Alert -->
|
<!-- Bankruptcy Alert -->
|
||||||
<div id="bankruptcyAlert" class="alert alert-danger d-flex align-items-center mb-4 d-none border-0 shadow-sm" role="alert" style="background-color: #ffeaea; color: #842029;">
|
<div id="bankruptcyAlert" class="alert alert-danger d-flex align-items-center mb-4 d-none border-0 shadow-sm" role="alert" style="background-color: #ffeaea; color: #842029;">
|
||||||
<i class="bi bi-shield-exclamation flex-shrink-0 me-3 fs-2 animate__animated animate__pulse animate__infinite"></i>
|
<i class="bi bi-shield-exclamation flex-shrink-0 me-3 fs-2 animate__animated animate__pulse animate__infinite"></i>
|
||||||
@ -1188,6 +1194,12 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
<!-- Alert Notes Components -->
|
||||||
|
<script src="/static/alert_notes/alert_check.js"></script>
|
||||||
|
{% include "alert_notes/frontend/alert_box.html" %}
|
||||||
|
{% include "alert_notes/frontend/alert_modal.html" %}
|
||||||
|
{% include "alert_notes/frontend/alert_form_modal.html" %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const customerId = parseInt(window.location.pathname.split('/').pop());
|
const customerId = parseInt(window.location.pathname.split('/').pop());
|
||||||
let customerData = null;
|
let customerData = null;
|
||||||
@ -1403,6 +1415,9 @@ function displayCustomer(customer) {
|
|||||||
? new Date(customer.economic_last_sync_at).toLocaleString('da-DK')
|
? new Date(customer.economic_last_sync_at).toLocaleString('da-DK')
|
||||||
: '-';
|
: '-';
|
||||||
document.getElementById('createdAt').textContent = new Date(customer.created_at).toLocaleString('da-DK');
|
document.getElementById('createdAt').textContent = new Date(customer.created_at).toLocaleString('da-DK');
|
||||||
|
|
||||||
|
// Load Alert Notes for this customer
|
||||||
|
loadAndDisplayAlerts('customer', customer.id, 'inline', 'alert-notes-container');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCustomerCallNumber(number) {
|
function renderCustomerCallNumber(number) {
|
||||||
|
|||||||
223
app/jobs/backfill_vtiger_archived_contacts.py
Normal file
223
app/jobs/backfill_vtiger_archived_contacts.py
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
"""
|
||||||
|
Backfill organization_name/contact_name on archived vTiger tickets.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m app.jobs.backfill_vtiger_archived_contacts --limit 200 --sleep 0.35 --retries 8
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from app.core.database import init_db, execute_query, execute_update
|
||||||
|
from app.services.vtiger_service import get_vtiger_service
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger("vtiger_backfill")
|
||||||
|
|
||||||
|
|
||||||
|
def _first_value(data: dict, keys: list[str]) -> Optional[str]:
|
||||||
|
for key in keys:
|
||||||
|
value = data.get(key)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
text = str(value).strip()
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_external_id(value: Optional[str]) -> bool:
|
||||||
|
if not value:
|
||||||
|
return False
|
||||||
|
text = str(value)
|
||||||
|
return "x" in text and len(text) >= 4
|
||||||
|
|
||||||
|
|
||||||
|
async def _query_with_retry(vtiger: Any, query_string: str, retries: int, base_delay: float) -> list[dict]:
|
||||||
|
for attempt in range(retries + 1):
|
||||||
|
result = await vtiger.query(query_string)
|
||||||
|
status_code = getattr(vtiger, "last_query_status", None)
|
||||||
|
error = getattr(vtiger, "last_query_error", None) or {}
|
||||||
|
error_code = error.get("code") if isinstance(error, dict) else None
|
||||||
|
|
||||||
|
if status_code != 429 and error_code != "TOO_MANY_REQUESTS":
|
||||||
|
return result
|
||||||
|
|
||||||
|
if attempt < retries:
|
||||||
|
await asyncio.sleep(base_delay * (2**attempt))
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def run(limit: int, sleep_seconds: float, retries: int, base_delay: float, only_missing_both: bool) -> None:
|
||||||
|
init_db()
|
||||||
|
vtiger = get_vtiger_service()
|
||||||
|
|
||||||
|
missing_clause = (
|
||||||
|
"COALESCE(NULLIF(BTRIM(organization_name), ''), NULL) IS NULL "
|
||||||
|
"AND COALESCE(NULLIF(BTRIM(contact_name), ''), NULL) IS NULL"
|
||||||
|
if only_missing_both
|
||||||
|
else "COALESCE(NULLIF(BTRIM(organization_name), ''), NULL) IS NULL OR COALESCE(NULLIF(BTRIM(contact_name), ''), NULL) IS NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
f"""
|
||||||
|
SELECT id, external_id, organization_name, contact_name
|
||||||
|
FROM tticket_archived_tickets
|
||||||
|
WHERE source_system = 'vtiger'
|
||||||
|
AND ({missing_clause})
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
logger.info("Candidates: %s", len(rows))
|
||||||
|
|
||||||
|
account_cache: dict[str, Optional[str]] = {}
|
||||||
|
contact_cache: dict[str, Optional[str]] = {}
|
||||||
|
contact_account_cache: dict[str, Optional[str]] = {}
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"candidates": len(rows),
|
||||||
|
"updated": 0,
|
||||||
|
"unchanged": 0,
|
||||||
|
"case_missing": 0,
|
||||||
|
"errors": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
archived_id = row["id"]
|
||||||
|
external_id = row["external_id"]
|
||||||
|
existing_org = (row.get("organization_name") or "").strip()
|
||||||
|
existing_contact = (row.get("contact_name") or "").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
case_rows = await _query_with_retry(
|
||||||
|
vtiger,
|
||||||
|
f"SELECT * FROM Cases WHERE id='{external_id}' LIMIT 1;",
|
||||||
|
retries=retries,
|
||||||
|
base_delay=base_delay,
|
||||||
|
)
|
||||||
|
if not case_rows:
|
||||||
|
stats["case_missing"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
case_data = case_rows[0]
|
||||||
|
organization_name = _first_value(case_data, ["accountname", "account_name", "organization", "company"])
|
||||||
|
contact_name = _first_value(case_data, ["contactname", "contact_name", "contact", "firstname", "lastname"])
|
||||||
|
|
||||||
|
account_id = _first_value(case_data, ["parent_id", "account_id", "accountid", "account"])
|
||||||
|
if not organization_name and _looks_like_external_id(account_id):
|
||||||
|
if account_id not in account_cache:
|
||||||
|
account_rows = await _query_with_retry(
|
||||||
|
vtiger,
|
||||||
|
f"SELECT * FROM Accounts WHERE id='{account_id}' LIMIT 1;",
|
||||||
|
retries=retries,
|
||||||
|
base_delay=base_delay,
|
||||||
|
)
|
||||||
|
account_cache[account_id] = _first_value(
|
||||||
|
account_rows[0] if account_rows else {},
|
||||||
|
["accountname", "account_name", "name"],
|
||||||
|
)
|
||||||
|
organization_name = account_cache.get(account_id)
|
||||||
|
|
||||||
|
contact_id = _first_value(case_data, ["contact_id", "contactid"])
|
||||||
|
if _looks_like_external_id(contact_id):
|
||||||
|
if contact_id not in contact_cache or contact_id not in contact_account_cache:
|
||||||
|
contact_rows = await _query_with_retry(
|
||||||
|
vtiger,
|
||||||
|
f"SELECT * FROM Contacts WHERE id='{contact_id}' LIMIT 1;",
|
||||||
|
retries=retries,
|
||||||
|
base_delay=base_delay,
|
||||||
|
)
|
||||||
|
contact_data = contact_rows[0] if contact_rows else {}
|
||||||
|
first_name = _first_value(contact_data, ["firstname", "first_name", "first"])
|
||||||
|
last_name = _first_value(contact_data, ["lastname", "last_name", "last"])
|
||||||
|
combined_name = " ".join([name for name in [first_name, last_name] if name]).strip()
|
||||||
|
contact_cache[contact_id] = combined_name or _first_value(
|
||||||
|
contact_data,
|
||||||
|
["contactname", "contact_name", "name"],
|
||||||
|
)
|
||||||
|
related_account_id = _first_value(
|
||||||
|
contact_data,
|
||||||
|
["account_id", "accountid", "account", "parent_id"],
|
||||||
|
)
|
||||||
|
contact_account_cache[contact_id] = related_account_id if _looks_like_external_id(related_account_id) else None
|
||||||
|
|
||||||
|
if not contact_name:
|
||||||
|
contact_name = contact_cache.get(contact_id)
|
||||||
|
|
||||||
|
if not organization_name:
|
||||||
|
related_account_id = contact_account_cache.get(contact_id)
|
||||||
|
if related_account_id:
|
||||||
|
if related_account_id not in account_cache:
|
||||||
|
account_rows = await _query_with_retry(
|
||||||
|
vtiger,
|
||||||
|
f"SELECT * FROM Accounts WHERE id='{related_account_id}' LIMIT 1;",
|
||||||
|
retries=retries,
|
||||||
|
base_delay=base_delay,
|
||||||
|
)
|
||||||
|
account_cache[related_account_id] = _first_value(
|
||||||
|
account_rows[0] if account_rows else {},
|
||||||
|
["accountname", "account_name", "name"],
|
||||||
|
)
|
||||||
|
organization_name = account_cache.get(related_account_id)
|
||||||
|
|
||||||
|
next_org = organization_name if (not existing_org and organization_name) else None
|
||||||
|
next_contact = contact_name if (not existing_contact and contact_name) else None
|
||||||
|
|
||||||
|
if not next_org and not next_contact:
|
||||||
|
stats["unchanged"] += 1
|
||||||
|
else:
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
UPDATE tticket_archived_tickets
|
||||||
|
SET organization_name = CASE
|
||||||
|
WHEN COALESCE(NULLIF(BTRIM(organization_name), ''), NULL) IS NULL THEN COALESCE(%s, organization_name)
|
||||||
|
ELSE organization_name
|
||||||
|
END,
|
||||||
|
contact_name = CASE
|
||||||
|
WHEN COALESCE(NULLIF(BTRIM(contact_name), ''), NULL) IS NULL THEN COALESCE(%s, contact_name)
|
||||||
|
ELSE contact_name
|
||||||
|
END,
|
||||||
|
last_synced_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(next_org, next_contact, archived_id),
|
||||||
|
)
|
||||||
|
stats["updated"] += 1
|
||||||
|
|
||||||
|
await asyncio.sleep(sleep_seconds)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
stats["errors"] += 1
|
||||||
|
logger.warning("Row %s (%s) failed: %s", archived_id, external_id, exc)
|
||||||
|
|
||||||
|
logger.info("Backfill result: %s", stats)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--limit", type=int, default=200)
|
||||||
|
parser.add_argument("--sleep", type=float, default=0.35)
|
||||||
|
parser.add_argument("--retries", type=int, default=8)
|
||||||
|
parser.add_argument("--base-delay", type=float, default=1.2)
|
||||||
|
parser.add_argument("--only-missing-both", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
run(
|
||||||
|
limit=args.limit,
|
||||||
|
sleep_seconds=args.sleep,
|
||||||
|
retries=args.retries,
|
||||||
|
base_delay=args.base_delay,
|
||||||
|
only_missing_both=args.only_missing_both,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -81,6 +81,12 @@
|
|||||||
border: 1px solid rgba(0,0,0,0.1);
|
border: 1px solid rgba(0,0,0,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Wider tooltip for relation type explanations */
|
||||||
|
.tooltip-wide .tooltip-inner {
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.tag-closed {
|
.tag-closed {
|
||||||
background-color: #e0e0e0;
|
background-color: #e0e0e0;
|
||||||
color: #666;
|
color: #666;
|
||||||
@ -953,7 +959,15 @@
|
|||||||
<div class="col-12 mb-3">
|
<div class="col-12 mb-3">
|
||||||
<div class="card h-100 d-flex flex-column" data-module="relations" data-has-content="{{ 'true' if relation_tree else 'false' }}">
|
<div class="card h-100 d-flex flex-column" data-module="relations" data-has-content="{{ 'true' if relation_tree else 'false' }}">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<h6 class="mb-0" style="color: var(--accent);">🔗 Relationer</h6>
|
<h6 class="mb-0" style="color: var(--accent);">
|
||||||
|
🔗 Relationer
|
||||||
|
<i class="bi bi-info-circle-fill ms-2"
|
||||||
|
style="font-size: 0.85rem; cursor: help; opacity: 0.7;"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-placement="right"
|
||||||
|
data-bs-html="true"
|
||||||
|
title="<div class='text-start'><strong>Relateret til:</strong> Faglig kobling uden direkte afhængighed.<br><strong>Afledt af:</strong> Denne sag er opstået på baggrund af en anden sag.<br><strong>Årsag til:</strong> Denne sag er årsagen til en anden sag.<br><strong>Blokkerer:</strong> Arbejde i en sag stopper fremdrift i den anden.</div>"></i>
|
||||||
|
</h6>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="showRelationModal()">
|
<button class="btn btn-sm btn-outline-primary" onclick="showRelationModal()">
|
||||||
<i class="bi bi-link-45deg"></i>
|
<i class="bi bi-link-45deg"></i>
|
||||||
@ -964,13 +978,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body flex-grow-1 overflow-auto" style="max-height: 300px;">
|
<div class="card-body flex-grow-1 overflow-auto" style="max-height: 300px;">
|
||||||
<div class="alert alert-light border small py-2 px-3 mb-3">
|
|
||||||
<div class="fw-semibold mb-1"><i class="bi bi-info-circle me-1"></i>Hvad betyder relationstyper?</div>
|
|
||||||
<div><strong>Relateret til</strong>: Faglig kobling uden direkte afhængighed.</div>
|
|
||||||
<div><strong>Afledt af</strong>: Denne sag er opstået på baggrund af en anden sag.</div>
|
|
||||||
<div><strong>Årsag til</strong>: Denne sag er årsagen til en anden sag.</div>
|
|
||||||
<div><strong>Blokkerer</strong>: Arbejde i en sag stopper fremdrift i den anden.</div>
|
|
||||||
</div>
|
|
||||||
{% macro render_tree(nodes) %}
|
{% macro render_tree(nodes) %}
|
||||||
<ul class="relation-tree">
|
<ul class="relation-tree">
|
||||||
{% for node in nodes %}
|
{% for node in nodes %}
|
||||||
@ -1546,6 +1553,13 @@
|
|||||||
|
|
||||||
// Initialize everything when DOM is ready
|
// Initialize everything when DOM is ready
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Initialize Bootstrap tooltips
|
||||||
|
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((el) => {
|
||||||
|
new bootstrap.Tooltip(el, {
|
||||||
|
customClass: 'tooltip-wide'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize modals
|
// Initialize modals
|
||||||
contactSearchModal = new bootstrap.Modal(document.getElementById('contactSearchModal'));
|
contactSearchModal = new bootstrap.Modal(document.getElementById('contactSearchModal'));
|
||||||
customerSearchModal = new bootstrap.Modal(document.getElementById('customerSearchModal'));
|
customerSearchModal = new bootstrap.Modal(document.getElementById('customerSearchModal'));
|
||||||
|
|||||||
@ -212,7 +212,7 @@ async def yealink_established(
|
|||||||
ekstern_value = ekstern_e164 or ((ekstern_raw or "").strip() or None)
|
ekstern_value = ekstern_e164 or ((ekstern_raw or "").strip() or None)
|
||||||
suffix8 = phone_suffix_8(ekstern_raw)
|
suffix8 = phone_suffix_8(ekstern_raw)
|
||||||
|
|
||||||
user_id = TelefoniService.find_user_by_extension(local_extension)
|
user_ids = TelefoniService.find_user_by_extension(local_extension)
|
||||||
|
|
||||||
kontakt = TelefoniService.find_contact_by_phone_suffix(suffix8)
|
kontakt = TelefoniService.find_contact_by_phone_suffix(suffix8)
|
||||||
kontakt_id = kontakt.get("id") if kontakt else None
|
kontakt_id = kontakt.get("id") if kontakt else None
|
||||||
@ -237,9 +237,11 @@ async def yealink_established(
|
|||||||
"received_at": datetime.utcnow().isoformat(),
|
"received_at": datetime.utcnow().isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Store call with first user_id (or None if no users)
|
||||||
|
primary_user_id = user_ids[0] if user_ids else None
|
||||||
row = TelefoniService.upsert_call(
|
row = TelefoniService.upsert_call(
|
||||||
callid=resolved_callid,
|
callid=resolved_callid,
|
||||||
user_id=user_id,
|
user_id=primary_user_id,
|
||||||
direction=direction,
|
direction=direction,
|
||||||
ekstern_nummer=ekstern_value,
|
ekstern_nummer=ekstern_value,
|
||||||
intern_extension=(local_extension or "")[:16] or None,
|
intern_extension=(local_extension or "")[:16] or None,
|
||||||
@ -248,19 +250,19 @@ async def yealink_established(
|
|||||||
started_at=datetime.utcnow(),
|
started_at=datetime.utcnow(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if user_id:
|
# Send websocket notification to ALL users with this extension
|
||||||
await manager.send_to_user(
|
if user_ids:
|
||||||
user_id,
|
call_data = {
|
||||||
"incoming_call",
|
|
||||||
{
|
|
||||||
"call_id": str(row.get("id") or resolved_callid),
|
"call_id": str(row.get("id") or resolved_callid),
|
||||||
"number": ekstern_e164 or (ekstern_raw or ""),
|
"number": ekstern_e164 or (ekstern_raw or ""),
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
"contact": kontakt,
|
"contact": kontakt,
|
||||||
"recent_cases": contact_details.get("recent_cases", []),
|
"recent_cases": contact_details.get("recent_cases", []),
|
||||||
"last_call": contact_details.get("last_call"),
|
"last_call": contact_details.get("last_call"),
|
||||||
},
|
}
|
||||||
)
|
for user_id in user_ids:
|
||||||
|
await manager.send_to_user(user_id, "incoming_call", call_data)
|
||||||
|
logger.info("📞 Telefoni notification sent to %d user(s) for extension=%s", len(user_ids), local_extension)
|
||||||
else:
|
else:
|
||||||
logger.info("⚠️ Telefoni established: no mapped user for extension=%s (callid=%s)", local_extension, resolved_callid)
|
logger.info("⚠️ Telefoni established: no mapped user for extension=%s (callid=%s)", local_extension, resolved_callid)
|
||||||
|
|
||||||
@ -384,7 +386,9 @@ async def telefoni_test_popup(
|
|||||||
if user_id:
|
if user_id:
|
||||||
target_user_id = int(user_id)
|
target_user_id = int(user_id)
|
||||||
elif extension:
|
elif extension:
|
||||||
target_user_id = TelefoniService.find_user_by_extension(extension)
|
# find_user_by_extension now returns list - take first match for test
|
||||||
|
user_ids = TelefoniService.find_user_by_extension(extension)
|
||||||
|
target_user_id = user_ids[0] if user_ids else None
|
||||||
|
|
||||||
if not target_user_id:
|
if not target_user_id:
|
||||||
raise HTTPException(status_code=422, detail="Provide user_id or extension when using token auth")
|
raise HTTPException(status_code=422, detail="Provide user_id or extension when using token auth")
|
||||||
|
|||||||
@ -9,14 +9,15 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
class TelefoniService:
|
class TelefoniService:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_user_by_extension(extension: Optional[str]) -> Optional[int]:
|
def find_user_by_extension(extension: Optional[str]) -> list[int]:
|
||||||
|
"""Find all users with the given extension - returns list of user_ids."""
|
||||||
if not extension:
|
if not extension:
|
||||||
return None
|
return []
|
||||||
row = execute_query_single(
|
rows = execute_query(
|
||||||
"SELECT user_id FROM users WHERE telefoni_aktiv = TRUE AND telefoni_extension = %s LIMIT 1",
|
"SELECT user_id FROM users WHERE telefoni_aktiv = TRUE AND telefoni_extension = %s ORDER BY user_id",
|
||||||
(extension,),
|
(extension,),
|
||||||
)
|
)
|
||||||
return int(row["user_id"]) if row and row.get("user_id") is not None else None
|
return [int(row["user_id"]) for row in rows if row.get("user_id") is not None]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_contact_by_phone_suffix(suffix8: Optional[str]) -> Optional[dict]:
|
def find_contact_by_phone_suffix(suffix8: Optional[str]) -> Optional[dict]:
|
||||||
|
|||||||
2
main.py
2
main.py
@ -29,6 +29,7 @@ from app.customers.backend import router as customers_api
|
|||||||
from app.customers.backend import views as customers_views
|
from app.customers.backend import views as customers_views
|
||||||
from app.customers.backend import bmc_office_router
|
from app.customers.backend import bmc_office_router
|
||||||
# from app.hardware.backend import router as hardware_api # Replaced by hardware module
|
# from app.hardware.backend import router as hardware_api # Replaced by hardware module
|
||||||
|
from app.alert_notes.backend import router as alert_notes_api
|
||||||
from app.billing.backend import router as billing_api
|
from app.billing.backend import router as billing_api
|
||||||
from app.billing.frontend import views as billing_views
|
from app.billing.frontend import views as billing_views
|
||||||
from app.system.backend import router as system_api
|
from app.system.backend import router as system_api
|
||||||
@ -274,6 +275,7 @@ async def auth_middleware(request: Request, call_next):
|
|||||||
app.include_router(customers_api.router, prefix="/api/v1", tags=["Customers"])
|
app.include_router(customers_api.router, prefix="/api/v1", tags=["Customers"])
|
||||||
app.include_router(bmc_office_router.router, prefix="/api/v1", tags=["BMC Office"])
|
app.include_router(bmc_office_router.router, prefix="/api/v1", tags=["BMC Office"])
|
||||||
# app.include_router(hardware_api.router, prefix="/api/v1", tags=["Hardware"]) # Replaced by hardware module
|
# app.include_router(hardware_api.router, prefix="/api/v1", tags=["Hardware"]) # Replaced by hardware module
|
||||||
|
app.include_router(alert_notes_api, prefix="/api/v1", tags=["Alert Notes"])
|
||||||
app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"])
|
app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"])
|
||||||
app.include_router(system_api.router, prefix="/api/v1", tags=["System"])
|
app.include_router(system_api.router, prefix="/api/v1", tags=["System"])
|
||||||
app.include_router(dashboard_api.router, prefix="/api/v1", tags=["Dashboard"])
|
app.include_router(dashboard_api.router, prefix="/api/v1", tags=["Dashboard"])
|
||||||
|
|||||||
102
migrations/1001_alert_notes_system.sql
Normal file
102
migrations/1001_alert_notes_system.sql
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
-- Migration 1001: Alert Notes System
|
||||||
|
-- Description: Contextual popup/info system for customers and contacts
|
||||||
|
-- Date: 2026-02-17
|
||||||
|
|
||||||
|
-- Alert Notes main table
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_notes (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
entity_type VARCHAR(20) NOT NULL CHECK (entity_type IN ('customer', 'contact')),
|
||||||
|
entity_id INTEGER NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
severity VARCHAR(20) NOT NULL DEFAULT 'info' CHECK (severity IN ('info', 'warning', 'critical')),
|
||||||
|
requires_acknowledgement BOOLEAN DEFAULT TRUE,
|
||||||
|
active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Alert Note Restrictions (who is allowed to handle the customer/contact)
|
||||||
|
-- Supports both group-based and user-based restrictions
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_note_restrictions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
alert_note_id INTEGER NOT NULL REFERENCES alert_notes(id) ON DELETE CASCADE,
|
||||||
|
restriction_type VARCHAR(20) NOT NULL CHECK (restriction_type IN ('group', 'user')),
|
||||||
|
restriction_id INTEGER NOT NULL,
|
||||||
|
-- restriction_id references either groups.id or users.user_id depending on restriction_type
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(alert_note_id, restriction_type, restriction_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Alert Note Acknowledgements (tracking who has seen and acknowledged alerts)
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_note_acknowledgements (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
alert_note_id INTEGER NOT NULL REFERENCES alert_notes(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
acknowledged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(alert_note_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_notes_entity ON alert_notes(entity_type, entity_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_notes_active ON alert_notes(active) WHERE active = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_notes_severity ON alert_notes(severity);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_notes_created_by ON alert_notes(created_by_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_note_restrictions_alert ON alert_note_restrictions(alert_note_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_note_acknowledgements_alert ON alert_note_acknowledgements(alert_note_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_note_acknowledgements_user ON alert_note_acknowledgements(user_id);
|
||||||
|
|
||||||
|
-- Trigger to update updated_at timestamp
|
||||||
|
CREATE OR REPLACE FUNCTION update_alert_notes_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trigger_update_alert_notes_updated_at
|
||||||
|
BEFORE UPDATE ON alert_notes
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_alert_notes_updated_at();
|
||||||
|
|
||||||
|
-- Add alert_notes permissions
|
||||||
|
INSERT INTO permissions (code, description, category) VALUES
|
||||||
|
('alert_notes.view', 'View alert notes', 'alert_notes'),
|
||||||
|
('alert_notes.create', 'Create alert notes', 'alert_notes'),
|
||||||
|
('alert_notes.edit', 'Edit alert notes', 'alert_notes'),
|
||||||
|
('alert_notes.delete', 'Delete alert notes', 'alert_notes'),
|
||||||
|
('alert_notes.manage', 'Full management of alert notes', 'alert_notes')
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
|
-- Assign all alert_notes permissions to Administrators group
|
||||||
|
INSERT INTO group_permissions (group_id, permission_id)
|
||||||
|
SELECT g.id, p.id
|
||||||
|
FROM groups g
|
||||||
|
CROSS JOIN permissions p
|
||||||
|
WHERE g.name = 'Administrators' AND p.category = 'alert_notes'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Assign create, edit permissions to Managers group
|
||||||
|
INSERT INTO group_permissions (group_id, permission_id)
|
||||||
|
SELECT g.id, p.id
|
||||||
|
FROM groups g
|
||||||
|
CROSS JOIN permissions p
|
||||||
|
WHERE g.name = 'Managers' AND p.code IN (
|
||||||
|
'alert_notes.view', 'alert_notes.create', 'alert_notes.edit'
|
||||||
|
)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Assign view permission to all other groups (Technicians, Viewers)
|
||||||
|
INSERT INTO group_permissions (group_id, permission_id)
|
||||||
|
SELECT g.id, p.id
|
||||||
|
FROM groups g
|
||||||
|
CROSS JOIN permissions p
|
||||||
|
WHERE g.name IN ('Technicians', 'Viewers') AND p.code = 'alert_notes.view'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Verify migration
|
||||||
|
SELECT 'Alert Notes system created successfully' AS status,
|
||||||
|
(SELECT COUNT(*) FROM alert_notes) AS alert_notes_count,
|
||||||
|
(SELECT COUNT(*) FROM permissions WHERE category = 'alert_notes') AS permissions_count;
|
||||||
224
scripts/backfill_vtiger_archived_contacts.py
Normal file
224
scripts/backfill_vtiger_archived_contacts.py
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Backfill organization_name/contact_name on archived vTiger tickets.
|
||||||
|
|
||||||
|
Usage (inside api container):
|
||||||
|
python scripts/backfill_vtiger_archived_contacts.py --limit 200 --sleep 0.35 --retries 8
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from app.core.database import init_db, execute_query, execute_update
|
||||||
|
from app.services.vtiger_service import get_vtiger_service
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger("vtiger_backfill")
|
||||||
|
|
||||||
|
|
||||||
|
def _first_value(data: dict, keys: list[str]) -> Optional[str]:
|
||||||
|
for key in keys:
|
||||||
|
value = data.get(key)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
text = str(value).strip()
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_external_id(value: Optional[str]) -> bool:
|
||||||
|
if not value:
|
||||||
|
return False
|
||||||
|
text = str(value)
|
||||||
|
return "x" in text and len(text) >= 4
|
||||||
|
|
||||||
|
|
||||||
|
async def _query_with_retry(vtiger: Any, query_string: str, retries: int, base_delay: float) -> list[dict]:
|
||||||
|
for attempt in range(retries + 1):
|
||||||
|
result = await vtiger.query(query_string)
|
||||||
|
status_code = getattr(vtiger, "last_query_status", None)
|
||||||
|
error = getattr(vtiger, "last_query_error", None) or {}
|
||||||
|
error_code = error.get("code") if isinstance(error, dict) else None
|
||||||
|
|
||||||
|
if status_code != 429 and error_code != "TOO_MANY_REQUESTS":
|
||||||
|
return result
|
||||||
|
|
||||||
|
if attempt < retries:
|
||||||
|
await asyncio.sleep(base_delay * (2**attempt))
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def run(limit: int, sleep_seconds: float, retries: int, base_delay: float, only_missing_both: bool) -> None:
|
||||||
|
init_db()
|
||||||
|
vtiger = get_vtiger_service()
|
||||||
|
|
||||||
|
missing_clause = (
|
||||||
|
"COALESCE(NULLIF(BTRIM(organization_name), ''), NULL) IS NULL "
|
||||||
|
"AND COALESCE(NULLIF(BTRIM(contact_name), ''), NULL) IS NULL"
|
||||||
|
if only_missing_both
|
||||||
|
else "COALESCE(NULLIF(BTRIM(organization_name), ''), NULL) IS NULL OR COALESCE(NULLIF(BTRIM(contact_name), ''), NULL) IS NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
f"""
|
||||||
|
SELECT id, external_id, organization_name, contact_name
|
||||||
|
FROM tticket_archived_tickets
|
||||||
|
WHERE source_system = 'vtiger'
|
||||||
|
AND ({missing_clause})
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
logger.info("Candidates: %s", len(rows))
|
||||||
|
|
||||||
|
account_cache: dict[str, Optional[str]] = {}
|
||||||
|
contact_cache: dict[str, Optional[str]] = {}
|
||||||
|
contact_account_cache: dict[str, Optional[str]] = {}
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"candidates": len(rows),
|
||||||
|
"updated": 0,
|
||||||
|
"unchanged": 0,
|
||||||
|
"case_missing": 0,
|
||||||
|
"errors": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
archived_id = row["id"]
|
||||||
|
external_id = row["external_id"]
|
||||||
|
existing_org = (row.get("organization_name") or "").strip()
|
||||||
|
existing_contact = (row.get("contact_name") or "").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
case_rows = await _query_with_retry(
|
||||||
|
vtiger,
|
||||||
|
f"SELECT * FROM Cases WHERE id='{external_id}' LIMIT 1;",
|
||||||
|
retries=retries,
|
||||||
|
base_delay=base_delay,
|
||||||
|
)
|
||||||
|
if not case_rows:
|
||||||
|
stats["case_missing"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
case_data = case_rows[0]
|
||||||
|
organization_name = _first_value(case_data, ["accountname", "account_name", "organization", "company"])
|
||||||
|
contact_name = _first_value(case_data, ["contactname", "contact_name", "contact", "firstname", "lastname"])
|
||||||
|
|
||||||
|
account_id = _first_value(case_data, ["parent_id", "account_id", "accountid", "account"])
|
||||||
|
if not organization_name and _looks_like_external_id(account_id):
|
||||||
|
if account_id not in account_cache:
|
||||||
|
account_rows = await _query_with_retry(
|
||||||
|
vtiger,
|
||||||
|
f"SELECT * FROM Accounts WHERE id='{account_id}' LIMIT 1;",
|
||||||
|
retries=retries,
|
||||||
|
base_delay=base_delay,
|
||||||
|
)
|
||||||
|
account_cache[account_id] = _first_value(
|
||||||
|
account_rows[0] if account_rows else {},
|
||||||
|
["accountname", "account_name", "name"],
|
||||||
|
)
|
||||||
|
organization_name = account_cache.get(account_id)
|
||||||
|
|
||||||
|
contact_id = _first_value(case_data, ["contact_id", "contactid"])
|
||||||
|
if _looks_like_external_id(contact_id):
|
||||||
|
if contact_id not in contact_cache or contact_id not in contact_account_cache:
|
||||||
|
contact_rows = await _query_with_retry(
|
||||||
|
vtiger,
|
||||||
|
f"SELECT * FROM Contacts WHERE id='{contact_id}' LIMIT 1;",
|
||||||
|
retries=retries,
|
||||||
|
base_delay=base_delay,
|
||||||
|
)
|
||||||
|
contact_data = contact_rows[0] if contact_rows else {}
|
||||||
|
first_name = _first_value(contact_data, ["firstname", "first_name", "first"])
|
||||||
|
last_name = _first_value(contact_data, ["lastname", "last_name", "last"])
|
||||||
|
combined_name = " ".join([name for name in [first_name, last_name] if name]).strip()
|
||||||
|
contact_cache[contact_id] = combined_name or _first_value(
|
||||||
|
contact_data,
|
||||||
|
["contactname", "contact_name", "name"],
|
||||||
|
)
|
||||||
|
related_account_id = _first_value(
|
||||||
|
contact_data,
|
||||||
|
["account_id", "accountid", "account", "parent_id"],
|
||||||
|
)
|
||||||
|
contact_account_cache[contact_id] = related_account_id if _looks_like_external_id(related_account_id) else None
|
||||||
|
|
||||||
|
if not contact_name:
|
||||||
|
contact_name = contact_cache.get(contact_id)
|
||||||
|
|
||||||
|
if not organization_name:
|
||||||
|
related_account_id = contact_account_cache.get(contact_id)
|
||||||
|
if related_account_id:
|
||||||
|
if related_account_id not in account_cache:
|
||||||
|
account_rows = await _query_with_retry(
|
||||||
|
vtiger,
|
||||||
|
f"SELECT * FROM Accounts WHERE id='{related_account_id}' LIMIT 1;",
|
||||||
|
retries=retries,
|
||||||
|
base_delay=base_delay,
|
||||||
|
)
|
||||||
|
account_cache[related_account_id] = _first_value(
|
||||||
|
account_rows[0] if account_rows else {},
|
||||||
|
["accountname", "account_name", "name"],
|
||||||
|
)
|
||||||
|
organization_name = account_cache.get(related_account_id)
|
||||||
|
|
||||||
|
next_org = organization_name if (not existing_org and organization_name) else None
|
||||||
|
next_contact = contact_name if (not existing_contact and contact_name) else None
|
||||||
|
|
||||||
|
if not next_org and not next_contact:
|
||||||
|
stats["unchanged"] += 1
|
||||||
|
else:
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
UPDATE tticket_archived_tickets
|
||||||
|
SET organization_name = CASE
|
||||||
|
WHEN COALESCE(NULLIF(BTRIM(organization_name), ''), NULL) IS NULL THEN COALESCE(%s, organization_name)
|
||||||
|
ELSE organization_name
|
||||||
|
END,
|
||||||
|
contact_name = CASE
|
||||||
|
WHEN COALESCE(NULLIF(BTRIM(contact_name), ''), NULL) IS NULL THEN COALESCE(%s, contact_name)
|
||||||
|
ELSE contact_name
|
||||||
|
END,
|
||||||
|
last_synced_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(next_org, next_contact, archived_id),
|
||||||
|
)
|
||||||
|
stats["updated"] += 1
|
||||||
|
|
||||||
|
await asyncio.sleep(sleep_seconds)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
stats["errors"] += 1
|
||||||
|
logger.warning("Row %s (%s) failed: %s", archived_id, external_id, exc)
|
||||||
|
|
||||||
|
logger.info("Backfill result: %s", stats)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--limit", type=int, default=200)
|
||||||
|
parser.add_argument("--sleep", type=float, default=0.35)
|
||||||
|
parser.add_argument("--retries", type=int, default=8)
|
||||||
|
parser.add_argument("--base-delay", type=float, default=1.2)
|
||||||
|
parser.add_argument("--only-missing-both", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
run(
|
||||||
|
limit=args.limit,
|
||||||
|
sleep_seconds=args.sleep,
|
||||||
|
retries=args.retries,
|
||||||
|
base_delay=args.base_delay,
|
||||||
|
only_missing_both=args.only_missing_both,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
131
static/alert_notes/alert_check.js
Normal file
131
static/alert_notes/alert_check.js
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Alert Notes JavaScript Module
|
||||||
|
* Handles loading and displaying alert notes for customers and contacts
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and display alerts for an entity
|
||||||
|
* @param {string} entityType - 'customer' or 'contact'
|
||||||
|
* @param {number} entityId - The entity ID
|
||||||
|
* @param {string} mode - 'inline' (show in page) or 'modal' (show popup)
|
||||||
|
* @param {string} containerId - Optional container ID for inline mode (default: 'alert-notes-container')
|
||||||
|
*/
|
||||||
|
async function loadAndDisplayAlerts(entityType, entityId, mode = 'inline', containerId = 'alert-notes-container') {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/alert-notes/check?entity_type=${entityType}&entity_id=${entityId}`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Failed to fetch alerts:', response.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.has_alerts) {
|
||||||
|
// No alerts - clear container if in inline mode
|
||||||
|
if (mode === 'inline') {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store for later use
|
||||||
|
window.currentAlertData = data;
|
||||||
|
|
||||||
|
if (mode === 'modal') {
|
||||||
|
// Show modal popup
|
||||||
|
showAlertModal(data.alerts);
|
||||||
|
} else {
|
||||||
|
// Show inline
|
||||||
|
displayAlertsInline(data.alerts, containerId, data.user_has_acknowledged);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading alerts:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display alerts inline in a container
|
||||||
|
* @param {Array} alerts - Array of alert objects
|
||||||
|
* @param {string} containerId - Container element ID
|
||||||
|
* @param {boolean} userHasAcknowledged - Whether user has acknowledged all
|
||||||
|
*/
|
||||||
|
function displayAlertsInline(alerts, containerId, userHasAcknowledged) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) {
|
||||||
|
console.error('Alert container not found:', containerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing content
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// Add each alert
|
||||||
|
alerts.forEach(alert => {
|
||||||
|
// Set user_has_acknowledged on individual alert if needed
|
||||||
|
alert.user_has_acknowledged = userHasAcknowledged;
|
||||||
|
|
||||||
|
// Render using the renderAlertBox function from alert_box.html
|
||||||
|
const alertHtml = renderAlertBox(alert);
|
||||||
|
container.innerHTML += alertHtml;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acknowledge a single alert
|
||||||
|
* @param {number} alertId - The alert ID
|
||||||
|
* @param {HTMLElement} buttonElement - The button that was clicked
|
||||||
|
*/
|
||||||
|
async function acknowledgeAlert(alertId, buttonElement) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/alert-notes/${alertId}/acknowledge`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'acknowledged' || data.status === 'already_acknowledged') {
|
||||||
|
// Remove the alert box with fade animation
|
||||||
|
const alertBox = buttonElement.closest('.alert-note-box');
|
||||||
|
if (alertBox) {
|
||||||
|
alertBox.style.opacity = '0';
|
||||||
|
alertBox.style.transform = 'translateX(-20px)';
|
||||||
|
alertBox.style.transition = 'all 0.3s';
|
||||||
|
setTimeout(() => alertBox.remove(), 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error acknowledging alert:', error);
|
||||||
|
alert('Kunne ikke markere som læst. Prøv igen.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize alert checking on page load
|
||||||
|
* Call this from your page's DOMContentLoaded or similar
|
||||||
|
* @param {string} entityType - 'customer' or 'contact'
|
||||||
|
* @param {number} entityId - The entity ID
|
||||||
|
* @param {Object} options - Optional settings {mode: 'inline'|'modal', containerId: 'element-id'}
|
||||||
|
*/
|
||||||
|
function initAlertNotes(entityType, entityId, options = {}) {
|
||||||
|
const mode = options.mode || 'inline';
|
||||||
|
const containerId = options.containerId || 'alert-notes-container';
|
||||||
|
|
||||||
|
loadAndDisplayAlerts(entityType, entityId, mode, containerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make functions globally available
|
||||||
|
window.loadAndDisplayAlerts = loadAndDisplayAlerts;
|
||||||
|
window.displayAlertsInline = displayAlertsInline;
|
||||||
|
window.acknowledgeAlert = acknowledgeAlert;
|
||||||
|
window.initAlertNotes = initAlertNotes;
|
||||||
199
templates/alert_notes/frontend/alert_box.html
Normal file
199
templates/alert_notes/frontend/alert_box.html
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
<!-- Alert Notes Box Component - For inline display on detail pages -->
|
||||||
|
<style>
|
||||||
|
.alert-note-box {
|
||||||
|
border-left: 5px solid;
|
||||||
|
padding: 15px 20px;
|
||||||
|
margin: 15px 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-box:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-info {
|
||||||
|
border-left-color: #0dcaf0;
|
||||||
|
background: #d1ecf1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .alert-note-info {
|
||||||
|
background: rgba(13, 202, 240, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-warning {
|
||||||
|
border-left-color: #ffc107;
|
||||||
|
background: #fff3cd;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .alert-note-warning {
|
||||||
|
background: rgba(255, 193, 7, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-critical {
|
||||||
|
border-left-color: #dc3545;
|
||||||
|
background: #f8d7da;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .alert-note-critical {
|
||||||
|
background: rgba(220, 53, 69, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-severity-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-severity-badge.info {
|
||||||
|
background: #0dcaf0;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-severity-badge.warning {
|
||||||
|
background: #ffc107;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-severity-badge.critical {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-message {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-restrictions {
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(0,0,0,0.05);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .alert-note-restrictions {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-restrictions strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note-acknowledge-btn {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 4px 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Template structure (fill via JavaScript) -->
|
||||||
|
<div id="alert-notes-container"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function renderAlertBox(alert) {
|
||||||
|
const severityClass = `alert-note-${alert.severity}`;
|
||||||
|
const severityBadgeClass = alert.severity;
|
||||||
|
|
||||||
|
let restrictionsHtml = '';
|
||||||
|
if (alert.restrictions && alert.restrictions.length > 0) {
|
||||||
|
const restrictionNames = alert.restrictions.map(r => r.restriction_name).join(', ');
|
||||||
|
restrictionsHtml = `
|
||||||
|
<div class="alert-note-restrictions">
|
||||||
|
<strong><i class="bi bi-shield-lock"></i> Håndteres kun af:</strong>
|
||||||
|
${restrictionNames}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let acknowledgeBtn = '';
|
||||||
|
if (alert.requires_acknowledgement && !alert.user_has_acknowledged) {
|
||||||
|
acknowledgeBtn = `
|
||||||
|
<button class="btn btn-sm btn-outline-secondary alert-note-acknowledge-btn"
|
||||||
|
onclick="acknowledgeAlert(${alert.id}, this)">
|
||||||
|
<i class="bi bi-check-circle"></i> Forstået
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit button (always show for admins/creators)
|
||||||
|
const editBtn = `
|
||||||
|
<button class="btn btn-sm btn-outline-primary alert-note-acknowledge-btn"
|
||||||
|
onclick="openAlertNoteForm('${alert.entity_type}', ${alert.entity_id}, ${alert.id})"
|
||||||
|
title="Rediger alert note">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const createdBy = alert.created_by_user_name ? ` • Oprettet af ${alert.created_by_user_name}` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="alert-note-box ${severityClass}" data-alert-id="${alert.id}">
|
||||||
|
<div class="alert-note-title">
|
||||||
|
<span class="alert-note-severity-badge ${severityBadgeClass}">
|
||||||
|
${alert.severity === 'info' ? 'INFO' : alert.severity === 'warning' ? 'ADVARSEL' : 'KRITISK'}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
${editBtn}
|
||||||
|
${acknowledgeBtn}
|
||||||
|
</div>
|
||||||
|
${alert.title}
|
||||||
|
</div>
|
||||||
|
<div class="alert-note-message">${alert.message}</div>
|
||||||
|
${restrictionsHtml}
|
||||||
|
<div class="alert-note-footer">
|
||||||
|
<span class="text-muted">
|
||||||
|
<i class="bi bi-calendar"></i> ${new Date(alert.created_at).toLocaleDateString('da-DK')}${createdBy}
|
||||||
|
</span>
|
||||||
|
${acknowledgeBtn}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function acknowledgeAlert(alertId, buttonElement) {
|
||||||
|
fetch(`/api/v1/alert-notes/${alertId}/acknowledge`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'acknowledged' || data.status === 'already_acknowledged') {
|
||||||
|
// Remove the alert box with fade animation
|
||||||
|
const alertBox = buttonElement.closest('.alert-note-box');
|
||||||
|
alertBox.style.opacity = '0';
|
||||||
|
alertBox.style.transform = 'translateX(-20px)';
|
||||||
|
setTimeout(() => alertBox.remove(), 300);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error acknowledging alert:', error);
|
||||||
|
alert('Kunne ikke markere som læst. Prøv igen.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
551
templates/alert_notes/frontend/alert_form_modal.html
Normal file
551
templates/alert_notes/frontend/alert_form_modal.html
Normal file
@ -0,0 +1,551 @@
|
|||||||
|
<!-- Alert Note Create/Edit Modal -->
|
||||||
|
<div class="modal fade" id="alertNoteFormModal" tabindex="-1" aria-labelledby="alertNoteFormModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-warning bg-opacity-10 border-bottom border-warning">
|
||||||
|
<h5 class="modal-title d-flex align-items-center" id="alertNoteFormModalLabel">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill text-warning me-2" style="font-size: 1.3rem;"></i>
|
||||||
|
<span id="alertFormTitle" class="fw-bold">Opret Alert Note</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="max-height: 70vh; overflow-y: auto;">
|
||||||
|
<form id="alertNoteForm">
|
||||||
|
<input type="hidden" id="alertNoteId" value="">
|
||||||
|
<input type="hidden" id="alertEntityType" value="">
|
||||||
|
<input type="hidden" id="alertEntityId" value="">
|
||||||
|
|
||||||
|
<!-- Titel Section -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="alertTitle" class="form-label fw-semibold">
|
||||||
|
Titel <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
id="alertTitle"
|
||||||
|
required
|
||||||
|
maxlength="255"
|
||||||
|
placeholder="Kort beskrivende titel">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Besked Section -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="alertMessage" class="form-label fw-semibold">
|
||||||
|
Besked <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea class="form-control"
|
||||||
|
id="alertMessage"
|
||||||
|
rows="6"
|
||||||
|
required
|
||||||
|
placeholder="Detaljeret information der skal vises..."
|
||||||
|
style="font-family: inherit; line-height: 1.6;"></textarea>
|
||||||
|
<div class="form-text mt-2">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
Du kan bruge linjeskift for formatering
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alvorlighed Section -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="alertSeverity" class="form-label fw-semibold">
|
||||||
|
Alvorlighed <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<select class="form-select form-select-lg" id="alertSeverity" required>
|
||||||
|
<option value="info">ℹ️ Info - General kontekst</option>
|
||||||
|
<option value="warning" selected>⚠️ Advarsel - Særlige forhold</option>
|
||||||
|
<option value="critical">🚨 Kritisk - Følsomme forhold</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Checkboxes Section -->
|
||||||
|
<div class="mb-4 p-3 bg-light rounded">
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="alertRequiresAck"
|
||||||
|
checked>
|
||||||
|
<label class="form-check-label" for="alertRequiresAck">
|
||||||
|
<strong>Kræv bekræftelse</strong>
|
||||||
|
<div class="text-muted small mt-1">
|
||||||
|
Brugere skal klikke "Forstået" for at bekræfte at de har set advarslen
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="alertActive"
|
||||||
|
checked>
|
||||||
|
<label class="form-check-label" for="alertActive">
|
||||||
|
<strong>Aktiv</strong>
|
||||||
|
<div class="text-muted small mt-1">
|
||||||
|
Alert noten vises på kunde/kontakt siden
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<!-- Restrictions Section -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold d-flex align-items-center mb-3">
|
||||||
|
<i class="bi bi-shield-lock me-2 text-primary"></i>
|
||||||
|
Begrænsninger (Valgfri)
|
||||||
|
</label>
|
||||||
|
<div class="alert alert-info d-flex align-items-start mb-4">
|
||||||
|
<i class="bi bi-info-circle-fill me-2 mt-1"></i>
|
||||||
|
<div>
|
||||||
|
<strong>Hvad er begrænsninger?</strong>
|
||||||
|
<p class="mb-0 mt-1 small">
|
||||||
|
Angiv hvilke grupper eller brugere der må håndtere denne kunde/kontakt.
|
||||||
|
Lad felterne stå tomme hvis alle må håndtere kunden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="alertGroups" class="form-label fw-semibold">
|
||||||
|
<i class="bi bi-people-fill me-1"></i>
|
||||||
|
Godkendte Grupper
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="alertGroups" multiple size="5">
|
||||||
|
<!-- Populated via JavaScript -->
|
||||||
|
</select>
|
||||||
|
<div class="form-text mt-2">
|
||||||
|
<i class="bi bi-hand-index me-1"></i>
|
||||||
|
Hold Ctrl/Cmd for at vælge flere
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label for="alertUsers" class="form-label fw-semibold">
|
||||||
|
<i class="bi bi-person-fill me-1"></i>
|
||||||
|
Godkendte Brugere
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="alertUsers" multiple size="5">
|
||||||
|
<!-- Populated via JavaScript -->
|
||||||
|
</select>
|
||||||
|
<div class="form-text mt-2">
|
||||||
|
<i class="bi bi-hand-index me-1"></i>
|
||||||
|
Hold Ctrl/Cmd for at vælge flere
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer bg-light">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||||
|
<i class="bi bi-x-circle me-2"></i>
|
||||||
|
Annuller
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-primary btn-lg" id="saveAlertNoteBtn" onclick="saveAlertNote()">
|
||||||
|
<i class="bi bi-save me-2"></i>
|
||||||
|
Gem Alert Note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Modal Header Styling */
|
||||||
|
#alertNoteFormModal .modal-header {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteFormModal .modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteFormModal .modal-footer {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Labels */
|
||||||
|
#alertNoteFormModal .form-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input Fields */
|
||||||
|
#alertNoteFormModal .form-control,
|
||||||
|
#alertNoteFormModal .form-select {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteFormModal .form-control:focus,
|
||||||
|
#alertNoteFormModal .form-select:focus {
|
||||||
|
border-color: var(--bs-primary);
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Textarea specific */
|
||||||
|
#alertNoteFormModal textarea.form-control {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Multiselect Styling */
|
||||||
|
#alertNoteFormModal select[multiple] {
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteFormModal select[multiple]:focus {
|
||||||
|
border-color: var(--bs-primary);
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.15);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteFormModal select[multiple] option {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteFormModal select[multiple] option:hover {
|
||||||
|
background: rgba(13, 110, 253, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteFormModal select[multiple] option:checked {
|
||||||
|
background: var(--bs-primary);
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox Container */
|
||||||
|
#alertNoteFormModal .form-check {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteFormModal .form-check:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] #alertNoteFormModal .form-check:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteFormModal .form-check-input {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteFormModal .form-check-label {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert Info Box */
|
||||||
|
#alertNoteFormModal .alert-info {
|
||||||
|
border-left: 4px solid var(--bs-info);
|
||||||
|
background: rgba(13, 202, 240, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] #alertNoteFormModal .alert-info {
|
||||||
|
background: rgba(13, 202, 240, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background Color Theme Support */
|
||||||
|
[data-bs-theme="dark"] #alertNoteFormModal .bg-light {
|
||||||
|
background: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] #alertNoteFormModal .modal-header {
|
||||||
|
background: rgba(255, 193, 7, 0.1) !important;
|
||||||
|
border-bottom-color: rgba(255, 193, 7, 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Text Helpers */
|
||||||
|
#alertNoteFormModal .form-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Divider */
|
||||||
|
#alertNoteFormModal hr {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#alertNoteFormModal .row > .col-md-6 {
|
||||||
|
margin-bottom: 1rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let alertFormModal = null;
|
||||||
|
let currentAlertEntityType = null;
|
||||||
|
let currentAlertEntityId = null;
|
||||||
|
|
||||||
|
async function openAlertNoteForm(entityType, entityId, alertId = null) {
|
||||||
|
currentAlertEntityType = entityType;
|
||||||
|
currentAlertEntityId = entityId;
|
||||||
|
|
||||||
|
// Load groups and users for restrictions
|
||||||
|
await loadGroupsAndUsers();
|
||||||
|
|
||||||
|
if (alertId) {
|
||||||
|
// Edit mode
|
||||||
|
await loadAlertForEdit(alertId);
|
||||||
|
document.getElementById('alertFormTitle').textContent = 'Rediger Alert Note';
|
||||||
|
} else {
|
||||||
|
// Create mode
|
||||||
|
document.getElementById('alertFormTitle').textContent = 'Opret Alert Note';
|
||||||
|
document.getElementById('alertNoteForm').reset();
|
||||||
|
document.getElementById('alertNoteId').value = '';
|
||||||
|
document.getElementById('alertRequiresAck').checked = true;
|
||||||
|
document.getElementById('alertActive').checked = true;
|
||||||
|
document.getElementById('alertSeverity').value = 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('alertEntityType').value = entityType;
|
||||||
|
document.getElementById('alertEntityId').value = entityId;
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
const modalEl = document.getElementById('alertNoteFormModal');
|
||||||
|
alertFormModal = new bootstrap.Modal(modalEl);
|
||||||
|
alertFormModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGroupsAndUsers() {
|
||||||
|
try {
|
||||||
|
// Load groups
|
||||||
|
const groupsResponse = await fetch('/api/v1/admin/groups', {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (groupsResponse.ok) {
|
||||||
|
const groups = await groupsResponse.json();
|
||||||
|
const groupsSelect = document.getElementById('alertGroups');
|
||||||
|
groupsSelect.innerHTML = groups.map(g =>
|
||||||
|
`<option value="${g.id}">${g.name}</option>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load users
|
||||||
|
const usersResponse = await fetch('/api/v1/admin/users', {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (usersResponse.ok) {
|
||||||
|
const users = await usersResponse.json();
|
||||||
|
const usersSelect = document.getElementById('alertUsers');
|
||||||
|
usersSelect.innerHTML = users.map(u =>
|
||||||
|
`<option value="${u.user_id}">${u.full_name || u.username} (${u.username})</option>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading groups/users:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAlertForEdit(alertId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/alert-notes?entity_type=${currentAlertEntityType}&entity_id=${currentAlertEntityId}`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to load alert');
|
||||||
|
|
||||||
|
const alerts = await response.json();
|
||||||
|
const alert = alerts.find(a => a.id === alertId);
|
||||||
|
|
||||||
|
if (!alert) throw new Error('Alert not found');
|
||||||
|
|
||||||
|
document.getElementById('alertNoteId').value = alert.id;
|
||||||
|
document.getElementById('alertTitle').value = alert.title;
|
||||||
|
document.getElementById('alertMessage').value = alert.message;
|
||||||
|
document.getElementById('alertSeverity').value = alert.severity;
|
||||||
|
document.getElementById('alertRequiresAck').checked = alert.requires_acknowledgement;
|
||||||
|
document.getElementById('alertActive').checked = alert.active;
|
||||||
|
|
||||||
|
// Set restrictions
|
||||||
|
if (alert.restrictions && alert.restrictions.length > 0) {
|
||||||
|
const groupIds = alert.restrictions
|
||||||
|
.filter(r => r.restriction_type === 'group')
|
||||||
|
.map(r => r.restriction_id);
|
||||||
|
const userIds = alert.restrictions
|
||||||
|
.filter(r => r.restriction_type === 'user')
|
||||||
|
.map(r => r.restriction_id);
|
||||||
|
|
||||||
|
// Select options
|
||||||
|
Array.from(document.getElementById('alertGroups').options).forEach(opt => {
|
||||||
|
opt.selected = groupIds.includes(parseInt(opt.value));
|
||||||
|
});
|
||||||
|
Array.from(document.getElementById('alertUsers').options).forEach(opt => {
|
||||||
|
opt.selected = userIds.includes(parseInt(opt.value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading alert for edit:', error);
|
||||||
|
alert('Kunne ikke indlæse alert. Prøv igen.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAlertNote() {
|
||||||
|
const form = document.getElementById('alertNoteForm');
|
||||||
|
if (!form.checkValidity()) {
|
||||||
|
form.reportValidity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alertId = document.getElementById('alertNoteId').value;
|
||||||
|
const isEdit = !!alertId;
|
||||||
|
|
||||||
|
// Get selected groups and users
|
||||||
|
const selectedGroups = Array.from(document.getElementById('alertGroups').selectedOptions)
|
||||||
|
.map(opt => parseInt(opt.value));
|
||||||
|
const selectedUsers = Array.from(document.getElementById('alertUsers').selectedOptions)
|
||||||
|
.map(opt => parseInt(opt.value));
|
||||||
|
|
||||||
|
// Build data object - different structure for create vs update
|
||||||
|
let data;
|
||||||
|
if (isEdit) {
|
||||||
|
// PATCH: Only send fields to update (no entity_type, entity_id)
|
||||||
|
data = {
|
||||||
|
title: document.getElementById('alertTitle').value,
|
||||||
|
message: document.getElementById('alertMessage').value,
|
||||||
|
severity: document.getElementById('alertSeverity').value,
|
||||||
|
requires_acknowledgement: document.getElementById('alertRequiresAck').checked,
|
||||||
|
active: document.getElementById('alertActive').checked,
|
||||||
|
restriction_group_ids: selectedGroups,
|
||||||
|
restriction_user_ids: selectedUsers
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// POST: Include entity_type and entity_id for creation
|
||||||
|
data = {
|
||||||
|
entity_type: document.getElementById('alertEntityType').value,
|
||||||
|
entity_id: parseInt(document.getElementById('alertEntityId').value),
|
||||||
|
title: document.getElementById('alertTitle').value,
|
||||||
|
message: document.getElementById('alertMessage').value,
|
||||||
|
severity: document.getElementById('alertSeverity').value,
|
||||||
|
requires_acknowledgement: document.getElementById('alertRequiresAck').checked,
|
||||||
|
active: document.getElementById('alertActive').checked,
|
||||||
|
restriction_group_ids: selectedGroups,
|
||||||
|
restriction_user_ids: selectedUsers
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saveBtn = document.getElementById('saveAlertNoteBtn');
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Gemmer...';
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log('Saving alert note:', { isEdit, alertId, data });
|
||||||
|
|
||||||
|
let response;
|
||||||
|
if (isEdit) {
|
||||||
|
// Update existing
|
||||||
|
response = await fetch(`/api/v1/alert-notes/${alertId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
response = await fetch('/api/v1/alert-notes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMsg = 'Failed to save alert note';
|
||||||
|
try {
|
||||||
|
const error = await response.json();
|
||||||
|
console.error('API Error Response:', error);
|
||||||
|
|
||||||
|
// Handle Pydantic validation errors
|
||||||
|
if (error.detail && Array.isArray(error.detail)) {
|
||||||
|
errorMsg = error.detail.map(e => `${e.loc.join('.')}: ${e.msg}`).join('\n');
|
||||||
|
} else if (error.detail) {
|
||||||
|
errorMsg = error.detail;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg = `HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
|
alertFormModal.hide();
|
||||||
|
|
||||||
|
// Reload alerts on page
|
||||||
|
loadAndDisplayAlerts(
|
||||||
|
currentAlertEntityType,
|
||||||
|
currentAlertEntityId,
|
||||||
|
'inline',
|
||||||
|
'alert-notes-container'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
showSuccessToast(isEdit ? 'Alert note opdateret!' : 'Alert note oprettet!');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving alert note:', error);
|
||||||
|
|
||||||
|
// Show detailed error message
|
||||||
|
const errorDiv = document.createElement('div');
|
||||||
|
errorDiv.className = 'alert alert-danger alert-dismissible fade show mt-3';
|
||||||
|
errorDiv.innerHTML = `
|
||||||
|
<strong>Kunne ikke gemme alert note:</strong><br>
|
||||||
|
<pre style="white-space: pre-wrap; margin-top: 10px; font-size: 0.9em;">${error.message}</pre>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Insert error before form
|
||||||
|
const modalBody = document.querySelector('#alertNoteFormModal .modal-body');
|
||||||
|
modalBody.insertBefore(errorDiv, modalBody.firstChild);
|
||||||
|
|
||||||
|
// Auto-remove after 10 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (errorDiv.parentNode) {
|
||||||
|
errorDiv.remove();
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
} finally {
|
||||||
|
const saveBtn = document.getElementById('saveAlertNoteBtn');
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.innerHTML = '<i class="bi bi-save me-2"></i>Gem Alert Note';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSuccessToast(message) {
|
||||||
|
// Simple toast notification
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'alert alert-success position-fixed bottom-0 end-0 m-3';
|
||||||
|
toast.style.zIndex = '9999';
|
||||||
|
toast.innerHTML = `<i class="bi bi-check-circle me-2"></i>${message}`;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('fade');
|
||||||
|
setTimeout(() => toast.remove(), 150);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make functions globally available
|
||||||
|
window.openAlertNoteForm = openAlertNoteForm;
|
||||||
|
window.saveAlertNote = saveAlertNote;
|
||||||
|
</script>
|
||||||
198
templates/alert_notes/frontend/alert_modal.html
Normal file
198
templates/alert_notes/frontend/alert_modal.html
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
<!-- Alert Notes Modal Component - For popup display -->
|
||||||
|
<div class="modal fade" id="alertNoteModal" tabindex="-1" aria-labelledby="alertNoteModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header" id="alertModalHeader">
|
||||||
|
<h5 class="modal-title" id="alertNoteModalLabel">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill"></i> Vigtig information
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="alertModalBody">
|
||||||
|
<!-- Alert content will be inserted here -->
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" id="alertModalFooter">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="alertModalAcknowledgeBtn" style="display: none;">
|
||||||
|
<i class="bi bi-check-circle"></i> Forstået
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#alertNoteModal .modal-header.severity-info {
|
||||||
|
background: linear-gradient(135deg, #0dcaf0 0%, #00b4d8 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteModal .modal-header.severity-warning {
|
||||||
|
background: linear-gradient(135deg, #ffc107 0%, #ffb703 100%);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#alertNoteModal .modal-header.severity-critical {
|
||||||
|
background: linear-gradient(135deg, #dc3545 0%, #bb2d3b 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-modal-content {
|
||||||
|
padding: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-modal-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 2px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-modal-message {
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-modal-restrictions {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid #0f4c75;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .alert-modal-restrictions {
|
||||||
|
background: #2c3034;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-modal-restrictions strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-modal-restrictions ul {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-modal-meta {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 15px;
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentAlertModal = null;
|
||||||
|
let currentAlerts = [];
|
||||||
|
|
||||||
|
function showAlertModal(alerts) {
|
||||||
|
if (!alerts || alerts.length === 0) return;
|
||||||
|
|
||||||
|
currentAlerts = alerts;
|
||||||
|
const modal = document.getElementById('alertNoteModal');
|
||||||
|
const modalHeader = document.getElementById('alertModalHeader');
|
||||||
|
const modalBody = document.getElementById('alertModalBody');
|
||||||
|
const modalAckBtn = document.getElementById('alertModalAcknowledgeBtn');
|
||||||
|
|
||||||
|
// Set severity styling (use highest severity)
|
||||||
|
const highestSeverity = alerts.find(a => a.severity === 'critical') ? 'critical' :
|
||||||
|
alerts.find(a => a.severity === 'warning') ? 'warning' : 'info';
|
||||||
|
|
||||||
|
modalHeader.className = `modal-header severity-${highestSeverity}`;
|
||||||
|
|
||||||
|
// Build content
|
||||||
|
let contentHtml = '';
|
||||||
|
|
||||||
|
alerts.forEach((alert, index) => {
|
||||||
|
const severityText = alert.severity === 'info' ? 'INFO' :
|
||||||
|
alert.severity === 'warning' ? 'ADVARSEL' : 'KRITISK';
|
||||||
|
|
||||||
|
let restrictionsHtml = '';
|
||||||
|
if (alert.restrictions && alert.restrictions.length > 0) {
|
||||||
|
const restrictionsList = alert.restrictions
|
||||||
|
.map(r => `<li>${r.restriction_name}</li>`)
|
||||||
|
.join('');
|
||||||
|
restrictionsHtml = `
|
||||||
|
<div class="alert-modal-restrictions">
|
||||||
|
<strong><i class="bi bi-shield-lock"></i> Kun følgende må håndtere denne ${alert.entity_type === 'customer' ? 'kunde' : 'kontakt'}:</strong>
|
||||||
|
<ul>${restrictionsList}</ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdBy = alert.created_by_user_name ? ` • Oprettet af ${alert.created_by_user_name}` : '';
|
||||||
|
|
||||||
|
contentHtml += `
|
||||||
|
<div class="alert-modal-content" data-alert-id="${alert.id}">
|
||||||
|
${index > 0 ? '<hr>' : ''}
|
||||||
|
<div class="alert-modal-title">
|
||||||
|
<span class="badge bg-${alert.severity === 'critical' ? 'danger' : alert.severity === 'warning' ? 'warning' : 'info'}">
|
||||||
|
${severityText}
|
||||||
|
</span>
|
||||||
|
${alert.title}
|
||||||
|
</div>
|
||||||
|
<div class="alert-modal-message">${alert.message}</div>
|
||||||
|
${restrictionsHtml}
|
||||||
|
<div class="alert-modal-meta">
|
||||||
|
<i class="bi bi-calendar"></i> ${new Date(alert.created_at).toLocaleDateString('da-DK', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})}${createdBy}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
modalBody.innerHTML = contentHtml;
|
||||||
|
|
||||||
|
// Show acknowledge button if any alert requires it and user hasn't acknowledged
|
||||||
|
const requiresAck = alerts.some(a => a.requires_acknowledgement && !a.user_has_acknowledged);
|
||||||
|
if (requiresAck) {
|
||||||
|
modalAckBtn.style.display = 'inline-block';
|
||||||
|
modalAckBtn.onclick = function() {
|
||||||
|
acknowledgeAllAlerts();
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
modalAckBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
currentAlertModal = new bootstrap.Modal(modal);
|
||||||
|
currentAlertModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function acknowledgeAllAlerts() {
|
||||||
|
const promises = currentAlerts
|
||||||
|
.filter(a => a.requires_acknowledgement && !a.user_has_acknowledged)
|
||||||
|
.map(alert => {
|
||||||
|
return fetch(`/api/v1/alert-notes/${alert.id}/acknowledge`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all(promises)
|
||||||
|
.then(() => {
|
||||||
|
if (currentAlertModal) {
|
||||||
|
currentAlertModal.hide();
|
||||||
|
}
|
||||||
|
// Reload alerts on the page if in inline view
|
||||||
|
if (typeof loadAlerts === 'function') {
|
||||||
|
loadAlerts();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error acknowledging alerts:', error);
|
||||||
|
alert('Kunne ikke markere som læst. Prøv igen.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Loading…
Reference in New Issue
Block a user