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 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()">
|
||||
<i class="bi bi-pencil me-2"></i>Rediger
|
||||
</button>
|
||||
@ -144,6 +147,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert Notes Container -->
|
||||
<div id="alert-notes-container"></div>
|
||||
|
||||
<!-- Content Layout with Sidebar Navigation -->
|
||||
<div class="row">
|
||||
<div class="col-lg-3 col-md-4">
|
||||
@ -784,6 +790,12 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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>
|
||||
const contactId = parseInt(window.location.pathname.split('/').pop());
|
||||
let contactData = null;
|
||||
@ -931,6 +943,9 @@ function displayContact(contact) {
|
||||
if (document.querySelector('a[href="#companies"]').classList.contains('active')) {
|
||||
displayCompanies(contact.companies);
|
||||
}
|
||||
|
||||
// Load Alert Notes for this contact
|
||||
loadAndDisplayAlerts('contact', contact.id, 'inline', 'alert-notes-container');
|
||||
}
|
||||
|
||||
function renderNumberActions(number, allowSms = false, contact = null) {
|
||||
|
||||
@ -245,6 +245,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<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()">
|
||||
<i class="bi bi-pencil-square me-2"></i>Rediger Kunde
|
||||
</button>
|
||||
@ -255,6 +258,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert Notes Container -->
|
||||
<div id="alert-notes-container"></div>
|
||||
|
||||
<!-- 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;">
|
||||
<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 %}
|
||||
|
||||
{% 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>
|
||||
const customerId = parseInt(window.location.pathname.split('/').pop());
|
||||
let customerData = null;
|
||||
@ -1403,6 +1415,9 @@ function displayCustomer(customer) {
|
||||
? new Date(customer.economic_last_sync_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) {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/* Wider tooltip for relation type explanations */
|
||||
.tooltip-wide .tooltip-inner {
|
||||
max-width: 400px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tag-closed {
|
||||
background-color: #e0e0e0;
|
||||
color: #666;
|
||||
@ -953,7 +959,15 @@
|
||||
<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-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">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="showRelationModal()">
|
||||
<i class="bi bi-link-45deg"></i>
|
||||
@ -964,13 +978,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<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) %}
|
||||
<ul class="relation-tree">
|
||||
{% for node in nodes %}
|
||||
@ -1546,6 +1553,13 @@
|
||||
|
||||
// Initialize everything when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize Bootstrap tooltips
|
||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((el) => {
|
||||
new bootstrap.Tooltip(el, {
|
||||
customClass: 'tooltip-wide'
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize modals
|
||||
contactSearchModal = new bootstrap.Modal(document.getElementById('contactSearchModal'));
|
||||
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)
|
||||
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_id = kontakt.get("id") if kontakt else None
|
||||
@ -237,9 +237,11 @@ async def yealink_established(
|
||||
"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(
|
||||
callid=resolved_callid,
|
||||
user_id=user_id,
|
||||
user_id=primary_user_id,
|
||||
direction=direction,
|
||||
ekstern_nummer=ekstern_value,
|
||||
intern_extension=(local_extension or "")[:16] or None,
|
||||
@ -248,19 +250,19 @@ async def yealink_established(
|
||||
started_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
if user_id:
|
||||
await manager.send_to_user(
|
||||
user_id,
|
||||
"incoming_call",
|
||||
{
|
||||
# Send websocket notification to ALL users with this extension
|
||||
if user_ids:
|
||||
call_data = {
|
||||
"call_id": str(row.get("id") or resolved_callid),
|
||||
"number": ekstern_e164 or (ekstern_raw or ""),
|
||||
"direction": direction,
|
||||
"contact": kontakt,
|
||||
"recent_cases": contact_details.get("recent_cases", []),
|
||||
"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:
|
||||
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:
|
||||
target_user_id = int(user_id)
|
||||
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:
|
||||
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:
|
||||
@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:
|
||||
return None
|
||||
row = execute_query_single(
|
||||
"SELECT user_id FROM users WHERE telefoni_aktiv = TRUE AND telefoni_extension = %s LIMIT 1",
|
||||
return []
|
||||
rows = execute_query(
|
||||
"SELECT user_id FROM users WHERE telefoni_aktiv = TRUE AND telefoni_extension = %s ORDER BY user_id",
|
||||
(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
|
||||
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 bmc_office_router
|
||||
# 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.frontend import views as billing_views
|
||||
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(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(alert_notes_api, prefix="/api/v1", tags=["Alert Notes"])
|
||||
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(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