feat: integrate bug reporting module and enhance screenshot functionality
- Added bug reporting API router to main application. - Enhanced screenshot functionality in bug-report.js with timeout handling and dynamic loading of html2canvas library. - Improved screenshot capture strategies and error handling for better user experience. - Updated modal handling for bug reports to include screenshot previews and status messages. - Refactored code for better readability and maintainability. feat: implement user-specific case status and preferences - Created migration to allow dynamic case statuses in sag_sager table. - Added user_sag_list_preferences table for per-user preferences on case list filters. - Introduced user_menu_preferences table to manage per-user menu visibility preferences with triggers for updated_at timestamps.
This commit is contained in:
parent
70a01db422
commit
ce75f12f56
@ -273,6 +273,10 @@ class AnyDeskIdAdd(BaseModel):
|
||||
label: Optional[str] = None
|
||||
|
||||
|
||||
class MenuPreferencesUpdate(BaseModel):
|
||||
hidden_menu_keys: list[str] = []
|
||||
|
||||
|
||||
@router.get("/me/anydesk-ids")
|
||||
async def get_my_anydesk_ids(current_user: dict = Depends(get_current_user)):
|
||||
rows = execute_query(
|
||||
@ -306,3 +310,73 @@ async def delete_my_anydesk_id(entry_id: int, current_user: dict = Depends(get_c
|
||||
if not rows:
|
||||
raise HTTPException(status_code=404, detail="Ikke fundet")
|
||||
return {"message": "Slettet"}
|
||||
|
||||
|
||||
@router.get("/me/menu-preferences")
|
||||
async def get_my_menu_preferences(current_user: dict = Depends(get_current_user)):
|
||||
"""Get current user's menu visibility preferences."""
|
||||
try:
|
||||
rows = execute_query(
|
||||
"""
|
||||
SELECT menu_key
|
||||
FROM user_menu_preferences
|
||||
WHERE user_id = %s
|
||||
AND visible = FALSE
|
||||
ORDER BY menu_key ASC
|
||||
""",
|
||||
(current_user["id"],),
|
||||
) or []
|
||||
return {"hidden_menu_keys": [str(r.get("menu_key") or "") for r in rows if r.get("menu_key")]}
|
||||
except Exception as exc:
|
||||
if "user_menu_preferences" in str(exc):
|
||||
logger.warning("⚠️ user_menu_preferences table not found; returning defaults")
|
||||
return {"hidden_menu_keys": []}
|
||||
raise
|
||||
|
||||
|
||||
@router.patch("/me/menu-preferences")
|
||||
async def update_my_menu_preferences(
|
||||
payload: MenuPreferencesUpdate,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Replace current user's hidden menu keys."""
|
||||
keys = []
|
||||
seen = set()
|
||||
for raw in payload.hidden_menu_keys or []:
|
||||
key = str(raw or "").strip().lower()
|
||||
if not key:
|
||||
continue
|
||||
if len(key) > 120:
|
||||
continue
|
||||
if any(ch for ch in key if not (ch.isalnum() or ch in {"-", "_"})):
|
||||
continue
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
keys.append(key)
|
||||
|
||||
try:
|
||||
execute_query(
|
||||
"DELETE FROM user_menu_preferences WHERE user_id = %s",
|
||||
(current_user["id"],),
|
||||
)
|
||||
|
||||
for key in keys:
|
||||
execute_query(
|
||||
"""
|
||||
INSERT INTO user_menu_preferences (user_id, menu_key, visible)
|
||||
VALUES (%s, %s, FALSE)
|
||||
ON CONFLICT (user_id, menu_key)
|
||||
DO UPDATE SET visible = EXCLUDED.visible, updated_at = NOW()
|
||||
""",
|
||||
(current_user["id"], key),
|
||||
)
|
||||
|
||||
return {"message": "Menuindstillinger gemt", "hidden_menu_keys": keys}
|
||||
except Exception as exc:
|
||||
if "user_menu_preferences" in str(exc):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Menuindstillinger er ikke klar endnu. Kør migration 191 først.",
|
||||
)
|
||||
raise
|
||||
|
||||
@ -507,6 +507,218 @@ async def get_related_contacts(contact_id: int):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/contacts/{contact_id}/cases")
|
||||
async def get_contact_cases(contact_id: int):
|
||||
"""Get cases linked directly to a contact and cases from the contact's primary company."""
|
||||
try:
|
||||
contact_row = execute_query(
|
||||
"""
|
||||
SELECT
|
||||
c.id,
|
||||
(
|
||||
SELECT cu.id
|
||||
FROM contact_companies cc
|
||||
JOIN customers cu ON cu.id = cc.customer_id
|
||||
WHERE cc.contact_id = c.id
|
||||
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
||||
LIMIT 1
|
||||
) AS company_id,
|
||||
(
|
||||
SELECT cu.name
|
||||
FROM contact_companies cc
|
||||
JOIN customers cu ON cu.id = cc.customer_id
|
||||
WHERE cc.contact_id = c.id
|
||||
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
||||
LIMIT 1
|
||||
) AS company_name
|
||||
FROM contacts c
|
||||
WHERE c.id = %s
|
||||
""",
|
||||
(contact_id,),
|
||||
)
|
||||
|
||||
if not contact_row:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
|
||||
company_id = contact_row[0].get("company_id")
|
||||
|
||||
contact_cases = execute_query(
|
||||
"""
|
||||
SELECT
|
||||
s.id,
|
||||
s.titel,
|
||||
s.status,
|
||||
s.customer_id,
|
||||
cu.name AS customer_name,
|
||||
s.created_at,
|
||||
s.updated_at
|
||||
FROM sag_sager s
|
||||
INNER JOIN sag_kontakter sk ON s.id = sk.sag_id
|
||||
LEFT JOIN customers cu ON cu.id = s.customer_id
|
||||
WHERE sk.contact_id = %s
|
||||
AND s.deleted_at IS NULL
|
||||
AND sk.deleted_at IS NULL
|
||||
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
(contact_id,),
|
||||
) or []
|
||||
|
||||
company_cases = []
|
||||
if company_id:
|
||||
company_cases = execute_query(
|
||||
"""
|
||||
SELECT
|
||||
s.id,
|
||||
s.titel,
|
||||
s.status,
|
||||
s.customer_id,
|
||||
cu.name AS customer_name,
|
||||
s.created_at,
|
||||
s.updated_at
|
||||
FROM sag_sager s
|
||||
LEFT JOIN customers cu ON cu.id = s.customer_id
|
||||
WHERE s.customer_id = %s
|
||||
AND s.deleted_at IS NULL
|
||||
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
(company_id,),
|
||||
) or []
|
||||
|
||||
return {
|
||||
"contact": {
|
||||
"id": contact_row[0]["id"],
|
||||
"company_id": company_id,
|
||||
"company_name": contact_row[0].get("company_name"),
|
||||
},
|
||||
"contact_cases": contact_cases,
|
||||
"company_cases": company_cases,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get cases for contact {contact_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/contacts/{contact_id}/case-context")
|
||||
async def get_contact_case_context(contact_id: int):
|
||||
"""Get case suggestions for a contact: contact cases, company cases and related contacts."""
|
||||
try:
|
||||
contact_rows = execute_query(
|
||||
"""
|
||||
SELECT
|
||||
c.id,
|
||||
c.first_name,
|
||||
c.last_name,
|
||||
c.email,
|
||||
c.phone,
|
||||
c.mobile,
|
||||
c.title,
|
||||
c.department,
|
||||
c.is_active,
|
||||
c.created_at,
|
||||
c.updated_at,
|
||||
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) AS company_names
|
||||
FROM contacts c
|
||||
LEFT JOIN contact_companies cc ON c.id = cc.contact_id
|
||||
LEFT JOIN customers cu ON cc.customer_id = cu.id
|
||||
WHERE c.id = %s
|
||||
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile, c.title, c.department, c.is_active, c.created_at, c.updated_at
|
||||
""",
|
||||
(contact_id,),
|
||||
) or []
|
||||
if not contact_rows:
|
||||
return {"contact_cases": [], "company_cases": [], "related_contacts": []}
|
||||
|
||||
customer_ids = get_contact_customer_ids(contact_id)
|
||||
placeholders = ",".join(["%s"] * len(customer_ids)) if customer_ids else ""
|
||||
|
||||
contact_cases = execute_query(
|
||||
"""
|
||||
SELECT
|
||||
s.id,
|
||||
s.titel,
|
||||
s.status,
|
||||
s.customer_id,
|
||||
cu.name AS customer_name,
|
||||
s.created_at,
|
||||
s.updated_at
|
||||
FROM sag_sager s
|
||||
INNER JOIN sag_kontakter sk ON s.id = sk.sag_id
|
||||
LEFT JOIN customers cu ON cu.id = s.customer_id
|
||||
WHERE sk.contact_id = %s
|
||||
AND s.deleted_at IS NULL
|
||||
AND sk.deleted_at IS NULL
|
||||
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
(contact_id,),
|
||||
) or []
|
||||
|
||||
company_cases = []
|
||||
related_contacts = []
|
||||
if customer_ids:
|
||||
company_cases = execute_query(
|
||||
f"""
|
||||
SELECT
|
||||
s.id,
|
||||
s.titel,
|
||||
s.status,
|
||||
s.customer_id,
|
||||
cu.name AS customer_name,
|
||||
s.created_at,
|
||||
s.updated_at
|
||||
FROM sag_sager s
|
||||
LEFT JOIN customers cu ON cu.id = s.customer_id
|
||||
WHERE s.customer_id IN ({placeholders})
|
||||
AND s.deleted_at IS NULL
|
||||
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
tuple(customer_ids),
|
||||
) or []
|
||||
|
||||
related_contacts = execute_query(
|
||||
f"""
|
||||
SELECT
|
||||
c.id,
|
||||
c.first_name,
|
||||
c.last_name,
|
||||
c.email,
|
||||
c.phone,
|
||||
c.mobile,
|
||||
c.title,
|
||||
c.department,
|
||||
c.is_active,
|
||||
c.created_at,
|
||||
c.updated_at,
|
||||
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) AS company_names
|
||||
FROM contacts c
|
||||
JOIN contact_companies cc ON c.id = cc.contact_id
|
||||
JOIN customers cu ON cc.customer_id = cu.id
|
||||
WHERE cc.customer_id IN ({placeholders})
|
||||
AND c.id <> %s
|
||||
AND c.is_active = TRUE
|
||||
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile, c.title, c.department, c.is_active, c.created_at, c.updated_at
|
||||
ORDER BY c.last_name, c.first_name
|
||||
LIMIT 10
|
||||
""",
|
||||
tuple(customer_ids + [contact_id]),
|
||||
) or []
|
||||
|
||||
return {
|
||||
"contact_cases": contact_cases,
|
||||
"company_cases": company_cases,
|
||||
"related_contacts": related_contacts,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get case context for contact {contact_id}: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/contacts/{contact_id}/subscriptions")
|
||||
async def get_contact_subscriptions(contact_id: int):
|
||||
customer_id = get_primary_customer_id(contact_id)
|
||||
|
||||
@ -225,6 +225,70 @@
|
||||
.btn-edit-customer:hover i {
|
||||
transform: rotate(-15deg) scale(1.1);
|
||||
}
|
||||
|
||||
.contacts-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.contacts-table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.contacts-table thead th {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(15, 76, 117, 0.06);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
padding: 0.82rem 0.8rem;
|
||||
}
|
||||
|
||||
.contacts-table tbody td {
|
||||
border-color: rgba(0, 0, 0, 0.06);
|
||||
padding: 0.85rem 0.8rem;
|
||||
}
|
||||
|
||||
.contacts-table .contact-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.contacts-table .contact-email a {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.contacts-table .contact-number {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contacts-table .contact-mobile-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.contacts-table .contact-mobile-wrap .btn {
|
||||
padding: 0.18rem 0.52rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.contacts-table .primary-pill {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
#contactsContainer {
|
||||
min-width: 920px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@ -550,31 +614,36 @@
|
||||
<!-- Contacts Tab -->
|
||||
<div class="tab-pane fade" id="contacts">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h5 class="fw-bold mb-0">Kontaktpersoner</h5>
|
||||
<div>
|
||||
<h5 class="fw-bold mb-0">Kontaktpersoner</h5>
|
||||
<small class="text-muted">Direkte kontaktoplysninger for denne kunde</small>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" onclick="showAddContactModal()">
|
||||
<i class="bi bi-plus-lg me-2"></i>Tilføj Kontakt
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-responsive" id="contactsContainer">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Navn</th>
|
||||
<th>Titel</th>
|
||||
<th>Email</th>
|
||||
<th>Telefon</th>
|
||||
<th>Mobil</th>
|
||||
<th>Primær</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-4">
|
||||
<div class="spinner-border text-primary"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="contacts-panel">
|
||||
<div class="table-responsive" id="contactsContainer">
|
||||
<table class="table table-hover align-middle contacts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Navn</th>
|
||||
<th>Titel</th>
|
||||
<th>Email</th>
|
||||
<th>Telefon</th>
|
||||
<th>Mobil</th>
|
||||
<th>Primær</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-4">
|
||||
<div class="spinner-border text-primary"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2588,9 +2657,10 @@ function displayUtilityCompany(payload) {
|
||||
|
||||
async function loadContacts() {
|
||||
const container = document.getElementById('contactsContainer');
|
||||
container.innerHTML = `
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
|
||||
const renderContactsTable = (bodyHtml) => `
|
||||
<table class="table table-hover align-middle contacts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Navn</th>
|
||||
<th>Titel</th>
|
||||
@ -2601,14 +2671,20 @@ async function loadContacts() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-4">
|
||||
<div class="spinner-border text-primary"></div>
|
||||
</td>
|
||||
</tr>
|
||||
${bodyHtml}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
container.innerHTML = `
|
||||
${renderContactsTable(`
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-4">
|
||||
<div class="spinner-border text-primary"></div>
|
||||
</td>
|
||||
</tr>
|
||||
`)}
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/customers/${customerId}/contacts`);
|
||||
@ -2620,20 +2696,27 @@ async function loadContacts() {
|
||||
}
|
||||
|
||||
const rows = contacts.map(contact => {
|
||||
const firstName = String(contact.first_name || '').trim();
|
||||
const lastName = String(contact.last_name || '').trim();
|
||||
const displayName = [firstName, lastName].filter(Boolean).join(' ') || String(contact.name || '').trim() || '—';
|
||||
const mobileValue = String(contact.mobile || contact.mobile_phone || '').trim();
|
||||
const phoneValue = String(contact.phone || '').trim();
|
||||
const titleValue = String(contact.title || contact.role || '').trim();
|
||||
|
||||
const email = contact.email ? `<a href="mailto:${contact.email}">${escapeHtml(contact.email)}</a>` : '—';
|
||||
const phone = contact.phone ? `<a href="tel:${contact.phone}">${escapeHtml(contact.phone)}</a>` : '—';
|
||||
const mobile = contact.mobile
|
||||
? `<div class="d-flex align-items-center gap-2 flex-wrap"><a href="tel:${contact.mobile}">${escapeHtml(contact.mobile)}</a><button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt('${escapeHtml(contact.mobile)}', '${escapeHtml(contact.name || '')}', ${contact.id || 'null'})">SMS</button></div>`
|
||||
const phone = phoneValue ? `<span class="contact-number"><a href="tel:${phoneValue}">${escapeHtml(phoneValue)}</a></span>` : '—';
|
||||
const mobile = mobileValue
|
||||
? `<div class="contact-mobile-wrap"><span class="contact-number"><a href="tel:${mobileValue}">${escapeHtml(mobileValue)}</a></span><button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt('${escapeHtml(mobileValue)}', '${escapeHtml(displayName)}', ${contact.id || 'null'})">SMS</button></div>`
|
||||
: '—';
|
||||
const title = contact.title ? escapeHtml(contact.title) : '—';
|
||||
const primaryBadge = contact.is_primary ? '<span class="badge bg-primary">Primær</span>' : '—';
|
||||
const title = titleValue ? escapeHtml(titleValue) : '—';
|
||||
const primaryBadge = contact.is_primary ? '<span class="badge bg-primary primary-pill">Primær</span>' : '—';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td class="fw-semibold">${escapeHtml(contact.name || '-') }</td>
|
||||
<td class="contact-name">${escapeHtml(displayName)}</td>
|
||||
<td>${title}</td>
|
||||
<td>${email}</td>
|
||||
<td>${phone}</td>
|
||||
<td class="contact-email">${email}</td>
|
||||
<td class="contact-number">${phone}</td>
|
||||
<td>${mobile}</td>
|
||||
<td>${primaryBadge}</td>
|
||||
</tr>
|
||||
@ -2641,21 +2724,7 @@ async function loadContacts() {
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = `
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Navn</th>
|
||||
<th>Titel</th>
|
||||
<th>Email</th>
|
||||
<th>Telefon</th>
|
||||
<th>Mobil</th>
|
||||
<th>Primær</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
${renderContactsTable(rows)}
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error('Failed to load contacts:', error);
|
||||
|
||||
@ -97,24 +97,34 @@ def _normalize_case_status(status_value: Optional[str]) -> str:
|
||||
allowed_statuses = ["åben", "under behandling", "afventer", "løst", "lukket"]
|
||||
|
||||
allowed_map = {s.lower(): s for s in allowed_statuses}
|
||||
open_aliases = {"åben", "open", "under behandling", "afventer", "i_gang", "on_hold"}
|
||||
closed_aliases = {"lukket", "closed", "løst", "afsluttet", "resolved", "done"}
|
||||
open_default = allowed_map.get("åben", allowed_statuses[0])
|
||||
closed_default = allowed_map.get("lukket", allowed_map.get("løst", open_default))
|
||||
|
||||
if not status_value:
|
||||
return allowed_map.get("åben", allowed_statuses[0])
|
||||
return open_default
|
||||
|
||||
normalized = str(status_value).strip().lower()
|
||||
if normalized in allowed_map:
|
||||
return allowed_map[normalized]
|
||||
|
||||
if normalized in open_aliases:
|
||||
return open_default
|
||||
|
||||
if normalized in closed_aliases:
|
||||
return closed_default
|
||||
|
||||
# Backward compatibility for legacy mapping
|
||||
if normalized == "afventer" and "åben" in allowed_map:
|
||||
return allowed_map["åben"]
|
||||
return open_default
|
||||
|
||||
# Do not force unknown values back to default; preserve user-entered/custom DB values
|
||||
raw_value = str(status_value).strip()
|
||||
if raw_value:
|
||||
return raw_value
|
||||
|
||||
return allowed_map.get("åben", allowed_statuses[0])
|
||||
return open_default
|
||||
|
||||
|
||||
def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]:
|
||||
@ -332,6 +342,10 @@ class SagBuzzwordSelectionRequest(BaseModel):
|
||||
selected_text: str = Field(..., min_length=1, max_length=2000)
|
||||
|
||||
|
||||
class SagListPreferencesUpdate(BaseModel):
|
||||
type_filters: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _normalize_email_list(values: List[str], field_name: str) -> List[str]:
|
||||
cleaned: List[str] = []
|
||||
for value in values or []:
|
||||
@ -1064,6 +1078,80 @@ async def list_recent_sager(request: Request, limit: int = Query(10, ge=1, le=10
|
||||
logger.error("❌ Error listing recent cases for user %s: %s", user_id, e)
|
||||
raise HTTPException(status_code=500, detail="Failed to list recent cases")
|
||||
|
||||
@router.get("/sag/me/list-preferences")
|
||||
async def get_my_sag_list_preferences(request: Request):
|
||||
user_id = _get_user_id_from_request(request)
|
||||
try:
|
||||
rows = execute_query(
|
||||
"""
|
||||
SELECT type_filters
|
||||
FROM user_sag_list_preferences
|
||||
WHERE user_id = %s
|
||||
""",
|
||||
(user_id,),
|
||||
) or []
|
||||
if not rows:
|
||||
return {"type_filters": []}
|
||||
|
||||
raw = rows[0].get("type_filters")
|
||||
parsed = []
|
||||
if isinstance(raw, list):
|
||||
parsed = raw
|
||||
elif isinstance(raw, str):
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
if isinstance(payload, list):
|
||||
parsed = payload
|
||||
except Exception:
|
||||
parsed = []
|
||||
|
||||
normalized = []
|
||||
seen = set()
|
||||
for value in parsed:
|
||||
item = str(value or "").strip().lower()
|
||||
if not item or item in seen:
|
||||
continue
|
||||
seen.add(item)
|
||||
normalized.append(item)
|
||||
|
||||
return {"type_filters": normalized}
|
||||
except Exception as e:
|
||||
if "user_sag_list_preferences" in str(e):
|
||||
return {"type_filters": []}
|
||||
logger.error("❌ Could not load sag list preferences for user %s: %s", user_id, e)
|
||||
raise HTTPException(status_code=500, detail="Failed to load list preferences")
|
||||
|
||||
@router.patch("/sag/me/list-preferences")
|
||||
async def update_my_sag_list_preferences(request: Request, payload: SagListPreferencesUpdate):
|
||||
user_id = _get_user_id_from_request(request)
|
||||
try:
|
||||
normalized = []
|
||||
seen = set()
|
||||
for value in payload.type_filters or []:
|
||||
item = str(value or "").strip().lower()
|
||||
if not item or item in seen:
|
||||
continue
|
||||
if len(item) > 80:
|
||||
continue
|
||||
seen.add(item)
|
||||
normalized.append(item)
|
||||
|
||||
execute_query(
|
||||
"""
|
||||
INSERT INTO user_sag_list_preferences (user_id, type_filters, updated_at)
|
||||
VALUES (%s, %s::jsonb, NOW())
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET
|
||||
type_filters = EXCLUDED.type_filters,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(user_id, json.dumps(normalized)),
|
||||
)
|
||||
return {"type_filters": normalized}
|
||||
except Exception as e:
|
||||
logger.error("❌ Could not update sag list preferences for user %s: %s", user_id, e)
|
||||
raise HTTPException(status_code=500, detail="Failed to save list preferences")
|
||||
|
||||
|
||||
@router.get("/sag/{sag_id}/modules")
|
||||
async def get_case_module_prefs(sag_id: int):
|
||||
|
||||
@ -158,6 +158,38 @@ def _fetch_case_status_options() -> list[str]:
|
||||
return values
|
||||
|
||||
|
||||
def _fetch_closed_case_statuses() -> list[str]:
|
||||
values = []
|
||||
seen = set()
|
||||
|
||||
def _add(value: Optional[str]) -> None:
|
||||
candidate = str(value or "").strip().lower()
|
||||
if not candidate or candidate in seen:
|
||||
return
|
||||
seen.add(candidate)
|
||||
values.append(candidate)
|
||||
|
||||
setting_row = execute_query(
|
||||
"SELECT value FROM settings WHERE key = %s",
|
||||
("case_statuses",)
|
||||
)
|
||||
|
||||
if setting_row and setting_row[0].get("value"):
|
||||
try:
|
||||
parsed = json.loads(setting_row[0].get("value") or "[]")
|
||||
for item in parsed if isinstance(parsed, list) else []:
|
||||
if isinstance(item, dict) and item.get("is_closed"):
|
||||
_add(item.get("value"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not values:
|
||||
for fallback in ["lukket", "løst", "afsluttet", "closed", "resolved", "done"]:
|
||||
_add(fallback)
|
||||
|
||||
return values
|
||||
|
||||
|
||||
@router.get("/sag", response_class=HTMLResponse)
|
||||
async def sager_liste(
|
||||
request: Request,
|
||||
@ -171,6 +203,7 @@ async def sager_liste(
|
||||
):
|
||||
"""Display list of all cases."""
|
||||
try:
|
||||
closed_statuses = _fetch_closed_case_statuses()
|
||||
# Coerce string params to optional ints
|
||||
customer_id_int = _coerce_optional_int(customer_id)
|
||||
requested_unassigned = bool(unassigned) or str(ansvarlig_bruger_id or "").strip().upper() == "__UNASSIGNED__"
|
||||
@ -241,9 +274,16 @@ async def sager_liste(
|
||||
query += ")"
|
||||
query += " AND (s.start_date IS NULL OR s.start_date <= NOW())"
|
||||
|
||||
if status:
|
||||
normalized_status = str(status or "").strip().lower()
|
||||
if normalized_status == "all":
|
||||
pass
|
||||
elif normalized_status:
|
||||
query += " AND s.status = %s"
|
||||
params.append(status)
|
||||
else:
|
||||
placeholders = ", ".join(["%s"] * len(closed_statuses))
|
||||
query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})"
|
||||
params.extend(closed_statuses)
|
||||
if customer_id_int:
|
||||
query += " AND s.customer_id = %s"
|
||||
params.append(customer_id_int)
|
||||
@ -298,9 +338,15 @@ async def sager_liste(
|
||||
fallback_query += " AND (s.deferred_until IS NULL OR s.deferred_until <= NOW())"
|
||||
fallback_query += " AND (s.start_date IS NULL OR s.start_date <= NOW())"
|
||||
|
||||
if status:
|
||||
if normalized_status == "all":
|
||||
pass
|
||||
elif normalized_status:
|
||||
fallback_query += " AND s.status = %s"
|
||||
fallback_params.append(status)
|
||||
else:
|
||||
placeholders = ", ".join(["%s"] * len(closed_statuses))
|
||||
fallback_query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})"
|
||||
fallback_params.extend(closed_statuses)
|
||||
if customer_id_int:
|
||||
fallback_query += " AND s.customer_id = %s"
|
||||
fallback_params.append(customer_id_int)
|
||||
@ -389,6 +435,7 @@ async def sager_liste(
|
||||
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
|
||||
"current_assigned_group_id": assigned_group_id_int,
|
||||
"current_unassigned": requested_unassigned,
|
||||
"closed_statuses": closed_statuses,
|
||||
})
|
||||
except Exception:
|
||||
logger.exception("❌ Error displaying case list")
|
||||
@ -409,6 +456,7 @@ async def sager_liste(
|
||||
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
|
||||
"current_assigned_group_id": assigned_group_id_int,
|
||||
"current_unassigned": requested_unassigned,
|
||||
"closed_statuses": _fetch_closed_case_statuses(),
|
||||
})
|
||||
|
||||
@router.get("/sag/new", response_class=HTMLResponse)
|
||||
|
||||
@ -65,6 +65,16 @@
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sag-table thead th.col-expand,
|
||||
.sag-table tbody td.col-expand {
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
max-width: 44px;
|
||||
text-align: center;
|
||||
padding-left: 0.45rem;
|
||||
padding-right: 0.45rem;
|
||||
}
|
||||
|
||||
.sag-table tbody tr {
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
@ -177,11 +187,6 @@
|
||||
content: none;
|
||||
}
|
||||
|
||||
.tree-row.has-children td:first-child {
|
||||
position: relative;
|
||||
padding-left: 2.5rem !important;
|
||||
}
|
||||
|
||||
.tree-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -200,6 +205,11 @@
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.col-expand .tree-toggle {
|
||||
position: static;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.tree-toggle:hover {
|
||||
background: var(--accent);
|
||||
@ -215,19 +225,11 @@
|
||||
border-top: none !important;
|
||||
}
|
||||
|
||||
.tree-child td:first-child {
|
||||
position: relative;
|
||||
padding-left: 2.5rem !important;
|
||||
}
|
||||
|
||||
.tree-child td:first-child:before {
|
||||
content: '└';
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: rgba(0,0,0,0.3);
|
||||
font-size: 1.2rem;
|
||||
.tree-child .child-branch {
|
||||
color: rgba(0,0,0,0.4);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.relation-badge {
|
||||
@ -250,6 +252,8 @@
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: #e9ecef;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.status-åben {
|
||||
@ -261,6 +265,28 @@
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-under-behandling,
|
||||
.status-i-gang,
|
||||
.status-in-progress {
|
||||
background: #dbeafe;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.status-afventer,
|
||||
.status-on-hold {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.status-løst,
|
||||
.status-afsluttet,
|
||||
.status-resolved,
|
||||
.status-done,
|
||||
.status-closed {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.filter-pills {
|
||||
display: flex;
|
||||
@ -322,20 +348,229 @@
|
||||
letter-spacing: 0.35px;
|
||||
}
|
||||
|
||||
.owner-cell {
|
||||
.type-filter-wrap {
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.type-filter-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.type-filter-dropdown .dropdown-toggle {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #ced4da;
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
font-weight: 400;
|
||||
font-size: 0.86rem;
|
||||
padding: 0.28rem 2rem 0.28rem 0.65rem;
|
||||
min-height: calc(1.4em + 0.56rem + 2px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.type-filter-dropdown .dropdown-toggle:hover,
|
||||
.type-filter-dropdown .dropdown-toggle:focus,
|
||||
.type-filter-dropdown .dropdown-toggle:active,
|
||||
.type-filter-dropdown .dropdown-toggle.show {
|
||||
border-color: #86b7fe;
|
||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||
color: var(--text-primary);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.type-filter-dropdown .dropdown-toggle::after {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.type-filter-dropdown .dropdown-menu {
|
||||
width: min(340px, 92vw);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
padding: 0.45rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.type-filter-checkbox-list {
|
||||
max-height: 12.5rem;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
|
||||
.type-filter-checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.34rem 0.42rem;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.2rem;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.type-filter-checkbox-item:hover {
|
||||
background: rgba(15, 76, 117, 0.08);
|
||||
}
|
||||
|
||||
.type-filter-checkbox-item input {
|
||||
margin-top: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.type-filter-checkbox-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #16384f;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.type-filter-menu-actions {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
margin-top: 0.35rem;
|
||||
padding-top: 0.38rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.type-filter-menu-actions .btn {
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.2;
|
||||
padding: 0.2rem 0.45rem;
|
||||
}
|
||||
|
||||
.type-filter-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.type-filter-selection {
|
||||
font-size: 0.73rem;
|
||||
color: var(--text-secondary);
|
||||
min-height: 1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.type-filter-empty {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 0.22rem 0.1rem;
|
||||
}
|
||||
|
||||
.mini-filter-wrap {
|
||||
min-width: 190px;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.mini-filter-dropdown .dropdown-toggle {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #ced4da;
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
font-weight: 400;
|
||||
font-size: 0.86rem;
|
||||
padding: 0.28rem 2rem 0.28rem 0.65rem;
|
||||
min-height: calc(1.4em + 0.56rem + 2px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.mini-filter-dropdown .dropdown-toggle:hover,
|
||||
.mini-filter-dropdown .dropdown-toggle:focus,
|
||||
.mini-filter-dropdown .dropdown-toggle:active,
|
||||
.mini-filter-dropdown .dropdown-toggle.show {
|
||||
border-color: #86b7fe;
|
||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||
color: var(--text-primary);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.mini-filter-dropdown .dropdown-menu {
|
||||
width: min(320px, 92vw);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
padding: 0.45rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.mini-filter-list {
|
||||
max-height: 12.5rem;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
|
||||
.mini-filter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.52rem;
|
||||
padding: 0.34rem 0.42rem;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.15rem;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.mini-filter-item:hover {
|
||||
background: rgba(15, 76, 117, 0.08);
|
||||
}
|
||||
|
||||
.mini-filter-item input {
|
||||
margin-top: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mini-filter-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #16384f;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mini-filter-footer {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
margin-top: 0.35rem;
|
||||
padding-top: 0.35rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.mini-filter-clear {
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.2;
|
||||
padding: 0.2rem 0.45rem;
|
||||
}
|
||||
|
||||
.owner-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.owner-avatar {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.66rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: #fff;
|
||||
@ -343,6 +578,15 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.owner-avatar.group-avatar {
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
.owner-avatar.empty-avatar {
|
||||
background: #cbd5e1;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.owner-name {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
@ -391,6 +635,17 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% macro initials_bubble(label, group=false) -%}
|
||||
{% if label %}
|
||||
{% set norm = label.strip() %}
|
||||
{% set parts = norm.split() %}
|
||||
{% set initials = ((parts[0][0] if parts|length > 0 else norm[0]) ~ (parts[1][0] if parts|length > 1 else ''))|upper %}
|
||||
<span class="owner-avatar {% if group %}group-avatar{% endif %}" title="{{ norm }}">{{ initials }}</span>
|
||||
{% else %}
|
||||
<span class="owner-avatar empty-avatar" title="Ikke sat">-</span>
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
<div class="container-fluid" style="max-width: none; padding-top: 0.65rem;">
|
||||
<div id="sagTopAlerts" class="sag-top-alerts d-none"></div>
|
||||
|
||||
@ -428,39 +683,66 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap align-items-center gap-3 mb-3">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mb-3">
|
||||
<div class="filter-pills">
|
||||
<div class="filter-pill active" data-filter="all">Alle</div>
|
||||
<div class="filter-pill" data-filter="åben">Åbne</div>
|
||||
<div class="filter-pill" data-filter="lukket">Lukkede</div>
|
||||
</div>
|
||||
<div style="min-width: 200px;">
|
||||
<select class="form-select" id="typeFilter">
|
||||
<option value="all">Alle typer</option>
|
||||
</select>
|
||||
<div class="type-filter-wrap">
|
||||
<div class="dropdown type-filter-dropdown mb-1">
|
||||
<button class="btn dropdown-toggle" type="button" id="typeFilterDropdownBtn" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
|
||||
<span id="typeFilterDropdownLabel">Vælg typer</span>
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="typeFilterDropdownBtn">
|
||||
<div class="type-filter-checkbox-list" id="typeFilterCheckboxList"></div>
|
||||
<div class="type-filter-menu-actions">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" id="clearTypeFilterBtn">Ryd</button>
|
||||
<button class="btn btn-sm btn-primary" type="button" id="saveTypeFilterDefaultBtn">Gem</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<select class="d-none" id="typeFilter" multiple title="Type filter data source"></select>
|
||||
</div>
|
||||
<form id="assignmentFilterForm" class="d-flex flex-wrap gap-2 align-items-center" method="get" action="/sag">
|
||||
<div style="min-width: 220px;">
|
||||
<select class="form-select" name="ansvarlig_bruger_id" id="assigneeFilter">
|
||||
<div class="mini-filter-wrap">
|
||||
<div class="dropdown mini-filter-dropdown mb-1">
|
||||
<button class="btn dropdown-toggle" type="button" id="assigneeDropdownBtn" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
|
||||
<span id="assigneeDropdownLabel">Ansvarlig</span>
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="assigneeDropdownBtn">
|
||||
<div class="mini-filter-list" id="assigneeFilterList"></div>
|
||||
<div class="mini-filter-footer">
|
||||
<button class="btn btn-sm btn-outline-secondary mini-filter-clear" type="button" id="clearAssigneeFilterBtn">Ryd</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<select class="d-none" name="ansvarlig_bruger_id" id="assigneeFilter" multiple>
|
||||
<option value="">Alle medarbejdere</option>
|
||||
<option value="__UNASSIGNED__" {% if current_unassigned %}selected{% endif %}>Uden ansvarlig</option>
|
||||
{% for user in assignment_users or [] %}
|
||||
<option value="{{ user.user_id }}" {% if current_ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mini-filter-wrap">
|
||||
<div class="dropdown mini-filter-dropdown mb-1">
|
||||
<button class="btn dropdown-toggle" type="button" id="groupDropdownBtn" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
|
||||
<span id="groupDropdownLabel">Grupper</span>
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="groupDropdownBtn">
|
||||
<div class="mini-filter-list" id="groupFilterList"></div>
|
||||
<div class="mini-filter-footer">
|
||||
<button class="btn btn-sm btn-outline-secondary mini-filter-clear" type="button" id="clearGroupFilterBtn">Ryd</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="min-width: 220px;">
|
||||
<select class="form-select" name="assigned_group_id" id="groupFilter">
|
||||
<select class="d-none" name="assigned_group_id" id="groupFilter" multiple>
|
||||
<option value="">Alle grupper</option>
|
||||
{% for group in assignment_groups or [] %}
|
||||
<option value="{{ group.id }}" {% if current_assigned_group_id == group.id %}selected{% endif %}>{{ group.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% if include_deferred %}
|
||||
<input type="hidden" name="include_deferred" value="1">
|
||||
{% endif %}
|
||||
</form>
|
||||
</select>
|
||||
</div>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{{ toggle_include_deferred_url }}">
|
||||
{% if include_deferred %}Skjul udsatte{% else %}Vis udsatte{% endif %}
|
||||
</a>
|
||||
@ -472,12 +754,14 @@
|
||||
<table class="sag-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-expand"></th>
|
||||
<th style="width: 90px;">SagsID</th>
|
||||
<th style="width: 180px;">Virksom.</th>
|
||||
<th style="width: 150px;">Kontakt</th>
|
||||
<th style="width: 300px;">Beskr.</th>
|
||||
<th style="width: 120px;">Type</th>
|
||||
<th style="width: 110px;">Prioritet</th>
|
||||
<th style="width: 120px;">Status</th>
|
||||
<th style="width: 160px;">Ansvarl.</th>
|
||||
<th style="width: 170px;">Gruppe/Level</th>
|
||||
<th style="width: 240px;">Næste todo</th>
|
||||
@ -494,12 +778,16 @@
|
||||
<tr class="tree-row {% if has_relations %}has-children{% endif %}"
|
||||
data-sag-id="{{ sag.id }}"
|
||||
data-status="{{ sag.status }}"
|
||||
data-type="{{ sag.template_key or sag.type or 'ticket' }}">
|
||||
<td>
|
||||
data-type="{{ sag.template_key or sag.type or 'ticket' }}"
|
||||
data-assignee-id="{{ sag.ansvarlig_bruger_id if sag.ansvarlig_bruger_id else '' }}"
|
||||
data-group-id="{{ sag.assigned_group_id if sag.assigned_group_id else '' }}">
|
||||
<td class="col-expand" onclick="event.stopPropagation();">
|
||||
{% if has_relations %}
|
||||
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
|
||||
{% endif %}
|
||||
<span class="sag-id">#{{ sag.id }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="sag-id" role="button" onclick="window.location.href='/sag/{{ sag.id }}/v3'">#{{ sag.id }}</span>
|
||||
{% if (sag.unread_email_count or 0) > 0 %}
|
||||
{% set unread_level = sag.unread_email_level or 'fresh' %}
|
||||
<span class="sag-unread-badge sag-unread-{{ unread_level }}" title="{{ sag.unread_email_count }} ulæste e-mails">
|
||||
@ -522,22 +810,20 @@
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
|
||||
{{ sag.priority if sag.priority else 'normal' }}
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'">
|
||||
{% set status_raw = sag.status if sag.status else 'åben' %}
|
||||
{% set status_class = status_raw|lower|replace(' ', '-') %}
|
||||
<span class="status-badge status-{{ status_class }}">{{ status_raw }}</span>
|
||||
</td>
|
||||
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}/v3'">
|
||||
{% if sag.ansvarlig_navn %}
|
||||
{% set owner_name = sag.ansvarlig_navn.strip() %}
|
||||
{% set owner_parts = owner_name.split() %}
|
||||
<div class="owner-cell">
|
||||
<span class="owner-avatar">
|
||||
{{ ((owner_parts[0][0] if owner_parts|length > 0 else owner_name[0]) ~ (owner_parts[1][0] if owner_parts|length > 1 else ''))|upper }}
|
||||
</span>
|
||||
<span class="owner-name">{{ owner_name }}</span>
|
||||
{{ initials_bubble(sag.ansvarlig_navn) }}
|
||||
</div>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ sag.assigned_group_name if sag.assigned_group_name else '-' }}
|
||||
<div class="owner-cell">
|
||||
{{ initials_bubble(sag.assigned_group_name, true) }}
|
||||
</div>
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem; white-space: normal; max-width: 240px;">
|
||||
{% if sag.next_todo_title %}
|
||||
@ -569,9 +855,10 @@
|
||||
{% if related_sag and rel.target_id not in seen_targets %}
|
||||
{% set _ = seen_targets.append(rel.target_id) %}
|
||||
{% set all_rel_types = relations_map[sag.id]|selectattr('target_id', 'equalto', rel.target_id)|map(attribute='type')|list %}
|
||||
<tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.template_key or related_sag.type or 'ticket' }}" style="display: none;">
|
||||
<tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.template_key or related_sag.type or 'ticket' }}" data-assignee-id="{{ related_sag.ansvarlig_bruger_id if related_sag.ansvarlig_bruger_id else '' }}" data-group-id="{{ related_sag.assigned_group_id if related_sag.assigned_group_id else '' }}" style="display: none;">
|
||||
<td class="col-expand"><span class="child-branch">└</span></td>
|
||||
<td>
|
||||
<span class="sag-id">#{{ related_sag.id }}</span>
|
||||
<span class="sag-id" role="button" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">#{{ related_sag.id }}</span>
|
||||
{% if (related_sag.unread_email_count or 0) > 0 %}
|
||||
{% set child_unread_level = related_sag.unread_email_level or 'fresh' %}
|
||||
<span class="sag-unread-badge sag-unread-{{ child_unread_level }}" title="{{ related_sag.unread_email_count }} ulæste e-mails">
|
||||
@ -597,22 +884,20 @@
|
||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
|
||||
{{ related_sag.priority if related_sag.priority else 'normal' }}
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
|
||||
{% set related_status_raw = related_sag.status if related_sag.status else 'åben' %}
|
||||
{% set related_status_class = related_status_raw|lower|replace(' ', '-') %}
|
||||
<span class="status-badge status-{{ related_status_class }}">{{ related_status_raw }}</span>
|
||||
</td>
|
||||
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
|
||||
{% if related_sag.ansvarlig_navn %}
|
||||
{% set owner_name = related_sag.ansvarlig_navn.strip() %}
|
||||
{% set owner_parts = owner_name.split() %}
|
||||
<div class="owner-cell">
|
||||
<span class="owner-avatar">
|
||||
{{ ((owner_parts[0][0] if owner_parts|length > 0 else owner_name[0]) ~ (owner_parts[1][0] if owner_parts|length > 1 else ''))|upper }}
|
||||
</span>
|
||||
<span class="owner-name">{{ owner_name }}</span>
|
||||
{{ initials_bubble(related_sag.ansvarlig_navn) }}
|
||||
</div>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }}
|
||||
<div class="owner-cell">
|
||||
{{ initials_bubble(related_sag.assigned_group_name, true) }}
|
||||
</div>
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem; white-space: normal; max-width: 240px;">
|
||||
{% if related_sag.next_todo_title %}
|
||||
@ -741,30 +1026,41 @@
|
||||
const allRows = document.querySelectorAll('.tree-row');
|
||||
let currentSearch = '';
|
||||
let currentFilter = 'all';
|
||||
let currentType = 'all';
|
||||
let currentTypes = new Set();
|
||||
let currentAssignees = new Set();
|
||||
let currentGroups = new Set();
|
||||
const closedStatuses = new Set({{ (closed_statuses or ['lukket', 'løst', 'afsluttet', 'closed', 'resolved', 'done'])|tojson }});
|
||||
|
||||
const assigneeFilter = document.getElementById('assigneeFilter');
|
||||
const groupFilter = document.getElementById('groupFilter');
|
||||
const assignmentFilterForm = document.getElementById('assignmentFilterForm');
|
||||
|
||||
if (assigneeFilter && assignmentFilterForm) {
|
||||
assigneeFilter.addEventListener('change', () => assignmentFilterForm.submit());
|
||||
}
|
||||
if (groupFilter && assignmentFilterForm) {
|
||||
groupFilter.addEventListener('change', () => assignmentFilterForm.submit());
|
||||
}
|
||||
const assigneeFilterList = document.getElementById('assigneeFilterList');
|
||||
const groupFilterList = document.getElementById('groupFilterList');
|
||||
const assigneeDropdownLabel = document.getElementById('assigneeDropdownLabel');
|
||||
const groupDropdownLabel = document.getElementById('groupDropdownLabel');
|
||||
const clearAssigneeFilterBtn = document.getElementById('clearAssigneeFilterBtn');
|
||||
const clearGroupFilterBtn = document.getElementById('clearGroupFilterBtn');
|
||||
|
||||
function applyFilters() {
|
||||
const search = currentSearch;
|
||||
|
||||
allRows.forEach(row => {
|
||||
const text = row.textContent.toLowerCase();
|
||||
const status = row.dataset.status;
|
||||
const status = String(row.dataset.status || '').toLowerCase();
|
||||
const type = row.dataset.type || 'ticket';
|
||||
const assigneeRaw = String(row.dataset.assigneeId || '').trim();
|
||||
const groupRaw = String(row.dataset.groupId || '').trim();
|
||||
const assigneeId = assigneeRaw || '__UNASSIGNED__';
|
||||
const groupId = groupRaw;
|
||||
const matchesSearch = text.includes(search);
|
||||
const matchesFilter = currentFilter === 'all' || status === currentFilter;
|
||||
const matchesType = currentType === 'all' || type === currentType;
|
||||
const visible = matchesSearch && matchesFilter && matchesType;
|
||||
const isClosed = closedStatuses.has(status);
|
||||
const matchesFilter = currentFilter === 'all'
|
||||
|| (currentFilter === 'åben' && !isClosed)
|
||||
|| (currentFilter === 'lukket' && isClosed)
|
||||
|| status === currentFilter;
|
||||
const matchesType = currentTypes.size === 0 || currentTypes.has(type);
|
||||
const matchesAssignee = currentAssignees.size === 0 || currentAssignees.has(assigneeId);
|
||||
const matchesGroup = currentGroups.size === 0 || currentGroups.has(groupId);
|
||||
const visible = matchesSearch && matchesFilter && matchesType && matchesAssignee && matchesGroup;
|
||||
|
||||
row.style.display = visible ? '' : 'none';
|
||||
|
||||
@ -773,18 +1069,130 @@
|
||||
const children = document.querySelectorAll(`tr[data-parent="${sagId}"]`);
|
||||
children.forEach(child => {
|
||||
const childText = child.textContent.toLowerCase();
|
||||
const childStatus = child.dataset.status;
|
||||
const childStatus = String(child.dataset.status || '').toLowerCase();
|
||||
const childType = child.dataset.type || 'ticket';
|
||||
const childAssigneeRaw = String(child.dataset.assigneeId || '').trim();
|
||||
const childGroupRaw = String(child.dataset.groupId || '').trim();
|
||||
const childAssigneeId = childAssigneeRaw || '__UNASSIGNED__';
|
||||
const childGroupId = childGroupRaw;
|
||||
const childMatchesSearch = childText.includes(search);
|
||||
const childMatchesFilter = currentFilter === 'all' || childStatus === currentFilter;
|
||||
const childMatchesType = currentType === 'all' || childType === currentType;
|
||||
const childVisible = visible && row.classList.contains('expanded') && childMatchesSearch && childMatchesFilter && childMatchesType;
|
||||
const childIsClosed = closedStatuses.has(childStatus);
|
||||
const childMatchesFilter = currentFilter === 'all'
|
||||
|| (currentFilter === 'åben' && !childIsClosed)
|
||||
|| (currentFilter === 'lukket' && childIsClosed)
|
||||
|| childStatus === currentFilter;
|
||||
const childMatchesType = currentTypes.size === 0 || currentTypes.has(childType);
|
||||
const childMatchesAssignee = currentAssignees.size === 0 || currentAssignees.has(childAssigneeId);
|
||||
const childMatchesGroup = currentGroups.size === 0 || currentGroups.has(childGroupId);
|
||||
const childVisible = visible && row.classList.contains('expanded') && childMatchesSearch && childMatchesFilter && childMatchesType && childMatchesAssignee && childMatchesGroup;
|
||||
child.style.display = childVisible ? '' : 'none';
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderMiniFilterOptions(selectEl, listEl, selectedSet, labelEl, defaultLabel) {
|
||||
if (!selectEl || !listEl) return;
|
||||
const options = Array.from(selectEl.options || []).filter((opt) => String(opt.value || '').trim() !== '');
|
||||
if (options.length === 0) {
|
||||
listEl.innerHTML = '<span class="type-filter-empty">Ingen muligheder</span>';
|
||||
if (labelEl) labelEl.textContent = defaultLabel;
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = options.map((opt) => {
|
||||
const value = String(opt.value || '').trim();
|
||||
const isChecked = selectedSet.has(value);
|
||||
const safeValue = value.replace(/"/g, '"');
|
||||
const safeLabel = String(opt.textContent || value).replace(/"/g, '"');
|
||||
const safeId = `${selectEl.id}-opt-${value.replace(/[^a-zA-Z0-9_-]+/g, '-')}`;
|
||||
return `
|
||||
<label class="mini-filter-item" for="${safeId}">
|
||||
<input class="form-check-input" type="checkbox" id="${safeId}" data-value="${safeValue}" ${isChecked ? 'checked' : ''}>
|
||||
<span class="mini-filter-label">${safeLabel}</span>
|
||||
</label>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
if (labelEl) {
|
||||
labelEl.textContent = selectedSet.size === 0 ? defaultLabel : `${selectedSet.size} valgt`;
|
||||
}
|
||||
}
|
||||
|
||||
function syncMiniSelect(selectEl, selectedSet) {
|
||||
if (!selectEl) return;
|
||||
Array.from(selectEl.options || []).forEach((opt) => {
|
||||
const value = String(opt.value || '').trim();
|
||||
opt.selected = value !== '' && selectedSet.has(value);
|
||||
});
|
||||
}
|
||||
|
||||
if (assigneeFilterList && assigneeFilter) {
|
||||
assigneeFilterList.addEventListener('change', function(event) {
|
||||
const checkbox = event.target.closest('input[type="checkbox"][data-value]');
|
||||
if (!checkbox) return;
|
||||
const value = String(checkbox.dataset.value || '').trim();
|
||||
if (!value) return;
|
||||
if (checkbox.checked) currentAssignees.add(value);
|
||||
else currentAssignees.delete(value);
|
||||
syncMiniSelect(assigneeFilter, currentAssignees);
|
||||
renderMiniFilterOptions(assigneeFilter, assigneeFilterList, currentAssignees, assigneeDropdownLabel, 'Ansvarlig');
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
if (groupFilterList && groupFilter) {
|
||||
groupFilterList.addEventListener('change', function(event) {
|
||||
const checkbox = event.target.closest('input[type="checkbox"][data-value]');
|
||||
if (!checkbox) return;
|
||||
const value = String(checkbox.dataset.value || '').trim();
|
||||
if (!value) return;
|
||||
if (checkbox.checked) currentGroups.add(value);
|
||||
else currentGroups.delete(value);
|
||||
syncMiniSelect(groupFilter, currentGroups);
|
||||
renderMiniFilterOptions(groupFilter, groupFilterList, currentGroups, groupDropdownLabel, 'Grupper');
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
if (clearAssigneeFilterBtn) {
|
||||
clearAssigneeFilterBtn.addEventListener('click', function() {
|
||||
currentAssignees = new Set();
|
||||
syncMiniSelect(assigneeFilter, currentAssignees);
|
||||
renderMiniFilterOptions(assigneeFilter, assigneeFilterList, currentAssignees, assigneeDropdownLabel, 'Ansvarlig');
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
if (clearGroupFilterBtn) {
|
||||
clearGroupFilterBtn.addEventListener('click', function() {
|
||||
currentGroups = new Set();
|
||||
syncMiniSelect(groupFilter, currentGroups);
|
||||
renderMiniFilterOptions(groupFilter, groupFilterList, currentGroups, groupDropdownLabel, 'Grupper');
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
function initMiniFilterSelections() {
|
||||
if (assigneeFilter) {
|
||||
currentAssignees = new Set(
|
||||
Array.from(assigneeFilter.selectedOptions || [])
|
||||
.map((opt) => String(opt.value || '').trim())
|
||||
.filter((value) => value)
|
||||
);
|
||||
renderMiniFilterOptions(assigneeFilter, assigneeFilterList, currentAssignees, assigneeDropdownLabel, 'Ansvarlig');
|
||||
}
|
||||
|
||||
if (groupFilter) {
|
||||
currentGroups = new Set(
|
||||
Array.from(groupFilter.selectedOptions || [])
|
||||
.map((opt) => String(opt.value || '').trim())
|
||||
.filter((value) => value)
|
||||
);
|
||||
renderMiniFilterOptions(groupFilter, groupFilterList, currentGroups, groupDropdownLabel, 'Grupper');
|
||||
}
|
||||
}
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function(e) {
|
||||
currentSearch = e.target.value.toLowerCase();
|
||||
@ -807,9 +1215,83 @@
|
||||
});
|
||||
|
||||
const typeFilter = document.getElementById('typeFilter');
|
||||
if (typeFilter) {
|
||||
typeFilter.addEventListener('change', function() {
|
||||
currentType = this.value || 'all';
|
||||
const typeFilterCheckboxList = document.getElementById('typeFilterCheckboxList');
|
||||
const typeFilterDropdownLabel = document.getElementById('typeFilterDropdownLabel');
|
||||
const typeFilterSelection = document.getElementById('typeFilterSelection');
|
||||
const clearTypeFilterBtn = document.getElementById('clearTypeFilterBtn');
|
||||
const saveTypeFilterDefaultBtn = document.getElementById('saveTypeFilterDefaultBtn');
|
||||
|
||||
function getSelectedTypesFromUi() {
|
||||
return Array.from(currentTypes);
|
||||
}
|
||||
|
||||
function renderTypeFilterOptions() {
|
||||
if (!typeFilterCheckboxList || !typeFilter) return;
|
||||
const options = Array.from(typeFilter.options || []);
|
||||
if (options.length === 0) {
|
||||
typeFilterCheckboxList.innerHTML = '<span class="type-filter-empty">Ingen typer fundet</span>';
|
||||
if (typeFilterSelection) typeFilterSelection.textContent = 'Ingen typer tilgaengelige';
|
||||
if (typeFilterDropdownLabel) typeFilterDropdownLabel.textContent = 'Ingen typer';
|
||||
return;
|
||||
}
|
||||
|
||||
typeFilterCheckboxList.innerHTML = options.map((opt) => {
|
||||
const value = String(opt.value || '').trim();
|
||||
const key = value.toLowerCase();
|
||||
const isActive = currentTypes.has(key);
|
||||
const safeValue = value.replace(/"/g, '"');
|
||||
const safeId = `type-filter-opt-${key.replace(/[^a-z0-9_-]+/g, '-')}`;
|
||||
return `
|
||||
<label class="type-filter-checkbox-item" for="${safeId}">
|
||||
<input class="form-check-input" type="checkbox" id="${safeId}" data-type="${safeValue}" ${isActive ? 'checked' : ''}>
|
||||
<span class="type-filter-checkbox-label">${value}</span>
|
||||
</label>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
if (typeFilterSelection) {
|
||||
const selectedLabels = options
|
||||
.filter((opt) => currentTypes.has(String(opt.value || '').trim().toLowerCase()))
|
||||
.map((opt) => String(opt.value || '').trim())
|
||||
.filter(Boolean);
|
||||
if (selectedLabels.length === 0) {
|
||||
typeFilterSelection.textContent = 'Ingen typer valgt';
|
||||
if (typeFilterDropdownLabel) typeFilterDropdownLabel.textContent = 'Vælg typer';
|
||||
} else {
|
||||
typeFilterSelection.textContent = `Valgt: ${selectedLabels.join(', ')}`;
|
||||
if (typeFilterDropdownLabel) typeFilterDropdownLabel.textContent = `${selectedLabels.length} valgt`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applySelectedTypesToUi() {
|
||||
if (!typeFilter) return;
|
||||
Array.from(typeFilter.options || []).forEach((opt) => {
|
||||
opt.selected = currentTypes.has(String(opt.value || '').trim().toLowerCase());
|
||||
});
|
||||
renderTypeFilterOptions();
|
||||
}
|
||||
|
||||
if (typeFilterCheckboxList) {
|
||||
typeFilterCheckboxList.addEventListener('change', function(event) {
|
||||
const checkbox = event.target.closest('input[type="checkbox"][data-type]');
|
||||
if (!checkbox) return;
|
||||
const typeValue = String(checkbox.dataset.type || '').trim().toLowerCase();
|
||||
if (!typeValue) return;
|
||||
if (checkbox.checked) {
|
||||
currentTypes.add(typeValue);
|
||||
} else {
|
||||
currentTypes.delete(typeValue);
|
||||
}
|
||||
applySelectedTypesToUi();
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
if (clearTypeFilterBtn) {
|
||||
clearTypeFilterBtn.addEventListener('click', function() {
|
||||
currentTypes = new Set();
|
||||
applySelectedTypesToUi();
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
@ -833,16 +1315,68 @@
|
||||
|
||||
configuredTypes.forEach((t) => rowTypes.add(String(t || '').trim()));
|
||||
const mergedTypes = Array.from(rowTypes).filter(Boolean).sort((a, b) => a.localeCompare(b, 'da'));
|
||||
if (mergedTypes.length === 0) return;
|
||||
if (mergedTypes.length === 0) {
|
||||
typeFilter.innerHTML = '';
|
||||
renderTypeFilterOptions();
|
||||
return;
|
||||
}
|
||||
|
||||
typeFilter.innerHTML = `<option value="all">Alle typer</option>` +
|
||||
mergedTypes.map(type => `<option value="${type}">${type}</option>`).join('');
|
||||
typeFilter.innerHTML = mergedTypes.map(type => `<option value="${type}">${type}</option>`).join('');
|
||||
applySelectedTypesToUi();
|
||||
} catch (err) {
|
||||
console.error('Failed to load case types', err);
|
||||
}
|
||||
}
|
||||
|
||||
loadTypeFilters();
|
||||
async function loadTypeFilterPreferences() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/sag/me/list-preferences', { credentials: 'include' });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const fromServer = Array.isArray(data?.type_filters) ? data.type_filters : [];
|
||||
currentTypes = new Set(fromServer.map((v) => String(v || '').trim().toLowerCase()).filter(Boolean));
|
||||
applySelectedTypesToUi();
|
||||
applyFilters();
|
||||
} catch (err) {
|
||||
console.error('Failed to load type filter preferences', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTypeFilterPreferences() {
|
||||
if (!saveTypeFilterDefaultBtn) return;
|
||||
const selected = getSelectedTypesFromUi();
|
||||
saveTypeFilterDefaultBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/api/v1/sag/me/list-preferences', {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type_filters: selected }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Typefilter gemt som standard', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save type filter preferences', err);
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Kunne ikke gemme typefilter', 'error');
|
||||
}
|
||||
} finally {
|
||||
saveTypeFilterDefaultBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (saveTypeFilterDefaultBtn) {
|
||||
saveTypeFilterDefaultBtn.addEventListener('click', saveTypeFilterPreferences);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
initMiniFilterSelections();
|
||||
await loadTypeFilters();
|
||||
await loadTypeFilterPreferences();
|
||||
applyFilters();
|
||||
})();
|
||||
|
||||
if (topAlertCustomerId) {
|
||||
loadSagTopAlertsForCustomer(topAlertCustomerId);
|
||||
|
||||
@ -331,6 +331,9 @@ async def yealink_established(
|
||||
"direction": direction,
|
||||
"contact": kontakt,
|
||||
"recent_cases": contact_details.get("recent_cases", []),
|
||||
"contact_cases": contact_details.get("contact_cases", []),
|
||||
"company_cases": contact_details.get("company_cases", []),
|
||||
"related_contacts": contact_details.get("related_contacts", []),
|
||||
"last_call": contact_details.get("last_call"),
|
||||
}
|
||||
for user_id in user_ids:
|
||||
|
||||
@ -176,18 +176,56 @@ class TelefoniService:
|
||||
return bool(rows)
|
||||
|
||||
@staticmethod
|
||||
def get_contact_details(contact_id: int) -> dict:
|
||||
"""
|
||||
Get extended contact details including:
|
||||
- Latest 3 open cases
|
||||
- Last call date
|
||||
"""
|
||||
def get_contact_details(contact_id: int, company_id: Optional[int] = None) -> dict:
|
||||
"""Get extended contact details for telefoni popups and call notifications."""
|
||||
if not contact_id:
|
||||
return {"recent_cases": [], "last_call": None}
|
||||
return {
|
||||
"recent_cases": [],
|
||||
"contact_cases": [],
|
||||
"company_cases": [],
|
||||
"related_contacts": [],
|
||||
"last_call": None,
|
||||
}
|
||||
|
||||
# Get the 3 newest open cases for this contact
|
||||
cases_query = """
|
||||
SELECT
|
||||
contact_row = execute_query_single(
|
||||
"""
|
||||
SELECT
|
||||
c.id,
|
||||
c.first_name,
|
||||
c.last_name,
|
||||
c.email,
|
||||
c.phone,
|
||||
c.mobile,
|
||||
c.title,
|
||||
c.department,
|
||||
c.is_active,
|
||||
c.user_company,
|
||||
(
|
||||
SELECT cu.id
|
||||
FROM contact_companies cc
|
||||
JOIN customers cu ON cu.id = cc.customer_id
|
||||
WHERE cc.contact_id = c.id
|
||||
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
||||
LIMIT 1
|
||||
) AS company_id,
|
||||
(
|
||||
SELECT cu.name
|
||||
FROM contact_companies cc
|
||||
JOIN customers cu ON cu.id = cc.customer_id
|
||||
WHERE cc.contact_id = c.id
|
||||
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
||||
LIMIT 1
|
||||
) AS company
|
||||
FROM contacts c
|
||||
WHERE c.id = %s
|
||||
""",
|
||||
(contact_id,),
|
||||
)
|
||||
|
||||
effective_company_id = company_id or (contact_row.get("company_id") if contact_row else None)
|
||||
|
||||
open_cases_query = """
|
||||
SELECT
|
||||
s.id,
|
||||
s.titel,
|
||||
s.created_at
|
||||
@ -200,11 +238,77 @@ class TelefoniService:
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT 3
|
||||
"""
|
||||
cases = execute_query(cases_query, (contact_id,)) or []
|
||||
recent_open_cases = execute_query(open_cases_query, (contact_id,)) or []
|
||||
|
||||
contact_cases_query = """
|
||||
SELECT
|
||||
s.id,
|
||||
s.titel,
|
||||
s.status,
|
||||
s.customer_id,
|
||||
cu.name AS customer_name,
|
||||
s.created_at,
|
||||
s.updated_at
|
||||
FROM sag_sager s
|
||||
INNER JOIN sag_kontakter sk ON s.id = sk.sag_id
|
||||
LEFT JOIN customers cu ON cu.id = s.customer_id
|
||||
WHERE sk.contact_id = %s
|
||||
AND s.deleted_at IS NULL
|
||||
AND sk.deleted_at IS NULL
|
||||
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
|
||||
LIMIT 5
|
||||
"""
|
||||
contact_cases = execute_query(contact_cases_query, (contact_id,)) or []
|
||||
|
||||
company_cases = []
|
||||
related_contacts = []
|
||||
if effective_company_id:
|
||||
company_cases_query = """
|
||||
SELECT
|
||||
s.id,
|
||||
s.titel,
|
||||
s.status,
|
||||
s.customer_id,
|
||||
cu.name AS customer_name,
|
||||
s.created_at,
|
||||
s.updated_at
|
||||
FROM sag_sager s
|
||||
LEFT JOIN customers cu ON cu.id = s.customer_id
|
||||
WHERE s.customer_id = %s
|
||||
AND s.deleted_at IS NULL
|
||||
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
|
||||
LIMIT 5
|
||||
"""
|
||||
company_cases = execute_query(company_cases_query, (effective_company_id,)) or []
|
||||
|
||||
related_contacts_query = """
|
||||
SELECT
|
||||
c.id,
|
||||
c.first_name,
|
||||
c.last_name,
|
||||
c.email,
|
||||
c.phone,
|
||||
c.mobile,
|
||||
c.title,
|
||||
c.department,
|
||||
c.is_active,
|
||||
c.created_at,
|
||||
c.updated_at,
|
||||
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) AS company_names
|
||||
FROM contacts c
|
||||
INNER JOIN contact_companies cc ON c.id = cc.contact_id
|
||||
INNER JOIN customers cu ON cc.customer_id = cu.id
|
||||
WHERE cc.customer_id = %s
|
||||
AND c.id <> %s
|
||||
AND c.is_active = TRUE
|
||||
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile, c.title, c.department, c.is_active, c.created_at, c.updated_at
|
||||
ORDER BY c.last_name, c.first_name
|
||||
LIMIT 8
|
||||
"""
|
||||
related_contacts = execute_query(related_contacts_query, (effective_company_id, contact_id)) or []
|
||||
|
||||
# Get the most recent call for this contact
|
||||
last_call_query = """
|
||||
SELECT
|
||||
SELECT
|
||||
t.started_at,
|
||||
t.bruger_id,
|
||||
t.duration_sec,
|
||||
@ -218,7 +322,7 @@ class TelefoniService:
|
||||
LIMIT 1
|
||||
"""
|
||||
last_call_row = execute_query_single(last_call_query, (contact_id,))
|
||||
|
||||
|
||||
last_call_data = None
|
||||
if last_call_row:
|
||||
last_call_data = {
|
||||
@ -234,7 +338,46 @@ class TelefoniService:
|
||||
"titel": case["titel"],
|
||||
"created_at": case["created_at"],
|
||||
}
|
||||
for case in cases
|
||||
for case in recent_open_cases
|
||||
],
|
||||
"contact_cases": [
|
||||
{
|
||||
"id": case["id"],
|
||||
"titel": case["titel"],
|
||||
"status": case.get("status"),
|
||||
"customer_id": case.get("customer_id"),
|
||||
"customer_name": case.get("customer_name"),
|
||||
"created_at": case.get("created_at"),
|
||||
"updated_at": case.get("updated_at"),
|
||||
}
|
||||
for case in contact_cases
|
||||
],
|
||||
"company_cases": [
|
||||
{
|
||||
"id": case["id"],
|
||||
"titel": case["titel"],
|
||||
"status": case.get("status"),
|
||||
"customer_id": case.get("customer_id"),
|
||||
"customer_name": case.get("customer_name"),
|
||||
"created_at": case.get("created_at"),
|
||||
"updated_at": case.get("updated_at"),
|
||||
}
|
||||
for case in company_cases
|
||||
],
|
||||
"related_contacts": [
|
||||
{
|
||||
"id": contact["id"],
|
||||
"first_name": contact.get("first_name"),
|
||||
"last_name": contact.get("last_name"),
|
||||
"email": contact.get("email"),
|
||||
"phone": contact.get("phone"),
|
||||
"mobile": contact.get("mobile"),
|
||||
"title": contact.get("title"),
|
||||
"department": contact.get("department"),
|
||||
"is_active": contact.get("is_active"),
|
||||
"company_names": contact.get("company_names") or [],
|
||||
}
|
||||
for contact in related_contacts
|
||||
],
|
||||
"last_call": last_call_data,
|
||||
}
|
||||
|
||||
@ -1517,6 +1517,18 @@ async def scan_document(file_path: str):
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-4 mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h5 class="mb-0 fw-bold">Menu visning (min konto)</h5>
|
||||
<button class="btn btn-sm btn-primary" type="button" id="saveMenuVisibilityBtn">
|
||||
<i class="bi bi-save me-2"></i>Gem
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-muted mb-3">Vælg hvilke hovedpunkter og underpunkter du vil se i topmenuen.</p>
|
||||
<div id="menuVisibilityFeedback" class="small mb-2 text-muted"></div>
|
||||
<div id="menuVisibilityGrid" class="row g-3"></div>
|
||||
</div>
|
||||
|
||||
<div class="card p-4">
|
||||
<h5 class="mb-4 fw-bold">System Indstillinger</h5>
|
||||
<div id="systemSettings">
|
||||
@ -5317,12 +5329,173 @@ async function deactivateStage(stageId) {
|
||||
loadPipelineStages();
|
||||
}
|
||||
|
||||
const MENU_VISIBILITY_GROUPS = [
|
||||
{
|
||||
title: 'Hovedmenu',
|
||||
items: [
|
||||
{ key: 'menu-crm', label: 'CRM' },
|
||||
{ key: 'menu-sager', label: 'Sager' },
|
||||
{ key: 'menu-kalender', label: 'Kalender' },
|
||||
{ key: 'menu-support', label: 'Support' },
|
||||
{ key: 'menu-salg', label: 'Salg' },
|
||||
{ key: 'menu-okonomi', label: 'Økonomi' },
|
||||
{ key: 'menu-datamigration', label: 'Data migration' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'CRM underpunkter',
|
||||
items: [
|
||||
{ key: 'menu-crm-customers', label: 'Kunder' },
|
||||
{ key: 'menu-crm-contacts', label: 'Kontakter' },
|
||||
{ key: 'menu-crm-vendors', label: 'Leverandører' },
|
||||
{ key: 'menu-crm-links', label: 'Links' },
|
||||
{ key: 'menu-crm-locations', label: 'Lokaliteter' },
|
||||
{ key: 'menu-crm-opportunities', label: 'Muligheder' },
|
||||
{ key: 'menu-crm-pipeline', label: 'Pipeline' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Support underpunkter',
|
||||
items: [
|
||||
{ key: 'menu-support-conversations', label: 'Mine Samtaler' },
|
||||
{ key: 'menu-support-tickets', label: 'Arkiverede Tickets' },
|
||||
{ key: 'menu-support-emails', label: 'Email' },
|
||||
{ key: 'menu-support-telefoni', label: 'Telefoni' },
|
||||
{ key: 'menu-support-mission', label: 'Mission Control' },
|
||||
{ key: 'menu-support-anydesk', label: 'AnyDesk Sessions' },
|
||||
{ key: 'menu-support-hardware', label: 'BMC Assets' },
|
||||
{ key: 'menu-support-hardware-customers', label: 'Kundehardware' },
|
||||
{ key: 'menu-support-eset', label: 'ESET Oversigt' },
|
||||
{ key: 'menu-support-manual', label: 'Manualer' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Salg/Økonomi underpunkter',
|
||||
items: [
|
||||
{ key: 'menu-salg-orders', label: 'Ordre' },
|
||||
{ key: 'menu-salg-products', label: 'Produkter' },
|
||||
{ key: 'menu-salg-webshop', label: 'Webshop Administration' },
|
||||
{ key: 'menu-okonomi-time-queue', label: 'Time Queue' },
|
||||
{ key: 'menu-okonomi-supplier-invoices', label: 'Leverandør fakturaer' },
|
||||
{ key: 'menu-okonomi-prepaid', label: 'Prepaid Cards' },
|
||||
{ key: 'menu-okonomi-fixed-price', label: 'Fastpris Aftaler' },
|
||||
{ key: 'menu-okonomi-subscriptions', label: 'Abonnementer' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Data migration underpunkter',
|
||||
items: [
|
||||
{ key: 'menu-datamigration-dashboard', label: 'Dashboard' },
|
||||
{ key: 'menu-datamigration-registrations', label: 'Registreringer' },
|
||||
{ key: 'menu-datamigration-wizard', label: 'Godkend Timer' },
|
||||
{ key: 'menu-datamigration-employee-log', label: 'Medarbejder Log' },
|
||||
{ key: 'menu-datamigration-service-contract-wizard', label: 'Servicekontrakt Migration' },
|
||||
{ key: 'menu-datamigration-service-contract-report', label: 'Servicekontrakt Rapport' },
|
||||
{ key: 'menu-datamigration-orders', label: 'Ordrer' },
|
||||
{ key: 'menu-datamigration-customers', label: 'Kunder' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function setMenuVisibilityFeedback(message, type = 'muted') {
|
||||
const el = document.getElementById('menuVisibilityFeedback');
|
||||
if (!el) return;
|
||||
const cls = type === 'error' ? 'text-danger' : type === 'success' ? 'text-success' : 'text-muted';
|
||||
el.className = `small mb-2 ${cls}`;
|
||||
el.textContent = message;
|
||||
}
|
||||
|
||||
function renderMenuVisibilityGrid(hiddenKeys = []) {
|
||||
const grid = document.getElementById('menuVisibilityGrid');
|
||||
if (!grid) return;
|
||||
const hiddenSet = new Set((hiddenKeys || []).map(v => String(v || '').trim().toLowerCase()));
|
||||
|
||||
grid.innerHTML = MENU_VISIBILITY_GROUPS.map(group => {
|
||||
const checkboxes = group.items.map(item => {
|
||||
const checked = hiddenSet.has(item.key.toLowerCase()) ? '' : 'checked';
|
||||
return `
|
||||
<div class="form-check mb-1">
|
||||
<input class="form-check-input menu-visibility-checkbox" type="checkbox" value="${item.key}" id="menu-toggle-${item.key}" ${checked}>
|
||||
<label class="form-check-label" for="menu-toggle-${item.key}">${item.label}</label>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<div class="fw-semibold mb-2">${group.title}</div>
|
||||
${checkboxes}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadMenuVisibilityPreferences() {
|
||||
try {
|
||||
setMenuVisibilityFeedback('Indlæser menuindstillinger...');
|
||||
const response = await fetch('/api/v1/auth/me/menu-preferences', { credentials: 'include' });
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, 'Kunne ikke indlæse menuindstillinger'));
|
||||
}
|
||||
const data = await response.json();
|
||||
renderMenuVisibilityGrid(data.hidden_menu_keys || []);
|
||||
setMenuVisibilityFeedback('');
|
||||
} catch (error) {
|
||||
renderMenuVisibilityGrid([]);
|
||||
setMenuVisibilityFeedback(error.message || 'Kunne ikke indlæse menuindstillinger', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMenuVisibilityPreferences() {
|
||||
const saveBtn = document.getElementById('saveMenuVisibilityBtn');
|
||||
const checks = Array.from(document.querySelectorAll('.menu-visibility-checkbox'));
|
||||
const hiddenKeys = checks
|
||||
.filter(el => !el.checked)
|
||||
.map(el => String(el.value || '').trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
if (saveBtn) saveBtn.disabled = true;
|
||||
setMenuVisibilityFeedback('Gemmer menuindstillinger...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/me/menu-preferences', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ hidden_menu_keys: hiddenKeys }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, 'Kunne ikke gemme menuindstillinger'));
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('bmc:menu-preferences-updated', {
|
||||
detail: { hidden_menu_keys: hiddenKeys },
|
||||
}));
|
||||
|
||||
setMenuVisibilityFeedback('Menuindstillinger gemt.', 'success');
|
||||
showNotification('Menuindstillinger gemt', 'success');
|
||||
} catch (error) {
|
||||
setMenuVisibilityFeedback(error.message || 'Kunne ikke gemme menuindstillinger', 'error');
|
||||
showNotification(error.message || 'Kunne ikke gemme menuindstillinger', 'error');
|
||||
} finally {
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Load on page ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadSettings();
|
||||
loadUsers();
|
||||
setupTagModalListeners();
|
||||
loadPipelineStages();
|
||||
loadMenuVisibilityPreferences();
|
||||
|
||||
const saveMenuVisibilityBtn = document.getElementById('saveMenuVisibilityBtn');
|
||||
if (saveMenuVisibilityBtn) {
|
||||
saveMenuVisibilityBtn.addEventListener('click', saveMenuVisibilityPreferences);
|
||||
}
|
||||
|
||||
const telefoniTemplate = document.getElementById('telefoniActionTemplate');
|
||||
const telefoniDefaultExt = document.getElementById('telefoniDefaultExtension');
|
||||
|
||||
@ -734,6 +734,21 @@
|
||||
background-color: var(--accent-light);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Make section headers in nav dropdowns clearly non-clickable labels */
|
||||
#navbarNav .dropdown-menu li > .dropdown-header {
|
||||
margin: 0.2rem 0 0.35rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 0.75rem 1rem;
|
||||
@ -778,98 +793,98 @@
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav mx-auto">
|
||||
<li class="nav-item dropdown">
|
||||
<li class="nav-item dropdown" data-menu-key="menu-crm">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-people me-2"></i>CRM
|
||||
</a>
|
||||
<ul class="dropdown-menu mt-2">
|
||||
<li><h6 class="dropdown-header">Kunderelationer</h6></li>
|
||||
<li><a class="dropdown-item py-2" href="/customers">Kunder</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/contacts">Kontakter</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/vendors">Leverandører</a></li>
|
||||
<li data-menu-key="menu-crm-customers"><a class="dropdown-item py-2" href="/customers">Kunder</a></li>
|
||||
<li data-menu-key="menu-crm-contacts"><a class="dropdown-item py-2" href="/contacts">Kontakter</a></li>
|
||||
<li data-menu-key="menu-crm-vendors"><a class="dropdown-item py-2" href="/vendors">Leverandører</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><h6 class="dropdown-header">Struktur</h6></li>
|
||||
<li><a class="dropdown-item py-2" href="/links">Links</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/app/locations">Lokaliteter</a></li>
|
||||
<li data-menu-key="menu-crm-links"><a class="dropdown-item py-2" href="/links">Links</a></li>
|
||||
<li data-menu-key="menu-crm-locations"><a class="dropdown-item py-2" href="/app/locations">Lokaliteter</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><h6 class="dropdown-header">Pipeline</h6></li>
|
||||
<li><a class="dropdown-item py-2" href="/opportunities"><i class="bi bi-briefcase me-2"></i>Muligheder</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/pipeline"><i class="bi bi-diagram-3 me-2"></i>Pipeline</a></li>
|
||||
<li data-menu-key="menu-crm-opportunities"><a class="dropdown-item py-2" href="/opportunities"><i class="bi bi-briefcase me-2"></i>Muligheder</a></li>
|
||||
<li data-menu-key="menu-crm-pipeline"><a class="dropdown-item py-2" href="/pipeline"><i class="bi bi-diagram-3 me-2"></i>Pipeline</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<li class="nav-item" data-menu-key="menu-sager">
|
||||
<a class="nav-link" href="/sag">
|
||||
<i class="bi bi-list-check me-2"></i>Sager
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<li class="nav-item" data-menu-key="menu-kalender">
|
||||
<a class="nav-link" href="/calendar">
|
||||
<i class="bi bi-calendar3 me-2"></i>Kalender
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<li class="nav-item dropdown" data-menu-key="menu-support">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-headset me-2"></i>Support
|
||||
</a>
|
||||
<ul class="dropdown-menu mt-2">
|
||||
<li><h6 class="dropdown-header">Support</h6></li>
|
||||
<li><a class="dropdown-item py-2" href="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/ticket/archived"><i class="bi bi-archive me-2"></i>Arkiverede Tickets</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/emails"><i class="bi bi-envelope me-2"></i>Email</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/anydesk/sessions"><i class="bi bi-display me-2"></i>AnyDesk Sessions</a></li>
|
||||
<li data-menu-key="menu-support-conversations"><a class="dropdown-item py-2" href="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></li>
|
||||
<li data-menu-key="menu-support-tickets"><a class="dropdown-item py-2" href="/ticket/archived"><i class="bi bi-archive me-2"></i>Arkiverede Tickets</a></li>
|
||||
<li data-menu-key="menu-support-emails"><a class="dropdown-item py-2" href="/emails"><i class="bi bi-envelope me-2"></i>Email</a></li>
|
||||
<li data-menu-key="menu-support-telefoni"><a class="dropdown-item py-2" href="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</a></li>
|
||||
<li data-menu-key="menu-support-mission"><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li>
|
||||
<li data-menu-key="menu-support-anydesk"><a class="dropdown-item py-2" href="/anydesk/sessions"><i class="bi bi-display me-2"></i>AnyDesk Sessions</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><h6 class="dropdown-header">Assets & Licenser</h6></li>
|
||||
<li><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>BMC Assets</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/hardware/customers"><i class="bi bi-building me-2"></i>Kundehardware</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/hardware/eset"><i class="bi bi-shield-check me-2"></i>ESET Oversigt</a></li>
|
||||
<li data-menu-key="menu-support-hardware"><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>BMC Assets</a></li>
|
||||
<li data-menu-key="menu-support-hardware-customers"><a class="dropdown-item py-2" href="/hardware/customers"><i class="bi bi-building me-2"></i>Kundehardware</a></li>
|
||||
<li data-menu-key="menu-support-eset"><a class="dropdown-item py-2" href="/hardware/eset"><i class="bi bi-shield-check me-2"></i>ESET Oversigt</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><h6 class="dropdown-header">Værktøjer</h6></li>
|
||||
<li><a class="dropdown-item py-2" href="/manual"><i class="bi bi-journal-richtext me-2"></i>Manualer</a></li>
|
||||
<li data-menu-key="menu-support-manual"><a class="dropdown-item py-2" href="/manual"><i class="bi bi-journal-richtext me-2"></i>Manualer</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<li class="nav-item dropdown" data-menu-key="menu-salg">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-cart3 me-2"></i>Salg
|
||||
</a>
|
||||
<ul class="dropdown-menu mt-2">
|
||||
<li><h6 class="dropdown-header">Salg</h6></li>
|
||||
<li><a class="dropdown-item py-2" href="/ordre"><i class="bi bi-receipt me-2"></i>Ordre</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/products"><i class="bi bi-box-seam me-2"></i>Produkter</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/webshop"><i class="bi bi-shop me-2"></i>Webshop Administration</a></li>
|
||||
<li data-menu-key="menu-salg-orders"><a class="dropdown-item py-2" href="/ordre"><i class="bi bi-receipt me-2"></i>Ordre</a></li>
|
||||
<li data-menu-key="menu-salg-products"><a class="dropdown-item py-2" href="/products"><i class="bi bi-box-seam me-2"></i>Produkter</a></li>
|
||||
<li data-menu-key="menu-salg-webshop"><a class="dropdown-item py-2" href="/webshop"><i class="bi bi-shop me-2"></i>Webshop Administration</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<li class="nav-item dropdown" data-menu-key="menu-okonomi">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-currency-dollar me-2"></i>Økonomi
|
||||
</a>
|
||||
<ul class="dropdown-menu mt-2">
|
||||
<li><h6 class="dropdown-header">Fakturering</h6></li>
|
||||
<li><a class="dropdown-item py-2" href="/economy/time-queue"><i class="bi bi-clock-history me-2"></i>Time Queue</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/billing/supplier-invoices"><i class="bi bi-receipt me-2"></i>Leverandør fakturaer</a></li>
|
||||
<li data-menu-key="menu-okonomi-time-queue"><a class="dropdown-item py-2" href="/economy/time-queue"><i class="bi bi-clock-history me-2"></i>Time Queue</a></li>
|
||||
<li data-menu-key="menu-okonomi-supplier-invoices"><a class="dropdown-item py-2" href="/billing/supplier-invoices"><i class="bi bi-receipt me-2"></i>Leverandør fakturaer</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><h6 class="dropdown-header">Aftaler</h6></li>
|
||||
<li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
|
||||
<li data-menu-key="menu-okonomi-prepaid"><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
|
||||
<li data-menu-key="menu-okonomi-fixed-price"><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
|
||||
<li data-menu-key="menu-okonomi-subscriptions"><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="dropdown">
|
||||
<div class="dropdown" data-menu-key="menu-datamigration">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-clock-history me-2"></i>Data migration
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end mt-2">
|
||||
<li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/employee-log"><i class="bi bi-bar-chart-steps me-2"></i>Medarbejder Log</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/service-contract-report"><i class="bi bi-file-earmark-bar-graph me-2"></i>Servicekontrakt Rapport</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
|
||||
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
|
||||
<li data-menu-key="menu-datamigration-dashboard"><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
|
||||
<li data-menu-key="menu-datamigration-registrations"><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
|
||||
<li data-menu-key="menu-datamigration-wizard"><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
|
||||
<li data-menu-key="menu-datamigration-employee-log"><a class="dropdown-item py-2" href="/timetracking/employee-log"><i class="bi bi-bar-chart-steps me-2"></i>Medarbejder Log</a></li>
|
||||
<li data-menu-key="menu-datamigration-service-contract-wizard"><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li>
|
||||
<li data-menu-key="menu-datamigration-service-contract-report"><a class="dropdown-item py-2" href="/timetracking/service-contract-report"><i class="bi bi-file-earmark-bar-graph me-2"></i>Servicekontrakt Rapport</a></li>
|
||||
<li data-menu-key="menu-datamigration-orders"><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
|
||||
<li data-menu-key="menu-datamigration-customers"><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="btn btn-light rounded-circle border-0" id="globalSearchBtn" style="background: var(--accent-light); color: var(--accent);" title="Global søgning (Cmd/Ctrl+K)">
|
||||
@ -1269,7 +1284,7 @@ window.addEventListener('unhandledrejection', function(event) {
|
||||
<script src="/static/js/notifications.js?v=1.0"></script>
|
||||
<script src="/static/js/telefoni.js?v=2.3"></script>
|
||||
<script src="/static/js/sms.js?v=1.0"></script>
|
||||
<script src="/static/js/bug-report.js?v=1.0"></script>
|
||||
<script src="/static/js/bug-report.js?v=1.4"></script>
|
||||
<script src="/static/js/bottom-bar.js?v=2.23"></script>
|
||||
<script>
|
||||
// Dark Mode Toggle Logic
|
||||
@ -1348,11 +1363,138 @@ window.addEventListener('unhandledrejection', function(event) {
|
||||
}
|
||||
});
|
||||
|
||||
const MENU_PREFS_CACHE_KEY = 'bmc_menu_hidden_keys';
|
||||
|
||||
function normalizeHiddenMenuKeys(keys) {
|
||||
if (!Array.isArray(keys)) return [];
|
||||
const seen = new Set();
|
||||
const normalized = [];
|
||||
for (const raw of keys) {
|
||||
const key = String(raw || '').trim().toLowerCase();
|
||||
if (!key) continue;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
normalized.push(key);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isVisibleActionItem(li) {
|
||||
if (!li) return false;
|
||||
const anchor = li.querySelector('a.dropdown-item');
|
||||
if (!anchor) return false;
|
||||
if (li.style.display === 'none') return false;
|
||||
if (anchor.style.display === 'none') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function cleanupDropdownSections(menu) {
|
||||
const items = Array.from(menu.children).filter((node) => node.tagName === 'LI');
|
||||
|
||||
// Reset section and divider display first, then recalculate.
|
||||
items.forEach((li) => {
|
||||
if (li.querySelector('h6.dropdown-header') || li.querySelector('hr.dropdown-divider')) {
|
||||
li.style.display = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Hide headers that no longer have visible action items below them.
|
||||
items.forEach((li, idx) => {
|
||||
if (!li.querySelector('h6.dropdown-header')) return;
|
||||
|
||||
let hasVisibleItem = false;
|
||||
for (let j = idx + 1; j < items.length; j += 1) {
|
||||
if (items[j].querySelector('h6.dropdown-header')) break;
|
||||
if (isVisibleActionItem(items[j])) {
|
||||
hasVisibleItem = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
li.style.display = hasVisibleItem ? '' : 'none';
|
||||
});
|
||||
|
||||
// Hide separators that don't separate visible action groups.
|
||||
items.forEach((li, idx) => {
|
||||
if (!li.querySelector('hr.dropdown-divider')) return;
|
||||
|
||||
let hasVisibleBefore = false;
|
||||
for (let j = idx - 1; j >= 0; j -= 1) {
|
||||
if (isVisibleActionItem(items[j])) {
|
||||
hasVisibleBefore = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let hasVisibleAfter = false;
|
||||
for (let j = idx + 1; j < items.length; j += 1) {
|
||||
if (isVisibleActionItem(items[j])) {
|
||||
hasVisibleAfter = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
li.style.display = hasVisibleBefore && hasVisibleAfter ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupNavbarDropdowns(hiddenSet) {
|
||||
document.querySelectorAll('#navbarNav .dropdown-menu').forEach((menu) => {
|
||||
cleanupDropdownSections(menu);
|
||||
});
|
||||
|
||||
document.querySelectorAll('#navbarNav .nav-item.dropdown[data-menu-key], #navbarNav .d-flex > .dropdown[data-menu-key]').forEach((dropdownRoot) => {
|
||||
const key = String(dropdownRoot.getAttribute('data-menu-key') || '').trim().toLowerCase();
|
||||
if (!key || hiddenSet.has(key)) return;
|
||||
|
||||
const menu = dropdownRoot.querySelector(':scope > .dropdown-menu');
|
||||
if (!menu) return;
|
||||
|
||||
const hasVisibleItems = Array.from(menu.children).some((li) => isVisibleActionItem(li));
|
||||
dropdownRoot.style.display = hasVisibleItems ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function applyMenuVisibility(hiddenKeys) {
|
||||
const hidden = new Set(normalizeHiddenMenuKeys(hiddenKeys));
|
||||
document.querySelectorAll('[data-menu-key]').forEach((node) => {
|
||||
const key = String(node.getAttribute('data-menu-key') || '').trim().toLowerCase();
|
||||
if (!key) return;
|
||||
node.style.display = hidden.has(key) ? 'none' : '';
|
||||
});
|
||||
cleanupNavbarDropdowns(hidden);
|
||||
window.__bmcMenuHiddenKeys = Array.from(hidden);
|
||||
}
|
||||
|
||||
async function loadAndApplyMenuVisibility() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/me/menu-preferences', { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`HTTP_${res.status}`);
|
||||
const data = await res.json();
|
||||
const hidden = normalizeHiddenMenuKeys(data.hidden_menu_keys || []);
|
||||
localStorage.setItem(MENU_PREFS_CACHE_KEY, JSON.stringify(hidden));
|
||||
applyMenuVisibility(hidden);
|
||||
} catch (_err) {
|
||||
try {
|
||||
const cached = JSON.parse(localStorage.getItem(MENU_PREFS_CACHE_KEY) || '[]');
|
||||
applyMenuVisibility(cached);
|
||||
} catch (_cacheErr) {
|
||||
applyMenuVisibility([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('bmc:menu-preferences-updated', (event) => {
|
||||
const hidden = normalizeHiddenMenuKeys(event?.detail?.hidden_menu_keys || []);
|
||||
localStorage.setItem(MENU_PREFS_CACHE_KEY, JSON.stringify(hidden));
|
||||
applyMenuVisibility(hidden);
|
||||
});
|
||||
|
||||
// Global Search Modal (Cmd+K) - Initialize after DOM is ready
|
||||
let selectedResultIndex = -1;
|
||||
let allResults = [];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadAndApplyMenuVisibility();
|
||||
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
|
||||
const searchBubbleBtn = document.getElementById('globalSearchBtn');
|
||||
const contextManualBtn = document.getElementById('contextManualBtn');
|
||||
@ -2043,6 +2185,11 @@ window.addEventListener('unhandledrejection', function(event) {
|
||||
Reminders
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="profile-menu-tab" data-bs-toggle="tab" data-bs-target="#profile-menu" type="button" role="tab">
|
||||
Menu
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="profileTabsContent">
|
||||
@ -2133,6 +2280,22 @@ window.addEventListener('unhandledrejection', function(event) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="profile-menu" role="tabpanel" tabindex="0">
|
||||
<div class="card border-0">
|
||||
<div class="card-body px-0">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0 text-primary"><i class="bi bi-layout-text-window-reverse me-2"></i>Menuvisning (min konto)</h6>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" id="profMenuShowAllBtn">Vis alle</button>
|
||||
<button class="btn btn-sm btn-primary" type="button" id="profMenuSaveBtn">Gem</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="profMenuFeedback" class="small text-muted mb-2"></div>
|
||||
<div id="profMenuPrefsGrid" class="row g-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@ -2143,6 +2306,141 @@ window.addEventListener('unhandledrejection', function(event) {
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const PROFILE_MENU_PREF_ITEMS = [
|
||||
{ key: 'menu-crm', label: 'CRM' },
|
||||
{ key: 'menu-sager', label: 'Sager' },
|
||||
{ key: 'menu-kalender', label: 'Kalender' },
|
||||
{ key: 'menu-support', label: 'Support' },
|
||||
{ key: 'menu-salg', label: 'Salg' },
|
||||
{ key: 'menu-okonomi', label: 'Økonomi' },
|
||||
{ key: 'menu-datamigration', label: 'Data migration' },
|
||||
{ key: 'menu-crm-customers', label: 'CRM: Kunder' },
|
||||
{ key: 'menu-crm-contacts', label: 'CRM: Kontakter' },
|
||||
{ key: 'menu-crm-vendors', label: 'CRM: Leverandører' },
|
||||
{ key: 'menu-crm-links', label: 'CRM: Links' },
|
||||
{ key: 'menu-crm-locations', label: 'CRM: Lokaliteter' },
|
||||
{ key: 'menu-crm-opportunities', label: 'CRM: Muligheder' },
|
||||
{ key: 'menu-crm-pipeline', label: 'CRM: Pipeline' },
|
||||
{ key: 'menu-support-conversations', label: 'Support: Mine Samtaler' },
|
||||
{ key: 'menu-support-tickets', label: 'Support: Arkiverede Tickets' },
|
||||
{ key: 'menu-support-emails', label: 'Support: Email' },
|
||||
{ key: 'menu-support-telefoni', label: 'Support: Telefoni' },
|
||||
{ key: 'menu-support-mission', label: 'Support: Mission Control' },
|
||||
{ key: 'menu-support-anydesk', label: 'Support: AnyDesk Sessions' },
|
||||
{ key: 'menu-support-hardware', label: 'Support: BMC Assets' },
|
||||
{ key: 'menu-support-hardware-customers', label: 'Support: Kundehardware' },
|
||||
{ key: 'menu-support-eset', label: 'Support: ESET Oversigt' },
|
||||
{ key: 'menu-support-manual', label: 'Support: Manualer' },
|
||||
{ key: 'menu-salg-orders', label: 'Salg: Ordre' },
|
||||
{ key: 'menu-salg-products', label: 'Salg: Produkter' },
|
||||
{ key: 'menu-salg-webshop', label: 'Salg: Webshop Administration' },
|
||||
{ key: 'menu-okonomi-time-queue', label: 'Økonomi: Time Queue' },
|
||||
{ key: 'menu-okonomi-supplier-invoices', label: 'Økonomi: Leverandør fakturaer' },
|
||||
{ key: 'menu-okonomi-prepaid', label: 'Økonomi: Prepaid Cards' },
|
||||
{ key: 'menu-okonomi-fixed-price', label: 'Økonomi: Fastpris Aftaler' },
|
||||
{ key: 'menu-okonomi-subscriptions', label: 'Økonomi: Abonnementer' },
|
||||
{ key: 'menu-datamigration-dashboard', label: 'Data migration: Dashboard' },
|
||||
{ key: 'menu-datamigration-registrations', label: 'Data migration: Registreringer' },
|
||||
{ key: 'menu-datamigration-wizard', label: 'Data migration: Godkend Timer' },
|
||||
{ key: 'menu-datamigration-employee-log', label: 'Data migration: Medarbejder Log' },
|
||||
{ key: 'menu-datamigration-service-contract-wizard', label: 'Data migration: Servicekontrakt Migration' },
|
||||
{ key: 'menu-datamigration-service-contract-report', label: 'Data migration: Servicekontrakt Rapport' },
|
||||
{ key: 'menu-datamigration-orders', label: 'Data migration: Ordrer' },
|
||||
{ key: 'menu-datamigration-customers', label: 'Data migration: Kunder' },
|
||||
];
|
||||
|
||||
function setProfileMenuFeedback(message, type = 'muted') {
|
||||
const el = document.getElementById('profMenuFeedback');
|
||||
if (!el) return;
|
||||
const cls = type === 'error' ? 'text-danger' : type === 'success' ? 'text-success' : 'text-muted';
|
||||
el.className = `small ${cls} mb-2`;
|
||||
el.textContent = message || '';
|
||||
}
|
||||
|
||||
function renderProfileMenuPrefs(hiddenKeys = []) {
|
||||
const grid = document.getElementById('profMenuPrefsGrid');
|
||||
if (!grid) return;
|
||||
const hidden = new Set((hiddenKeys || []).map(v => String(v || '').trim().toLowerCase()));
|
||||
grid.innerHTML = PROFILE_MENU_PREF_ITEMS.map(item => {
|
||||
const checked = hidden.has(item.key.toLowerCase()) ? '' : 'checked';
|
||||
return `
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="form-check mb-1">
|
||||
<input class="form-check-input prof-menu-checkbox" type="checkbox" id="prof-menu-${item.key}" value="${item.key}" ${checked}>
|
||||
<label class="form-check-label" for="prof-menu-${item.key}">${item.label}</label>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadProfileMenuPreferences() {
|
||||
try {
|
||||
setProfileMenuFeedback('Indlæser menu...');
|
||||
const res = await fetch('/api/v1/auth/me/menu-preferences', { credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) throw new Error('Session udløbet. Log ind igen.');
|
||||
if (res.status === 404) throw new Error('Menu-endpoint mangler på serveren (genstart API).');
|
||||
let detail = '';
|
||||
try {
|
||||
const err = await res.json();
|
||||
detail = err?.detail || '';
|
||||
} catch (_parseErr) {
|
||||
detail = '';
|
||||
}
|
||||
throw new Error(detail || `Kunne ikke indlæse menu (HTTP ${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
renderProfileMenuPrefs(data.hidden_menu_keys || []);
|
||||
setProfileMenuFeedback('');
|
||||
} catch (e) {
|
||||
renderProfileMenuPrefs(window.__bmcMenuHiddenKeys || []);
|
||||
setProfileMenuFeedback(e.message || 'Kunne ikke indlæse menu', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProfileMenuPreferences() {
|
||||
const checks = Array.from(document.querySelectorAll('.prof-menu-checkbox'));
|
||||
const hiddenKeys = checks
|
||||
.filter(cb => !cb.checked)
|
||||
.map(cb => String(cb.value || '').trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
const saveBtn = document.getElementById('profMenuSaveBtn');
|
||||
if (saveBtn) saveBtn.disabled = true;
|
||||
setProfileMenuFeedback('Gemmer menu...');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/me/menu-preferences', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ hidden_menu_keys: hiddenKeys })
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) throw new Error('Session udløbet. Log ind igen.');
|
||||
if (res.status === 404) throw new Error('Menu-endpoint mangler på serveren (genstart API).');
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Kunne ikke gemme menu');
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('bmc:menu-preferences-updated', {
|
||||
detail: { hidden_menu_keys: hiddenKeys }
|
||||
}));
|
||||
setProfileMenuFeedback('Menu gemt.', 'success');
|
||||
} catch (e) {
|
||||
setProfileMenuFeedback(e.message || 'Kunne ikke gemme menu', 'error');
|
||||
} finally {
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showAllProfileMenuItems() {
|
||||
document.querySelectorAll('.prof-menu-checkbox').forEach((cb) => {
|
||||
cb.checked = true;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadReminderPreferences() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/users/me/notification-preferences', { credentials: 'include' });
|
||||
@ -2349,12 +2647,18 @@ window.addEventListener('unhandledrejection', function(event) {
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const saveMenuBtn = document.getElementById('profMenuSaveBtn');
|
||||
if (saveMenuBtn) saveMenuBtn.addEventListener('click', saveProfileMenuPreferences);
|
||||
const showAllBtn = document.getElementById('profMenuShowAllBtn');
|
||||
if (showAllBtn) showAllBtn.addEventListener('click', showAllProfileMenuItems);
|
||||
|
||||
const profileModalEl = document.getElementById('profileModal');
|
||||
if (profileModalEl) {
|
||||
profileModalEl.addEventListener('shown.bs.modal', () => {
|
||||
loadReminderPreferences();
|
||||
loadProfileReminders();
|
||||
loadUserProfile();
|
||||
loadProfileMenuPreferences();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
2
main.py
2
main.py
@ -141,6 +141,7 @@ from app.modules.bottom_bar.backend import router as bottom_bar_api
|
||||
from app.modules.bottom_bar.backend import public_router as bottom_bar_public_api
|
||||
from app.modules.rentals.backend import router as rentals_api
|
||||
from app.modules.task_templates.backend import router as task_templates_api
|
||||
from app.bug_reports.backend import router as bug_reports_api
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@ -440,6 +441,7 @@ app.include_router(opportunities_api.router, prefix="/api/v1", tags=["Opportunit
|
||||
app.include_router(auth_api.router, prefix="/api/v1/auth", tags=["Auth"])
|
||||
app.include_router(auth_admin_api.router, prefix="/api/v1", tags=["Auth Admin"])
|
||||
app.include_router(anydesk.router, prefix="/api/v1", tags=["Remote Support"])
|
||||
app.include_router(bug_reports_api.router, prefix="/api/v1", tags=["Bug Reports"])
|
||||
|
||||
# Module Routers
|
||||
app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"])
|
||||
|
||||
12
migrations/1005_sag_status_dynamic_values.sql
Normal file
12
migrations/1005_sag_status_dynamic_values.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- Allow dynamic case statuses managed by settings instead of hard-coded DB enum-like check
|
||||
-- Replaces legacy constraint that only allowed 'åben' and 'lukket'.
|
||||
|
||||
ALTER TABLE sag_sager
|
||||
DROP CONSTRAINT IF EXISTS sag_sager_status_check;
|
||||
|
||||
ALTER TABLE sag_sager
|
||||
DROP CONSTRAINT IF EXISTS sag_sager_status_nonempty_check;
|
||||
|
||||
ALTER TABLE sag_sager
|
||||
ADD CONSTRAINT sag_sager_status_nonempty_check
|
||||
CHECK (length(trim(status)) > 0);
|
||||
10
migrations/1006_user_sag_list_preferences.sql
Normal file
10
migrations/1006_user_sag_list_preferences.sql
Normal file
@ -0,0 +1,10 @@
|
||||
-- Per-user preferences for sag list filters (e.g. default type selections)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sag_list_preferences (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
type_filters JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sag_list_preferences_updated_at
|
||||
ON user_sag_list_preferences(updated_at DESC);
|
||||
40
migrations/191_user_menu_preferences.sql
Normal file
40
migrations/191_user_menu_preferences.sql
Normal file
@ -0,0 +1,40 @@
|
||||
-- Migration 191: Per-user menu visibility preferences
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_menu_preferences (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
menu_key VARCHAR(120) NOT NULL,
|
||||
visible BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, menu_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_menu_preferences_user_id
|
||||
ON user_menu_preferences(user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_menu_preferences_menu_key
|
||||
ON user_menu_preferences(menu_key);
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_user_menu_preferences_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_trigger
|
||||
WHERE tgname = 'user_menu_preferences_updated_at_trigger'
|
||||
) THEN
|
||||
CREATE TRIGGER user_menu_preferences_updated_at_trigger
|
||||
BEFORE UPDATE ON user_menu_preferences
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_user_menu_preferences_updated_at();
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@ -5,6 +5,7 @@
|
||||
let screenshotDataUrl = null;
|
||||
let pendingScreenshotPromise = null;
|
||||
let isCapturingDisplayMedia = false;
|
||||
let html2canvasLoaderPromise = null;
|
||||
|
||||
const pushLog = (type, args) => {
|
||||
try {
|
||||
@ -85,6 +86,20 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function withTimeout(promise, ms, timeoutMessage) {
|
||||
let timer = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(timeoutMessage || 'Operation timed out')), ms);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function isCrossOriginUrl(url) {
|
||||
try {
|
||||
if (!url) return false;
|
||||
@ -116,6 +131,25 @@
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
async function ensureHtml2Canvas() {
|
||||
if (window.html2canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!html2canvasLoaderPromise) {
|
||||
html2canvasLoaderPromise = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js';
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('Could not load screenshot library'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
await html2canvasLoaderPromise;
|
||||
}
|
||||
|
||||
async function takeScreenshotViaDisplayMedia() {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
||||
throw new Error('Display media API not supported');
|
||||
@ -158,9 +192,7 @@
|
||||
}
|
||||
|
||||
async function takeScreenshot() {
|
||||
if (!window.html2canvas) {
|
||||
throw new Error('Screenshot library not loaded');
|
||||
}
|
||||
await ensureHtml2Canvas();
|
||||
|
||||
const doc = document.documentElement;
|
||||
const body = document.body;
|
||||
@ -183,15 +215,31 @@
|
||||
|
||||
const common = {
|
||||
useCORS: true,
|
||||
allowTaint: false,
|
||||
allowTaint: true,
|
||||
logging: false,
|
||||
scale: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
imageTimeout: 3000,
|
||||
imageTimeout: 7000,
|
||||
ignoreElements: shouldIgnoreInScreenshot,
|
||||
removeContainer: true,
|
||||
};
|
||||
|
||||
// Strategy 0: Viewport capture via foreignObject (works on many CSS-heavy pages)
|
||||
try {
|
||||
return await renderScreenshot(document.body || document.documentElement, {
|
||||
...common,
|
||||
foreignObjectRendering: true,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
windowWidth: window.innerWidth,
|
||||
windowHeight: window.innerHeight,
|
||||
scrollX: window.scrollX,
|
||||
scrollY: window.scrollY,
|
||||
});
|
||||
} catch (e0) {
|
||||
console.warn('Bug report screenshot strategy 0 failed', e0);
|
||||
}
|
||||
|
||||
// Strategy 1: Full page (most useful when it works)
|
||||
try {
|
||||
return await renderScreenshot(document.documentElement, {
|
||||
@ -207,9 +255,6 @@
|
||||
});
|
||||
} catch (e1) {
|
||||
console.warn('Bug report screenshot strategy 1 failed', e1);
|
||||
if (String(e1?.message || '').toLowerCase().includes('unsupported color function "color"')) {
|
||||
throw new Error('Html2canvas understøtter ikke denne browsers farveprofil (color()).');
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Main content only (explicit selectors avoid navbar-only captures)
|
||||
@ -231,9 +276,6 @@
|
||||
});
|
||||
} catch (e2) {
|
||||
console.warn('Bug report screenshot strategy 2 failed', e2);
|
||||
if (String(e2?.message || '').toLowerCase().includes('unsupported color function "color"')) {
|
||||
throw new Error('Html2canvas understøtter ikke denne browsers farveprofil (color()).');
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Automatic screenshot failed');
|
||||
@ -284,29 +326,62 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function openBugReportModal() {
|
||||
async function captureScreenshotBeforeModal() {
|
||||
try {
|
||||
screenshotDataUrl = await withTimeout(
|
||||
takeScreenshotViaDisplayMedia(),
|
||||
12000,
|
||||
'Skærmvalg timed out'
|
||||
);
|
||||
return { ok: true, viaDisplayMedia: true };
|
||||
} catch (displayErr) {
|
||||
console.warn('Bug report display-media first attempt failed', displayErr);
|
||||
}
|
||||
|
||||
const attemptAutoCapture = async () => {
|
||||
const promise = pendingScreenshotPromise || takeScreenshot();
|
||||
pendingScreenshotPromise = promise;
|
||||
return await promise;
|
||||
};
|
||||
|
||||
try {
|
||||
screenshotDataUrl = await attemptAutoCapture();
|
||||
return { ok: true };
|
||||
} catch (firstError) {
|
||||
console.warn('Bug report screenshot first attempt failed', firstError);
|
||||
|
||||
try {
|
||||
pendingScreenshotPromise = withTimeout(
|
||||
takeScreenshot(),
|
||||
8000,
|
||||
'Automatic screenshot timed out'
|
||||
);
|
||||
screenshotDataUrl = await pendingScreenshotPromise;
|
||||
return { ok: true, recovered: true };
|
||||
} catch (secondError) {
|
||||
console.warn('Bug report screenshot retry failed', secondError);
|
||||
screenshotDataUrl = null;
|
||||
return { ok: false, error: secondError || firstError };
|
||||
}
|
||||
} finally {
|
||||
pendingScreenshotPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function openBugReportModal(statusText, statusIsError) {
|
||||
if (!bugModal) {
|
||||
const modalEl = document.getElementById('bugReportModal');
|
||||
if (!modalEl || !window.bootstrap) return;
|
||||
bugModal = new bootstrap.Modal(modalEl);
|
||||
}
|
||||
|
||||
setStatus('Tager screenshot...');
|
||||
screenshotDataUrl = null;
|
||||
setPreview(null);
|
||||
|
||||
try {
|
||||
screenshotDataUrl = pendingScreenshotPromise
|
||||
? await pendingScreenshotPromise
|
||||
: await takeScreenshot();
|
||||
setPreview(screenshotDataUrl);
|
||||
setStatus('Screenshot klar. Udfyld felterne og send.');
|
||||
} catch (e) {
|
||||
console.warn('Bug report screenshot failed', e);
|
||||
setStatus('Kunne ikke tage screenshot automatisk. Klik "Tag screenshot via skærmdeling" eller indsæt med Cmd+V.', true);
|
||||
} finally {
|
||||
pendingScreenshotPromise = null;
|
||||
}
|
||||
setPreview(screenshotDataUrl);
|
||||
setStatus(
|
||||
statusText || (screenshotDataUrl
|
||||
? 'Screenshot klar. Udfyld felterne og send.'
|
||||
: 'Kunne ikke tage screenshot automatisk. Klik "Tag screenshot via skærmdeling" eller indsæt med Cmd+V.'),
|
||||
Boolean(statusIsError)
|
||||
);
|
||||
|
||||
bugModal.show();
|
||||
}
|
||||
@ -438,15 +513,21 @@
|
||||
const modalEl = document.getElementById('bugReportModal');
|
||||
|
||||
if (btn) {
|
||||
const primeCapture = () => {
|
||||
prepareScreenshotFromTrigger(true);
|
||||
};
|
||||
|
||||
btn.addEventListener('pointerdown', primeCapture);
|
||||
btn.addEventListener('click', (e) => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
prepareScreenshotFromTrigger(false);
|
||||
openBugReportModal();
|
||||
const capture = await captureScreenshotBeforeModal();
|
||||
if (capture.ok) {
|
||||
const msg = capture.viaDisplayMedia
|
||||
? 'Screenshot taget ved klik via skærmdeling. Udfyld felterne og send.'
|
||||
: 'Screenshot taget ved klik. Udfyld felterne og send.';
|
||||
openBugReportModal(msg, false);
|
||||
} else {
|
||||
const reason = String(capture?.error?.message || '').trim();
|
||||
const errorMsg = reason
|
||||
? `Kunne ikke tage screenshot automatisk (${reason}). Klik \"Tag screenshot via skærmdeling\" eller indsæt med Cmd+V.`
|
||||
: 'Kunne ikke tage screenshot automatisk. Klik "Tag screenshot via skærmdeling" eller indsæt med Cmd+V.';
|
||||
openBugReportModal(errorMsg, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -486,8 +567,20 @@
|
||||
if (isTyping) return;
|
||||
if (e.ctrlKey && e.shiftKey && (e.key === 'B' || e.key === 'b')) {
|
||||
e.preventDefault();
|
||||
prepareScreenshotFromTrigger(true);
|
||||
openBugReportModal();
|
||||
captureScreenshotBeforeModal().then((capture) => {
|
||||
if (capture.ok) {
|
||||
const msg = capture.viaDisplayMedia
|
||||
? 'Screenshot taget ved klik via skærmdeling. Udfyld felterne og send.'
|
||||
: 'Screenshot taget ved klik. Udfyld felterne og send.';
|
||||
openBugReportModal(msg, false);
|
||||
} else {
|
||||
const reason = String(capture?.error?.message || '').trim();
|
||||
const errorMsg = reason
|
||||
? `Kunne ikke tage screenshot automatisk (${reason}). Klik \"Tag screenshot via skærmdeling\" eller indsæt med Cmd+V.`
|
||||
: 'Kunne ikke tage screenshot automatisk. Klik "Tag screenshot via skærmdeling" eller indsæt med Cmd+V.';
|
||||
openBugReportModal(errorMsg, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -59,15 +59,146 @@
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function normalizeNamePart(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLocaleLowerCase('da-DK')
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function normalizeDigits(value) {
|
||||
return String(value || '').replace(/\D+/g, '');
|
||||
}
|
||||
|
||||
function buildToastContact(contactData) {
|
||||
if (!contactData) return null;
|
||||
|
||||
const firstName = String(contactData.first_name || '').trim();
|
||||
const lastName = String(contactData.last_name || '').trim();
|
||||
const name = String(contactData.name || `${firstName} ${lastName}` || '').trim();
|
||||
const primaryCompany = Array.isArray(contactData.companies) && contactData.companies.length > 0
|
||||
? contactData.companies[0]
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: contactData.id,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
name,
|
||||
company: primaryCompany?.name || contactData.company || '',
|
||||
company_id: primaryCompany?.id || contactData.company_id || null,
|
||||
phone: contactData.phone || null,
|
||||
mobile: contactData.mobile || null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatCaseLabel(caseItem) {
|
||||
if (!caseItem) return '';
|
||||
const title = String(caseItem.titel || caseItem.title || `Sag #${caseItem.id || ''}`).trim();
|
||||
const customer = String(caseItem.customer_name || '').trim();
|
||||
return customer ? `${title} · ${customer}` : title;
|
||||
}
|
||||
|
||||
function formatContactLabel(contactItem) {
|
||||
if (!contactItem) return '';
|
||||
const firstName = String(contactItem.first_name || '').trim();
|
||||
const lastName = String(contactItem.last_name || '').trim();
|
||||
const name = [firstName, lastName].filter(Boolean).join(' ') || `Kontakt #${contactItem.id || ''}`;
|
||||
const companyNames = Array.isArray(contactItem.company_names) ? contactItem.company_names.filter(Boolean) : [];
|
||||
return companyNames.length ? `${name} · ${companyNames[0]}` : name;
|
||||
}
|
||||
|
||||
async function fetchContactByName(firstName, lastName, phoneValue = '') {
|
||||
const fullName = [String(firstName || '').trim(), String(lastName || '').trim()]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const digits = normalizeDigits(phoneValue);
|
||||
if (!fullName && !digits) return null;
|
||||
|
||||
const queries = [];
|
||||
if (fullName) queries.push(fullName);
|
||||
if (digits && digits !== fullName) queries.push(phoneValue || digits);
|
||||
|
||||
const contactMap = new Map();
|
||||
for (const query of queries) {
|
||||
const qs = new URLSearchParams({ search: query, limit: '25' });
|
||||
const res = await fetch(`/api/v1/contacts?${qs.toString()}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const payload = await res.json();
|
||||
const contacts = Array.isArray(payload?.contacts) ? payload.contacts : [];
|
||||
contacts.forEach((item) => {
|
||||
if (item?.id) contactMap.set(Number(item.id), item);
|
||||
});
|
||||
}
|
||||
|
||||
const contacts = Array.from(contactMap.values());
|
||||
const wantedFirst = normalizeNamePart(firstName);
|
||||
const wantedLast = normalizeNamePart(lastName);
|
||||
const wantedDigits = normalizeDigits(phoneValue);
|
||||
|
||||
const exactNameMatch = contacts.find((item) => {
|
||||
return wantedFirst && normalizeNamePart(item?.first_name) === wantedFirst
|
||||
&& normalizeNamePart(item?.last_name) === wantedLast;
|
||||
});
|
||||
if (exactNameMatch) return exactNameMatch;
|
||||
|
||||
if (wantedDigits) {
|
||||
const exactNumberMatch = contacts.find((item) => {
|
||||
const numbers = [item?.phone, item?.mobile].map(normalizeDigits).filter(Boolean);
|
||||
return numbers.some((number) => number === wantedDigits || number.endsWith(wantedDigits) || wantedDigits.endsWith(number));
|
||||
});
|
||||
if (exactNumberMatch) return exactNumberMatch;
|
||||
}
|
||||
|
||||
return contacts[0] || null;
|
||||
}
|
||||
|
||||
async function fetchFullContact(contactId) {
|
||||
const res = await fetch(`/api/v1/contacts/${Number(contactId)}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchContactCases(contactId) {
|
||||
const res = await fetch(`/api/v1/contacts/${Number(contactId)}/cases`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function searchCases(query) {
|
||||
const qs = new URLSearchParams({ q: String(query || '').trim() });
|
||||
const res = await fetch(`/api/v1/search/sag?${qs.toString()}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
const payload = await res.json();
|
||||
return Array.isArray(payload) ? payload : [];
|
||||
}
|
||||
|
||||
function showIncomingCallToast(data) {
|
||||
const container = ensureContainer();
|
||||
const contact = data.contact || null;
|
||||
let currentData = { ...data };
|
||||
let contact = currentData.contact || null;
|
||||
const number = data.number || '';
|
||||
const title = contact?.name ? contact.name : 'Ukendt nummer';
|
||||
const company = contact?.company ? contact.company : '';
|
||||
const recentCases = data.recent_cases || [];
|
||||
const lastCall = data.last_call;
|
||||
|
||||
const callId = data.call_id;
|
||||
|
||||
const toastEl = document.createElement('div');
|
||||
@ -76,77 +207,387 @@
|
||||
toastEl.setAttribute('aria-live', 'assertive');
|
||||
toastEl.setAttribute('aria-atomic', 'true');
|
||||
|
||||
const openContactBtn = contact?.id
|
||||
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-action="open-contact">Åbn kontakt</button>`
|
||||
: '';
|
||||
|
||||
// Build recent cases HTML
|
||||
let casesHtml = '';
|
||||
if (recentCases.length > 0) {
|
||||
casesHtml = '<div class="mt-2 mb-2"><small class="text-muted fw-semibold">Åbne sager:</small>';
|
||||
recentCases.forEach(c => {
|
||||
casesHtml += `<div class="small"><a href="/sag/${c.id}" class="text-decoration-none" target="_blank">${escapeHtml(c.titel)}</a></div>`;
|
||||
});
|
||||
casesHtml += '</div>';
|
||||
function closeSmartCaseModal() {
|
||||
const existing = document.getElementById('telefoni-smart-case-modal');
|
||||
if (existing) existing.remove();
|
||||
document.querySelectorAll('[data-telefoni-backdrop]').forEach((el) => el.remove());
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
|
||||
// Build last call HTML
|
||||
let lastCallHtml = '';
|
||||
if (lastCall) {
|
||||
// lastCall can be either a date string (legacy) or an object with started_at and bruger_navn
|
||||
const callDate = lastCall.started_at ? lastCall.started_at : lastCall;
|
||||
const lastCallDate = new Date(callDate);
|
||||
const now = new Date();
|
||||
const diffMs = now - lastCallDate;
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
let timeAgo = '';
|
||||
if (diffDays === 0) {
|
||||
timeAgo = 'I dag';
|
||||
} else if (diffDays === 1) {
|
||||
timeAgo = 'I går';
|
||||
} else if (diffDays < 7) {
|
||||
timeAgo = `${diffDays} dage siden`;
|
||||
function openCaseSmartModal() {
|
||||
closeSmartCaseModal();
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'telefoni-smart-case-modal';
|
||||
modal.className = 'modal fade show';
|
||||
modal.style.display = 'block';
|
||||
modal.setAttribute('tabindex', '-1');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.innerHTML = `
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||
<div class="modal-content border-0 shadow-lg">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<div>
|
||||
<h5 class="modal-title mb-0"><i class="bi bi-link-45deg me-2"></i>Link sag</h5>
|
||||
<div class="small opacity-75">${escapeHtml(number || 'Ukendt nummer')} · vælg en relevant sag eller søg videre</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white" data-action="close-smart-case"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul class="nav nav-pills gap-2 mb-3" role="tablist">
|
||||
<li class="nav-item"><button class="nav-link active" type="button" data-tab-target="contact">Kontaktens sager</button></li>
|
||||
<li class="nav-item"><button class="nav-link" type="button" data-tab-target="company">Firmaets sager</button></li>
|
||||
<li class="nav-item"><button class="nav-link" type="button" data-tab-target="related">Relaterede kontakter</button></li>
|
||||
<li class="nav-item"><button class="nav-link" type="button" data-tab-target="search">Søg sager</button></li>
|
||||
</ul>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted mb-1">Manuel sag-ID</label>
|
||||
<div class="input-group">
|
||||
<input type="number" min="1" class="form-control" data-role="manual-case-id" placeholder="Fx 1234">
|
||||
<button type="button" class="btn btn-outline-secondary" data-action="link-manual-case">Link sag</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane-holder" data-pane="contact">
|
||||
<div class="text-muted small p-2"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser...</div>
|
||||
</div>
|
||||
<div class="tab-pane-holder d-none" data-pane="company">
|
||||
<div class="text-muted small p-2"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser...</div>
|
||||
</div>
|
||||
<div class="tab-pane-holder d-none" data-pane="related">
|
||||
<div class="text-muted small p-2"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser...</div>
|
||||
</div>
|
||||
<div class="tab-pane-holder d-none" data-pane="search">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted mb-1">Søg i alle sager</label>
|
||||
<input type="text" class="form-control" data-role="case-search-input" placeholder="Titel, kunde, ID, buzzword...">
|
||||
</div>
|
||||
<div class="alert alert-light border mb-0" data-role="search-hint">Skriv mindst 2 tegn for at søge.</div>
|
||||
<div class="list-group mt-3" data-role="search-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop fade show';
|
||||
backdrop.dataset.telefoniBackdrop = '1';
|
||||
document.body.appendChild(backdrop);
|
||||
document.body.classList.add('modal-open');
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────
|
||||
const showPane = (paneName) => {
|
||||
modal.querySelectorAll('.tab-pane-holder').forEach((pane) => {
|
||||
pane.classList.toggle('d-none', pane.dataset.pane !== paneName);
|
||||
});
|
||||
modal.querySelectorAll('[data-tab-target]').forEach((tabBtn) => {
|
||||
tabBtn.classList.toggle('active', tabBtn.dataset.tabTarget === paneName);
|
||||
});
|
||||
};
|
||||
|
||||
const renderCaseList = (pane, items, emptyText) => {
|
||||
const paneEl = modal.querySelector(`[data-pane="${pane}"]`);
|
||||
if (!paneEl) return;
|
||||
if (!items || items.length === 0) {
|
||||
paneEl.innerHTML = `<div class="alert alert-light border mb-0">${escapeHtml(emptyText)}</div>`;
|
||||
return;
|
||||
}
|
||||
paneEl.innerHTML = `<div class="list-group">${items.map((item) => {
|
||||
const caseId = Number(item.id);
|
||||
const label = formatCaseLabel(item);
|
||||
const meta = [item.status, item.created_at ? new Date(item.created_at).toLocaleDateString('da-DK') : '']
|
||||
.filter(Boolean).join(' · ');
|
||||
return `
|
||||
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center gap-3" data-case-id="${caseId}">
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(label)}</div>
|
||||
<div class="small text-muted">${escapeHtml(meta || `ID: ${caseId}`)}</div>
|
||||
</div>
|
||||
<i class="bi bi-arrow-right-circle text-primary"></i>
|
||||
</button>`;
|
||||
}).join('')}</div>`;
|
||||
paneEl.querySelectorAll('[data-case-id]').forEach((button) => {
|
||||
button.addEventListener('click', async () => {
|
||||
const caseId = Number(button.getAttribute('data-case-id'));
|
||||
await patchCallCase(caseId);
|
||||
closeSmartCaseModal();
|
||||
window.location.href = `/sag/${caseId}/v3`;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const renderRelatedContacts = (relatedContacts) => {
|
||||
const paneEl = modal.querySelector('[data-pane="related"]');
|
||||
if (!paneEl) return;
|
||||
if (!relatedContacts.length) {
|
||||
paneEl.innerHTML = '<div class="alert alert-light border mb-0">Ingen relaterede kontakter fundet</div>';
|
||||
return;
|
||||
}
|
||||
paneEl.innerHTML = `<div class="list-group">${relatedContacts.map((item) => {
|
||||
const contactId = Number(item.id);
|
||||
const label = formatContactLabel(item);
|
||||
const phone = String(item.phone || item.mobile || '').trim();
|
||||
return `
|
||||
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center gap-3" data-contact-id="${contactId}">
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(label)}</div>
|
||||
<div class="small text-muted">${escapeHtml(phone || 'Ingen telefon')}</div>
|
||||
</div>
|
||||
<i class="bi bi-person-lines-fill text-primary"></i>
|
||||
</button>`;
|
||||
}).join('')}</div>`;
|
||||
paneEl.querySelectorAll('[data-contact-id]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const cid = Number(button.getAttribute('data-contact-id'));
|
||||
if (cid > 0) window.location.href = `/contacts/${cid}`;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ── søg-fane ─────────────────────────────────────────────────────
|
||||
const searchInput = modal.querySelector('[data-role="case-search-input"]');
|
||||
const searchResults = modal.querySelector('[data-role="search-results"]');
|
||||
const searchHint = modal.querySelector('[data-role="search-hint"]');
|
||||
let searchTimer = null;
|
||||
let searchToken = 0;
|
||||
|
||||
const runSearch = async (query) => {
|
||||
const token = ++searchToken;
|
||||
const q = String(query || '').trim();
|
||||
if (q.length < 2) {
|
||||
searchHint.textContent = 'Skriv mindst 2 tegn for at søge.';
|
||||
searchResults.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
searchHint.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Søger...';
|
||||
searchResults.innerHTML = '';
|
||||
try {
|
||||
const results = await searchCases(q);
|
||||
if (token !== searchToken) return;
|
||||
if (!results.length) {
|
||||
searchHint.textContent = 'Ingen sager fundet';
|
||||
return;
|
||||
}
|
||||
searchHint.textContent = `Fandt ${results.length} sager`;
|
||||
searchResults.innerHTML = results.map((item) => {
|
||||
const caseId = Number(item.id);
|
||||
const label = formatCaseLabel(item);
|
||||
return `
|
||||
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" data-search-case-id="${caseId}">
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(label)}</div>
|
||||
<div class="small text-muted">${escapeHtml(item.status || '-')} · ID: ${caseId}</div>
|
||||
</div>
|
||||
<span class="badge text-bg-primary">Link</span>
|
||||
</button>`;
|
||||
}).join('');
|
||||
searchResults.querySelectorAll('[data-search-case-id]').forEach((button) => {
|
||||
button.addEventListener('click', async () => {
|
||||
const caseId = Number(button.getAttribute('data-search-case-id'));
|
||||
await patchCallCase(caseId);
|
||||
closeSmartCaseModal();
|
||||
window.location.href = `/sag/${caseId}/v3`;
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (token !== searchToken) return;
|
||||
searchHint.textContent = `Fejl: ${error?.message || 'ukendt fejl'}`;
|
||||
}
|
||||
};
|
||||
|
||||
searchInput?.addEventListener('input', () => {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => runSearch(searchInput.value), 250);
|
||||
});
|
||||
searchInput?.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
runSearch(searchInput.value);
|
||||
});
|
||||
|
||||
modal.querySelector('[data-action="link-manual-case"]')?.addEventListener('click', async () => {
|
||||
const manualValue = Number(modal.querySelector('[data-role="manual-case-id"]')?.value || 0);
|
||||
if (!Number.isInteger(manualValue) || manualValue <= 0) {
|
||||
window.alert('Ugyldigt sag-ID');
|
||||
return;
|
||||
}
|
||||
await patchCallCase(manualValue);
|
||||
closeSmartCaseModal();
|
||||
window.location.href = `/sag/${manualValue}/v3`;
|
||||
});
|
||||
|
||||
modal.querySelector('[data-action="close-smart-case"]')?.addEventListener('click', closeSmartCaseModal);
|
||||
backdrop.addEventListener('click', closeSmartCaseModal);
|
||||
document.addEventListener('keydown', function onKeydown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
closeSmartCaseModal();
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
}
|
||||
});
|
||||
|
||||
modal.querySelectorAll('[data-tab-target]').forEach((tabBtn) => {
|
||||
tabBtn.addEventListener('click', () => showPane(tabBtn.dataset.tabTarget));
|
||||
});
|
||||
|
||||
// ── hent altid friske data fra API ────────────────────────────────
|
||||
const contactId = contact?.id ? Number(contact.id) : null;
|
||||
if (contactId) {
|
||||
fetchContactCases(contactId).then((ctx) => {
|
||||
const contactCases = ctx?.contact_cases || [];
|
||||
const companyCases = ctx?.company_cases || [];
|
||||
// Gem i currentData så efterfølgende åbninger også har data
|
||||
currentData.contact_cases = contactCases;
|
||||
currentData.company_cases = companyCases;
|
||||
|
||||
renderCaseList('contact', contactCases, 'Ingen sager tilknyttet denne kontakt');
|
||||
renderCaseList('company', companyCases, 'Ingen sager på firmaet');
|
||||
|
||||
// Relaterede kontakter fra WS-payload (hurtig) eller tom
|
||||
const relatedContacts = Array.isArray(currentData.related_contacts)
|
||||
? currentData.related_contacts : [];
|
||||
renderRelatedContacts(relatedContacts);
|
||||
|
||||
const hasSuggestions = contactCases.length > 0 || companyCases.length > 0;
|
||||
if (!hasSuggestions) {
|
||||
showPane('search');
|
||||
const term = String(contact?.name || contact?.company || number || '').trim();
|
||||
if (term.length >= 2) {
|
||||
searchInput.value = term;
|
||||
runSearch(term);
|
||||
} else {
|
||||
setTimeout(() => searchInput?.focus(), 50);
|
||||
}
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.warn('📞 fetchContactCases fejlede:', err?.message || err);
|
||||
renderCaseList('contact', [], 'Ingen data');
|
||||
renderCaseList('company', [], 'Ingen data');
|
||||
renderRelatedContacts([]);
|
||||
showPane('search');
|
||||
const term = String(contact?.name || contact?.company || number || '').trim();
|
||||
if (term.length >= 2) {
|
||||
searchInput.value = term;
|
||||
runSearch(term);
|
||||
} else {
|
||||
setTimeout(() => searchInput?.focus(), 50);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
timeAgo = lastCallDate.toLocaleDateString('da-DK');
|
||||
}
|
||||
|
||||
const brugerInfo = lastCall.bruger_navn ? ` (${escapeHtml(lastCall.bruger_navn)})` : '';
|
||||
|
||||
// Format duration
|
||||
let durationInfo = '';
|
||||
if (lastCall.duration_sec) {
|
||||
const mins = Math.floor(lastCall.duration_sec / 60);
|
||||
const secs = lastCall.duration_sec % 60;
|
||||
if (mins > 0) {
|
||||
durationInfo = ` - ${mins}m ${secs}s`;
|
||||
// Ingen kontakt — vis søge-fanen med det samme
|
||||
renderCaseList('contact', [], 'Ingen kontakt tilknyttet dette opkald');
|
||||
renderCaseList('company', [], 'Ingen kontakt tilknyttet dette opkald');
|
||||
renderRelatedContacts([]);
|
||||
showPane('search');
|
||||
const term = String(number || '').trim();
|
||||
if (term.length >= 2) {
|
||||
searchInput.value = term;
|
||||
runSearch(term);
|
||||
} else {
|
||||
durationInfo = ` - ${secs}s`;
|
||||
setTimeout(() => searchInput?.focus(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
lastCallHtml = `<div class="small text-muted mt-2"><i class="bi bi-clock-history me-1"></i>Sidst snakket: ${timeAgo}${brugerInfo}${durationInfo}</div>`;
|
||||
}
|
||||
|
||||
toastEl.innerHTML = `
|
||||
<div class="toast-header">
|
||||
<strong class="me-auto"><i class="bi bi-telephone me-2"></i>Opkald</strong>
|
||||
<small class="text-muted">${escapeHtml(data.direction === 'outbound' ? 'Udgående' : 'Indgående')}</small>
|
||||
<button type="button" class="btn-close ms-2" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
<div class="fw-bold">${escapeHtml(number)}</div>
|
||||
<div>${escapeHtml(title)}</div>
|
||||
${company ? `<div class="text-muted small">${escapeHtml(company)}</div>` : ''}
|
||||
${lastCallHtml}
|
||||
${casesHtml}
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
${openContactBtn}
|
||||
<button type="button" class="btn btn-sm btn-primary" data-action="create-case">Opret sag</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" data-action="link-case">Link sag</button>
|
||||
function renderToastBody() {
|
||||
contact = currentData.contact || null;
|
||||
const title = contact?.name ? contact.name : 'Ukendt nummer';
|
||||
const company = contact?.company ? contact.company : '';
|
||||
const recentCases = currentData.recent_cases || [];
|
||||
const lastCall = currentData.last_call;
|
||||
const openContactBtn = contact?.id
|
||||
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-action="open-contact">Åbn kontakt</button>`
|
||||
: '';
|
||||
|
||||
let casesHtml = '';
|
||||
if (recentCases.length > 0) {
|
||||
casesHtml = '<div class="mt-2 mb-2"><small class="text-muted fw-semibold">Åbne sager:</small>';
|
||||
recentCases.forEach(c => {
|
||||
casesHtml += `<div class="small"><a href="/sag/${c.id}" class="text-decoration-none" target="_blank">${escapeHtml(c.titel)}</a></div>`;
|
||||
});
|
||||
casesHtml += '</div>';
|
||||
}
|
||||
|
||||
let lastCallHtml = '';
|
||||
if (lastCall) {
|
||||
const callDate = lastCall.started_at ? lastCall.started_at : lastCall;
|
||||
const lastCallDate = new Date(callDate);
|
||||
const now = new Date();
|
||||
const diffMs = now - lastCallDate;
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
let timeAgo = '';
|
||||
if (diffDays === 0) {
|
||||
timeAgo = 'I dag';
|
||||
} else if (diffDays === 1) {
|
||||
timeAgo = 'I går';
|
||||
} else if (diffDays < 7) {
|
||||
timeAgo = `${diffDays} dage siden`;
|
||||
} else {
|
||||
timeAgo = lastCallDate.toLocaleDateString('da-DK');
|
||||
}
|
||||
|
||||
const brugerInfo = lastCall.bruger_navn ? ` (${escapeHtml(lastCall.bruger_navn)})` : '';
|
||||
|
||||
let durationInfo = '';
|
||||
if (lastCall.duration_sec) {
|
||||
const mins = Math.floor(lastCall.duration_sec / 60);
|
||||
const secs = lastCall.duration_sec % 60;
|
||||
durationInfo = mins > 0 ? ` - ${mins}m ${secs}s` : ` - ${secs}s`;
|
||||
}
|
||||
|
||||
lastCallHtml = `<div class="small text-muted mt-2"><i class="bi bi-clock-history me-1"></i>Sidst snakket: ${timeAgo}${brugerInfo}${durationInfo}</div>`;
|
||||
}
|
||||
|
||||
const quickCreateHtml = !contact?.id ? `
|
||||
<div class="border rounded p-2 mt-3 bg-light-subtle">
|
||||
<div class="small fw-semibold mb-2">Hurtig opret kontakt</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<input type="text" class="form-control form-control-sm" data-role="quick-first-name" placeholder="Fornavn">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<input type="text" class="form-control form-control-sm" data-role="quick-last-name" placeholder="Efternavn">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="email" class="form-control form-control-sm" data-role="quick-email" placeholder="E-mail (valgfri)">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-text mt-0">Finder først eksisterende kontakt på navn og opdaterer telefon, hvis den kun mangler nummer.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<button type="button" class="btn btn-sm btn-success" data-action="quick-create-contact">Opret kontakt</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
` : '';
|
||||
|
||||
toastEl.innerHTML = `
|
||||
<div class="toast-header">
|
||||
<strong class="me-auto"><i class="bi bi-telephone me-2"></i>Opkald</strong>
|
||||
<small class="text-muted">${escapeHtml(currentData.direction === 'outbound' ? 'Udgående' : 'Indgående')}</small>
|
||||
<button type="button" class="btn-close ms-2" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
<div class="fw-bold">${escapeHtml(number)}</div>
|
||||
<div>${escapeHtml(title)}</div>
|
||||
${company ? `<div class="text-muted small">${escapeHtml(company)}</div>` : ''}
|
||||
${lastCallHtml}
|
||||
${casesHtml}
|
||||
${quickCreateHtml}
|
||||
<div class="d-flex gap-2 mt-3 flex-wrap">
|
||||
${openContactBtn}
|
||||
<button type="button" class="btn btn-sm btn-primary" data-action="create-case">Opret sag</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" data-action="link-case">Link sag</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderToastBody();
|
||||
|
||||
container.appendChild(toastEl);
|
||||
const toast = new bootstrap.Toast(toastEl, { autohide: false });
|
||||
@ -177,6 +618,125 @@
|
||||
return parsedCaseId;
|
||||
}
|
||||
|
||||
async function patchCallContact(contactId) {
|
||||
if (!Number.isInteger(Number(callId)) || Number(callId) <= 0) {
|
||||
throw new Error('Mangler call_id');
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/v1/telefoni/calls/${Number(callId)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ kontakt_id: Number(contactId) })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function createOrUpdateContactFromToast() {
|
||||
const firstNameInput = toastEl.querySelector('[data-role="quick-first-name"]');
|
||||
const lastNameInput = toastEl.querySelector('[data-role="quick-last-name"]');
|
||||
const emailInput = toastEl.querySelector('[data-role="quick-email"]');
|
||||
const firstName = String(firstNameInput?.value || '').trim();
|
||||
const lastName = String(lastNameInput?.value || '').trim();
|
||||
const email = String(emailInput?.value || '').trim();
|
||||
|
||||
if (!firstName) {
|
||||
window.alert('Fornavn er påkrævet');
|
||||
firstNameInput?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const actionBtn = toastEl.querySelector('[data-action="quick-create-contact"]');
|
||||
if (!actionBtn) return;
|
||||
|
||||
actionBtn.disabled = true;
|
||||
const originalText = actionBtn.textContent;
|
||||
actionBtn.textContent = 'Gemmer...';
|
||||
|
||||
try {
|
||||
const phoneValue = String(number || '').trim() || null;
|
||||
const existing = await fetchContactByName(firstName, lastName, phoneValue || '');
|
||||
let savedContactId = null;
|
||||
|
||||
if (existing?.id) {
|
||||
const existingHasPhone = String(existing.phone || existing.mobile || '').trim();
|
||||
const shouldUpdatePhone = !existingHasPhone && phoneValue;
|
||||
const shouldUpdateEmail = !String(existing.email || '').trim() && email;
|
||||
|
||||
if (shouldUpdatePhone || shouldUpdateEmail) {
|
||||
const updatePayload = {};
|
||||
if (shouldUpdatePhone) updatePayload.phone = phoneValue;
|
||||
if (shouldUpdateEmail) updatePayload.email = email;
|
||||
|
||||
const updateRes = await fetch(`/api/v1/contacts/${Number(existing.id)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(updatePayload)
|
||||
});
|
||||
if (!updateRes.ok) {
|
||||
const text = await updateRes.text();
|
||||
throw new Error(text || `HTTP ${updateRes.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
savedContactId = Number(existing.id);
|
||||
} else {
|
||||
const createRes = await fetch('/api/v1/contacts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email: email || null,
|
||||
phone: phoneValue,
|
||||
title: null,
|
||||
})
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
const text = await createRes.text();
|
||||
throw new Error(text || `HTTP ${createRes.status}`);
|
||||
}
|
||||
|
||||
const created = await createRes.json();
|
||||
savedContactId = Number(created?.id || 0);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(savedContactId) || savedContactId <= 0) {
|
||||
throw new Error('Kontakt-ID mangler');
|
||||
}
|
||||
|
||||
await patchCallContact(savedContactId);
|
||||
const [fullContact, caseContext] = await Promise.all([
|
||||
fetchFullContact(savedContactId),
|
||||
fetchContactCases(savedContactId),
|
||||
]);
|
||||
currentData = {
|
||||
...currentData,
|
||||
contact: buildToastContact(fullContact),
|
||||
contact_cases: caseContext?.contact_cases || currentData.contact_cases || [],
|
||||
company_cases: caseContext?.company_cases || currentData.company_cases || [],
|
||||
related_contacts: caseContext?.related_contacts || currentData.related_contacts || [],
|
||||
};
|
||||
renderToastBody();
|
||||
} catch (err) {
|
||||
window.alert(`Kunne ikke oprette kontakt: ${err?.message || 'ukendt fejl'}`);
|
||||
} finally {
|
||||
const refreshedActionBtn = toastEl.querySelector('[data-action="quick-create-contact"]');
|
||||
if (refreshedActionBtn) {
|
||||
refreshedActionBtn.disabled = false;
|
||||
refreshedActionBtn.textContent = originalText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toastEl.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('button[data-action]');
|
||||
if (!btn) return;
|
||||
@ -193,26 +753,10 @@
|
||||
window.location.href = `/sag/new?${qs.toString()}`;
|
||||
}
|
||||
if (action === 'link-case') {
|
||||
const answer = window.prompt('Indtast eksisterende sag-ID, som opkaldet skal linkes til:');
|
||||
if (answer === null) return;
|
||||
const caseId = Number(String(answer).trim());
|
||||
if (!Number.isInteger(caseId) || caseId <= 0) {
|
||||
window.alert('Ugyldigt sag-ID');
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Gemmer...';
|
||||
try {
|
||||
const linkedCaseId = await patchCallCase(caseId);
|
||||
window.location.href = `/sag/${linkedCaseId}/v3`;
|
||||
} catch (err) {
|
||||
window.alert(`Kunne ikke linke sag: ${err?.message || 'ukendt fejl'}`);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
}
|
||||
openCaseSmartModal();
|
||||
}
|
||||
if (action === 'quick-create-contact') {
|
||||
await createOrUpdateContactFromToast();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user