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:
Christian 2026-02-17 12:49:11 +01:00
parent 3cddb71cec
commit e6b4d8fb47
21 changed files with 3406 additions and 29 deletions

View File

@ -0,0 +1 @@
"""Alert Notes Module"""

View File

@ -0,0 +1,4 @@
"""Alert Notes Backend Module"""
from app.alert_notes.backend.router import router
__all__ = ["router"]

View 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}

View 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

View 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>

View 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;

View 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>

View 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>

View File

@ -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) {

View File

@ -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) {

View 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()

View File

@ -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'));

View File

@ -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")

View File

@ -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]:

View File

@ -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"])

View 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;

View 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()

View 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;

View 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>

View 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>

View 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>