feat: add SMS service and frontend integration

- Implement SmsService class for sending SMS via CPSMS API.
- Add SMS sending functionality in the frontend with validation and user feedback.
- Create database migrations for SMS message storage and telephony features.
- Introduce telephony settings and user-specific configurations for click-to-call functionality.
- Enhance user experience with toast notifications for incoming calls and actions.
This commit is contained in:
Christian 2026-02-14 02:26:29 +01:00
parent 7eda0ce58b
commit 0831715d3a
54 changed files with 6244 additions and 172 deletions

View File

@ -22,6 +22,12 @@ ENABLE_RELOAD=false # Set to true for live code reload (causes log spam in Dock
SECRET_KEY=change-this-in-production-use-random-string SECRET_KEY=change-this-in-production-use-random-string
CORS_ORIGINS=http://localhost:8000,http://localhost:3000 CORS_ORIGINS=http://localhost:8000,http://localhost:3000
# Telefoni (Yealink) callbacks security (MUST set at least one)
# Option A: Shared secret token (recommended)
TELEFONI_SHARED_SECRET=
# Option B: IP whitelist (LAN only) - supports IPs and CIDRs
TELEFONI_IP_WHITELIST=127.0.0.1
# Shadow Admin (Emergency Access) # Shadow Admin (Emergency Access)
SHADOW_ADMIN_ENABLED=false SHADOW_ADMIN_ENABLED=false
SHADOW_ADMIN_USERNAME=shadowadmin SHADOW_ADMIN_USERNAME=shadowadmin

View File

@ -49,6 +49,10 @@ API_RELOAD=false
# Brug: python -c "import secrets; print(secrets.token_urlsafe(32))" # Brug: python -c "import secrets; print(secrets.token_urlsafe(32))"
SECRET_KEY=CHANGEME_GENERATE_RANDOM_SECRET_KEY SECRET_KEY=CHANGEME_GENERATE_RANDOM_SECRET_KEY
# Telefoni (Yealink) callbacks security (MUST set at least one)
TELEFONI_SHARED_SECRET=
TELEFONI_IP_WHITELIST=
# CORS origins - IP adresse med port # CORS origins - IP adresse med port
CORS_ORIGINS=http://172.16.31.183:8001 CORS_ORIGINS=http://172.16.31.183:8001

View File

@ -5,7 +5,7 @@ from fastapi import APIRouter, HTTPException, status, Depends
from app.core.auth_dependencies import require_permission from app.core.auth_dependencies import require_permission
from app.core.auth_service import AuthService from app.core.auth_service import AuthService
from app.core.database import execute_query, execute_query_single, execute_insert, execute_update from app.core.database import execute_query, execute_query_single, execute_insert, execute_update
from app.models.schemas import UserAdminCreate, UserGroupsUpdate, GroupCreate, GroupPermissionsUpdate from app.models.schemas import UserAdminCreate, UserGroupsUpdate, GroupCreate, GroupPermissionsUpdate, UserTwoFactorResetRequest
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -19,6 +19,7 @@ async def list_users():
""" """
SELECT u.user_id, u.username, u.email, u.full_name, SELECT u.user_id, u.username, u.email, u.full_name,
u.is_active, u.is_superadmin, u.is_2fa_enabled, u.is_active, u.is_superadmin, u.is_2fa_enabled,
u.telefoni_extension, u.telefoni_aktiv, u.telefoni_phone_ip, u.telefoni_phone_username,
u.created_at, u.last_login_at, u.created_at, u.last_login_at,
COALESCE(array_remove(array_agg(g.name), NULL), ARRAY[]::varchar[]) AS groups COALESCE(array_remove(array_agg(g.name), NULL), ARRAY[]::varchar[]) AS groups
FROM users u FROM users u
@ -93,6 +94,33 @@ async def update_user_groups(user_id: int, payload: UserGroupsUpdate):
return {"message": "Groups updated"} return {"message": "Groups updated"}
@router.post("/admin/users/{user_id}/2fa/reset")
async def reset_user_2fa(
user_id: int,
payload: UserTwoFactorResetRequest,
current_user: dict = Depends(require_permission("users.manage"))
):
ok = AuthService.admin_reset_user_2fa(user_id)
if not ok:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
reason = (payload.reason or "").strip()
if reason:
logger.info(
"✅ Admin reset 2FA for user_id=%s by %s (reason: %s)",
user_id,
current_user.get("username"),
reason
)
else:
logger.info(
"✅ Admin reset 2FA for user_id=%s by %s",
user_id,
current_user.get("username")
)
return {"message": "2FA reset"}
@router.get("/admin/groups", dependencies=[Depends(require_permission("users.manage"))]) @router.get("/admin/groups", dependencies=[Depends(require_permission("users.manage"))])
async def list_groups(): async def list_groups():
groups = execute_query( groups = execute_query(

View File

@ -24,6 +24,7 @@ class LoginResponse(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer"
user: dict user: dict
requires_2fa_setup: bool = False
class LogoutRequest(BaseModel): class LogoutRequest(BaseModel):
@ -70,6 +71,11 @@ async def login(request: Request, credentials: LoginRequest, response: Response)
is_superadmin=user['is_superadmin'], is_superadmin=user['is_superadmin'],
is_shadow_admin=user.get('is_shadow_admin', False) is_shadow_admin=user.get('is_shadow_admin', False)
) )
requires_2fa_setup = (
not user.get("is_shadow_admin", False)
and not user.get("is_2fa_enabled", False)
)
response.set_cookie( response.set_cookie(
key="access_token", key="access_token",
@ -81,7 +87,8 @@ async def login(request: Request, credentials: LoginRequest, response: Response)
return LoginResponse( return LoginResponse(
access_token=access_token, access_token=access_token,
user=user user=user,
requires_2fa_setup=requires_2fa_setup
) )

View File

@ -18,3 +18,14 @@ async def login_page(request: Request):
"auth/frontend/login.html", "auth/frontend/login.html",
{"request": request} {"request": request}
) )
@router.get("/2fa/setup", response_class=HTMLResponse)
async def two_factor_setup_page(request: Request):
"""
Render 2FA setup page
"""
return templates.TemplateResponse(
"auth/frontend/2fa_setup.html",
{"request": request}
)

View File

@ -0,0 +1,145 @@
{% extends "shared/frontend/base.html" %}
{% block title %}2FA Setup - BMC Hub{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center align-items-center" style="min-height: 80vh;">
<div class="col-md-6 col-lg-5">
<div class="card shadow-sm">
<div class="card-body p-4">
<div class="text-center mb-4">
<h2 class="fw-bold" style="color: var(--primary-color);">2FA Setup</h2>
<p class="text-muted">Opsaet tofaktor for din konto</p>
</div>
<div id="statusMessage" class="alert alert-info" role="alert">
Klik "Generer 2FA" for at starte opsaetningen.
</div>
<div class="d-grid gap-2 mb-3">
<button class="btn btn-primary" id="generateBtn">
<i class="bi bi-shield-lock me-2"></i>
Generer 2FA
</button>
</div>
<div id="setupDetails" class="d-none">
<div class="mb-3">
<label class="form-label">Secret</label>
<input type="text" class="form-control" id="totpSecret" readonly>
</div>
<div class="mb-3">
<label class="form-label">Provisioning URI</label>
<textarea class="form-control" id="provisioningUri" rows="3" readonly></textarea>
</div>
<div class="mb-3">
<label class="form-label">2FA-kode</label>
<input type="text" class="form-control" id="otpCode" placeholder="Indtast 2FA-kode">
</div>
<div class="d-grid gap-2">
<button class="btn btn-success" id="enableBtn">
<i class="bi bi-check-circle me-2"></i>
Aktiver 2FA
</button>
</div>
</div>
<div class="text-center mt-3">
<a href="/" class="text-decoration-none text-muted">Spring over for nu</a>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const statusMessage = document.getElementById('statusMessage');
const generateBtn = document.getElementById('generateBtn');
const enableBtn = document.getElementById('enableBtn');
const setupDetails = document.getElementById('setupDetails');
const totpSecret = document.getElementById('totpSecret');
const provisioningUri = document.getElementById('provisioningUri');
const otpCode = document.getElementById('otpCode');
async function ensureAuthenticated() {
const token = localStorage.getItem('access_token');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
try {
const response = await fetch('/api/v1/auth/me', { headers, credentials: 'include' });
if (!response.ok) {
window.location.href = '/login';
}
} catch (error) {
window.location.href = '/login';
}
}
generateBtn.addEventListener('click', async () => {
statusMessage.className = 'alert alert-info';
statusMessage.textContent = 'Genererer 2FA...';
try {
const response = await fetch('/api/v1/auth/2fa/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
const data = await response.json();
if (!response.ok) {
statusMessage.className = 'alert alert-danger';
statusMessage.textContent = data.detail || 'Kunne ikke generere 2FA.';
return;
}
totpSecret.value = data.secret || '';
provisioningUri.value = data.provisioning_uri || '';
setupDetails.classList.remove('d-none');
statusMessage.className = 'alert alert-success';
statusMessage.textContent = '2FA secret genereret. Indtast koden fra din authenticator.';
} catch (error) {
statusMessage.className = 'alert alert-danger';
statusMessage.textContent = 'Kunne ikke generere 2FA.';
}
});
enableBtn.addEventListener('click', async () => {
const code = (otpCode.value || '').trim();
if (!code) {
statusMessage.className = 'alert alert-warning';
statusMessage.textContent = 'Indtast 2FA-koden.';
return;
}
try {
const response = await fetch('/api/v1/auth/2fa/enable', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ otp_code: code })
});
const data = await response.json();
if (!response.ok) {
statusMessage.className = 'alert alert-danger';
statusMessage.textContent = data.detail || 'Kunne ikke aktivere 2FA.';
return;
}
statusMessage.className = 'alert alert-success';
statusMessage.textContent = '2FA aktiveret. Du bliver sendt videre.';
setTimeout(() => {
window.location.href = '/';
}, 1200);
} catch (error) {
statusMessage.className = 'alert alert-danger';
statusMessage.textContent = 'Kunne ikke aktivere 2FA.';
}
});
ensureAuthenticated();
</script>
{% endblock %}

View File

@ -124,7 +124,13 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
const d = new Date(); const d = new Date();
d.setTime(d.getTime() + (24*60*60*1000)); d.setTime(d.getTime() + (24*60*60*1000));
document.cookie = `access_token=${data.access_token};expires=${d.toUTCString()};path=/;SameSite=Lax`; document.cookie = `access_token=${data.access_token};expires=${d.toUTCString()};path=/;SameSite=Lax`;
if (data.requires_2fa_setup) {
const goSetup = confirm('2FA er ikke opsat. Vil du opsaette 2FA nu?');
window.location.href = goSetup ? '/2fa/setup' : '/';
return;
}
// Redirect to dashboard // Redirect to dashboard
window.location.href = '/'; window.location.href = '/';
} else { } else {

View File

@ -406,3 +406,55 @@ async def get_contact_subscription_billing_matrix(
if not customer_id: if not customer_id:
raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde") raise HTTPException(status_code=404, detail="Kontakt har ingen tilknyttet kunde")
return await get_subscription_billing_matrix(customer_id, months) return await get_subscription_billing_matrix(customer_id, months)
@router.get("/contacts/{contact_id}/kontakt")
async def get_contact_kontakt_history(contact_id: int, limit: int = Query(default=200, ge=1, le=1000)):
try:
exists = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
if not exists:
raise HTTPException(status_code=404, detail="Contact not found")
query = """
SELECT * FROM (
SELECT
'call' AS type,
t.id::text AS event_id,
t.started_at AS happened_at,
t.direction,
t.ekstern_nummer AS number,
NULL::text AS message,
t.duration_sec,
COALESCE(u.full_name, u.username) AS user_name,
NULL::text AS sms_status
FROM telefoni_opkald t
LEFT JOIN users u ON u.user_id = t.bruger_id
WHERE t.kontakt_id = %s
UNION ALL
SELECT
'sms' AS type,
s.id::text AS event_id,
s.created_at AS happened_at,
NULL::text AS direction,
s.recipient AS number,
s.message,
NULL::int AS duration_sec,
COALESCE(u.full_name, u.username) AS user_name,
s.status AS sms_status
FROM sms_messages s
LEFT JOIN users u ON u.user_id = s.bruger_id
WHERE s.kontakt_id = %s
) z
ORDER BY z.happened_at DESC NULLS LAST
LIMIT %s
"""
rows = execute_query(query, (contact_id, contact_id, limit)) or []
return {"items": rows}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to fetch kontakt history for contact {contact_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@ -205,8 +205,8 @@
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#activity"> <a class="nav-link" data-bs-toggle="tab" href="#kontakt">
<i class="bi bi-clock-history"></i>Aktivitet <i class="bi bi-chat-left-text"></i>Kontakt
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
@ -489,11 +489,16 @@
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <div>
<h5 class="fw-bold mb-0">Hardware</h5> <h5 class="fw-bold mb-0">Hardware</h5>
<small class="text-muted">Hardware knyttet til kontaktens firmaer</small> <small class="text-muted">Kun hardware knyttet direkte til denne kontakt</small>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" href="/hardware/new">
<i class="bi bi-plus-lg me-2"></i>Nyt hardware
</a>
<button class="btn btn-outline-primary btn-sm" onclick="loadUnassignedHardware()">
<i class="bi bi-search me-2"></i>Gennemgå hardware uden ejer
</button>
</div> </div>
<a class="btn btn-outline-secondary btn-sm" id="hardwareCustomerLink" href="#">
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn kundedetalje
</a>
</div> </div>
<div class="table-responsive" id="contactHardwareContainer"> <div class="table-responsive" id="contactHardwareContainer">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
@ -519,6 +524,28 @@
<div id="contactHardwareEmpty" class="text-center py-5 text-muted d-none"> <div id="contactHardwareEmpty" class="text-center py-5 text-muted d-none">
Ingen hardware fundet for denne kontakt Ingen hardware fundet for denne kontakt
</div> </div>
<div class="mt-4">
<h6 class="fw-bold mb-2">Hardware uden ejer</h6>
<div class="small text-muted mb-3">Gennemgå og tilknyt hardware til denne kontakt.</div>
<div class="table-responsive d-none" id="unassignedHardwareContainer">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Hardware</th>
<th>Type</th>
<th>Serienr.</th>
<th>Status</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody id="unassignedHardwareTable"></tbody>
</table>
</div>
<div id="unassignedHardwareEmpty" class="text-center py-3 text-muted d-none">
Ingen hardware uden ejer fundet
</div>
</div>
</div> </div>
<!-- Nextcloud Tab --> <!-- Nextcloud Tab -->
@ -583,10 +610,17 @@
</div> </div>
</div> </div>
<!-- Activity Tab --> <!-- Kontakt Tab -->
<div class="tab-pane fade" id="activity"> <div class="tab-pane fade" id="kontakt">
<h5 class="fw-bold mb-4">Aktivitet</h5> <div class="d-flex justify-content-between align-items-center mb-4">
<div id="activityContainer"> <h5 class="fw-bold mb-0">Kontakt historik</h5>
<div class="btn-group btn-group-sm" role="group" aria-label="Kontakt filter">
<button type="button" class="btn btn-outline-secondary active" id="kontaktFilterAll" onclick="setKontaktFilter('all')">Alle</button>
<button type="button" class="btn btn-outline-secondary" id="kontaktFilterSms" onclick="setKontaktFilter('sms')">SMS</button>
<button type="button" class="btn btn-outline-secondary" id="kontaktFilterCall" onclick="setKontaktFilter('call')">Opkald</button>
</div>
</div>
<div id="kontaktContainer">
<div class="text-center py-5"> <div class="text-center py-5">
<div class="spinner-border text-primary"></div> <div class="spinner-border text-primary"></div>
</div> </div>
@ -754,6 +788,8 @@
const contactId = parseInt(window.location.pathname.split('/').pop()); const contactId = parseInt(window.location.pathname.split('/').pop());
let contactData = null; let contactData = null;
let primaryCustomerId = null; let primaryCustomerId = null;
let kontaktHistoryItems = [];
let kontaktHistoryFilter = 'all';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadContact(); loadContact();
@ -820,10 +856,10 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
const activityTab = document.querySelector('a[href="#activity"]'); const kontaktTab = document.querySelector('a[href="#kontakt"]');
if (activityTab) { if (kontaktTab) {
activityTab.addEventListener('shown.bs.tab', () => { kontaktTab.addEventListener('shown.bs.tab', () => {
loadActivity(); loadKontaktHistory();
}); });
} }
@ -869,8 +905,8 @@ function displayContact(contact) {
// Contact Information // Contact Information
document.getElementById('fullName').textContent = `${contact.first_name} ${contact.last_name}`; document.getElementById('fullName').textContent = `${contact.first_name} ${contact.last_name}`;
document.getElementById('email').textContent = contact.email || '-'; document.getElementById('email').textContent = contact.email || '-';
document.getElementById('phone').textContent = contact.phone || '-'; document.getElementById('phone').innerHTML = renderNumberActions(contact.phone, false, contact);
document.getElementById('mobile').textContent = contact.mobile || '-'; document.getElementById('mobile').innerHTML = renderNumberActions(contact.mobile, true, contact);
// Role & Position // Role & Position
document.getElementById('title').textContent = contact.title || '-'; document.getElementById('title').textContent = contact.title || '-';
@ -897,6 +933,61 @@ function displayContact(contact) {
} }
} }
function renderNumberActions(number, allowSms = false, contact = null) {
const clean = String(number || '').trim();
if (!clean) return '-';
const contactLabel = contact ? `${contact.first_name || ''} ${contact.last_name || ''}`.trim() : '';
return `
<div class="d-flex gap-2 align-items-center justify-content-end flex-wrap">
<span>${escapeHtml(clean)}</span>
<button type="button" class="btn btn-sm btn-outline-success" onclick="contactDetailCallViaYealink('${escapeHtml(clean)}')">Ring op</button>
${allowSms ? `<button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt('${escapeHtml(clean)}', '${escapeHtml(contactLabel)}', ${contact?.id || 'null'})">SMS</button>` : ''}
</div>
`;
}
let contactDetailCurrentUserId = null;
async function ensureContactDetailCurrentUserId() {
if (contactDetailCurrentUserId !== null) return contactDetailCurrentUserId;
try {
const res = await fetch('/api/v1/auth/me', { credentials: 'include' });
if (!res.ok) return null;
const me = await res.json();
contactDetailCurrentUserId = Number(me?.id) || null;
return contactDetailCurrentUserId;
} catch (e) {
return null;
}
}
async function contactDetailCallViaYealink(number) {
const clean = String(number || '').trim();
if (!clean || clean === '-') {
alert('Intet gyldigt nummer at ringe til');
return;
}
const userId = await ensureContactDetailCurrentUserId();
try {
const res = await fetch('/api/v1/telefoni/click-to-call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ number: clean, user_id: userId })
});
if (!res.ok) {
const t = await res.text();
alert('Ring ud fejlede: ' + t);
return;
}
alert('Ringer ud via Yealink...');
} catch (e) {
alert('Kunne ikke starte opkald');
}
}
function populateSessionCompanySelect(contact) { function populateSessionCompanySelect(contact) {
const select = document.getElementById('sessionCompanySelect'); const select = document.getElementById('sessionCompanySelect');
if (!select) return; if (!select) return;
@ -1034,12 +1125,15 @@ async function loadRelatedContacts() {
tableBody.innerHTML = contacts.map(c => { tableBody.innerHTML = contacts.map(c => {
const name = `${c.first_name || ''} ${c.last_name || ''}`.trim(); const name = `${c.first_name || ''} ${c.last_name || ''}`.trim();
const companies = (c.company_names || []).join(', '); const companies = (c.company_names || []).join(', ');
const phoneOrMobile = c.mobile
? `<div class="d-flex align-items-center gap-2 flex-wrap"><span>${escapeHtml(c.mobile)}</span><button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt('${escapeHtml(c.mobile)}', '${escapeHtml(name || '')}', ${c.id || 'null'})">SMS</button></div>`
: escapeHtml(c.phone || '—');
return ` return `
<tr> <tr>
<td><a href="/contacts/${c.id}" class="text-decoration-none">${escapeHtml(name || 'Ukendt')}</a></td> <td><a href="/contacts/${c.id}" class="text-decoration-none">${escapeHtml(name || 'Ukendt')}</a></td>
<td>${escapeHtml(c.title || '—')}</td> <td>${escapeHtml(c.title || '—')}</td>
<td>${escapeHtml(c.email || '—')}</td> <td>${escapeHtml(c.email || '—')}</td>
<td>${escapeHtml(c.phone || c.mobile || '—')}</td> <td>${phoneOrMobile}</td>
<td>${escapeHtml(companies || '—')}</td> <td>${escapeHtml(companies || '—')}</td>
</tr> </tr>
`; `;
@ -1438,6 +1532,82 @@ async function loadContactHardware() {
} }
} }
async function loadUnassignedHardware() {
const tableBody = document.getElementById('unassignedHardwareTable');
const empty = document.getElementById('unassignedHardwareEmpty');
const container = document.getElementById('unassignedHardwareContainer');
if (!tableBody || !empty || !container) return;
container.classList.remove('d-none');
empty.classList.add('d-none');
tableBody.innerHTML = `
<tr>
<td colspan="5" class="text-center py-4">
<div class="spinner-border text-primary"></div>
</td>
</tr>
`;
try {
const response = await fetch('/api/v1/hardware/unassigned?limit=200');
if (!response.ok) throw new Error('Kunne ikke hente hardware uden ejer');
const items = await response.json();
if (!Array.isArray(items) || items.length === 0) {
container.classList.add('d-none');
empty.classList.remove('d-none');
return;
}
tableBody.innerHTML = items.map(item => {
const label = [item.brand, item.model].filter(Boolean).join(' ') || item.asset_type || '—';
const serial = item.serial_number || '—';
const status = item.status || '—';
return `
<tr>
<td class="fw-semibold">${escapeHtml(label)}</td>
<td>${escapeHtml(item.asset_type || '—')}</td>
<td>${escapeHtml(serial)}</td>
<td>${escapeHtml(status)}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-success" onclick="relateUnassignedHardware(${item.id})">
<i class="bi bi-link-45deg me-1"></i>Tilknyt
</button>
</td>
</tr>
`;
}).join('');
} catch (error) {
console.error('Failed to load unassigned hardware:', error);
container.classList.add('d-none');
empty.classList.remove('d-none');
empty.textContent = 'Kunne ikke hente hardware uden ejer';
}
}
async function relateUnassignedHardware(hardwareId) {
try {
const response = await fetch(`/api/v1/hardware/${hardwareId}/assign-contact`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contact_id: contactId })
});
if (!response.ok) {
const err = await response.text();
alert('Kunne ikke tilknytte hardware: ' + err);
return;
}
await loadContactHardware();
await loadUnassignedHardware();
} catch (error) {
console.error('Failed to relate hardware:', error);
alert('Kunne ikke tilknytte hardware');
}
}
async function loadBillingMatrix() { async function loadBillingMatrix() {
const loading = document.getElementById('billingMatrixLoading'); const loading = document.getElementById('billingMatrixLoading');
const container = document.getElementById('billingMatrixContainer'); const container = document.getElementById('billingMatrixContainer');
@ -1660,14 +1830,99 @@ async function loadNextcloudStatus() {
} }
} }
async function loadActivity() { async function loadKontaktHistory() {
const container = document.getElementById('activityContainer'); const container = document.getElementById('kontaktContainer');
if (!container) return; if (!container) return;
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>'; container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
setTimeout(() => { try {
container.innerHTML = '<div class="text-muted text-center py-5">Ingen aktivitet at vise</div>'; const response = await fetch(`/api/v1/contacts/${contactId}/kontakt?limit=200`);
}, 400); if (!response.ok) throw new Error('Kunne ikke hente kontakt historik');
const data = await response.json();
kontaktHistoryItems = data.items || [];
renderKontaktHistoryTable();
} catch (error) {
console.error('Failed to load kontakt history:', error);
container.innerHTML = '<div class="alert alert-danger">Kunne ikke hente kontakt historik</div>';
}
}
function setKontaktFilter(filter) {
kontaktHistoryFilter = filter;
document.getElementById('kontaktFilterAll')?.classList.toggle('active', filter === 'all');
document.getElementById('kontaktFilterSms')?.classList.toggle('active', filter === 'sms');
document.getElementById('kontaktFilterCall')?.classList.toggle('active', filter === 'call');
renderKontaktHistoryTable();
}
function renderKontaktHistoryTable() {
const container = document.getElementById('kontaktContainer');
if (!container) return;
const filteredItems = (kontaktHistoryItems || []).filter(item => {
if (kontaktHistoryFilter === 'all') return true;
return item.type === kontaktHistoryFilter;
});
if (!filteredItems.length) {
const msg = kontaktHistoryItems.length
? 'Ingen hændelser matcher filteret'
: 'Ingen opkald eller SMS fundet';
container.innerHTML = `<div class="text-muted text-center py-5">${msg}</div>`;
return;
}
container.innerHTML = `
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Tid</th>
<th>Type</th>
<th>Retning/Status</th>
<th>Nummer</th>
<th>Indhold</th>
<th>Varighed</th>
<th>Bruger</th>
</tr>
</thead>
<tbody>
${filteredItems.map(renderKontaktHistoryRow).join('')}
</tbody>
</table>
</div>
`;
}
function renderKontaktHistoryRow(item) {
const ts = item.happened_at ? new Date(item.happened_at).toLocaleString('da-DK') : '-';
const typeBadge = item.type === 'sms'
? '<span class="badge bg-primary-subtle text-primary-emphasis">SMS</span>'
: '<span class="badge bg-success-subtle text-success-emphasis">Opkald</span>';
const dirOrStatus = item.type === 'sms'
? (item.sms_status || '-')
: (item.direction === 'outbound' ? 'Udgående' : 'Indgående');
const message = item.type === 'sms'
? escapeHtml(item.message || '-')
: '-';
const duration = item.duration_sec && Number(item.duration_sec) > 0
? `${Math.floor(Number(item.duration_sec) / 60)}:${String(Number(item.duration_sec) % 60).padStart(2, '0')}`
: '-';
return `
<tr>
<td>${ts}</td>
<td>${typeBadge}</td>
<td>${escapeHtml(dirOrStatus || '-')}</td>
<td>${escapeHtml(item.number || '-')}</td>
<td class="text-break" style="max-width: 420px;">${message}</td>
<td>${duration}</td>
<td>${escapeHtml(item.user_name || '-')}</td>
</tr>
`;
} }
async function loadConversations() { async function loadConversations() {

View File

@ -376,6 +376,19 @@ function displayContacts(contacts) {
const companyDisplay = companyNames.length > 0 const companyDisplay = companyNames.length > 0
? companyNames.slice(0, 2).join(', ') + (companyNames.length > 2 ? '...' : '') ? companyNames.slice(0, 2).join(', ') + (companyNames.length > 2 ? '...' : '')
: '-'; : '-';
const fullName = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
const mobileLine = contact.mobile
? `<div class="small text-muted d-flex align-items-center gap-2">${escapeHtml(contact.mobile)}
<button class="btn btn-sm btn-outline-success py-0 px-2" onclick="event.stopPropagation(); contactsCallViaYealink('${escapeHtml(contact.mobile)}')">Ring op</button>
<button class="btn btn-sm btn-outline-primary py-0 px-2" onclick="event.stopPropagation(); openSmsPrompt('${escapeHtml(contact.mobile)}', '${escapeHtml(fullName)}', ${contact.id || 'null'})">SMS</button>
</div>`
: '';
const phoneLine = !contact.mobile
? `<div class="small text-muted d-flex align-items-center gap-2">${escapeHtml(contact.phone || '-')}
${contact.phone ? `<button class="btn btn-sm btn-outline-success py-0 px-2" onclick="event.stopPropagation(); contactsCallViaYealink('${escapeHtml(contact.phone)}')">Ring op</button>` : ''}
</div>`
: '';
const smsLine = mobileLine || phoneLine;
return ` return `
<tr style="cursor: pointer;" onclick="viewContact(${contact.id})"> <tr style="cursor: pointer;" onclick="viewContact(${contact.id})">
@ -390,7 +403,7 @@ function displayContacts(contacts) {
</td> </td>
<td> <td>
<div class="fw-medium">${contact.email || '-'}</div> <div class="fw-medium">${contact.email || '-'}</div>
<div class="small text-muted">${contact.mobile || contact.phone || '-'}</div> ${smsLine}
</td> </td>
<td class="text-muted">${contact.title || '-'}</td> <td class="text-muted">${contact.title || '-'}</td>
<td> <td>
@ -451,6 +464,48 @@ function editContact(contactId) {
loadContactForEdit(contactId); loadContactForEdit(contactId);
} }
let contactsCurrentUserId = null;
async function ensureContactsCurrentUserId() {
if (contactsCurrentUserId !== null) return contactsCurrentUserId;
try {
const res = await fetch('/api/v1/auth/me', { credentials: 'include' });
if (!res.ok) return null;
const me = await res.json();
contactsCurrentUserId = Number(me?.id) || null;
return contactsCurrentUserId;
} catch (e) {
return null;
}
}
async function contactsCallViaYealink(number) {
const clean = String(number || '').trim();
if (!clean || clean === '-') {
alert('Intet gyldigt nummer at ringe til');
return;
}
const userId = await ensureContactsCurrentUserId();
try {
const res = await fetch('/api/v1/telefoni/click-to-call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ number: clean, user_id: userId })
});
if (!res.ok) {
const t = await res.text();
alert('Ring ud fejlede: ' + t);
return;
}
alert('Ringer ud via Yealink...');
} catch (e) {
alert('Kunne ikke starte opkald');
}
}
async function loadContactForEdit(contactId) { async function loadContactForEdit(contactId) {
try { try {
const response = await fetch(`/api/v1/contacts/${contactId}`); const response = await fetch(`/api/v1/contacts/${contactId}`);

View File

@ -139,6 +139,23 @@ class AuthService:
(user_id,) (user_id,)
) )
return True return True
@staticmethod
def admin_reset_user_2fa(user_id: int) -> bool:
"""Admin reset: disable 2FA and remove TOTP secret without OTP"""
user = execute_query_single(
"SELECT user_id FROM users WHERE user_id = %s",
(user_id,)
)
if not user:
return False
execute_update(
"UPDATE users SET is_2fa_enabled = FALSE, totp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s",
(user_id,)
)
return True
@staticmethod @staticmethod
def create_access_token( def create_access_token(
@ -234,34 +251,9 @@ class AuthService:
Returns: Returns:
User dict if successful, None otherwise User dict if successful, None otherwise
""" """
# Shadow Admin shortcut # Normalize username once (used by both normal and shadow login paths)
shadow_username = (settings.SHADOW_ADMIN_USERNAME or "shadowadmin").strip().lower() shadow_username = (settings.SHADOW_ADMIN_USERNAME or "shadowadmin").strip().lower()
request_username = (username or "").strip().lower() request_username = (username or "").strip().lower()
if settings.SHADOW_ADMIN_ENABLED and request_username == shadow_username:
if not settings.SHADOW_ADMIN_PASSWORD or not settings.SHADOW_ADMIN_TOTP_SECRET:
logger.error("❌ Shadow admin enabled but not configured")
return None, "Shadow admin not configured"
if not secrets.compare_digest(password, settings.SHADOW_ADMIN_PASSWORD):
logger.warning(f"❌ Shadow admin login failed from IP: {ip_address}")
return None, "Invalid username or password"
if not otp_code:
return None, "2FA code required"
if not AuthService.verify_totp_code(settings.SHADOW_ADMIN_TOTP_SECRET, otp_code):
logger.warning(f"❌ Shadow admin 2FA failed from IP: {ip_address}")
return None, "Invalid 2FA code"
logger.warning(f"⚠️ Shadow admin login used from IP: {ip_address}")
return {
"user_id": 0,
"username": settings.SHADOW_ADMIN_USERNAME,
"email": settings.SHADOW_ADMIN_EMAIL,
"full_name": settings.SHADOW_ADMIN_FULL_NAME,
"is_superadmin": True,
"is_shadow_admin": True
}, None
# Get user # Get user
user = execute_query_single( user = execute_query_single(
@ -273,6 +265,38 @@ class AuthService:
(username, username)) (username, username))
if not user: if not user:
# Shadow Admin fallback (only when no regular user matches)
if settings.SHADOW_ADMIN_ENABLED and request_username == shadow_username:
if not settings.SHADOW_ADMIN_PASSWORD or not settings.SHADOW_ADMIN_TOTP_SECRET:
logger.error("❌ Shadow admin enabled but not configured")
return None, "Shadow admin not configured"
if not secrets.compare_digest(password, settings.SHADOW_ADMIN_PASSWORD):
logger.warning(f"❌ Shadow admin login failed from IP: {ip_address}")
return None, "Invalid username or password"
if not settings.AUTH_DISABLE_2FA:
if not otp_code:
return None, "2FA code required"
if not AuthService.verify_totp_code(settings.SHADOW_ADMIN_TOTP_SECRET, otp_code):
logger.warning(f"❌ Shadow admin 2FA failed from IP: {ip_address}")
return None, "Invalid 2FA code"
else:
logger.warning(f"⚠️ 2FA disabled via settings for shadow admin login from IP: {ip_address}")
logger.warning(f"⚠️ Shadow admin login used from IP: {ip_address}")
return {
"user_id": 0,
"username": settings.SHADOW_ADMIN_USERNAME,
"email": settings.SHADOW_ADMIN_EMAIL,
"full_name": settings.SHADOW_ADMIN_FULL_NAME,
"is_superadmin": True,
"is_shadow_admin": True,
"is_2fa_enabled": True,
"has_2fa_configured": True
}, None
logger.warning(f"❌ Login failed: User not found - {username}") logger.warning(f"❌ Login failed: User not found - {username}")
return None, "Invalid username or password" return None, "Invalid username or password"
@ -323,7 +347,9 @@ class AuthService:
return None, "Invalid username or password" return None, "Invalid username or password"
# 2FA check (only once per grace window) # 2FA check (only once per grace window)
if user.get('is_2fa_enabled'): if settings.AUTH_DISABLE_2FA:
logger.warning(f"⚠️ 2FA disabled via settings for login: {username}")
elif user.get('is_2fa_enabled'):
if not user.get('totp_secret'): if not user.get('totp_secret'):
return None, "2FA not configured" return None, "2FA not configured"
@ -364,7 +390,9 @@ class AuthService:
'email': user['email'], 'email': user['email'],
'full_name': user['full_name'], 'full_name': user['full_name'],
'is_superadmin': bool(user['is_superadmin']), 'is_superadmin': bool(user['is_superadmin']),
'is_shadow_admin': False 'is_shadow_admin': False,
'is_2fa_enabled': bool(user.get('is_2fa_enabled')),
'has_2fa_configured': bool(user.get('totp_secret'))
}, None }, None
@staticmethod @staticmethod

View File

@ -49,6 +49,7 @@ class Settings(BaseSettings):
# 2FA grace period (hours) before re-prompting # 2FA grace period (hours) before re-prompting
TWO_FA_GRACE_HOURS: int = 24 TWO_FA_GRACE_HOURS: int = 24
AUTH_DISABLE_2FA: bool = False
# Logging # Logging
LOG_LEVEL: str = "INFO" LOG_LEVEL: str = "INFO"
@ -212,6 +213,10 @@ class Settings(BaseSettings):
ANYDESK_DRY_RUN: bool = True # SAFETY: Log without executing API calls ANYDESK_DRY_RUN: bool = True # SAFETY: Log without executing API calls
ANYDESK_TIMEOUT_SECONDS: int = 30 ANYDESK_TIMEOUT_SECONDS: int = 30
ANYDESK_AUTO_START_SESSION: bool = True # Auto-start session when requested ANYDESK_AUTO_START_SESSION: bool = True # Auto-start session when requested
# Telefoni (Yealink) Integration
TELEFONI_SHARED_SECRET: str = "" # If set, required as ?token=...
TELEFONI_IP_WHITELIST: str = "172.16.31.0/24" # CSV of IPs/CIDRs, e.g. "192.168.1.0/24,10.0.0.10"
# ESET Integration # ESET Integration
ESET_ENABLED: bool = False ESET_ENABLED: bool = False

View File

@ -969,6 +969,93 @@ async def get_customer_contacts(customer_id: int):
return rows or [] return rows or []
@router.get("/customers/{customer_id}/kontakt")
async def get_customer_kontakt_history(customer_id: int, limit: int = Query(default=300, ge=1, le=2000)):
"""Get unified contact communication history (calls + SMS) for all company contacts."""
customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,))
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
sms_table_exists = execute_query_single("SELECT to_regclass('public.sms_messages') AS name")
has_sms_table = bool(sms_table_exists and sms_table_exists.get("name"))
if has_sms_table:
query = """
SELECT * FROM (
SELECT
'call' AS type,
t.id::text AS event_id,
t.started_at AS happened_at,
t.direction,
COALESCE(
NULLIF(TRIM(t.ekstern_nummer), ''),
NULLIF(TRIM(t.raw_payload->>'caller'), ''),
NULLIF(TRIM(t.raw_payload->>'callee'), '')
) AS number,
NULL::text AS message,
t.duration_sec,
COALESCE(u.full_name, u.username) AS user_name,
c.id AS contact_id,
TRIM(CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, ''))) AS contact_name,
NULL::text AS sms_status
FROM telefoni_opkald t
JOIN contacts c ON c.id = t.kontakt_id
JOIN contact_companies cc ON cc.contact_id = c.id AND cc.customer_id = %s
LEFT JOIN users u ON u.user_id = t.bruger_id
UNION ALL
SELECT
'sms' AS type,
s.id::text AS event_id,
s.created_at AS happened_at,
NULL::text AS direction,
s.recipient AS number,
s.message,
NULL::int AS duration_sec,
COALESCE(u.full_name, u.username) AS user_name,
c.id AS contact_id,
TRIM(CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, ''))) AS contact_name,
s.status AS sms_status
FROM sms_messages s
JOIN contacts c ON c.id = s.kontakt_id
JOIN contact_companies cc ON cc.contact_id = c.id AND cc.customer_id = %s
LEFT JOIN users u ON u.user_id = s.bruger_id
) x
ORDER BY x.happened_at DESC NULLS LAST
LIMIT %s
"""
rows = execute_query(query, (customer_id, customer_id, limit))
else:
query = """
SELECT
'call' AS type,
t.id::text AS event_id,
t.started_at AS happened_at,
t.direction,
COALESCE(
NULLIF(TRIM(t.ekstern_nummer), ''),
NULLIF(TRIM(t.raw_payload->>'caller'), ''),
NULLIF(TRIM(t.raw_payload->>'callee'), '')
) AS number,
NULL::text AS message,
t.duration_sec,
COALESCE(u.full_name, u.username) AS user_name,
c.id AS contact_id,
TRIM(CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, ''))) AS contact_name,
NULL::text AS sms_status
FROM telefoni_opkald t
JOIN contacts c ON c.id = t.kontakt_id
JOIN contact_companies cc ON cc.contact_id = c.id AND cc.customer_id = %s
LEFT JOIN users u ON u.user_id = t.bruger_id
ORDER BY t.started_at DESC NULLS LAST
LIMIT %s
"""
rows = execute_query(query, (customer_id, limit))
return {"items": rows or []}
@router.post("/customers/{customer_id}/contacts") @router.post("/customers/{customer_id}/contacts")
async def create_customer_contact(customer_id: int, contact: ContactCreate): async def create_customer_contact(customer_id: int, contact: ContactCreate):
"""Create a new contact for a customer""" """Create a new contact for a customer"""

View File

@ -303,6 +303,11 @@
<i class="bi bi-people"></i>Kontakter <i class="bi bi-people"></i>Kontakter
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#kontakt">
<i class="bi bi-chat-left-text"></i>Kontakt
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#invoices"> <a class="nav-link" data-bs-toggle="tab" href="#invoices">
<i class="bi bi-receipt"></i>Fakturaer <i class="bi bi-receipt"></i>Fakturaer
@ -508,6 +513,23 @@
</div> </div>
</div> </div>
<!-- Kontakt Tab -->
<div class="tab-pane fade" id="kontakt">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold mb-0">Kontakt historik</h5>
<div class="btn-group btn-group-sm" role="group" aria-label="Kontakt filter">
<button type="button" class="btn btn-outline-secondary active" id="customerKontaktFilterAll" onclick="setCustomerKontaktFilter('all')">Alle</button>
<button type="button" class="btn btn-outline-secondary" id="customerKontaktFilterSms" onclick="setCustomerKontaktFilter('sms')">SMS</button>
<button type="button" class="btn btn-outline-secondary" id="customerKontaktFilterCall" onclick="setCustomerKontaktFilter('call')">Opkald</button>
</div>
</div>
<div id="customerKontaktContainer">
<div class="text-center py-5">
<div class="spinner-border text-primary"></div>
</div>
</div>
</div>
<!-- Invoices Tab --> <!-- Invoices Tab -->
<div class="tab-pane fade" id="invoices"> <div class="tab-pane fade" id="invoices">
<h5 class="fw-bold mb-4">Fakturaer</h5> <h5 class="fw-bold mb-4">Fakturaer</h5>
@ -1171,6 +1193,8 @@ const customerId = parseInt(window.location.pathname.split('/').pop());
let customerData = null; let customerData = null;
let pipelineStages = []; let pipelineStages = [];
let allTagsCache = []; let allTagsCache = [];
let customerKontaktItems = [];
let customerKontaktFilter = 'all';
let eventListenersAdded = false; let eventListenersAdded = false;
@ -1189,6 +1213,13 @@ document.addEventListener('DOMContentLoaded', () => {
loadContacts(); loadContacts();
}, { once: false }); }, { once: false });
} }
const kontaktTab = document.querySelector('a[href="#kontakt"]');
if (kontaktTab) {
kontaktTab.addEventListener('shown.bs.tab', () => {
loadCustomerKontakt();
}, { once: false });
}
// Load subscriptions when tab is shown // Load subscriptions when tab is shown
const subscriptionsTab = document.querySelector('a[href="#subscriptions"]'); const subscriptionsTab = document.querySelector('a[href="#subscriptions"]');
@ -1336,7 +1367,7 @@ function displayCustomer(customer) {
? `${customer.postal_code} ${customer.city}` ? `${customer.postal_code} ${customer.city}`
: customer.city || '-'; : customer.city || '-';
document.getElementById('email').textContent = customer.email || '-'; document.getElementById('email').textContent = customer.email || '-';
document.getElementById('phone').textContent = customer.phone || '-'; document.getElementById('phone').innerHTML = renderCustomerCallNumber(customer.phone);
document.getElementById('website').textContent = customer.website || '-'; document.getElementById('website').textContent = customer.website || '-';
const wikiEl = document.getElementById('wikiLink'); const wikiEl = document.getElementById('wikiLink');
@ -1374,6 +1405,59 @@ function displayCustomer(customer) {
document.getElementById('createdAt').textContent = new Date(customer.created_at).toLocaleString('da-DK'); document.getElementById('createdAt').textContent = new Date(customer.created_at).toLocaleString('da-DK');
} }
function renderCustomerCallNumber(number) {
const clean = String(number || '').trim();
if (!clean) return '-';
return `
<div class="d-flex gap-2 align-items-center justify-content-end flex-wrap">
<span>${escapeHtml(clean)}</span>
<button type="button" class="btn btn-sm btn-outline-success" onclick="customerDetailCallViaYealink('${escapeHtml(clean)}')">Ring op</button>
</div>
`;
}
let customerDetailCurrentUserId = null;
async function ensureCustomerDetailCurrentUserId() {
if (customerDetailCurrentUserId !== null) return customerDetailCurrentUserId;
try {
const res = await fetch('/api/v1/auth/me', { credentials: 'include' });
if (!res.ok) return null;
const me = await res.json();
customerDetailCurrentUserId = Number(me?.id) || null;
return customerDetailCurrentUserId;
} catch (e) {
return null;
}
}
async function customerDetailCallViaYealink(number) {
const clean = String(number || '').trim();
if (!clean || clean === '-') {
alert('Intet gyldigt nummer at ringe til');
return;
}
const userId = await ensureCustomerDetailCurrentUserId();
try {
const res = await fetch('/api/v1/telefoni/click-to-call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ number: clean, user_id: userId })
});
if (!res.ok) {
const t = await res.text();
alert('Ring ud fejlede: ' + t);
return;
}
alert('Ringer ud via Yealink...');
} catch (e) {
alert('Kunne ikke starte opkald');
}
}
async function loadCustomerTags() { async function loadCustomerTags() {
try { try {
const response = await fetch(`/api/v1/tags/entity/customer/${customerId}`); const response = await fetch(`/api/v1/tags/entity/customer/${customerId}`);
@ -2175,7 +2259,9 @@ async function loadContacts() {
const rows = contacts.map(contact => { const rows = contacts.map(contact => {
const email = contact.email ? `<a href="mailto:${contact.email}">${escapeHtml(contact.email)}</a>` : '—'; const email = contact.email ? `<a href="mailto:${contact.email}">${escapeHtml(contact.email)}</a>` : '—';
const phone = contact.phone ? `<a href="tel:${contact.phone}">${escapeHtml(contact.phone)}</a>` : '—'; const phone = contact.phone ? `<a href="tel:${contact.phone}">${escapeHtml(contact.phone)}</a>` : '—';
const mobile = contact.mobile ? `<a href="tel:${contact.mobile}">${escapeHtml(contact.mobile)}</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 title = contact.title ? escapeHtml(contact.title) : '—'; const title = contact.title ? escapeHtml(contact.title) : '—';
const primaryBadge = contact.is_primary ? '<span class="badge bg-primary">Primær</span>' : '—'; const primaryBadge = contact.is_primary ? '<span class="badge bg-primary">Primær</span>' : '—';
@ -2992,6 +3078,106 @@ async function loadActivity() {
}, 500); }, 500);
} }
async function loadCustomerKontakt() {
const container = document.getElementById('customerKontaktContainer');
if (!container) return;
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
try {
const response = await fetch(`/api/v1/customers/${customerId}/kontakt?limit=300`);
if (!response.ok) throw new Error('Kunne ikke hente kontakt historik');
const data = await response.json();
customerKontaktItems = data.items || [];
renderCustomerKontaktTable();
} catch (error) {
console.error('Failed to load customer kontakt history:', error);
container.innerHTML = '<div class="alert alert-danger">Kunne ikke hente kontakt historik</div>';
}
}
function setCustomerKontaktFilter(filter) {
customerKontaktFilter = filter;
document.getElementById('customerKontaktFilterAll')?.classList.toggle('active', filter === 'all');
document.getElementById('customerKontaktFilterSms')?.classList.toggle('active', filter === 'sms');
document.getElementById('customerKontaktFilterCall')?.classList.toggle('active', filter === 'call');
renderCustomerKontaktTable();
}
function renderCustomerKontaktTable() {
const container = document.getElementById('customerKontaktContainer');
if (!container) return;
const filtered = (customerKontaktItems || []).filter(item => {
if (customerKontaktFilter === 'all') return true;
return item.type === customerKontaktFilter;
});
if (!filtered.length) {
const msg = customerKontaktItems.length
? 'Ingen hændelser matcher filteret'
: 'Ingen opkald eller SMS fundet for virksomhedens kontakter';
container.innerHTML = `<div class="text-muted text-center py-5">${msg}</div>`;
return;
}
container.innerHTML = `
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Tid</th>
<th>Kontakt</th>
<th>Type</th>
<th>Retning/Status</th>
<th>Nummer</th>
<th>Indhold</th>
<th>Varighed</th>
<th>Bruger</th>
</tr>
</thead>
<tbody>
${filtered.map(renderCustomerKontaktRow).join('')}
</tbody>
</table>
</div>
`;
}
function renderCustomerKontaktRow(item) {
const ts = item.happened_at ? new Date(item.happened_at).toLocaleString('da-DK') : '-';
const typeBadge = item.type === 'sms'
? '<span class="badge bg-primary-subtle text-primary-emphasis">SMS</span>'
: '<span class="badge bg-success-subtle text-success-emphasis">Opkald</span>';
const dirOrStatus = item.type === 'sms'
? (item.sms_status || '-')
: (item.direction === 'outbound' ? 'Udgående' : 'Indgående');
const message = item.type === 'sms' ? escapeHtml(item.message || '-') : '-';
const duration = item.duration_sec && Number(item.duration_sec) > 0
? `${Math.floor(Number(item.duration_sec) / 60)}:${String(Number(item.duration_sec) % 60).padStart(2, '0')}`
: '-';
const contactName = item.contact_id
? `<a href="/contacts/${item.contact_id}">${escapeHtml(item.contact_name || 'Ukendt')}</a>`
: escapeHtml(item.contact_name || '-');
return `
<tr>
<td>${ts}</td>
<td>${contactName}</td>
<td>${typeBadge}</td>
<td>${escapeHtml(dirOrStatus || '-')}</td>
<td>${escapeHtml(item.number || '-')}</td>
<td class="text-break" style="max-width: 380px;">${message}</td>
<td>${duration}</td>
<td>${escapeHtml(item.user_name || '-')}</td>
</tr>
`;
}
async function loadConversations() { async function loadConversations() {
const container = document.getElementById('conversationsContainer'); const container = document.getElementById('conversationsContainer');
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>'; container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';

View File

@ -4,7 +4,7 @@ Pydantic Models and Schemas
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from typing import Optional, List from typing import Optional, List
from datetime import datetime from datetime import datetime, date
class CustomerBase(BaseModel): class CustomerBase(BaseModel):
@ -194,6 +194,10 @@ class GroupPermissionsUpdate(BaseModel):
permission_ids: List[int] permission_ids: List[int]
class UserTwoFactorResetRequest(BaseModel):
reason: Optional[str] = None
# ===================================================== # =====================================================
# AnyDesk Remote Support Integration Schemas # AnyDesk Remote Support Integration Schemas
# ===================================================== # =====================================================
@ -254,6 +258,38 @@ class AnyDeskSessionWithWorklog(BaseModel):
suggested_worklog: AnyDeskWorklogSuggestion suggested_worklog: AnyDeskWorklogSuggestion
class TodoStepBase(BaseModel):
"""Base schema for case todo steps"""
title: str
description: Optional[str] = None
due_date: Optional[date] = None
class TodoStepCreate(TodoStepBase):
"""Schema for creating a todo step"""
pass
class TodoStepUpdate(BaseModel):
"""Schema for updating a todo step"""
is_done: Optional[bool] = None
class TodoStep(TodoStepBase):
"""Full todo step schema"""
id: int
sag_id: int
is_done: bool
created_by_user_id: Optional[int] = None
created_by_name: Optional[str] = None
created_at: datetime
completed_by_user_id: Optional[int] = None
completed_by_name: Optional[str] = None
completed_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class AnyDeskSessionHistory(BaseModel): class AnyDeskSessionHistory(BaseModel):
"""Session history response""" """Session history response"""
sessions: List[AnyDeskSessionDetail] sessions: List[AnyDeskSessionDetail]

View File

@ -0,0 +1,341 @@
import logging
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import Response
from app.core.database import execute_query
logger = logging.getLogger(__name__)
router = APIRouter()
def _parse_iso_datetime(value: str, fallback: datetime) -> datetime:
if not value:
return fallback
try:
return datetime.fromisoformat(value)
except ValueError:
return fallback
def _get_user_id(request: Request, user_id: int | None, only_mine: bool) -> int | None:
if user_id is not None:
return user_id
state_user_id = getattr(request.state, "user_id", None)
if state_user_id is not None:
return int(state_user_id)
if only_mine:
raise HTTPException(status_code=401, detail="User not authenticated")
return None
def _build_event(event_type: str, title: str, start_dt: datetime, url: str, extra: dict) -> dict:
payload = {
"id": f"{event_type}:{extra.get('reference_id')}",
"title": title,
"start": start_dt.isoformat(),
"url": url,
"event_type": event_type,
}
payload.update(extra)
return payload
def _escape_ical(value: str) -> str:
return (
value.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
.replace("\n", "\\n")
)
def _format_ical_dt(value: datetime) -> str:
return value.strftime("%Y%m%dT%H%M%S")
def _get_calendar_events(
request: Request,
start_dt: datetime,
end_dt: datetime,
only_mine: bool,
user_id: int | None,
customer_id: int | None,
types: str | None,
) -> list[dict]:
allowed_types = {
"case_deadline",
"case_deferred",
"case_reminder",
"deadline",
"deferred",
"reminder",
"meeting",
"technician_visit",
"obs",
}
requested_types = {
t.strip() for t in (types.split(",") if types else []) if t.strip()
}
if requested_types and not requested_types.issubset(allowed_types):
raise HTTPException(status_code=400, detail="Invalid event types")
type_map = {
"case_deadline": "deadline",
"case_deferred": "deferred",
"case_reminder": "reminder",
}
normalized_types = {type_map.get(t, t) for t in requested_types}
reminder_kinds = {"reminder", "meeting", "technician_visit", "obs", "deadline"}
include_deadline = not normalized_types or "deadline" in normalized_types
include_deferred = not normalized_types or "deferred" in normalized_types
include_reminder = not normalized_types or bool(reminder_kinds.intersection(normalized_types))
resolved_user_id = _get_user_id(request, user_id, only_mine)
events: list[dict] = []
if include_deadline:
query = """
SELECT s.id, s.titel, s.deadline, s.customer_id, c.name as customer_name,
s.ansvarlig_bruger_id, s.created_by_user_id
FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id
WHERE s.deleted_at IS NULL
AND s.deadline IS NOT NULL
AND s.deadline BETWEEN %s AND %s
"""
params: list = [start_dt, end_dt]
if only_mine and resolved_user_id is not None:
query += " AND (s.ansvarlig_bruger_id = %s OR s.created_by_user_id = %s)"
params.extend([resolved_user_id, resolved_user_id])
if customer_id:
query += " AND s.customer_id = %s"
params.append(customer_id)
rows = execute_query(query, tuple(params)) or []
for row in rows:
start_value = row.get("deadline")
if not start_value:
continue
title = f"Deadline: {row.get('titel', 'Sag')}"
events.append(
_build_event(
"case_deadline",
title,
start_value,
f"/sag/{row.get('id')}",
{
"reference_id": row.get("id"),
"reference_type": "case",
"event_kind": "deadline",
"customer_id": row.get("customer_id"),
"customer_name": row.get("customer_name"),
"status": "deadline",
},
)
)
if include_deferred:
query = """
SELECT s.id, s.titel, s.deferred_until, s.customer_id, c.name as customer_name,
s.ansvarlig_bruger_id, s.created_by_user_id
FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id
WHERE s.deleted_at IS NULL
AND s.deferred_until IS NOT NULL
AND s.deferred_until BETWEEN %s AND %s
"""
params = [start_dt, end_dt]
if only_mine and resolved_user_id is not None:
query += " AND (s.ansvarlig_bruger_id = %s OR s.created_by_user_id = %s)"
params.extend([resolved_user_id, resolved_user_id])
if customer_id:
query += " AND s.customer_id = %s"
params.append(customer_id)
rows = execute_query(query, tuple(params)) or []
for row in rows:
start_value = row.get("deferred_until")
if not start_value:
continue
title = f"Defer: {row.get('titel', 'Sag')}"
events.append(
_build_event(
"case_deferred",
title,
start_value,
f"/sag/{row.get('id')}",
{
"reference_id": row.get("id"),
"reference_type": "case",
"event_kind": "deferred",
"customer_id": row.get("customer_id"),
"customer_name": row.get("customer_name"),
"status": "deferred",
},
)
)
if include_reminder:
query = """
SELECT r.id, r.title, r.message, r.priority, r.event_type, r.next_check_at, r.scheduled_at,
r.sag_id, s.titel as sag_title, s.customer_id, c.name as customer_name,
r.recipient_user_ids, r.created_by_user_id
FROM sag_reminders r
JOIN sag_sager s ON s.id = r.sag_id
LEFT JOIN customers c ON s.customer_id = c.id
WHERE r.deleted_at IS NULL
AND r.is_active = true
AND s.deleted_at IS NULL
AND COALESCE(r.next_check_at, r.scheduled_at) BETWEEN %s AND %s
"""
params = [start_dt, end_dt]
requested_reminder_types = sorted(reminder_kinds.intersection(normalized_types))
if requested_reminder_types:
query += " AND r.event_type = ANY(%s)"
params.append(requested_reminder_types)
if only_mine and resolved_user_id is not None:
query += " AND ((r.recipient_user_ids IS NOT NULL AND %s = ANY(r.recipient_user_ids)) OR r.created_by_user_id = %s)"
params.extend([resolved_user_id, resolved_user_id])
if customer_id:
query += " AND s.customer_id = %s"
params.append(customer_id)
rows = execute_query(query, tuple(params)) or []
for row in rows:
start_value = row.get("next_check_at") or row.get("scheduled_at")
if not start_value:
continue
title = f"Reminder: {row.get('title', 'Reminder')}"
case_title = row.get("sag_title")
if case_title:
title = f"{title} · {case_title}"
events.append(
_build_event(
"case_reminder",
title,
start_value,
f"/sag/{row.get('sag_id')}",
{
"reference_id": row.get("id"),
"reference_type": "reminder",
"case_id": row.get("sag_id"),
"event_kind": row.get("event_type") or "reminder",
"customer_id": row.get("customer_id"),
"customer_name": row.get("customer_name"),
"event_type": row.get("event_type"),
"priority": row.get("priority"),
},
)
)
events.sort(key=lambda item: item.get("start") or "")
return events
@router.get("/calendar/events")
async def get_calendar_events(
request: Request,
start: str = Query(None),
end: str = Query(None),
only_mine: bool = Query(True),
user_id: int | None = Query(None),
customer_id: int | None = Query(None),
types: str | None = Query(None),
):
"""Aggregate calendar events from sag deadlines, deferred dates, and reminders."""
now = datetime.now()
start_dt = _parse_iso_datetime(start, now - timedelta(days=14))
end_dt = _parse_iso_datetime(end, now + timedelta(days=60))
if end_dt < start_dt:
raise HTTPException(status_code=400, detail="Invalid date range")
events = _get_calendar_events(
request=request,
start_dt=start_dt,
end_dt=end_dt,
only_mine=only_mine,
user_id=user_id,
customer_id=customer_id,
types=types,
)
return {"events": events}
@router.get("/calendar/ical")
async def get_calendar_ical(
request: Request,
start: str = Query(None),
end: str = Query(None),
only_mine: bool = Query(True),
user_id: int | None = Query(None),
customer_id: int | None = Query(None),
types: str | None = Query(None),
):
"""Serve calendar events as an iCal feed."""
now = datetime.now()
start_dt = _parse_iso_datetime(start, now - timedelta(days=14))
end_dt = _parse_iso_datetime(end, now + timedelta(days=60))
if end_dt < start_dt:
raise HTTPException(status_code=400, detail="Invalid date range")
events = _get_calendar_events(
request=request,
start_dt=start_dt,
end_dt=end_dt,
only_mine=only_mine,
user_id=user_id,
customer_id=customer_id,
types=types,
)
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//BMC Hub//Calendar//DA",
"CALSCALE:GREGORIAN",
"X-WR-CALNAME:BMC Hub Kalender",
]
for event in events:
start_value = datetime.fromisoformat(event.get("start"))
summary = _escape_ical(event.get("title", ""))
description_parts = []
if event.get("customer_name"):
description_parts.append(f"Kunde: {event.get('customer_name')}")
if event.get("event_type"):
description_parts.append(f"Type: {event.get('event_type')}")
if event.get("url"):
description_parts.append(f"Link: {event.get('url')}")
description = _escape_ical("\n".join(description_parts))
uid = _escape_ical(f"{event.get('id')}@bmc-hub")
lines.extend([
"BEGIN:VEVENT",
f"UID:{uid}",
f"DTSTAMP:{_format_ical_dt(now)}",
f"DTSTART:{_format_ical_dt(start_value)}",
f"SUMMARY:{summary}",
f"DESCRIPTION:{description}",
"END:VEVENT",
])
lines.append("END:VCALENDAR")
return Response(
content="\r\n".join(lines),
media_type="text/calendar; charset=utf-8",
)

View File

@ -0,0 +1,26 @@
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from fastapi import Depends
from app.core.auth_dependencies import get_optional_user
logger = logging.getLogger(__name__)
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/calendar", response_class=HTMLResponse)
async def calendar_overview(
request: Request,
current_user: dict | None = Depends(get_optional_user),
):
return templates.TemplateResponse(
"modules/calendar/templates/index.html",
{
"request": request,
"current_user": current_user,
},
)

View File

@ -0,0 +1,888 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Kalender - BMC Hub{% endblock %}
{% block extra_css %}
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.css" rel="stylesheet">
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Space+Grotesk:wght@500;600;700&display=swap');
:root {
--calendar-bg: #f1f5f9;
--calendar-ink: #0f172a;
--calendar-subtle: #5b6b80;
--calendar-glow: rgba(15, 76, 117, 0.18);
--calendar-card: #ffffff;
--calendar-border: rgba(15, 23, 42, 0.12);
--calendar-sun: #ffb703;
--calendar-sea: #0f4c75;
--calendar-mint: #2a9d8f;
--calendar-ember: #e63946;
--calendar-violet: #5f0f40;
--calendar-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
}
.calendar-page {
font-family: 'IBM Plex Sans', sans-serif;
color: var(--calendar-ink);
}
.calendar-hero {
background: radial-gradient(circle at top left, rgba(15, 76, 117, 0.15), transparent 45%),
linear-gradient(135deg, #e3edf7, #fdfbff 55%, #edf2f7);
border-radius: 20px;
padding: 2rem clamp(1.5rem, 3vw, 3rem);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 2rem;
align-items: center;
box-shadow: var(--calendar-shadow);
margin-bottom: 2rem;
position: relative;
overflow: hidden;
}
.calendar-hero::after {
content: "";
position: absolute;
top: -120px;
right: -140px;
width: 280px;
height: 280px;
background: radial-gradient(circle, rgba(255, 183, 3, 0.35), transparent 70%);
filter: blur(0px);
opacity: 0.8;
}
.hero-kicker {
text-transform: uppercase;
letter-spacing: 0.2em;
font-size: 0.7rem;
color: var(--calendar-sea);
font-weight: 600;
}
.calendar-hero h1 {
font-family: 'Space Grotesk', sans-serif;
font-size: clamp(2rem, 2.5vw, 3rem);
margin: 0.5rem 0 0.8rem;
}
.calendar-hero p {
color: var(--calendar-subtle);
margin-bottom: 1rem;
max-width: 46ch;
}
.hero-meta {
display: flex;
gap: 1rem;
align-items: center;
font-size: 0.9rem;
color: var(--calendar-subtle);
}
.hero-meta span {
font-weight: 600;
color: var(--calendar-ink);
}
.hero-ical {
margin-top: 0.75rem;
font-size: 0.85rem;
color: var(--calendar-subtle);
word-break: break-all;
}
.hero-ical a {
color: var(--calendar-sea);
font-weight: 600;
text-decoration: none;
}
.calendar-filter-card {
background: var(--calendar-card);
border-radius: 16px;
padding: 1.5rem;
border: 1px solid var(--calendar-border);
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
position: relative;
z-index: 1;
}
.filter-title {
font-weight: 600;
margin-bottom: 1rem;
font-size: 1rem;
}
.toggle-group {
display: inline-flex;
background: var(--calendar-bg);
border-radius: 999px;
padding: 0.25rem;
gap: 0.25rem;
}
.toggle-group button {
border: none;
background: transparent;
padding: 0.45rem 1rem;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 600;
color: var(--calendar-subtle);
}
.toggle-group button.active {
background: var(--calendar-sea);
color: #ffffff;
box-shadow: 0 6px 12px rgba(15, 76, 117, 0.3);
}
.filter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.filter-block label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--calendar-subtle);
margin-bottom: 0.35rem;
}
.filter-block select,
.filter-block input {
border-radius: 10px;
border: 1px solid var(--calendar-border);
padding: 0.5rem 0.75rem;
width: 100%;
background: #ffffff;
color: var(--calendar-ink);
}
.type-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.type-tag {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: var(--calendar-bg);
border-radius: 999px;
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
color: var(--calendar-subtle);
cursor: pointer;
}
.type-tag input {
accent-color: var(--calendar-sea);
}
.action-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 1rem;
}
.btn-calendar-action {
border: none;
border-radius: 999px;
background: var(--calendar-ink);
color: #ffffff;
padding: 0.5rem 1.2rem;
font-weight: 600;
}
.case-search-box {
position: relative;
}
.case-search-results {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: #ffffff;
border: 1px solid var(--calendar-border);
border-radius: 12px;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.12);
max-height: 280px;
overflow-y: auto;
z-index: 20;
display: none;
}
.case-search-results.show {
display: block;
}
.case-search-item {
padding: 0.65rem 0.85rem;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.case-search-item strong {
font-size: 0.9rem;
}
.case-search-item small {
color: var(--calendar-subtle);
font-size: 0.75rem;
}
.case-search-item:hover,
.case-search-item.active {
background: var(--accent-light);
}
.calendar-shell {
background: var(--calendar-card);
border-radius: 20px;
padding: 1.5rem;
border: 1px solid var(--calendar-border);
box-shadow: var(--calendar-shadow);
}
.calendar-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.view-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.view-buttons button {
border: 1px solid var(--calendar-border);
background: var(--calendar-bg);
border-radius: 999px;
padding: 0.4rem 0.9rem;
font-weight: 600;
font-size: 0.85rem;
color: var(--calendar-subtle);
}
.view-buttons button.active {
background: var(--calendar-ink);
color: #ffffff;
border-color: transparent;
}
.calendar-legend {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: 1.5rem;
color: var(--calendar-subtle);
font-size: 0.85rem;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.status-bar {
font-size: 0.85rem;
color: var(--calendar-subtle);
}
.fc {
font-family: 'IBM Plex Sans', sans-serif;
}
.fc .fc-toolbar-title {
font-family: 'Space Grotesk', sans-serif;
font-size: 1.3rem;
}
.fc .fc-daygrid-day-number {
color: var(--calendar-subtle);
}
.fc .fc-day-today {
background: rgba(255, 183, 3, 0.12) !important;
}
.fc-event {
border: none;
border-radius: 10px;
padding: 2px 6px;
font-size: 0.78rem;
}
.event-case_deadline,
.event-deadline {
background: rgba(230, 57, 70, 0.18);
color: #8b1d29;
}
.event-case_deferred,
.event-deferred {
background: rgba(95, 15, 64, 0.15);
color: #5f0f40;
}
.event-case_reminder,
.event-reminder {
background: rgba(42, 157, 143, 0.2);
color: #1f6f66;
}
.event-meeting {
background: rgba(15, 76, 117, 0.18);
color: #0f4c75;
}
.event-technician_visit {
background: rgba(255, 183, 3, 0.22);
color: #8a5b00;
}
.event-obs {
background: rgba(90, 96, 168, 0.2);
color: #3d3f7a;
}
.fade-up {
animation: fadeUp 0.6s ease both;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.calendar-hero {
padding: 1.5rem;
}
.calendar-shell {
padding: 1rem;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-4 calendar-page">
<div class="calendar-hero fade-up">
<div>
<div class="hero-kicker">BMC Hub Kalender</div>
<h1>Kalender for drift og overblik</h1>
<p>Samlet visning af sag-deadlines, deferred datoer og reminders. Brug det som et kontrolpanel, ikke som pynt.</p>
<div class="hero-meta">
<div>Events i interval: <span id="eventCount">0</span></div>
<div>|</div>
<div>Status: <span id="calendarStatus">Klar</span></div>
</div>
<div class="hero-ical">
iCal: <a href="{{ request.base_url }}api/v1/calendar/ical">{{ request.base_url }}api/v1/calendar/ical</a>
</div>
</div>
<div class="calendar-filter-card">
<div class="filter-title">Filtre og fokus</div>
<div class="toggle-group" role="group" aria-label="Mine eller alle">
<button type="button" id="mineToggle" class="active">Mine</button>
<button type="button" id="allToggle">Alle</button>
</div>
<div class="filter-grid">
<div class="filter-block">
<label for="customerSelect">Kunde</label>
<input type="text" id="customerSearch" placeholder="Sog kunde..." class="form-control mb-2">
<select id="customerSelect">
<option value="">Alle kunder</option>
</select>
</div>
<div class="filter-block">
<label>Event typer</label>
<div class="type-tags">
<label class="type-tag">
<input type="checkbox" class="type-filter" value="deadline" checked>
Deadline
</label>
<label class="type-tag">
<input type="checkbox" class="type-filter" value="deferred" checked>
Deferred
</label>
<label class="type-tag">
<input type="checkbox" class="type-filter" value="meeting" checked>
Moede
</label>
<label class="type-tag">
<input type="checkbox" class="type-filter" value="technician_visit" checked>
Teknikerbesoeg
</label>
<label class="type-tag">
<input type="checkbox" class="type-filter" value="obs" checked>
OBS
</label>
<label class="type-tag">
<input type="checkbox" class="type-filter" value="reminder" checked>
Reminder
</label>
</div>
</div>
</div>
<div class="action-row">
<div class="text-muted small">Opret aftaler direkte i kalenderen.</div>
<button class="btn-calendar-action" type="button" onclick="openCalendarModal()">Opret aftale</button>
</div>
</div>
</div>
<div class="calendar-shell fade-up">
<div class="calendar-toolbar">
<div class="view-buttons" id="viewButtons">
<button type="button" data-view="dayGridMonth" class="active">Maaned</button>
<button type="button" data-view="timeGridWeek">Uge</button>
<button type="button" data-view="timeGridDay">Dag</button>
<button type="button" data-view="listWeek">Agenda</button>
</div>
<div class="status-bar" id="rangeLabel">Indlaeser periode...</div>
</div>
<div id="calendar"></div>
<div class="calendar-legend">
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-ember);"></span>Deadline</div>
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-violet);"></span>Deferred</div>
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-sea);"></span>Moede</div>
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-sun);"></span>Teknikerbesoeg</div>
<div class="legend-item"><span class="legend-dot" style="background: #5a60a8;"></span>OBS</div>
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-mint);"></span>Reminder</div>
</div>
</div>
</div>
<div class="modal fade" id="calendarCreateModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Opret kalenderaftale</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-12 case-search-box">
<label class="form-label">Sag *</label>
<input type="text" class="form-control" id="caseSearch" placeholder="Sog sag...">
<div id="caseResults" class="case-search-results"></div>
<div class="form-text" id="caseSelectedHint">Ingen sag valgt</div>
</div>
<div class="col-md-6">
<label class="form-label">Type *</label>
<select class="form-select" id="calendarEventType">
<option value="meeting">Moede</option>
<option value="technician_visit">Teknikerbesoeg</option>
<option value="obs">OBS</option>
<option value="reminder">Reminder</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Tidspunkt *</label>
<input type="datetime-local" class="form-control" id="calendarEventTime">
</div>
<div class="col-12">
<label class="form-label">Titel *</label>
<input type="text" class="form-control" id="calendarEventTitle" placeholder="Fx Moede om status">
</div>
<div class="col-12">
<label class="form-label">Besked</label>
<textarea class="form-control" id="calendarEventMessage" rows="3"></textarea>
</div>
<div class="col-12">
<div class="alert alert-warning small d-none" id="calendarEventWarning">
Mangler bruger-id. Log ind igen eller opdater siden.
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="saveCalendarEvent()">Gem aftale</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.js"></script>
<script>
const calendarEl = document.getElementById('calendar');
const eventCountEl = document.getElementById('eventCount');
const calendarStatusEl = document.getElementById('calendarStatus');
const rangeLabelEl = document.getElementById('rangeLabel');
const customerSelect = document.getElementById('customerSelect');
const customerSearch = document.getElementById('customerSearch');
const mineToggle = document.getElementById('mineToggle');
const allToggle = document.getElementById('allToggle');
const viewButtons = document.getElementById('viewButtons');
const typeFilters = Array.from(document.querySelectorAll('.type-filter'));
const customerOptions = [];
const caseResults = document.getElementById('caseResults');
const caseSearch = document.getElementById('caseSearch');
const caseSelectedHint = document.getElementById('caseSelectedHint');
const calendarEventWarning = document.getElementById('calendarEventWarning');
let selectedCaseId = null;
let caseOptions = [];
let caseActiveIndex = -1;
let onlyMine = true;
function setToggle(activeMine) {
onlyMine = activeMine;
mineToggle.classList.toggle('active', activeMine);
allToggle.classList.toggle('active', !activeMine);
calendar.refetchEvents();
}
function getSelectedTypes() {
return typeFilters.filter(input => input.checked).map(input => input.value);
}
function getCalendarUserId() {
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.sub || payload.user_id;
} catch (e) {
console.warn('Could not decode token for calendar user_id');
}
}
const metaTag = document.querySelector('meta[name="user-id"]');
if (metaTag) return metaTag.getAttribute('content');
return null;
}
async function loadCustomers() {
try {
const response = await fetch('/api/v1/customers?limit=1000&offset=0');
if (!response.ok) {
return;
}
const data = await response.json();
const customers = data.customers || [];
customers.forEach(customer => {
customerOptions.push({ id: customer.id, name: customer.name });
const option = document.createElement('option');
option.value = customer.id;
option.textContent = customer.name;
customerSelect.appendChild(option);
});
} catch (err) {
console.warn('Customer load failed', err);
}
}
function filterCustomerOptions() {
const query = customerSearch.value.trim().toLowerCase();
customerSelect.innerHTML = '';
const allOption = document.createElement('option');
allOption.value = '';
allOption.textContent = 'Alle kunder';
customerSelect.appendChild(allOption);
customerOptions
.filter(item => !query || item.name.toLowerCase().includes(query))
.forEach(item => {
const option = document.createElement('option');
option.value = item.id;
option.textContent = item.name;
customerSelect.appendChild(option);
});
}
const calendar = new FullCalendar.Calendar(calendarEl, {
initialView: 'dayGridMonth',
height: 'auto',
dayMaxEvents: true,
nowIndicator: true,
locale: 'da',
eventTimeFormat: {
hour: '2-digit',
minute: '2-digit',
hour12: false
},
slotLabelFormat: {
hour: '2-digit',
minute: '2-digit',
hour12: false
},
events: async (info, successCallback, failureCallback) => {
calendarStatusEl.textContent = 'Henter data...';
const params = new URLSearchParams();
params.set('start', info.startStr);
params.set('end', info.endStr);
params.set('only_mine', onlyMine ? 'true' : 'false');
const selectedTypes = getSelectedTypes();
if (selectedTypes.length) {
params.set('types', selectedTypes.join(','));
}
if (customerSelect.value) {
params.set('customer_id', customerSelect.value);
}
try {
const response = await fetch(`/api/v1/calendar/events?${params.toString()}`);
if (!response.ok) {
throw new Error('Request failed');
}
const data = await response.json();
calendarStatusEl.textContent = 'Opdateret';
successCallback(data.events || []);
} catch (error) {
calendarStatusEl.textContent = 'Fejl';
failureCallback(error);
}
},
eventClassNames: (arg) => {
const kind = arg.event.extendedProps.event_kind || arg.event.extendedProps.event_type;
return [`event-${kind || arg.event.extendedProps.event_type}`];
},
eventDidMount: (info) => {
const customerName = info.event.extendedProps.customer_name;
if (customerName) {
info.el.title = `${info.event.title} - ${customerName}`;
}
},
datesSet: (info) => {
rangeLabelEl.textContent = `${info.startStr} til ${info.endStr}`;
},
eventsSet: (events) => {
eventCountEl.textContent = events.length;
}
});
calendar.render();
loadCustomers();
mineToggle.addEventListener('click', () => setToggle(true));
allToggle.addEventListener('click', () => setToggle(false));
typeFilters.forEach(input => {
input.addEventListener('change', () => calendar.refetchEvents());
});
customerSelect.addEventListener('change', () => calendar.refetchEvents());
customerSearch.addEventListener('input', () => {
filterCustomerOptions();
});
function renderCaseResults() {
caseResults.innerHTML = '';
if (!caseOptions.length) {
caseResults.classList.remove('show');
return;
}
caseOptions.forEach((item, index) => {
const div = document.createElement('div');
div.className = `case-search-item${index === caseActiveIndex ? ' active' : ''}`;
div.innerHTML = `<strong>${item.title}</strong><small>Sag #${item.id}</small>`;
div.addEventListener('click', () => selectCase(item));
caseResults.appendChild(div);
});
caseResults.classList.add('show');
}
function selectCase(item) {
selectedCaseId = item.id;
caseSearch.value = item.title;
caseSelectedHint.textContent = `Valgt sag #${item.id}`;
caseResults.classList.remove('show');
caseOptions = [];
caseActiveIndex = -1;
}
async function searchCases(query) {
if (!query || query.length < 1) {
caseOptions = [];
renderCaseResults();
return;
}
try {
const params = new URLSearchParams();
params.set('q', query);
params.set('limit', '12');
const response = await fetch(`/api/v1/sag?${params.toString()}`);
if (!response.ok) {
throw new Error('Request failed');
}
const data = await response.json();
caseOptions = (data || []).map(item => ({ id: item.id, title: item.titel || item.title || `Sag #${item.id}` }))
.sort((a, b) => a.title.localeCompare(b.title, 'da'))
.slice(0, 12);
caseActiveIndex = -1;
renderCaseResults();
} catch (err) {
console.warn('Case search failed', err);
caseOptions = [];
renderCaseResults();
}
}
let caseSearchTimer = null;
caseSearch.addEventListener('input', () => {
const query = caseSearch.value.trim();
selectedCaseId = null;
caseSelectedHint.textContent = 'Ingen sag valgt';
if (caseSearchTimer) {
clearTimeout(caseSearchTimer);
}
caseSearchTimer = setTimeout(() => searchCases(query), 200);
});
caseSearch.addEventListener('keydown', (event) => {
if (!caseOptions.length) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
caseActiveIndex = Math.min(caseActiveIndex + 1, caseOptions.length - 1);
renderCaseResults();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
caseActiveIndex = Math.max(caseActiveIndex - 1, 0);
renderCaseResults();
} else if (event.key === 'Enter') {
event.preventDefault();
if (caseActiveIndex >= 0) {
selectCase(caseOptions[caseActiveIndex]);
}
}
});
function openCalendarModal() {
const userId = getCalendarUserId();
if (calendarEventWarning) {
calendarEventWarning.classList.toggle('d-none', !!userId);
}
selectedCaseId = null;
caseSearch.value = '';
caseSelectedHint.textContent = 'Ingen sag valgt';
document.getElementById('calendarEventType').value = 'meeting';
document.getElementById('calendarEventTime').value = '';
document.getElementById('calendarEventTitle').value = '';
document.getElementById('calendarEventMessage').value = '';
caseOptions = [];
caseActiveIndex = -1;
caseResults.classList.remove('show');
new bootstrap.Modal(document.getElementById('calendarCreateModal')).show();
}
async function saveCalendarEvent() {
const userId = getCalendarUserId();
if (!userId) {
alert('Mangler bruger-id. Log ind igen.');
return;
}
const eventType = document.getElementById('calendarEventType').value;
const eventTime = document.getElementById('calendarEventTime').value;
const title = document.getElementById('calendarEventTitle').value.trim();
const message = document.getElementById('calendarEventMessage').value.trim();
if (!selectedCaseId) {
alert('Vælg en sag');
return;
}
if (!eventTime) {
alert('Vælg tidspunkt');
return;
}
if (!title) {
alert('Titel er påkrævet');
return;
}
const payload = {
title,
message: message || null,
priority: 'normal',
event_type: eventType,
trigger_type: 'time_based',
trigger_config: {},
recipient_user_ids: [Number(userId)],
recipient_emails: [],
notify_mattermost: false,
notify_email: false,
notify_frontend: true,
override_user_preferences: false,
recurrence_type: 'once',
recurrence_day_of_week: null,
recurrence_day_of_month: null,
scheduled_at: new Date(eventTime).toISOString()
};
try {
const res = await fetch(`/api/v1/sag/${selectedCaseId}/reminders?user_id=${userId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Kunne ikke oprette aftale');
}
bootstrap.Modal.getInstance(document.getElementById('calendarCreateModal')).hide();
calendar.refetchEvents();
} catch (err) {
alert('Fejl: ' + err.message);
}
}
viewButtons.addEventListener('click', (event) => {
const target = event.target.closest('button[data-view]');
if (!target) return;
const view = target.dataset.view;
calendar.changeView(view);
Array.from(viewButtons.querySelectorAll('button')).forEach(btn => {
btn.classList.toggle('active', btn === target);
});
});
const autoRefreshMs = 5 * 60 * 1000;
setInterval(() => {
if (document.visibilityState === 'visible') {
calendar.refetchEvents();
}
}, autoRefreshMs);
window.addEventListener('focus', () => {
calendar.refetchEvents();
});
</script>
{% endblock %}

View File

@ -203,6 +203,61 @@ async def list_hardware_by_contact(contact_id: int):
return all_results return all_results
@router.get("/hardware/unassigned", response_model=List[dict])
async def list_unassigned_hardware(limit: int = 200):
"""List hardware assets not linked to any contact."""
query = """
SELECT h.id, h.asset_type, h.brand, h.model, h.serial_number, h.status
FROM hardware_assets h
WHERE h.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM hardware_contacts hc WHERE hc.hardware_id = h.id
)
ORDER BY h.created_at DESC
LIMIT %s
"""
result = execute_query(query, (limit,))
return result or []
@router.post("/hardware/{hardware_id}/assign-contact", response_model=dict)
async def assign_hardware_to_contact(hardware_id: int, payload: dict):
"""Link hardware asset to a contact."""
contact_id = payload.get("contact_id")
if not contact_id:
raise HTTPException(status_code=422, detail="contact_id is required")
hardware = execute_query("SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL", (hardware_id,))
if not hardware:
raise HTTPException(status_code=404, detail="Hardware not found")
contact = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
execute_query(
"""
INSERT INTO hardware_contacts (hardware_id, contact_id, role, source)
VALUES (%s, %s, %s, %s)
ON CONFLICT (hardware_id, contact_id) DO NOTHING
""",
(hardware_id, contact_id, "primary", "manual"),
)
customer_id = _get_contact_customer(int(contact_id))
if customer_id:
execute_query(
"""
UPDATE hardware_assets
SET current_owner_customer_id = COALESCE(current_owner_customer_id, %s)
WHERE id = %s
""",
(customer_id, hardware_id),
)
return {"status": "ok"}
@router.post("/hardware", response_model=dict) @router.post("/hardware", response_model=dict)
async def create_hardware(data: dict): async def create_hardware(data: dict):
"""Create a new hardware asset.""" """Create a new hardware asset."""

View File

@ -1,5 +1,5 @@
import logging import logging
from typing import Optional from typing import Optional, Any
from fastapi import APIRouter, HTTPException, Query, Request, Form, Depends from fastapi import APIRouter, HTTPException, Query, Request, Form, Depends
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@ -101,16 +101,96 @@ def extract_eset_specs_summary(hardware: dict) -> dict:
adapter_names = [n.get("caption") for n in network_adapters if isinstance(n, dict) and n.get("caption")] adapter_names = [n.get("caption") for n in network_adapters if isinstance(n, dict) and n.get("caption")]
macs = [n.get("macAddress") for n in network_adapters if isinstance(n, dict) and n.get("macAddress")] macs = [n.get("macAddress") for n in network_adapters if isinstance(n, dict) and n.get("macAddress")]
deployed_components = [] installed_software = []
def _normalize_version(value: Any) -> str:
if isinstance(value, dict):
name = str(value.get("name") or "").strip()
if name:
return name
version_id = str(value.get("id") or "").strip()
if version_id:
return version_id
major = value.get("major")
minor = value.get("minor")
patch = value.get("patch")
if major is not None and minor is not None and patch is not None:
return f"{major}.{minor}.{patch}"
return ""
if value is None:
return ""
return str(value).strip()
def _add_software_item(name: Optional[str], version: Any = None) -> None:
if not name:
return
item_name = str(name).strip()
item_version = _normalize_version(version)
if not item_name:
return
if item_version:
installed_software.append(f"{item_name} {item_version}")
else:
installed_software.append(item_name)
# ESET standard format
for comp in specs.get("deployedComponents") or []: for comp in specs.get("deployedComponents") or []:
if not isinstance(comp, dict): if not isinstance(comp, dict):
continue continue
name = comp.get("displayName") or comp.get("name") _add_software_item(
version = (comp.get("version") or {}).get("name") comp.get("displayName") or comp.get("name"),
if name and version: comp.get("version"),
deployed_components.append(f"{name} {version}") )
elif name:
deployed_components.append(name) # Alternative common payload names
for comp in specs.get("installedSoftware") or []:
if isinstance(comp, dict):
_add_software_item(comp.get("displayName") or comp.get("name") or comp.get("softwareName"), comp.get("version"))
elif isinstance(comp, str):
_add_software_item(comp)
for comp in specs.get("applications") or []:
if isinstance(comp, dict):
_add_software_item(comp.get("displayName") or comp.get("name") or comp.get("applicationName"), comp.get("version"))
elif isinstance(comp, str):
_add_software_item(comp)
for comp in specs.get("activeProducts") or []:
if isinstance(comp, dict):
_add_software_item(
comp.get("displayName") or comp.get("name") or comp.get("productName") or comp.get("product"),
comp.get("version") or comp.get("productVersion"),
)
elif isinstance(comp, str):
_add_software_item(comp)
for key in ("applicationInventory", "softwareInventory"):
for comp in specs.get(key) or []:
if isinstance(comp, dict):
_add_software_item(
comp.get("displayName") or comp.get("name") or comp.get("applicationName") or comp.get("softwareName"),
comp.get("version") or comp.get("applicationVersion") or comp.get("softwareVersion"),
)
elif isinstance(comp, str):
_add_software_item(comp)
# Keep ordering but remove duplicates
seen = set()
deduped_installed_software = []
for item in installed_software:
key = item.lower()
if key in seen:
continue
seen.add(key)
deduped_installed_software.append(item)
installed_software_details = []
for item in deduped_installed_software:
match = item.rsplit(" ", 1)
if len(match) == 2 and any(ch.isdigit() for ch in match[1]):
installed_software_details.append({"name": match[0], "version": match[1]})
else:
installed_software_details.append({"name": item, "version": ""})
return { return {
"os_name": os_name, "os_name": os_name,
@ -131,7 +211,8 @@ def extract_eset_specs_summary(hardware: dict) -> dict:
"functionality_status": specs.get("functionalityStatus"), "functionality_status": specs.get("functionalityStatus"),
"last_sync_time": specs.get("lastSyncTime"), "last_sync_time": specs.get("lastSyncTime"),
"device_type": specs.get("deviceType"), "device_type": specs.get("deviceType"),
"deployed_components": deployed_components, "deployed_components": deduped_installed_software,
"installed_software_details": installed_software_details,
} }
@ -232,11 +313,24 @@ async def hardware_eset_overview(
matches = execute_query(matches_query) matches = execute_query(matches_query)
incidents_query = """ incidents_query = """
SELECT * SELECT
FROM eset_incidents i.*,
WHERE LOWER(COALESCE(severity, '')) IN ('critical', 'high', 'severe') hw.id AS hardware_id,
ORDER BY updated_at DESC NULLS LAST hw.current_owner_customer_id AS customer_id,
LIMIT 200 cust.name AS customer_name
FROM eset_incidents i
LEFT JOIN LATERAL (
SELECT h.id, h.current_owner_customer_id
FROM hardware_assets h
WHERE h.deleted_at IS NULL
AND LOWER(COALESCE(h.eset_uuid, '')) = LOWER(COALESCE(i.device_uuid, ''))
ORDER BY h.updated_at DESC NULLS LAST, h.id DESC
LIMIT 1
) hw ON TRUE
LEFT JOIN customers cust ON cust.id = hw.current_owner_customer_id
WHERE LOWER(COALESCE(i.severity, '')) IN ('critical', 'high', 'severe')
ORDER BY i.updated_at DESC NULLS LAST
LIMIT 5
""" """
incidents = execute_query(incidents_query) incidents = execute_query(incidents_query)

View File

@ -454,7 +454,7 @@
<h6 class="text-primary mb-0"><i class="bi bi-cpu me-2"></i>Specifikationer</h6> <h6 class="text-primary mb-0"><i class="bi bi-cpu me-2"></i>Specifikationer</h6>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if eset_specs and (eset_specs.os_name or eset_specs.primary_local_ip or eset_specs.cpu_models) %} {% if eset_specs and (eset_specs.os_name or eset_specs.primary_local_ip or eset_specs.cpu_models or eset_specs.deployed_components) %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm align-middle"> <table class="table table-sm align-middle">
<tbody> <tbody>
@ -512,7 +512,38 @@
</tr> </tr>
<tr> <tr>
<th>Installeret</th> <th>Installeret</th>
<td>{{ eset_specs.deployed_components | join(', ') if eset_specs.deployed_components else '-' }}</td> <td>
{% if eset_specs.installed_software_details %}
<div style="max-height: 240px; overflow: auto; border: 1px solid rgba(0,0,0,0.08); border-radius: 6px; padding: 0.5rem 0.75rem; background: rgba(0,0,0,0.02);">
<table class="table table-sm mb-0 align-middle">
<thead>
<tr>
<th style="width: 70%;">App</th>
<th>Version</th>
</tr>
</thead>
<tbody>
{% for app in eset_specs.installed_software_details %}
<tr>
<td class="text-break">{{ app.name }}</td>
<td>{{ app.version or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% elif eset_specs.deployed_components %}
<div style="max-height: 240px; overflow: auto; border: 1px solid rgba(0,0,0,0.08); border-radius: 6px; padding: 0.5rem 0.75rem; background: rgba(0,0,0,0.02);">
<ul class="mb-0 ps-3">
{% for app in eset_specs.deployed_components %}
<li>{{ app }}</li>
{% endfor %}
</ul>
</div>
{% else %}
-
{% endif %}
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@ -129,7 +129,7 @@
</div> </div>
<div class="section-card"> <div class="section-card">
<div class="section-title">🚨 Kritiske incidents</div> <div class="section-title">🚨 Kritiske incidents (seneste 5)</div>
{% if incidents %} {% if incidents %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle"> <table class="table table-hover align-middle">
@ -140,6 +140,7 @@
<th>Device UUID</th> <th>Device UUID</th>
<th>Detected</th> <th>Detected</th>
<th>Last Seen</th> <th>Last Seen</th>
<th>Handling</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -155,6 +156,24 @@
<td style="max-width: 220px; word-break: break-all;">{{ inc.device_uuid or '-' }}</td> <td style="max-width: 220px; word-break: break-all;">{{ inc.device_uuid or '-' }}</td>
<td>{{ inc.detected_at or '-' }}</td> <td>{{ inc.detected_at or '-' }}</td>
<td>{{ inc.last_seen or '-' }}</td> <td>{{ inc.last_seen or '-' }}</td>
<td>
{% set case_title = 'ESET incident ' ~ (inc.severity or 'critical') ~ ' - ' ~ (inc.device_uuid or 'ukendt enhed') %}
{% set case_description =
'Kilde: ESET incidents | ' ~
'Severity: ' ~ (inc.severity or '-') ~ ' | ' ~
'Status: ' ~ (inc.status or '-') ~ ' | ' ~
'Device UUID: ' ~ (inc.device_uuid or '-') ~ ' | ' ~
'Incident UUID: ' ~ (inc.incident_uuid or '-') ~ ' | ' ~
'Detected: ' ~ (inc.detected_at or '-') ~ ' | ' ~
'Last Seen: ' ~ (inc.last_seen or '-')
%}
<a
href="/sag/new?title={{ case_title | urlencode }}&description={{ case_description | urlencode }}{% if inc.customer_id %}&customer_id={{ inc.customer_id }}{% endif %}"
class="btn btn-sm btn-outline-primary"
>
Opret sag
</a>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@ -299,74 +299,129 @@
</div> </div>
{% if hardware and hardware|length > 0 %} {% if hardware and hardware|length > 0 %}
<div class="hardware-grid"> <div class="d-flex justify-content-end mb-3">
{% for item in hardware %} <div class="btn-group btn-group-sm" role="group" aria-label="Visning">
<div class="hardware-card" onclick="window.location.href='/hardware/{{ item.id }}'"> <button type="button" class="btn btn-outline-secondary active" id="viewCardsBtn" onclick="setHardwareView('cards')">Kort</button>
<div class="hardware-header"> <button type="button" class="btn btn-outline-secondary" id="viewTableBtn" onclick="setHardwareView('table')">Tabel</button>
<div class="hardware-icon"> </div>
{% if item.asset_type == 'pc' %}🖥️ </div>
{% elif item.asset_type == 'laptop' %}💻
{% elif item.asset_type == 'printer' %}🖨️ <div id="hardwareCardsView">
{% elif item.asset_type == 'skærm' %}🖥️ <div class="hardware-grid">
{% elif item.asset_type == 'telefon' %}📱 {% for item in hardware %}
{% elif item.asset_type == 'server' %}🗄️ <div class="hardware-card" onclick="window.location.href='/hardware/{{ item.id }}'">
{% elif item.asset_type == 'netværk' %}🌐 <div class="hardware-header">
{% else %}📦 <div class="hardware-icon">
{% if item.asset_type == 'pc' %}🖥️
{% elif item.asset_type == 'laptop' %}💻
{% elif item.asset_type == 'printer' %}🖨️
{% elif item.asset_type == 'skærm' %}🖥️
{% elif item.asset_type == 'telefon' %}📱
{% elif item.asset_type == 'server' %}🗄️
{% elif item.asset_type == 'netværk' %}🌐
{% else %}📦
{% endif %}
</div>
<div class="hardware-info">
<div class="hardware-title">{{ item.brand or 'Unknown' }} {{ item.model or '' }}</div>
<div class="hardware-subtitle">{{ item.serial_number or 'Ingen serienummer' }}</div>
</div>
</div>
<div class="hardware-details">
<div class="hardware-detail-row">
<span class="hardware-detail-label">Type:</span>
<span class="hardware-detail-value">{{ item.asset_type|title }}</span>
</div>
{% if item.anydesk_id or item.anydesk_link %}
<div class="hardware-detail-row">
<span class="hardware-detail-label">AnyDesk:</span>
<span class="hardware-detail-value">
{% if item.anydesk_link %}
<a href="{{ item.anydesk_link }}" target="_blank">{{ item.anydesk_id or 'Åbn' }}</a>
{% elif item.anydesk_id %}
<a href="anydesk://{{ item.anydesk_id }}" target="_blank">{{ item.anydesk_id }}</a>
{% endif %}
</span>
</div>
{% endif %}
{% if item.customer_name %}
<div class="hardware-detail-row">
<span class="hardware-detail-label">Ejer:</span>
<span class="hardware-detail-value">{{ item.customer_name }}</span>
</div>
{% elif item.current_owner_type %}
<div class="hardware-detail-row">
<span class="hardware-detail-label">Ejer:</span>
<span class="hardware-detail-value">{{ item.current_owner_type|title }}</span>
</div>
{% endif %}
{% if item.internal_asset_id %}
<div class="hardware-detail-row">
<span class="hardware-detail-label">Asset ID:</span>
<span class="hardware-detail-value">{{ item.internal_asset_id }}</span>
</div>
{% endif %} {% endif %}
</div> </div>
<div class="hardware-info">
<div class="hardware-title">{{ item.brand or 'Unknown' }} {{ item.model or '' }}</div>
<div class="hardware-subtitle">{{ item.serial_number or 'Ingen serienummer' }}</div>
</div>
</div>
<div class="hardware-details"> <div class="hardware-footer">
<div class="hardware-detail-row"> <span class="status-badge status-{{ item.status }}">
<span class="hardware-detail-label">Type:</span> {{ item.status|replace('_', ' ')|title }}
<span class="hardware-detail-value">{{ item.asset_type|title }}</span>
</div>
{% if item.anydesk_id or item.anydesk_link %}
<div class="hardware-detail-row">
<span class="hardware-detail-label">AnyDesk:</span>
<span class="hardware-detail-value">
{% if item.anydesk_link %}
<a href="{{ item.anydesk_link }}" target="_blank">{{ item.anydesk_id or 'Åbn' }}</a>
{% elif item.anydesk_id %}
<a href="anydesk://{{ item.anydesk_id }}" target="_blank">{{ item.anydesk_id }}</a>
{% endif %}
</span> </span>
</div> <div class="hardware-actions" onclick="event.stopPropagation()">
{% endif %} <a href="/hardware/{{ item.id }}" class="btn-action">👁️ Se</a>
{% if item.customer_name %} <a href="/hardware/{{ item.id }}/edit" class="btn-action">✏️ Rediger</a>
<div class="hardware-detail-row"> </div>
<span class="hardware-detail-label">Ejer:</span>
<span class="hardware-detail-value">{{ item.customer_name }}</span>
</div>
{% elif item.current_owner_type %}
<div class="hardware-detail-row">
<span class="hardware-detail-label">Ejer:</span>
<span class="hardware-detail-value">{{ item.current_owner_type|title }}</span>
</div>
{% endif %}
{% if item.internal_asset_id %}
<div class="hardware-detail-row">
<span class="hardware-detail-label">Asset ID:</span>
<span class="hardware-detail-value">{{ item.internal_asset_id }}</span>
</div>
{% endif %}
</div>
<div class="hardware-footer">
<span class="status-badge status-{{ item.status }}">
{{ item.status|replace('_', ' ')|title }}
</span>
<div class="hardware-actions" onclick="event.stopPropagation()">
<a href="/hardware/{{ item.id }}" class="btn-action">👁️ Se</a>
<a href="/hardware/{{ item.id }}/edit" class="btn-action">✏️ Rediger</a>
</div> </div>
</div> </div>
{% endfor %}
</div>
</div>
<div id="hardwareTableView" class="d-none">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Hardware</th>
<th>Type</th>
<th>Serienr.</th>
<th>Ejer</th>
<th>Status</th>
<th>AnyDesk</th>
<th class="text-end">Handling</th>
</tr>
</thead>
<tbody>
{% for item in hardware %}
<tr>
<td class="fw-semibold">{{ item.brand or 'Unknown' }} {{ item.model or '' }}</td>
<td>{{ item.asset_type|title }}</td>
<td>{{ item.serial_number or 'Ingen serienummer' }}</td>
<td>{{ item.customer_name or (item.current_owner_type|title if item.current_owner_type else '—') }}</td>
<td>
<span class="status-badge status-{{ item.status }}">
{{ item.status|replace('_', ' ')|title }}
</span>
</td>
<td>
{% if item.anydesk_link %}
<a href="{{ item.anydesk_link }}" target="_blank">{{ item.anydesk_id or 'Åbn' }}</a>
{% elif item.anydesk_id %}
<a href="anydesk://{{ item.anydesk_id }}" target="_blank">{{ item.anydesk_id }}</a>
{% else %}
{% endif %}
</td>
<td class="text-end">
<a href="/hardware/{{ item.id }}" class="btn-action">👁️ Se</a>
<a href="/hardware/{{ item.id }}/edit" class="btn-action">✏️ Rediger</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
{% endfor %}
</div> </div>
{% else %} {% else %}
<div class="empty-state"> <div class="empty-state">
@ -386,5 +441,25 @@
select.form.submit(); select.form.submit();
}); });
}); });
function setHardwareView(view) {
const cards = document.getElementById('hardwareCardsView');
const table = document.getElementById('hardwareTableView');
const cardsBtn = document.getElementById('viewCardsBtn');
const tableBtn = document.getElementById('viewTableBtn');
if (!cards || !table || !cardsBtn || !tableBtn) return;
const showTable = view === 'table';
cards.classList.toggle('d-none', showTable);
table.classList.toggle('d-none', !showTable);
cardsBtn.classList.toggle('active', !showTable);
tableBtn.classList.toggle('active', showTable);
localStorage.setItem('hardwareViewMode', showTable ? 'table' : 'cards');
}
const savedView = localStorage.getItem('hardwareViewMode');
if (savedView === 'table') {
setHardwareView('table');
}
</script> </script>
{% endblock %} {% endblock %}

View File

@ -60,6 +60,7 @@ class ReminderCreate(BaseModel):
title: str = Field(..., min_length=3, max_length=255) title: str = Field(..., min_length=3, max_length=255)
message: Optional[str] = None message: Optional[str] = None
priority: str = Field(default="normal", pattern="^(low|normal|high|urgent)$") priority: str = Field(default="normal", pattern="^(low|normal|high|urgent)$")
event_type: str = Field(default="reminder", pattern="^(reminder|meeting|technician_visit|obs|deadline)$")
trigger_type: str = Field(pattern="^(status_change|deadline_approaching|time_based)$") trigger_type: str = Field(pattern="^(status_change|deadline_approaching|time_based)$")
trigger_config: dict # JSON config for trigger trigger_config: dict # JSON config for trigger
recipient_user_ids: List[int] = [] recipient_user_ids: List[int] = []
@ -79,6 +80,7 @@ class ReminderUpdate(BaseModel):
title: Optional[str] = None title: Optional[str] = None
message: Optional[str] = None message: Optional[str] = None
priority: Optional[str] = None priority: Optional[str] = None
event_type: Optional[str] = Field(default=None, pattern="^(reminder|meeting|technician_visit|obs|deadline)$")
notify_mattermost: Optional[bool] = None notify_mattermost: Optional[bool] = None
notify_email: Optional[bool] = None notify_email: Optional[bool] = None
notify_frontend: Optional[bool] = None notify_frontend: Optional[bool] = None
@ -93,6 +95,7 @@ class ReminderResponse(BaseModel):
title: str title: str
message: Optional[str] message: Optional[str]
priority: str priority: str
event_type: Optional[str]
trigger_type: str trigger_type: str
recurrence_type: str recurrence_type: str
is_active: bool is_active: bool
@ -108,6 +111,7 @@ class ReminderProfileResponse(BaseModel):
title: str title: str
message: Optional[str] message: Optional[str]
priority: str priority: str
event_type: Optional[str]
trigger_type: str trigger_type: str
recurrence_type: str recurrence_type: str
is_active: bool is_active: bool
@ -251,7 +255,7 @@ async def list_sag_reminders(sag_id: int):
"""List all reminders for a case""" """List all reminders for a case"""
query = """ query = """
SELECT id, sag_id, title, message, priority, trigger_type, SELECT id, sag_id, title, message, priority, event_type, trigger_type,
recurrence_type, is_active, next_check_at, last_sent_at, created_at recurrence_type, is_active, next_check_at, last_sent_at, created_at
FROM sag_reminders FROM sag_reminders
WHERE sag_id = %s AND deleted_at IS NULL WHERE sag_id = %s AND deleted_at IS NULL
@ -267,6 +271,7 @@ async def list_sag_reminders(sag_id: int):
title=r['title'], title=r['title'],
message=r['message'], message=r['message'],
priority=r['priority'], priority=r['priority'],
event_type=r.get('event_type'),
trigger_type=r['trigger_type'], trigger_type=r['trigger_type'],
recurrence_type=r['recurrence_type'], recurrence_type=r['recurrence_type'],
is_active=r['is_active'], is_active=r['is_active'],
@ -284,7 +289,7 @@ async def list_my_reminders(request: Request):
user_id = _get_user_id_from_request(request) user_id = _get_user_id_from_request(request)
query = """ query = """
SELECT r.id, r.sag_id, r.title, r.message, r.priority, r.trigger_type, SELECT r.id, r.sag_id, r.title, r.message, r.priority, r.event_type, r.trigger_type,
r.recurrence_type, r.is_active, r.next_check_at, r.last_sent_at, r.created_at, r.recurrence_type, r.is_active, r.next_check_at, r.last_sent_at, r.created_at,
s.titel as case_title, c.name as customer_name s.titel as case_title, c.name as customer_name
FROM sag_reminders r FROM sag_reminders r
@ -304,6 +309,7 @@ async def list_my_reminders(request: Request):
title=r['title'], title=r['title'],
message=r['message'], message=r['message'],
priority=r['priority'], priority=r['priority'],
event_type=r.get('event_type'),
trigger_type=r['trigger_type'], trigger_type=r['trigger_type'],
recurrence_type=r['recurrence_type'], recurrence_type=r['recurrence_type'],
is_active=r['is_active'], is_active=r['is_active'],
@ -339,7 +345,7 @@ async def create_sag_reminder(sag_id: int, request: Request, reminder: ReminderC
import json import json
query = """ query = """
INSERT INTO sag_reminders ( INSERT INTO sag_reminders (
sag_id, title, message, priority, trigger_type, trigger_config, sag_id, title, message, priority, event_type, trigger_type, trigger_config,
recipient_user_ids, recipient_emails, recipient_user_ids, recipient_emails,
notify_mattermost, notify_email, notify_frontend, notify_mattermost, notify_email, notify_frontend,
override_user_preferences, override_user_preferences,
@ -348,7 +354,7 @@ async def create_sag_reminder(sag_id: int, request: Request, reminder: ReminderC
is_active, created_by_user_id is_active, created_by_user_id
) )
VALUES ( VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s, %s, %s, %s,
%s, %s,
@ -356,12 +362,12 @@ async def create_sag_reminder(sag_id: int, request: Request, reminder: ReminderC
%s, %s, %s, %s,
true, %s true, %s
) )
RETURNING id, sag_id, title, message, priority, trigger_type, RETURNING id, sag_id, title, message, priority, event_type, trigger_type,
recurrence_type, is_active, next_check_at, last_sent_at, created_at recurrence_type, is_active, next_check_at, last_sent_at, created_at
""" """
result = execute_insert(query, ( result = execute_insert(query, (
sag_id, reminder.title, reminder.message, reminder.priority, sag_id, reminder.title, reminder.message, reminder.priority, reminder.event_type,
reminder.trigger_type, json.dumps(reminder.trigger_config), reminder.trigger_type, json.dumps(reminder.trigger_config),
reminder.recipient_user_ids, reminder.recipient_emails, reminder.recipient_user_ids, reminder.recipient_emails,
reminder.notify_mattermost, reminder.notify_email, reminder.notify_frontend, reminder.notify_mattermost, reminder.notify_email, reminder.notify_frontend,
@ -380,7 +386,7 @@ async def create_sag_reminder(sag_id: int, request: Request, reminder: ReminderC
else: else:
reminder_id = int(raw_row) reminder_id = int(raw_row)
fetch_query = """ fetch_query = """
SELECT id, sag_id, title, message, priority, trigger_type, SELECT id, sag_id, title, message, priority, event_type, trigger_type,
recurrence_type, is_active, next_check_at, last_sent_at, created_at recurrence_type, is_active, next_check_at, last_sent_at, created_at
FROM sag_reminders FROM sag_reminders
WHERE id = %s WHERE id = %s
@ -398,6 +404,7 @@ async def create_sag_reminder(sag_id: int, request: Request, reminder: ReminderC
title=r['title'], title=r['title'],
message=r['message'], message=r['message'],
priority=r['priority'], priority=r['priority'],
event_type=r.get('event_type'),
trigger_type=r['trigger_type'], trigger_type=r['trigger_type'],
recurrence_type=r['recurrence_type'], recurrence_type=r['recurrence_type'],
is_active=r['is_active'], is_active=r['is_active'],
@ -430,6 +437,10 @@ async def update_sag_reminder(reminder_id: int, update: ReminderUpdate):
if update.priority is not None: if update.priority is not None:
updates.append("priority = %s") updates.append("priority = %s")
params.append(update.priority) params.append(update.priority)
if update.event_type is not None:
updates.append("event_type = %s")
params.append(update.event_type)
if update.notify_mattermost is not None: if update.notify_mattermost is not None:
updates.append("notify_mattermost = %s") updates.append("notify_mattermost = %s")

View File

@ -6,9 +6,10 @@ from datetime import datetime
from typing import List, Optional from typing import List, Optional
from uuid import uuid4 from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, UploadFile, File from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from app.core.database import execute_query, execute_query_single from app.core.database import execute_query, execute_query_single
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate
from app.core.config import settings from app.core.config import settings
from app.services.email_service import EmailService from app.services.email_service import EmailService
@ -22,6 +23,24 @@ from email.header import decode_header
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _get_user_id_from_request(request: Request) -> int:
user_id = getattr(request.state, "user_id", None)
if user_id is not None:
try:
return int(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid user_id format")
user_id = request.query_params.get("user_id")
if user_id:
try:
return int(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid user_id format")
raise HTTPException(status_code=401, detail="User not authenticated - provide user_id query parameter")
# ============================================================================ # ============================================================================
# SAGER - CRUD Operations # SAGER - CRUD Operations
# ============================================================================ # ============================================================================
@ -33,6 +52,9 @@ async def list_sager(
customer_id: Optional[int] = Query(None), customer_id: Optional[int] = Query(None),
ansvarlig_bruger_id: Optional[int] = Query(None), ansvarlig_bruger_id: Optional[int] = Query(None),
include_deferred: bool = Query(False), include_deferred: bool = Query(False),
q: Optional[str] = Query(None),
limit: Optional[int] = Query(None, ge=1, le=200),
offset: int = Query(0, ge=0),
): ):
"""List all cases with optional filtering.""" """List all cases with optional filtering."""
try: try:
@ -51,8 +73,17 @@ async def list_sager(
if ansvarlig_bruger_id: if ansvarlig_bruger_id:
query += " AND ansvarlig_bruger_id = %s" query += " AND ansvarlig_bruger_id = %s"
params.append(ansvarlig_bruger_id) params.append(ansvarlig_bruger_id)
if q:
query += " AND (LOWER(titel) LIKE %s OR CAST(id AS TEXT) LIKE %s)"
q_like = f"%{q.lower()}%"
params.extend([q_like, q_like])
query += " ORDER BY created_at DESC" query += " ORDER BY created_at DESC"
if limit is not None:
query += " LIMIT %s OFFSET %s"
params.extend([limit, offset])
cases = execute_query(query, tuple(params)) cases = execute_query(query, tuple(params))
@ -216,6 +247,147 @@ async def set_case_module_pref(sag_id: int, data: dict):
logger.error("❌ Error setting module pref: %s", e) logger.error("❌ Error setting module pref: %s", e)
raise HTTPException(status_code=500, detail="Failed to set module pref") raise HTTPException(status_code=500, detail="Failed to set module pref")
# =========================================================================
# SAG TODO STEPS
# =========================================================================
@router.get("/sag/{sag_id}/todo-steps", response_model=List[TodoStep])
async def list_todo_steps(sag_id: int):
try:
query = """
SELECT
t.*,
COALESCE(u_created.full_name, u_created.username) AS created_by_name,
COALESCE(u_completed.full_name, u_completed.username) AS completed_by_name
FROM sag_todo_steps t
LEFT JOIN users u_created ON u_created.user_id = t.created_by_user_id
LEFT JOIN users u_completed ON u_completed.user_id = t.completed_by_user_id
WHERE t.sag_id = %s AND t.deleted_at IS NULL
ORDER BY t.is_done ASC, t.due_date NULLS LAST, t.created_at DESC
"""
return execute_query(query, (sag_id,)) or []
except Exception as e:
logger.error("❌ Error listing todo steps: %s", e)
raise HTTPException(status_code=500, detail="Failed to list todo steps")
@router.post("/sag/{sag_id}/todo-steps", response_model=TodoStep)
async def create_todo_step(sag_id: int, request: Request, data: TodoStepCreate):
try:
user_id = _get_user_id_from_request(request)
if not data.title:
raise HTTPException(status_code=400, detail="title is required")
insert_query = """
INSERT INTO sag_todo_steps
(sag_id, title, description, due_date, created_by_user_id)
VALUES (%s, %s, %s, %s, %s)
RETURNING id
"""
result = execute_query(insert_query, (
sag_id,
data.title,
data.description,
data.due_date,
user_id
))
if not result:
raise HTTPException(status_code=500, detail="Failed to create todo step")
step_id = result[0]["id"]
return execute_query(
"""
SELECT
t.*,
COALESCE(u_created.full_name, u_created.username) AS created_by_name,
COALESCE(u_completed.full_name, u_completed.username) AS completed_by_name
FROM sag_todo_steps t
LEFT JOIN users u_created ON u_created.user_id = t.created_by_user_id
LEFT JOIN users u_completed ON u_completed.user_id = t.completed_by_user_id
WHERE t.id = %s
""",
(step_id,)
)[0]
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error creating todo step: %s", e)
raise HTTPException(status_code=500, detail="Failed to create todo step")
@router.patch("/sag/todo-steps/{step_id}", response_model=TodoStep)
async def update_todo_step(step_id: int, request: Request, data: TodoStepUpdate):
try:
if data.is_done is None:
raise HTTPException(status_code=400, detail="is_done is required")
user_id = _get_user_id_from_request(request)
if data.is_done:
update_query = """
UPDATE sag_todo_steps
SET is_done = TRUE,
completed_by_user_id = %s,
completed_at = CURRENT_TIMESTAMP
WHERE id = %s AND deleted_at IS NULL
RETURNING id
"""
result = execute_query(update_query, (user_id, step_id))
else:
update_query = """
UPDATE sag_todo_steps
SET is_done = FALSE,
completed_by_user_id = NULL,
completed_at = NULL
WHERE id = %s AND deleted_at IS NULL
RETURNING id
"""
result = execute_query(update_query, (step_id,))
if not result:
raise HTTPException(status_code=404, detail="Todo step not found")
return execute_query(
"""
SELECT
t.*,
COALESCE(u_created.full_name, u_created.username) AS created_by_name,
COALESCE(u_completed.full_name, u_completed.username) AS completed_by_name
FROM sag_todo_steps t
LEFT JOIN users u_created ON u_created.user_id = t.created_by_user_id
LEFT JOIN users u_completed ON u_completed.user_id = t.completed_by_user_id
WHERE t.id = %s
""",
(step_id,)
)[0]
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error updating todo step: %s", e)
raise HTTPException(status_code=500, detail="Failed to update todo step")
@router.delete("/sag/todo-steps/{step_id}")
async def delete_todo_step(step_id: int):
try:
result = execute_query(
"""
UPDATE sag_todo_steps
SET deleted_at = CURRENT_TIMESTAMP
WHERE id = %s AND deleted_at IS NULL
RETURNING id
""",
(step_id,)
)
if not result:
raise HTTPException(status_code=404, detail="Todo step not found")
return {"status": "deleted"}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error deleting todo step: %s", e)
raise HTTPException(status_code=500, detail="Failed to delete todo step")
@router.patch("/sag/{sag_id}") @router.patch("/sag/{sag_id}")
async def update_sag(sag_id: int, updates: dict): async def update_sag(sag_id: int, updates: dict):
"""Update a case.""" """Update a case."""
@ -974,6 +1146,129 @@ async def get_varekob_salg(sag_id: int, include_subcases: bool = True):
logger.error("❌ Error aggregating case data: %s", e) logger.error("❌ Error aggregating case data: %s", e)
raise HTTPException(status_code=500, detail="Failed to aggregate case data") raise HTTPException(status_code=500, detail="Failed to aggregate case data")
@router.get("/sag/{sag_id}/calendar-events")
async def get_case_calendar_events(sag_id: int, include_children: bool = True):
"""Return calendar events for a case and optionally its child cases."""
try:
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
if not check:
raise HTTPException(status_code=404, detail="Case not found")
if include_children:
case_tree_query = """
WITH RECURSIVE normalized_relations AS (
SELECT
CASE
WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN målsag_id
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN kilde_sag_id
ELSE kilde_sag_id
END AS parent_id,
CASE
WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN kilde_sag_id
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN målsag_id
ELSE målsag_id
END AS child_id
FROM sag_relationer
WHERE deleted_at IS NULL
),
case_tree AS (
SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL
UNION
SELECT nr.child_id
FROM normalized_relations nr
JOIN case_tree ct ON nr.parent_id = ct.id
)
SELECT s.id, s.titel
FROM sag_sager s
JOIN case_tree ct ON s.id = ct.id
WHERE s.deleted_at IS NULL
ORDER BY s.id
"""
case_rows = execute_query(case_tree_query, (sag_id,)) or []
else:
case_rows = execute_query(
"SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(sag_id,)
) or []
case_ids = [row["id"] for row in case_rows]
case_titles = {row["id"]: row.get("titel") for row in case_rows}
if not case_ids:
return {"current": [], "children": []}
placeholders = ",".join(["%s"] * len(case_ids))
reminder_query = f"""
SELECT r.id, r.sag_id, r.title, r.message, r.event_type, r.priority,
r.next_check_at, r.scheduled_at
FROM sag_reminders r
WHERE r.deleted_at IS NULL
AND r.is_active = true
AND r.sag_id IN ({placeholders})
"""
reminders = execute_query(reminder_query, tuple(case_ids)) or []
case_query = f"""
SELECT id, titel, deadline, deferred_until
FROM sag_sager
WHERE deleted_at IS NULL
AND id IN ({placeholders})
"""
case_dates = execute_query(case_query, tuple(case_ids)) or []
events_by_case: dict[int, list] = {cid: [] for cid in case_ids}
for row in reminders:
start_value = row.get("next_check_at") or row.get("scheduled_at")
if not start_value:
continue
events_by_case[row["sag_id"]].append({
"id": f"reminder:{row['id']}",
"title": row.get("title"),
"message": row.get("message"),
"event_kind": row.get("event_type") or "reminder",
"start": start_value.isoformat(),
"url": f"/sag/{row['sag_id']}"
})
for row in case_dates:
if row.get("deadline"):
events_by_case[row["id"]].append({
"id": f"deadline:{row['id']}",
"title": f"Deadline: {row.get('titel')}",
"event_kind": "deadline",
"start": row["deadline"].isoformat(),
"url": f"/sag/{row['id']}"
})
if row.get("deferred_until"):
events_by_case[row["id"]].append({
"id": f"deferred:{row['id']}",
"title": f"Deferred: {row.get('titel')}",
"event_kind": "deferred",
"start": row["deferred_until"].isoformat(),
"url": f"/sag/{row['id']}"
})
current_events = events_by_case.get(sag_id, [])
children = []
for cid in case_ids:
if cid == sag_id:
continue
children.append({
"case_id": cid,
"case_title": case_titles.get(cid) or f"Sag #{cid}",
"events": events_by_case.get(cid, [])
})
return {"current": current_events, "children": children}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error loading case calendar events: %s", e)
raise HTTPException(status_code=500, detail="Failed to load calendar events")
# ============================================================================ # ============================================================================
# VAREKØB & SALG - CRUD (Case-linked sale items) # VAREKØB & SALG - CRUD (Case-linked sale items)
# ============================================================================ # ============================================================================

View File

@ -396,6 +396,28 @@ async def sag_detaljer(request: Request, sag_id: int):
time_query = "SELECT * FROM tmodule_times WHERE sag_id = %s ORDER BY worked_date DESC" time_query = "SELECT * FROM tmodule_times WHERE sag_id = %s ORDER BY worked_date DESC"
time_entries = execute_query(time_query, (sag_id,)) time_entries = execute_query(time_query, (sag_id,))
# Fetch linked telephony call history
call_history_query = """
SELECT
t.id,
t.callid,
t.direction,
t.ekstern_nummer,
t.started_at,
t.ended_at,
t.duration_sec,
u.username,
u.full_name,
CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_name
FROM telefoni_opkald t
LEFT JOIN users u ON u.user_id = t.bruger_id
LEFT JOIN contacts c ON c.id = t.kontakt_id
WHERE t.sag_id = %s
ORDER BY t.started_at DESC
LIMIT 200
"""
call_history = execute_query(call_history_query, (sag_id,))
# Check for nextcloud integration (case-insensitive, insensitive to whitespace) # Check for nextcloud integration (case-insensitive, insensitive to whitespace)
logger.info(f"Checking tags for Nextcloud on case {sag_id}: {tags}") logger.info(f"Checking tags for Nextcloud on case {sag_id}: {tags}")
is_nextcloud = any(t['tag_navn'] and t['tag_navn'].strip().lower() == 'nextcloud' for t in tags) is_nextcloud = any(t['tag_navn'] and t['tag_navn'].strip().lower() == 'nextcloud' for t in tags)
@ -434,6 +456,7 @@ async def sag_detaljer(request: Request, sag_id: int):
"comments": comments, "comments": comments,
"solution": solution, "solution": solution,
"time_entries": time_entries, "time_entries": time_entries,
"call_history": call_history,
"is_nextcloud": is_nextcloud, "is_nextcloud": is_nextcloud,
"nextcloud_instance": nextcloud_instance, "nextcloud_instance": nextcloud_instance,
"related_case_options": related_case_options, "related_case_options": related_case_options,

View File

@ -296,6 +296,7 @@
let selectedContactsCompanies = {}; let selectedContactsCompanies = {};
let customerSearchTimeout; let customerSearchTimeout;
let contactSearchTimeout; let contactSearchTimeout;
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null };
// --- Character Counter --- // --- Character Counter ---
const beskrInput = document.getElementById('beskrivelse'); const beskrInput = document.getElementById('beskrivelse');
@ -450,6 +451,70 @@
loadHardwareForContacts(); loadHardwareForContacts();
} }
function readTelefoniPrefill() {
const params = new URLSearchParams(window.location.search || '');
const contactIdRaw = params.get('contact_id');
const titleRaw = params.get('title');
const callIdRaw = params.get('telefoni_opkald_id');
const customerIdRaw = params.get('customer_id');
const descriptionRaw = params.get('description');
const contactId = contactIdRaw ? parseInt(contactIdRaw) : null;
const customerId = customerIdRaw ? parseInt(customerIdRaw) : null;
telefoniPrefill.contactId = Number.isFinite(contactId) ? contactId : null;
telefoniPrefill.customerId = Number.isFinite(customerId) ? customerId : null;
telefoniPrefill.title = titleRaw ? String(titleRaw) : null;
telefoniPrefill.callId = callIdRaw ? String(callIdRaw) : null;
telefoniPrefill.description = descriptionRaw ? String(descriptionRaw) : null;
}
async function applyTelefoniPrefill() {
readTelefoniPrefill();
if (telefoniPrefill.title) {
const titelInput = document.getElementById('titel');
if (titelInput && !titelInput.value.trim()) {
titelInput.value = telefoniPrefill.title;
}
}
if (telefoniPrefill.description) {
const beskrInput = document.getElementById('beskrivelse');
if (beskrInput && !beskrInput.value.trim()) {
beskrInput.value = telefoniPrefill.description;
const charCount = document.getElementById('charCount');
if (charCount) {
charCount.textContent = beskrInput.value.length + " tegn";
}
}
}
if (telefoniPrefill.customerId && !selectedCustomer) {
try {
const customerRes = await fetch(`/api/v1/customers/${telefoniPrefill.customerId}`);
if (customerRes.ok) {
const customer = await customerRes.json();
const customerName = customer.name || `Kunde #${telefoniPrefill.customerId}`;
selectCustomer(telefoniPrefill.customerId, customerName);
}
} catch (e) {
console.error('Customer prefill failed', e);
}
}
if (telefoniPrefill.contactId) {
try {
const res = await fetch(`/api/v1/contacts/${telefoniPrefill.contactId}`);
if (!res.ok) return;
const c = await res.json();
const name = `${c.first_name || ''} ${c.last_name || ''}`.trim() || `Kontakt #${telefoniPrefill.contactId}`;
await selectContact(telefoniPrefill.contactId, name);
} catch (e) {
console.error('Telefoni prefill failed', e);
}
}
}
function removeContact(id) { function removeContact(id) {
delete selectedContacts[id]; delete selectedContacts[id];
delete selectedContactsCompanies[id]; delete selectedContactsCompanies[id];
@ -706,6 +771,7 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initializeSearch(); initializeSearch();
loadCaseTypesSelect(); loadCaseTypesSelect();
applyTelefoniPrefill();
}); });
// --- Form Submission --- // --- Form Submission ---
@ -798,6 +864,22 @@
await Promise.all(contactPromises); await Promise.all(contactPromises);
// Link telephony call -> case (best-effort)
if (telefoniPrefill.callId) {
try {
await fetch(`/api/v1/telefoni/calls/${encodeURIComponent(telefoniPrefill.callId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sag_id: result.id,
kontakt_id: telefoniPrefill.contactId || null
})
});
} catch (e) {
console.warn('Telefoni link failed', e);
}
}
// Ensure contact-company link exists // Ensure contact-company link exists
if (selectedCustomer) { if (selectedCustomer) {
const linkPromises = Object.keys(selectedContacts).map(cid => const linkPromises = Object.keys(selectedContacts).map(cid =>

View File

@ -741,11 +741,118 @@
</div> </div>
</div> </div>
<!-- ROW: Call History -->
<div class="row mb-3">
<div class="col-12 mb-3">
<div class="card h-100 d-flex flex-column" data-module="call-history" data-has-content="{{ 'true' if call_history else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">📞 Call historik</h6>
<a href="/telefoni" class="btn btn-sm btn-outline-primary">
<i class="bi bi-telephone"></i>
</a>
</div>
<div class="card-body p-0">
{% if call_history and call_history|length > 0 %}
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 align-middle">
<thead>
<tr>
<th class="ps-3">Dato</th>
<th>Retning</th>
<th>Nummer</th>
<th>Bruger</th>
<th class="text-end pe-3">Varighed</th>
</tr>
</thead>
<tbody>
{% for call in call_history %}
<tr>
<td class="ps-3">{{ call.started_at.strftime('%d/%m/%Y %H:%M') if call.started_at else '-' }}</td>
<td>{{ 'Udgående' if call.direction == 'outbound' else 'Indgående' }}</td>
<td>
{% if call.ekstern_nummer %}
<div class="d-flex gap-2 align-items-center flex-wrap">
<span>{{ call.ekstern_nummer }}</span>
<button type="button" class="btn btn-sm btn-outline-success" onclick="ringOutFromCase('{{ call.ekstern_nummer }}')">
Ring op
</button>
</div>
{% else %}
-
{% endif %}
</td>
<td>{{ call.full_name or call.username or '-' }}</td>
<td class="text-end pe-3">
{% if call.duration_sec is not none %}
{{ (call.duration_sec // 60)|int }}:{{ '%02d'|format((call.duration_sec % 60)|int) }}
{% elif call.ended_at %}
-
{% else %}
I gang
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-3 text-muted text-center">Ingen opkald linket til denne sag</div>
{% endif %}
</div>
</div>
</div>
</div>
<script>
let caseCurrentUserId = null;
async function ensureCaseCurrentUserId() {
if (caseCurrentUserId !== null) return caseCurrentUserId;
try {
const res = await fetch('/api/v1/auth/me', { credentials: 'include' });
if (!res.ok) return null;
const me = await res.json();
caseCurrentUserId = Number(me?.id) || null;
return caseCurrentUserId;
} catch (e) {
return null;
}
}
async function ringOutFromCase(number) {
const clean = String(number || '').trim();
if (!clean || clean === '-') {
alert('Intet gyldigt nummer at ringe til');
return;
}
const userId = await ensureCaseCurrentUserId();
try {
const res = await fetch('/api/v1/telefoni/click-to-call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ number: clean, user_id: userId })
});
if (!res.ok) {
const t = await res.text();
alert('Ring ud fejlede: ' + t);
return;
}
alert('Ringer ud via Yealink...');
} catch (e) {
alert('Kunne ikke starte opkald');
}
}
</script>
<!-- ROW 3: Files + Linked Emails --> <!-- ROW 3: Files + Linked Emails -->
<div class="row mb-3"> <div class="row mb-3">
<!-- Files --> <!-- Files -->
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div class="card h-100" data-module="files" data-has-content="unknown"> <div class="card h-100" data-module="files" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">📁 Filer & Dokumenter</h6> <h6 class="mb-0" style="color: var(--accent);">📁 Filer & Dokumenter</h6>
<input type="file" id="fileInput" multiple style="display: none;" onchange="handleFileUpload(this.files)"> <input type="file" id="fileInput" multiple style="display: none;" onchange="handleFileUpload(this.files)">
@ -756,19 +863,19 @@
<!-- Drag & Drop Zone --> <!-- Drag & Drop Zone -->
<div class="card-body p-0 d-flex flex-column" id="fileDropZone"> <div class="card-body p-0 d-flex flex-column" id="fileDropZone">
<div class="p-4 text-center border-bottom bg-light" id="fileDropMessage" style="cursor: pointer;" onclick="document.getElementById('fileInput').click()"> <div class="p-4 text-center border-bottom bg-light" id="fileDropMessage" style="cursor: pointer;" onclick="document.getElementById('fileInput').click()">
<i class="bi bi-cloud-arrow-up display-6 text-muted"></i> <i class="bi bi-cloud-arrow-up display-6 text-muted"></i>
<p class="small text-muted mb-0 mt-2">Træk filer hertil for at uploade</p> <p class="small text-muted mb-0 mt-2">Træk filer hertil for at uploade</p>
</div> </div>
<div class="list-group list-group-flush flex-grow-1 overflow-auto" id="files-list" style="max-height: 300px;"> <div class="list-group list-group-flush flex-grow-1 overflow-auto" id="files-list" style="max-height: 300px;">
<div class="p-3 text-center text-muted">Ingen filer fundet...</div> <div class="p-3 text-center text-muted">Ingen filer fundet...</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Linked Emails --> <!-- Linked Emails -->
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div class="card h-100" data-module="emails" data-has-content="unknown"> <div class="card h-100" data-module="emails" data-has-content="unknown">
<div class="card-header"> <div class="card-header">
<h6 class="mb-0" style="color: var(--accent);">📧 Linkede Emails</h6> <h6 class="mb-0" style="color: var(--accent);">📧 Linkede Emails</h6>
</div> </div>
@ -785,12 +892,10 @@
<div class="p-3 text-center text-muted">Ingen emails linket...</div> <div class="p-3 text-center text-muted">Ingen emails linket...</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- File Preview Modal --> <!-- File Preview Modal -->
<div class="modal fade" id="filePreviewModal" tabindex="-1" data-bs-backdrop="static"> <div class="modal fade" id="filePreviewModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-xl modal-dialog-centered"> <div class="modal-dialog modal-xl modal-dialog-centered">
@ -1100,6 +1205,7 @@
loadCaseHardware(); loadCaseHardware();
loadCaseLocations(); loadCaseLocations();
loadCaseWiki(); loadCaseWiki();
loadTodoSteps();
const wikiSearchInput = document.getElementById('wikiSearchInput'); const wikiSearchInput = document.getElementById('wikiSearchInput');
if (wikiSearchInput) { if (wikiSearchInput) {
@ -1111,6 +1217,11 @@
}); });
} }
const todoForm = document.getElementById('todoStepForm');
if (todoForm) {
todoForm.addEventListener('submit', createTodoStep);
}
// Focus on title when create modal opens // Focus on title when create modal opens
const createModalEl = document.getElementById('createRelatedCaseModal'); const createModalEl = document.getElementById('createRelatedCaseModal');
if (createModalEl) { if (createModalEl) {
@ -1153,8 +1264,8 @@
document.getElementById('contactInfoTitle').textContent = currentContactInfo.title || '-'; document.getElementById('contactInfoTitle').textContent = currentContactInfo.title || '-';
document.getElementById('contactInfoCompany').textContent = currentContactInfo.company || '-'; document.getElementById('contactInfoCompany').textContent = currentContactInfo.company || '-';
document.getElementById('contactInfoEmail').textContent = currentContactInfo.email || '-'; document.getElementById('contactInfoEmail').textContent = currentContactInfo.email || '-';
document.getElementById('contactInfoPhone').textContent = currentContactInfo.phone || '-'; document.getElementById('contactInfoPhone').innerHTML = renderCasePhone(currentContactInfo.phone);
document.getElementById('contactInfoMobile').textContent = currentContactInfo.mobile || '-'; document.getElementById('contactInfoMobile').innerHTML = renderCaseMobile(currentContactInfo.mobile, currentContactInfo.name);
document.getElementById('contactInfoRole').textContent = currentContactInfo.role || '-'; document.getElementById('contactInfoRole').textContent = currentContactInfo.role || '-';
const primaryBadge = document.getElementById('contactInfoPrimary'); const primaryBadge = document.getElementById('contactInfoPrimary');
@ -1167,6 +1278,23 @@
contactInfoModal.show(); contactInfoModal.show();
} }
function renderCasePhone(number) {
const clean = String(number || '').trim();
if (!clean || clean === '-') return '-';
return `<a href="tel:${escapeHtml(clean)}" style="color: var(--accent);">${escapeHtml(clean)}</a>`;
}
function renderCaseMobile(number, name) {
const clean = String(number || '').trim();
if (!clean || clean === '-') return '-';
return `
<div class="d-flex align-items-center gap-2 flex-wrap">
<a href="tel:${escapeHtml(clean)}" style="color: var(--accent);">${escapeHtml(clean)}</a>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt('${escapeHtml(clean)}', '${escapeHtml(name || '')}', ${currentContactInfo?.id || 'null'})">SMS</button>
</div>
`;
}
function openContactRoleFromInfo() { function openContactRoleFromInfo() {
if (!currentContactInfo) return; if (!currentContactInfo) return;
contactInfoModal.hide(); contactInfoModal.hide();
@ -1627,6 +1755,7 @@
if (hardware.length === 0) { if (hardware.length === 0) {
container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen hardware tilknyttet</div>'; container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen hardware tilknyttet</div>';
setModuleContentState('hardware', false);
return; return;
} }
@ -1650,9 +1779,11 @@
</div> </div>
`).join('')} `).join('')}
`; `;
setModuleContentState('hardware', true);
} catch (e) { } catch (e) {
console.error("Error loading hardware:", e); console.error("Error loading hardware:", e);
document.getElementById('hardware-list').innerHTML = '<div class="p-3 text-danger text-center">Fejl ved hentning</div>'; document.getElementById('hardware-list').innerHTML = '<div class="p-3 text-danger text-center">Fejl ved hentning</div>';
setModuleContentState('hardware', true);
} }
} }
@ -1693,6 +1824,7 @@
if (locations.length === 0) { if (locations.length === 0) {
container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen lokationer tilknyttet</div>'; container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen lokationer tilknyttet</div>';
setModuleContentState('locations', false);
return; return;
} }
@ -1715,9 +1847,11 @@
</div> </div>
`).join('')} `).join('')}
`; `;
setModuleContentState('locations', true);
} catch (e) { } catch (e) {
console.error("Error loading locations:", e); console.error("Error loading locations:", e);
document.getElementById('locations-list').innerHTML = '<div class="p-3 text-danger text-center">Fejl ved hentning</div>'; document.getElementById('locations-list').innerHTML = '<div class="p-3 text-danger text-center">Fejl ved hentning</div>';
setModuleContentState('locations', true);
} }
} }
@ -1728,6 +1862,7 @@
if (!wikiCustomerId) { if (!wikiCustomerId) {
container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen kunde tilknyttet</div>'; container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen kunde tilknyttet</div>';
setModuleContentState('wiki', false);
return; return;
} }
@ -1749,6 +1884,7 @@
const payload = await res.json(); const payload = await res.json();
if (payload.errors && payload.errors.length) { if (payload.errors && payload.errors.length) {
container.innerHTML = '<div class="p-3 text-center text-danger small">Wiki API fejlede</div>'; container.innerHTML = '<div class="p-3 text-center text-danger small">Wiki API fejlede</div>';
setModuleContentState('wiki', true);
return; return;
} }
@ -1756,6 +1892,7 @@
if (!pages.length) { if (!pages.length) {
container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen sider fundet</div>'; container.innerHTML = '<div class="p-3 text-center text-muted small">Ingen sider fundet</div>';
setModuleContentState('wiki', false);
return; return;
} }
@ -1770,9 +1907,185 @@
</a> </a>
`; `;
}).join(''); }).join('');
setModuleContentState('wiki', true);
} catch (e) { } catch (e) {
console.error('Error loading Wiki:', e); console.error('Error loading Wiki:', e);
container.innerHTML = '<div class="p-3 text-center text-danger small">Fejl ved hentning</div>'; container.innerHTML = '<div class="p-3 text-center text-danger small">Fejl ved hentning</div>';
setModuleContentState('wiki', true);
}
}
let todoUserId = null;
function getTodoUserId() {
if (todoUserId) return todoUserId;
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
todoUserId = payload.sub || payload.user_id;
return todoUserId;
} catch (e) {
console.warn('Could not decode token for todo user_id');
}
}
const metaTag = document.querySelector('meta[name="user-id"]');
if (metaTag) {
todoUserId = metaTag.getAttribute('content');
}
return todoUserId;
}
function formatTodoDate(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleDateString('da-DK');
}
function formatTodoDateTime(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleString('da-DK', { hour: '2-digit', minute: '2-digit', hour12: false });
}
function renderTodoSteps(steps) {
const list = document.getElementById('todo-steps-list');
if (!list) return;
if (!steps || steps.length === 0) {
list.innerHTML = '<div class="p-3 text-center text-muted">Ingen steps endnu</div>';
setModuleContentState('todo-steps', false);
return;
}
list.innerHTML = steps.map(step => {
const createdBy = step.created_by_name || 'Ukendt';
const completedBy = step.completed_by_name || 'Ukendt';
const dueLabel = step.due_date ? formatTodoDate(step.due_date) : '-';
const createdLabel = formatTodoDateTime(step.created_at);
const completedLabel = step.completed_at ? formatTodoDateTime(step.completed_at) : null;
const statusBadge = step.is_done
? '<span class="badge bg-success">Ferdig</span>'
: '<span class="badge bg-warning text-dark">Aaben</span>';
const toggleLabel = step.is_done ? 'Genaabn' : 'Ferdig';
const toggleClass = step.is_done ? 'btn-outline-secondary' : 'btn-outline-success';
return `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="flex-grow-1">
<div class="fw-semibold">${step.title}</div>
${step.description ? `<div class="small text-muted">${step.description}</div>` : ''}
<div class="small text-muted">Forfald: ${dueLabel}</div>
<div class="small text-muted">Oprettet af ${createdBy} · ${createdLabel}</div>
${step.is_done && completedLabel ? `<div class="small text-muted">Ferdiggjort af ${completedBy} · ${completedLabel}</div>` : ''}
</div>
<div class="d-flex flex-column align-items-end gap-2">
${statusBadge}
<div class="btn-group btn-group-sm" role="group">
<button class="btn ${toggleClass}" onclick="toggleTodoStep(${step.id}, ${step.is_done ? 'false' : 'true'})">${toggleLabel}</button>
<button class="btn btn-outline-danger" onclick="deleteTodoStep(${step.id})">Slet</button>
</div>
</div>
</div>
</div>
`;
}).join('');
setModuleContentState('todo-steps', true);
}
async function loadTodoSteps() {
const list = document.getElementById('todo-steps-list');
if (!list) return;
list.innerHTML = '<div class="p-3 text-center text-muted">Henter steps...</div>';
try {
const res = await fetch(`/api/v1/sag/${caseId}/todo-steps`);
if (!res.ok) throw new Error('Kunne ikke hente steps');
const steps = await res.json();
renderTodoSteps(steps || []);
} catch (e) {
console.error('Error loading todo steps:', e);
list.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning</div>';
setModuleContentState('todo-steps', true);
}
}
async function createTodoStep(event) {
event.preventDefault();
const titleInput = document.getElementById('todoStepTitle');
const descInput = document.getElementById('todoStepDescription');
const dueInput = document.getElementById('todoStepDueDate');
if (!titleInput) return;
const title = titleInput.value.trim();
if (!title) {
alert('Titel er paakraevet');
return;
}
const userId = getTodoUserId();
if (!userId) {
alert('Mangler bruger-id. Log ind igen.');
return;
}
try {
const res = await fetch(`/api/v1/sag/${caseId}/todo-steps?user_id=${userId}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description: descInput.value.trim() || null,
due_date: dueInput.value || null
})
}
);
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Kunne ikke oprette step');
}
titleInput.value = '';
descInput.value = '';
dueInput.value = '';
await loadTodoSteps();
} catch (e) {
alert('Fejl: ' + e.message);
}
}
async function toggleTodoStep(stepId, isDone) {
const userId = getTodoUserId();
if (!userId) {
alert('Mangler bruger-id. Log ind igen.');
return;
}
try {
const res = await fetch(`/api/v1/sag/todo-steps/${stepId}?user_id=${userId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_done: isDone })
}
);
if (!res.ok) throw new Error('Kunne ikke opdatere step');
await loadTodoSteps();
} catch (e) {
alert('Fejl: ' + e.message);
}
}
async function deleteTodoStep(stepId) {
if (!confirm('Slet dette step?')) return;
try {
const res = await fetch(`/api/v1/sag/todo-steps/${stepId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Kunne ikke slette step');
await loadTodoSteps();
} catch (e) {
alert('Fejl: ' + e.message);
} }
} }
@ -1971,6 +2284,25 @@
</div> </div>
</div> </div>
<div class="card h-100 d-flex flex-column right-module-card" data-module="todo-steps" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0" style="color: var(--accent);">✅ Todo steps</h6>
</div>
<div class="card-body p-0 d-flex flex-column" style="max-height: 260px;">
<form id="todoStepForm" class="p-3 border-bottom">
<input type="text" class="form-control form-control-sm mb-2" id="todoStepTitle" placeholder="Step titel" required>
<textarea class="form-control form-control-sm mb-2" id="todoStepDescription" rows="2" placeholder="Kort note (valgfri)"></textarea>
<div class="d-flex gap-2">
<input type="date" class="form-control form-control-sm" id="todoStepDueDate">
<button class="btn btn-sm btn-outline-primary" type="submit">Tilfoej</button>
</div>
</form>
<div class="list-group list-group-flush flex-grow-1 overflow-auto" id="todo-steps-list">
<div class="p-3 text-center text-muted">Ingen steps endnu</div>
</div>
</div>
</div>
<div class="card h-100 d-flex flex-column right-module-card" data-module="time" data-has-content="{{ 'true' if time_entries else 'false' }}"> <div class="card h-100 d-flex flex-column right-module-card" data-module="time" data-has-content="{{ 'true' if time_entries else 'false' }}">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-clock-history me-2"></i>Tid & Fakturering</h6> <h6 class="mb-0 text-primary"><i class="bi bi-clock-history me-2"></i>Tid & Fakturering</h6>
@ -2481,6 +2813,28 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card mb-3" id="caseCalendarCard" data-module="calendar" data-has-content="unknown">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-calendar3 me-2"></i>Kalenderaftaler</h6>
<button class="btn btn-sm btn-outline-primary" onclick="openCreateReminderModal('meeting')">
<i class="bi bi-plus-lg me-1"></i>Opret aftale
</button>
</div>
<div class="card-body">
<div class="mb-3">
<div class="small text-muted mb-2">Denne sag</div>
<div class="list-group" id="caseCalendarCurrent">
<div class="text-muted small">Indlæser aftaler...</div>
</div>
</div>
<div>
<div class="small text-muted mb-2">Børnesager</div>
<div id="caseCalendarChildren">
<div class="text-muted small">Indlæser børnesager...</div>
</div>
</div>
</div>
</div>
</div> </div>
<div class="col-lg-4"> <div class="col-lg-4">
<div class="card mb-3"> <div class="card mb-3">
@ -2525,6 +2879,16 @@
<option value="urgent">Kritisk</option> <option value="urgent">Kritisk</option>
</select> </select>
</div> </div>
<div class="col-md-6">
<label class="form-label">Aftaletype</label>
<select class="form-select" id="rem_event_type">
<option value="reminder" selected>Reminder</option>
<option value="meeting">Moede</option>
<option value="technician_visit">Teknikerbesoeg</option>
<option value="obs">OBS</option>
<option value="deadline">Deadline</option>
</select>
</div>
<div class="col-12"> <div class="col-12">
<label class="form-label">Besked</label> <label class="form-label">Besked</label>
<textarea class="form-control" id="rem_message" rows="3"></textarea> <textarea class="form-control" id="rem_message" rows="3"></textarea>
@ -2729,6 +3093,8 @@
saleItemsCache = data.sale_items || []; saleItemsCache = data.sale_items || [];
renderSaleItems(saleItemsCache); renderSaleItems(saleItemsCache);
renderTimeEntries(data.time_entries || []); renderTimeEntries(data.time_entries || []);
const hasSalesData = (data.sale_items || []).length > 0 || (data.time_entries || []).length > 0;
setModuleContentState('sales', hasSalesData);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
const saleBody = document.getElementById('saleItemsBody'); const saleBody = document.getElementById('saleItemsBody');
@ -2739,6 +3105,7 @@
if (timeBody) { if (timeBody) {
timeBody.innerHTML = '<tr><td colspan="3" class="text-center py-4 text-muted">Kunne ikke hente data</td></tr>'; timeBody.innerHTML = '<tr><td colspan="3" class="text-center py-4 text-muted">Kunne ikke hente data</td></tr>';
} }
setModuleContentState('sales', true);
} }
} }
@ -2927,7 +3294,7 @@
if (!value) return '-'; if (!value) return '-';
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-'; if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleString('da-DK'); return date.toLocaleString('da-DK', { hour12: false });
} }
function updateReminderTriggerFields() { function updateReminderTriggerFields() {
@ -2954,7 +3321,7 @@
domWrap.classList.toggle('d-none', recurrenceType !== 'monthly'); domWrap.classList.toggle('d-none', recurrenceType !== 'monthly');
} }
function openCreateReminderModal() { function openCreateReminderModal(defaultEventType) {
reminderUserId = getReminderUserId(); reminderUserId = getReminderUserId();
const warning = document.getElementById('rem_user_warning'); const warning = document.getElementById('rem_user_warning');
if (warning) warning.classList.toggle('d-none', !!reminderUserId); if (warning) warning.classList.toggle('d-none', !!reminderUserId);
@ -2963,6 +3330,7 @@
if (form) form.reset(); if (form) form.reset();
document.getElementById('rem_notify_frontend').checked = true; document.getElementById('rem_notify_frontend').checked = true;
document.getElementById('rem_priority').value = 'normal'; document.getElementById('rem_priority').value = 'normal';
document.getElementById('rem_event_type').value = defaultEventType || 'reminder';
document.getElementById('rem_trigger_type').value = 'time_based'; document.getElementById('rem_trigger_type').value = 'time_based';
document.getElementById('rem_recurrence_type').value = 'once'; document.getElementById('rem_recurrence_type').value = 'once';
updateReminderTriggerFields(); updateReminderTriggerFields();
@ -2977,6 +3345,7 @@
if (!reminderUserId) { if (!reminderUserId) {
list.innerHTML = '<div class="p-4 text-center text-muted">Kunne ikke finde bruger-id.</div>'; list.innerHTML = '<div class="p-4 text-center text-muted">Kunne ikke finde bruger-id.</div>';
setModuleContentState('reminders', true);
return; return;
} }
@ -2990,6 +3359,7 @@
} catch (e) { } catch (e) {
console.error(e); console.error(e);
list.innerHTML = '<div class="p-4 text-center text-danger">Fejl ved hentning af reminders</div>'; list.innerHTML = '<div class="p-4 text-center text-danger">Fejl ved hentning af reminders</div>';
setModuleContentState('reminders', true);
} }
} }
@ -2998,6 +3368,7 @@
if (!list) return; if (!list) return;
if (!reminders || reminders.length === 0) { if (!reminders || reminders.length === 0) {
list.innerHTML = '<div class="p-4 text-center text-muted">Ingen reminders endnu.</div>'; list.innerHTML = '<div class="p-4 text-center text-muted">Ingen reminders endnu.</div>';
setModuleContentState('reminders', false);
return; return;
} }
@ -3007,6 +3378,14 @@
deadline_approaching: 'Deadline' deadline_approaching: 'Deadline'
}; };
const eventTypeLabels = {
reminder: 'Reminder',
meeting: 'Moede',
technician_visit: 'Teknikerbesoeg',
obs: 'OBS',
deadline: 'Deadline'
};
const recurrenceLabels = { const recurrenceLabels = {
once: 'Én gang', once: 'Én gang',
daily: 'Dagligt', daily: 'Dagligt',
@ -3029,7 +3408,7 @@
<div class="fw-bold">${reminder.title}</div> <div class="fw-bold">${reminder.title}</div>
<div class="text-muted small">${reminder.message || '-'} </div> <div class="text-muted small">${reminder.message || '-'} </div>
<div class="small text-muted mt-1"> <div class="small text-muted mt-1">
Trigger: ${triggerLabels[reminder.trigger_type] || reminder.trigger_type} · Gentagelse: ${recurrenceLabels[reminder.recurrence_type] || reminder.recurrence_type} Type: ${eventTypeLabels[reminder.event_type] || reminder.event_type || 'Reminder'} · Trigger: ${triggerLabels[reminder.trigger_type] || reminder.trigger_type} · Gentagelse: ${recurrenceLabels[reminder.recurrence_type] || reminder.recurrence_type}
</div> </div>
<div class="small text-muted">Næste: ${nextCheck} · Oprettet: ${createdAt}</div> <div class="small text-muted">Næste: ${nextCheck} · Oprettet: ${createdAt}</div>
</div> </div>
@ -3043,6 +3422,7 @@
</div> </div>
`; `;
}).join(''); }).join('');
setModuleContentState('reminders', true);
} }
async function saveReminder() { async function saveReminder() {
@ -3055,6 +3435,7 @@
const title = document.getElementById('rem_title').value.trim(); const title = document.getElementById('rem_title').value.trim();
const message = document.getElementById('rem_message').value.trim(); const message = document.getElementById('rem_message').value.trim();
const priority = document.getElementById('rem_priority').value; const priority = document.getElementById('rem_priority').value;
const eventType = document.getElementById('rem_event_type').value;
const triggerType = document.getElementById('rem_trigger_type').value; const triggerType = document.getElementById('rem_trigger_type').value;
const scheduledAtValue = document.getElementById('rem_scheduled_at').value; const scheduledAtValue = document.getElementById('rem_scheduled_at').value;
const targetStatus = document.getElementById('rem_target_status').value; const targetStatus = document.getElementById('rem_target_status').value;
@ -3092,6 +3473,7 @@
title, title,
message: message || null, message: message || null,
priority, priority,
event_type: eventType,
trigger_type: triggerType, trigger_type: triggerType,
trigger_config: triggerConfig, trigger_config: triggerConfig,
recipient_user_ids: [Number(reminderUserId)], recipient_user_ids: [Number(reminderUserId)],
@ -3118,6 +3500,7 @@
} }
bootstrap.Modal.getInstance(document.getElementById('createReminderModal')).hide(); bootstrap.Modal.getInstance(document.getElementById('createReminderModal')).hide();
await loadReminders(); await loadReminders();
await loadCaseCalendar();
} catch (e) { } catch (e) {
alert('Fejl: ' + e.message); alert('Fejl: ' + e.message);
} }
@ -3129,15 +3512,93 @@
const res = await fetch(`/api/v1/sag/reminders/${reminderId}`, { method: 'DELETE' }); const res = await fetch(`/api/v1/sag/reminders/${reminderId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Kunne ikke slette reminder'); if (!res.ok) throw new Error('Kunne ikke slette reminder');
await loadReminders(); await loadReminders();
await loadCaseCalendar();
} catch (e) { } catch (e) {
alert('Fejl: ' + e.message); alert('Fejl: ' + e.message);
} }
} }
function formatCalendarEvent(event) {
const dateLabel = formatReminderDate(event.start);
const typeLabelMap = {
reminder: 'Reminder',
meeting: 'Moede',
technician_visit: 'Teknikerbesoeg',
obs: 'OBS',
deadline: 'Deadline',
deferred: 'Deferred'
};
const typeLabel = typeLabelMap[event.event_kind] || event.event_kind || 'Reminder';
return `
<a href="${event.url}" class="list-group-item list-group-item-action">
<div class="d-flex justify-content-between">
<div>
<div class="fw-semibold">${event.title || 'Aftale'}</div>
<div class="text-muted small">${typeLabel} · ${dateLabel}</div>
</div>
</div>
</a>
`;
}
async function loadCaseCalendar() {
const currentList = document.getElementById('caseCalendarCurrent');
const childrenList = document.getElementById('caseCalendarChildren');
if (!currentList || !childrenList) return;
currentList.innerHTML = '<div class="text-muted small">Indlæser aftaler...</div>';
childrenList.innerHTML = '<div class="text-muted small">Indlæser børnesager...</div>';
try {
const res = await fetch(`/api/v1/sag/${caseIds}/calendar-events?include_children=true`);
if (!res.ok) throw new Error('Kunne ikke hente kalenderaftaler');
const data = await res.json();
const currentEvents = data.current || [];
const childGroups = data.children || [];
const childCount = childGroups.reduce((sum, child) => sum + (child.events || []).length, 0);
const hasAnyEvents = currentEvents.length > 0 || childCount > 0;
if (!currentEvents.length) {
currentList.innerHTML = '<div class="text-muted small">Ingen aftaler for denne sag.</div>';
} else {
currentList.innerHTML = currentEvents
.map(formatCalendarEvent)
.join('');
}
if (!childGroups.length) {
childrenList.innerHTML = '<div class="text-muted small">Ingen børnesager.</div>';
} else {
childrenList.innerHTML = childGroups.map(child => {
const eventsHtml = (child.events || []).length
? child.events.map(formatCalendarEvent).join('')
: '<div class="text-muted small">Ingen aftaler.</div>';
return `
<div class="mb-3">
<div class="fw-semibold mb-1">${child.case_title}</div>
<div class="list-group">
${eventsHtml}
</div>
</div>
`;
}).join('');
}
setModuleContentState('calendar', hasAnyEvents);
} catch (e) {
console.error(e);
currentList.innerHTML = '<div class="text-danger small">Fejl ved hentning af aftaler.</div>';
childrenList.innerHTML = '';
setModuleContentState('calendar', true);
}
}
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
updateReminderTriggerFields(); updateReminderTriggerFields();
updateReminderRecurrenceFields(); updateReminderRecurrenceFields();
loadReminders(); loadReminders();
loadCaseCalendar();
}); });
</script> </script>
@ -3619,9 +4080,12 @@
<label class="small text-muted mb-1">Mobil</label> <label class="small text-muted mb-1">Mobil</label>
<div> <div>
{% if hovedkontakt.mobile %} {% if hovedkontakt.mobile %}
<a href="tel:{{ hovedkontakt.mobile }}" style="color: var(--accent);"> <div class="d-flex align-items-center gap-2 flex-wrap">
<i class="bi bi-phone me-1"></i>{{ hovedkontakt.mobile }} <a href="tel:{{ hovedkontakt.mobile }}" style="color: var(--accent);">
</a> <i class="bi bi-phone me-1"></i>{{ hovedkontakt.mobile }}
</a>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt({{ hovedkontakt.mobile|tojson }}, {{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name)|tojson }}, {{ hovedkontakt.id|default('null') }})">SMS</button>
</div>
{% else %} {% else %}
<span class="text-muted">Ingen mobil</span> <span class="text-muted">Ingen mobil</span>
{% endif %} {% endif %}
@ -4162,26 +4626,35 @@
// ========================================== // ==========================================
let modulePrefs = {}; let modulePrefs = {};
let currentCaseView = 'Sag-detalje';
function moduleHasContent(el) { function moduleHasContent(el) {
const attr = el.getAttribute('data-has-content'); const attr = el.getAttribute('data-has-content');
if (attr === 'true') return true; if (attr === 'true') return true;
if (attr === 'false') return false; if (attr === 'false') return false;
if (attr === 'unknown') return true; if (attr === 'unknown') return false;
if (el.querySelector('.person-card')) return true; if (el.querySelector('.person-card')) return true;
if (el.querySelector('.list-group-item')) return true; if (el.querySelector('.list-group-item')) return true;
return true; return true;
} }
function setModuleContentState(moduleKey, hasContent) {
const el = document.querySelector(`[data-module="${moduleKey}"]`);
if (!el) return;
el.setAttribute('data-has-content', hasContent ? 'true' : 'false');
applyViewLayout(currentCaseView);
}
function applyViewLayout(viewName) { function applyViewLayout(viewName) {
if (!viewName) return; if (!viewName) return;
currentCaseView = viewName;
document.body.setAttribute('data-case-view', viewName); document.body.setAttribute('data-case-view', viewName);
const viewDefaults = { const viewDefaults = {
'Pipeline': ['relations', 'sales', 'time'], 'Pipeline': ['relations', 'sales', 'time'],
'Kundevisning': ['customers', 'contacts', 'locations', 'wiki'], 'Kundevisning': ['customers', 'contacts', 'locations', 'wiki'],
'Sag-detalje': ['hardware', 'locations', 'contacts', 'customers', 'wiki', 'relations', 'files', 'emails', 'solution', 'time', 'sales', 'reminders'] 'Sag-detalje': ['hardware', 'locations', 'contacts', 'customers', 'wiki', 'todo-steps', 'relations', 'files', 'emails', 'solution', 'time', 'sales', 'reminders']
}; };
const standardModules = viewDefaults[viewName] || []; const standardModules = viewDefaults[viewName] || [];
@ -4203,7 +4676,7 @@
return; return;
} }
if (!standardModules.includes(moduleName) && !hasContent) { if (!hasContent) {
el.classList.add('d-none'); el.classList.add('d-none');
if (tabButton) tabButton.classList.add('d-none'); if (tabButton) tabButton.classList.add('d-none');
} else { } else {
@ -4320,10 +4793,12 @@
renderFiles(files); renderFiles(files);
} else { } else {
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af filer</div>'; container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af filer</div>';
setModuleContentState('files', true);
} }
} catch(e) { } catch(e) {
console.error(e); console.error(e);
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af filer</div>'; container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af filer</div>';
setModuleContentState('files', true);
} }
} }
@ -4331,8 +4806,10 @@
const container = document.getElementById('files-list'); const container = document.getElementById('files-list');
if(!files || files.length === 0) { if(!files || files.length === 0) {
container.innerHTML = '<div class="p-3 text-center text-muted">Ingen filer fundet...</div>'; container.innerHTML = '<div class="p-3 text-center text-muted">Ingen filer fundet...</div>';
setModuleContentState('files', false);
return; return;
} }
setModuleContentState('files', true);
container.innerHTML = files.map(f => { container.innerHTML = files.map(f => {
const size = (f.size_bytes / 1024 / 1024).toFixed(2) + ' MB'; const size = (f.size_bytes / 1024 / 1024).toFixed(2) + ' MB';
return ` return `
@ -4503,16 +4980,25 @@
if(res.ok) { if(res.ok) {
const emails = await res.json(); const emails = await res.json();
renderLinkedEmails(emails); renderLinkedEmails(emails);
} else {
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af emails</div>';
setModuleContentState('emails', true);
} }
} catch(e) { console.error(e); } } catch(e) {
console.error(e);
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af emails</div>';
setModuleContentState('emails', true);
}
} }
function renderLinkedEmails(emails) { function renderLinkedEmails(emails) {
const container = document.getElementById('linked-emails-list'); const container = document.getElementById('linked-emails-list');
if(!emails || emails.length === 0) { if(!emails || emails.length === 0) {
container.innerHTML = '<div class="p-3 text-center text-muted">Ingen linkede emails...</div>'; container.innerHTML = '<div class="p-3 text-center text-muted">Ingen linkede emails...</div>';
setModuleContentState('emails', false);
return; return;
} }
setModuleContentState('emails', true);
container.innerHTML = emails.map(e => ` container.innerHTML = emails.map(e => `
<div class="list-group-item"> <div class="list-group-item">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
@ -4973,6 +5459,7 @@
const res = await fetch(`/api/v1/subscriptions/by-sag/${subscriptionCaseId}`); const res = await fetch(`/api/v1/subscriptions/by-sag/${subscriptionCaseId}`);
if (res.status === 404) { if (res.status === 404) {
showSubscriptionCreateForm(); showSubscriptionCreateForm();
setModuleContentState('subscription', false);
return; return;
} }
if (!res.ok) { if (!res.ok) {
@ -4980,9 +5467,11 @@
} }
const subscription = await res.json(); const subscription = await res.json();
renderSubscription(subscription); renderSubscription(subscription);
setModuleContentState('subscription', true);
} catch (e) { } catch (e) {
console.error('Error loading subscription:', e); console.error('Error loading subscription:', e);
showSubscriptionCreateForm(); showSubscriptionCreateForm();
setModuleContentState('subscription', true);
} }
} }

View File

@ -0,0 +1 @@
"""Telefoni module package."""

View File

@ -0,0 +1 @@
"""Telefoni backend package."""

View File

@ -0,0 +1,667 @@
import json
import logging
import base64
from datetime import datetime
from typing import Optional
from urllib.error import URLError, HTTPError
from urllib.request import Request as UrlRequest, urlopen
from urllib.parse import urlsplit, urlunsplit
from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
from app.core.auth_service import AuthService
from app.core.auth_dependencies import require_permission
from app.core.config import settings
from app.core.database import execute_query, execute_query_single
from app.services.sms_service import SmsService
from .schemas import TelefoniCallLinkUpdate, TelefoniUserMappingUpdate, TelefoniClickToCallRequest, SmsSendRequest
from .service import TelefoniService
from .utils import (
digits_only,
extract_extension,
ip_in_whitelist,
is_outbound_call,
normalize_e164,
phone_suffix_8,
)
from .websocket import manager
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/sms/send")
async def send_sms(payload: SmsSendRequest, request: Request):
user_id = getattr(request.state, "user_id", None)
logger.info("📨 SMS send requested by user_id=%s", user_id)
contact_id = payload.contact_id
if not contact_id:
suffix8 = phone_suffix_8(payload.to)
contact = TelefoniService.find_contact_by_phone_suffix(suffix8)
contact_id = int(contact["id"]) if contact and contact.get("id") else None
if not contact_id:
raise HTTPException(status_code=400, detail="SMS skal knyttes til en kontakt")
try:
result = SmsService.send_sms(payload.to, payload.message, payload.sender)
execute_query(
"""
INSERT INTO sms_messages (kontakt_id, bruger_id, recipient, sender, message, status, provider_response)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb)
""",
(
contact_id,
user_id,
result.get("recipient") or payload.to,
payload.sender or settings.SMS_SENDER,
payload.message,
"sent",
json.dumps(result.get("result") or {}),
),
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
execute_query(
"""
INSERT INTO sms_messages (kontakt_id, bruger_id, recipient, sender, message, status, provider_response)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb)
""",
(
contact_id,
user_id,
payload.to,
payload.sender or settings.SMS_SENDER,
payload.message,
"failed",
json.dumps({"error": str(e)}),
),
)
raise HTTPException(status_code=502, detail=str(e))
except Exception as e:
logger.error("❌ SMS send failed: %s", e)
raise HTTPException(status_code=500, detail="SMS send failed")
return {
"status": "ok",
**result,
}
def _get_client_ip(request: Request) -> str:
cf_ip = request.headers.get("cf-connecting-ip")
if cf_ip:
return cf_ip.strip()
x_real_ip = request.headers.get("x-real-ip")
if x_real_ip:
return x_real_ip.strip()
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else ""
def _validate_yealink_request(request: Request, token: Optional[str]) -> None:
env_secret = (getattr(settings, "TELEFONI_SHARED_SECRET", "") or "").strip()
db_secret = (_get_setting_value("telefoni_shared_secret", "") or "").strip()
accepted_tokens = {s for s in (env_secret, db_secret) if s}
whitelist = (getattr(settings, "TELEFONI_IP_WHITELIST", "") or "").strip()
if not accepted_tokens and not whitelist:
logger.error("❌ Telefoni callbacks are not secured (no TELEFONI_SHARED_SECRET or TELEFONI_IP_WHITELIST set)")
raise HTTPException(status_code=403, detail="Telefoni callbacks not configured")
if token and token.strip() in accepted_tokens:
return
if whitelist:
client_ip = _get_client_ip(request)
if ip_in_whitelist(client_ip, whitelist):
return
raise HTTPException(status_code=403, detail="Forbidden")
@router.get("/telefoni/established")
async def yealink_established(
request: Request,
callid: Optional[str] = Query(None),
call_id: Optional[str] = Query(None),
caller: Optional[str] = Query(None),
callee: Optional[str] = Query(None),
remote: Optional[str] = Query(None),
local: Optional[str] = Query(None),
active_user: Optional[str] = Query(None),
called_number: Optional[str] = Query(None),
token: Optional[str] = Query(None),
):
"""Yealink Action URL: Established"""
_validate_yealink_request(request, token)
resolved_callid = (callid or call_id or "").strip()
if not resolved_callid:
raise HTTPException(status_code=422, detail="Missing callid/call_id")
def _sanitize(value: Optional[str]) -> Optional[str]:
if value is None:
return None
v = value.strip()
if not v:
return None
if v.startswith("$"):
return None
return v
def _is_external_number(value: Optional[str]) -> bool:
d = digits_only(value)
return len(d) >= 8
def _is_internal_number(value: Optional[str], local_ext: Optional[str]) -> bool:
d = digits_only(value)
if not d:
return False
local_d = digits_only(local_ext)
if local_d and d.endswith(local_d):
return True
return len(d) <= 6
local_value = _sanitize(local) or _sanitize(active_user)
caller_value = _sanitize(caller) or _sanitize(remote)
callee_value = _sanitize(callee)
called_number_value = _sanitize(called_number)
local_extension = extract_extension(local_value) or local_value
is_outbound = False
if called_number_value and _is_external_number(called_number_value):
is_outbound = True
elif caller_value and local_extension:
if not _is_internal_number(caller_value, local_extension):
is_outbound = is_outbound_call(caller_value, local_extension)
direction = "outbound" if is_outbound else "inbound"
candidates = [
callee_value,
called_number_value,
_sanitize(remote),
caller_value,
] if direction == "outbound" else [
caller_value,
_sanitize(remote),
callee_value,
called_number_value,
]
ekstern_raw = None
for candidate in candidates:
if candidate and _is_external_number(candidate):
ekstern_raw = candidate
break
if not ekstern_raw:
for candidate in candidates:
if candidate:
ekstern_raw = candidate
break
ekstern_e164 = normalize_e164(ekstern_raw)
ekstern_value = ekstern_e164 or ((ekstern_raw or "").strip() or None)
suffix8 = phone_suffix_8(ekstern_raw)
user_id = TelefoniService.find_user_by_extension(local_extension)
kontakt = TelefoniService.find_contact_by_phone_suffix(suffix8)
kontakt_id = kontakt.get("id") if kontakt else None
payload = {
"callid": resolved_callid,
"call_id": call_id,
"caller": caller_value,
"callee": callee_value,
"remote": remote,
"local": local_value,
"active_user": active_user,
"called_number": called_number_value,
"direction": direction,
"client_ip": _get_client_ip(request),
"user_agent": request.headers.get("user-agent"),
"received_at": datetime.utcnow().isoformat(),
}
row = TelefoniService.upsert_call(
callid=resolved_callid,
user_id=user_id,
direction=direction,
ekstern_nummer=ekstern_value,
intern_extension=(local_extension or "")[:16] or None,
kontakt_id=kontakt_id,
raw_payload=json.dumps(payload),
started_at=datetime.utcnow(),
)
if user_id:
await manager.send_to_user(
user_id,
"incoming_call",
{
"call_id": str(row.get("id") or resolved_callid),
"number": ekstern_e164 or (ekstern_raw or ""),
"direction": direction,
"contact": kontakt,
},
)
else:
logger.info("⚠️ Telefoni established: no mapped user for extension=%s (callid=%s)", local_extension, resolved_callid)
return {"status": "ok"}
@router.get("/telefoni/terminated")
async def yealink_terminated(
request: Request,
callid: Optional[str] = Query(None),
call_id: Optional[str] = Query(None),
duration: Optional[str] = Query(None),
call_duration: Optional[str] = Query(None),
token: Optional[str] = Query(None),
):
"""Yealink Action URL: Terminated"""
_validate_yealink_request(request, token)
resolved_callid = (callid or call_id or "").strip()
if not resolved_callid:
raise HTTPException(status_code=422, detail="Missing callid/call_id")
duration_raw = (duration or call_duration or "").strip() or None
duration_value: Optional[int] = None
if duration_raw:
try:
duration_value = int(duration_raw)
except ValueError:
# Accept common timer formats from PBX/phones: mm:ss or hh:mm:ss
if ":" in duration_raw:
parts = duration_raw.split(":")
try:
nums = [int(p) for p in parts]
if len(nums) == 2:
duration_value = nums[0] * 60 + nums[1]
elif len(nums) == 3:
duration_value = nums[0] * 3600 + nums[1] * 60 + nums[2]
except ValueError:
duration_value = None
if duration_value is None:
logger.info("⚠️ Telefoni terminated with unparseable duration='%s' (callid=%s)", duration_raw, resolved_callid)
updated = TelefoniService.terminate_call(resolved_callid, duration_value)
if not updated:
logger.info("⚠️ Telefoni terminated without established (callid=%s)", resolved_callid)
return {"status": "ok"}
@router.websocket("/telefoni/ws")
async def telefoni_ws(websocket: WebSocket):
token = websocket.query_params.get("token")
auth_header = (websocket.headers.get("authorization") or "").strip()
if not token and auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
if not token:
token = (websocket.cookies.get("access_token") or "").strip() or None
payload = AuthService.verify_token(token) if token else None
if not payload:
env_secret = (getattr(settings, "TELEFONI_SHARED_SECRET", "") or "").strip()
db_secret = (_get_setting_value("telefoni_shared_secret", "") or "").strip()
accepted_tokens = {s for s in (env_secret, db_secret) if s}
if token and token.strip() in accepted_tokens:
user_id_param = websocket.query_params.get("user_id")
try:
user_id = int(user_id_param) if user_id_param else None
except (TypeError, ValueError):
user_id = None
if not user_id:
logger.info("⚠️ Telefoni WS rejected: shared secret requires user_id")
await websocket.close(code=1008)
return
else:
logger.info("⚠️ Telefoni WS rejected: invalid or missing token")
await websocket.close(code=1008)
return
else:
user_id_value = payload.get("sub") or payload.get("user_id")
try:
user_id = int(user_id_value)
except (TypeError, ValueError):
logger.info("⚠️ Telefoni WS rejected: invalid user id in token")
await websocket.close(code=1008)
return
await manager.connect(user_id, websocket)
logger.info("✅ Telefoni WS connected for user_id=%s", user_id)
try:
while True:
# Keep alive / ignore client messages
await websocket.receive_text()
except WebSocketDisconnect:
logger.info(" Telefoni WS disconnected for user_id=%s", user_id)
await manager.disconnect(user_id, websocket)
except Exception:
logger.info(" Telefoni WS disconnected (exception) for user_id=%s", user_id)
await manager.disconnect(user_id, websocket)
@router.post("/telefoni/test-popup")
async def telefoni_test_popup(
request: Request,
token: Optional[str] = Query(None),
user_id: Optional[int] = Query(None),
extension: Optional[str] = Query(None),
):
"""Trigger test popup for currently authenticated user."""
target_user_id = getattr(request.state, "user_id", None)
env_secret = (getattr(settings, "TELEFONI_SHARED_SECRET", "") or "").strip()
db_secret = (_get_setting_value("telefoni_shared_secret", "") or "").strip()
accepted_tokens = {s for s in (env_secret, db_secret) if s}
token_valid = bool(token and token.strip() in accepted_tokens)
if not target_user_id:
if not token_valid:
raise HTTPException(status_code=401, detail="Not authenticated")
if user_id:
target_user_id = int(user_id)
elif extension:
target_user_id = TelefoniService.find_user_by_extension(extension)
if not target_user_id:
raise HTTPException(status_code=422, detail="Provide user_id or extension when using token auth")
conn_count = await manager.connection_count_for_user(int(target_user_id))
await manager.send_to_user(
int(target_user_id),
"incoming_call",
{
"call_id": f"test-{int(datetime.utcnow().timestamp())}",
"number": "+4511223344",
"direction": "inbound",
"contact": {
"id": None,
"name": "Test popup",
"company": "BMC Hub",
},
},
)
return {
"status": "ok",
"message": "Test popup event sent",
"user_id": int(target_user_id),
"ws_connections": conn_count,
"auth_mode": "token" if token_valid and not getattr(request.state, "user_id", None) else "session",
}
@router.get("/telefoni/users")
async def list_telefoni_users():
"""List users for log filtering (auth-protected by middleware)."""
rows = execute_query(
"""
SELECT user_id, username, full_name, telefoni_extension, telefoni_aktiv, telefoni_phone_ip, telefoni_phone_username
FROM users
ORDER BY COALESCE(full_name, username) ASC
""",
(),
)
return rows or []
@router.patch("/telefoni/admin/users/{user_id}", dependencies=[Depends(require_permission("users.manage"))])
async def update_telefoni_user_mapping(user_id: int, data: TelefoniUserMappingUpdate):
existing = execute_query_single("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
if not existing:
raise HTTPException(status_code=404, detail="User not found")
rows = execute_query(
"""
UPDATE users
SET
telefoni_extension = COALESCE(%s, telefoni_extension),
telefoni_aktiv = COALESCE(%s, telefoni_aktiv),
telefoni_phone_ip = COALESCE(%s, telefoni_phone_ip),
telefoni_phone_username = COALESCE(%s, telefoni_phone_username),
telefoni_phone_password = CASE
WHEN %s IS NULL OR %s = '' THEN telefoni_phone_password
ELSE %s
END
WHERE user_id = %s
RETURNING user_id, username, full_name, telefoni_extension, telefoni_aktiv, telefoni_phone_ip, telefoni_phone_username
""",
(
data.telefoni_extension,
data.telefoni_aktiv,
data.telefoni_phone_ip,
data.telefoni_phone_username,
data.telefoni_phone_password,
data.telefoni_phone_password,
data.telefoni_phone_password,
user_id,
),
)
return rows[0] if rows else {"status": "ok"}
def _get_setting_value(key: str, default: Optional[str] = None) -> Optional[str]:
row = execute_query_single("SELECT value FROM settings WHERE key = %s", (key,))
if not row:
return default
value = row.get("value")
if value is None or value == "":
return default
return str(value)
@router.post("/telefoni/click-to-call")
async def click_to_call(payload: TelefoniClickToCallRequest):
enabled = (_get_setting_value("telefoni_click_to_call_enabled", "false") or "false").lower() == "true"
if not enabled:
raise HTTPException(status_code=400, detail="Click-to-call is disabled")
template = _get_setting_value("telefoni_action_url_template", "") or ""
if not template:
raise HTTPException(status_code=400, detail="telefoni_action_url_template is not configured")
number_normalized = normalize_e164(payload.number) or payload.number.strip()
extension_value = (payload.extension or "").strip()
phone_ip_value = ""
phone_username_value = ""
phone_password_value = ""
if payload.user_id:
user_row = execute_query_single(
"""
SELECT telefoni_extension, telefoni_phone_ip, telefoni_phone_username, telefoni_phone_password
FROM users
WHERE user_id = %s
""",
(payload.user_id,),
)
if user_row:
if not extension_value:
extension_value = (user_row.get("telefoni_extension") or "").strip()
phone_ip_value = (user_row.get("telefoni_phone_ip") or "").strip()
phone_username_value = (user_row.get("telefoni_phone_username") or "").strip()
phone_password_value = (user_row.get("telefoni_phone_password") or "").strip()
if "{number}" not in template and "{raw_number}" not in template:
raise HTTPException(status_code=400, detail="Template must contain {number} or {raw_number}")
if "{phone_ip}" in template and not phone_ip_value:
raise HTTPException(status_code=400, detail="Template requires {phone_ip}, but selected user has no phone IP")
if "{phone_username}" in template and not phone_username_value:
raise HTTPException(status_code=400, detail="Template requires {phone_username}, but selected user has no phone username")
if "{phone_password}" in template and not phone_password_value:
raise HTTPException(status_code=400, detail="Template requires {phone_password}, but selected user has no phone password")
resolved_url = (
template
.replace("{number}", number_normalized)
.replace("{raw_number}", payload.number.strip())
.replace("{extension}", extension_value)
.replace("{phone_ip}", phone_ip_value)
.replace("{phone_username}", phone_username_value)
.replace("{phone_password}", phone_password_value)
)
auth_header: Optional[str] = None
if "@" in resolved_url:
parsed = urlsplit(resolved_url)
if not parsed.scheme or not parsed.netloc:
raise HTTPException(status_code=400, detail="Action URL template resolves to invalid URL")
netloc = parsed.netloc
if "@" in netloc:
userinfo, host_part = netloc.rsplit("@", 1)
if ":" in userinfo:
username, password = userinfo.split(":", 1)
else:
username, password = userinfo, ""
credentials = f"{username}:{password}".encode("utf-8")
auth_header = "Basic " + base64.b64encode(credentials).decode("ascii")
resolved_url = urlunsplit((parsed.scheme, host_part, parsed.path, parsed.query, parsed.fragment))
logger.info("📞 Click-to-call trigger: number=%s extension=%s", number_normalized, extension_value or "-")
try:
request = UrlRequest(resolved_url, method="GET")
if auth_header:
request.add_header("Authorization", auth_header)
with urlopen(request, timeout=8) as response:
status = getattr(response, "status", 200)
except HTTPError as e:
logger.error("❌ Click-to-call HTTP error: %s", e)
raise HTTPException(status_code=502, detail=f"Action URL returned HTTP {e.code}")
except URLError as e:
logger.error("❌ Click-to-call URL error: %s", e)
raise HTTPException(status_code=502, detail="Could not reach Action URL")
except Exception as e:
logger.error("❌ Click-to-call failed: %s", e)
raise HTTPException(status_code=500, detail="Click-to-call failed")
display_url = resolved_url
if auth_header:
display_url = "[basic-auth] " + resolved_url
return {
"status": "ok",
"action_url": display_url,
"http_status": status,
"number": number_normalized,
}
@router.get("/telefoni/calls")
async def list_calls(
user_id: Optional[int] = Query(None),
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
without_case: bool = Query(False),
limit: int = Query(200, ge=1, le=2000),
offset: int = Query(0, ge=0),
):
where = []
params = []
if user_id is not None:
where.append("t.bruger_id = %s")
params.append(user_id)
if date_from:
where.append("t.started_at >= %s")
params.append(date_from)
if date_to:
where.append("t.started_at <= %s")
params.append(date_to)
if without_case:
where.append("t.sag_id IS NULL")
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
query = f"""
SELECT
t.id,
t.callid,
t.bruger_id,
t.direction,
t.ekstern_nummer,
COALESCE(
NULLIF(TRIM(t.ekstern_nummer), ''),
NULLIF(TRIM(t.raw_payload->>'caller'), ''),
NULLIF(TRIM(t.raw_payload->>'callee'), '')
) AS display_number,
t.intern_extension,
t.kontakt_id,
t.sag_id,
t.started_at,
t.ended_at,
t.duration_sec,
t.created_at,
u.username,
u.full_name,
CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_name,
(
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 contact_company,
s.titel AS sag_titel
FROM telefoni_opkald t
LEFT JOIN users u ON u.user_id = t.bruger_id
LEFT JOIN contacts c ON c.id = t.kontakt_id
LEFT JOIN sag_sager s ON s.id = t.sag_id
{where_sql}
ORDER BY t.started_at DESC
LIMIT %s OFFSET %s
"""
params.extend([limit, offset])
rows = execute_query(query, tuple(params))
return rows or []
@router.patch("/telefoni/calls/{call_id}")
async def update_call_links(call_id: int, data: TelefoniCallLinkUpdate):
existing = execute_query_single("SELECT id FROM telefoni_opkald WHERE id = %s", (call_id,))
if not existing:
raise HTTPException(status_code=404, detail="Call not found")
fields = []
params = []
if "sag_id" in data.model_fields_set:
fields.append("sag_id = %s")
params.append(data.sag_id)
if "kontakt_id" in data.model_fields_set:
fields.append("kontakt_id = %s")
params.append(data.kontakt_id)
if not fields:
raise HTTPException(status_code=400, detail="No fields provided")
query = f"""
UPDATE telefoni_opkald
SET {", ".join(fields)}
WHERE id = %s
RETURNING *
"""
params.append(call_id)
rows = execute_query(query, tuple(params))
return rows[0] if rows else {"status": "ok"}

View File

@ -0,0 +1,28 @@
from pydantic import BaseModel
from typing import Optional
class TelefoniCallLinkUpdate(BaseModel):
sag_id: Optional[int] = None
kontakt_id: Optional[int] = None
class TelefoniUserMappingUpdate(BaseModel):
telefoni_extension: Optional[str] = None
telefoni_aktiv: Optional[bool] = None
telefoni_phone_ip: Optional[str] = None
telefoni_phone_username: Optional[str] = None
telefoni_phone_password: Optional[str] = None
class TelefoniClickToCallRequest(BaseModel):
number: str
extension: Optional[str] = None
user_id: Optional[int] = None
class SmsSendRequest(BaseModel):
to: str
message: str
sender: Optional[str] = None
contact_id: Optional[int] = None

View File

@ -0,0 +1,111 @@
import logging
from datetime import datetime
from typing import Any, Optional
from app.core.database import execute_query, execute_query_single
logger = logging.getLogger(__name__)
class TelefoniService:
@staticmethod
def find_user_by_extension(extension: Optional[str]) -> Optional[int]:
if not extension:
return None
row = execute_query_single(
"SELECT user_id FROM users WHERE telefoni_aktiv = TRUE AND telefoni_extension = %s LIMIT 1",
(extension,),
)
return int(row["user_id"]) if row and row.get("user_id") is not None else None
@staticmethod
def find_contact_by_phone_suffix(suffix8: Optional[str]) -> Optional[dict]:
if not suffix8:
return None
query = """
SELECT
c.id,
c.first_name,
c.last_name,
(
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 RIGHT(regexp_replace(COALESCE(c.phone, ''), '\\D', '', 'g'), 8) = %s
OR RIGHT(regexp_replace(COALESCE(c.mobile, ''), '\\D', '', 'g'), 8) = %s
ORDER BY c.id ASC
LIMIT 1
"""
row = execute_query_single(query, (suffix8, suffix8))
if not row:
return None
return {
"id": row["id"],
"name": f"{(row.get('first_name') or '').strip()} {(row.get('last_name') or '').strip()}".strip(),
"company": row.get("company"),
}
@staticmethod
def upsert_call(
*,
callid: str,
user_id: Optional[int],
direction: str,
ekstern_nummer: Optional[str],
intern_extension: Optional[str],
kontakt_id: Optional[int],
raw_payload: Any,
started_at: datetime,
) -> dict:
query = """
INSERT INTO telefoni_opkald
(callid, bruger_id, direction, ekstern_nummer, intern_extension, kontakt_id, started_at, raw_payload)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
ON CONFLICT (callid)
DO UPDATE SET
raw_payload = EXCLUDED.raw_payload,
direction = EXCLUDED.direction,
intern_extension = COALESCE(telefoni_opkald.intern_extension, EXCLUDED.intern_extension),
ekstern_nummer = COALESCE(telefoni_opkald.ekstern_nummer, EXCLUDED.ekstern_nummer),
bruger_id = COALESCE(telefoni_opkald.bruger_id, EXCLUDED.bruger_id),
kontakt_id = COALESCE(telefoni_opkald.kontakt_id, EXCLUDED.kontakt_id),
started_at = LEAST(telefoni_opkald.started_at, EXCLUDED.started_at)
RETURNING *
"""
rows = execute_query(
query,
(
callid,
user_id,
direction,
ekstern_nummer,
intern_extension,
kontakt_id,
started_at,
raw_payload,
),
)
return rows[0] if rows else {}
@staticmethod
def terminate_call(callid: str, duration_sec: Optional[int]) -> bool:
if not callid:
return False
rows = execute_query(
"""
UPDATE telefoni_opkald
SET ended_at = NOW(),
duration_sec = %s
WHERE callid = %s
RETURNING id
""",
(duration_sec, callid),
)
return bool(rows)

View File

@ -0,0 +1,102 @@
import ipaddress
import re
from typing import Optional
def digits_only(value: Optional[str]) -> str:
if not value:
return ""
return re.sub(r"\D+", "", value)
def normalize_e164(number: Optional[str]) -> Optional[str]:
if not number:
return None
raw = number.strip().replace(" ", "").replace("-", "")
if not raw:
return None
if raw.startswith("+"):
n = "+" + digits_only(raw)
return n if len(n) >= 9 else None
if raw.startswith("0045"):
rest = digits_only(raw[4:])
return "+45" + rest if len(rest) == 8 else ("+" + digits_only(raw) if len(digits_only(raw)) >= 9 else None)
d = digits_only(raw)
if len(d) == 8:
return "+45" + d
if d.startswith("45") and len(d) == 10:
return "+" + d
# Generic international (best-effort)
if len(d) >= 9:
return "+" + d
return None
def phone_suffix_8(number: Optional[str]) -> Optional[str]:
d = digits_only(number)
if len(d) < 8:
return None
return d[-8:]
def is_outbound_call(caller: Optional[str], local_extension: Optional[str]) -> bool:
caller_d = digits_only(caller)
local_d = digits_only(local_extension)
if not caller_d or not local_d:
return False
return caller_d.endswith(local_d)
def extract_extension(local_value: Optional[str]) -> Optional[str]:
if not local_value:
return None
raw = local_value.strip()
if not raw:
return None
if raw.isdigit():
return raw
# Common SIP format: sip:204_99773@pbx.sipserver.dk -> 204
sip_match = re.search(r"sip:([0-9]+)", raw, flags=re.IGNORECASE)
if sip_match:
return sip_match.group(1)
# Fallback: first digit run (at least 2 chars)
generic = re.search(r"([0-9]{2,})", raw)
if generic:
return generic.group(1)
return None
def ip_in_whitelist(client_ip: str, whitelist_csv: str) -> bool:
if not client_ip or not whitelist_csv:
return False
try:
ip_obj = ipaddress.ip_address(client_ip)
except ValueError:
return False
for entry in [e.strip() for e in whitelist_csv.split(",") if e.strip()]:
try:
if "/" in entry:
net = ipaddress.ip_network(entry, strict=False)
if ip_obj in net:
return True
else:
if ip_obj == ipaddress.ip_address(entry):
return True
except ValueError:
continue
return False

View File

@ -0,0 +1,67 @@
import asyncio
import json
import logging
from typing import Dict, Set
from fastapi import WebSocket
logger = logging.getLogger(__name__)
class TelefoniConnectionManager:
def __init__(self) -> None:
self._lock = asyncio.Lock()
self._connections: Dict[int, Set[WebSocket]] = {}
async def connect(self, user_id: int, websocket: WebSocket) -> None:
await websocket.accept()
async with self._lock:
self._connections.setdefault(user_id, set()).add(websocket)
logger.info("📞 WS manager: user_id=%s now has %s connection(s)", user_id, len(self._connections.get(user_id, set())))
async def disconnect(self, user_id: int, websocket: WebSocket) -> None:
async with self._lock:
ws_set = self._connections.get(user_id)
if not ws_set:
return
ws_set.discard(websocket)
if not ws_set:
self._connections.pop(user_id, None)
logger.info("📞 WS manager: user_id=%s disconnected (0 connections)", user_id)
else:
logger.info("📞 WS manager: user_id=%s now has %s connection(s)", user_id, len(ws_set))
async def active_users(self) -> list[int]:
async with self._lock:
return sorted(self._connections.keys())
async def connection_count_for_user(self, user_id: int) -> int:
async with self._lock:
return len(self._connections.get(user_id, set()))
async def send_to_user(self, user_id: int, event: str, payload: dict) -> None:
message = json.dumps({"event": event, "data": payload}, default=str)
async with self._lock:
targets = list(self._connections.get(user_id, set()))
if not targets:
active = await self.active_users()
logger.info("⚠️ WS send skipped: no active connections for user_id=%s (active users=%s)", user_id, active)
return
dead: list[WebSocket] = []
for ws in targets:
try:
await ws.send_text(message)
except Exception as e:
logger.warning("⚠️ WS send failed for user %s: %s", user_id, e)
dead.append(ws)
if dead:
async with self._lock:
ws_set = self._connections.get(user_id, set())
for ws in dead:
ws_set.discard(ws)
manager = TelefoniConnectionManager()

View File

@ -0,0 +1 @@
"""Telefoni frontend package."""

View File

@ -0,0 +1,14 @@
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
logger = logging.getLogger(__name__)
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/telefoni", response_class=HTMLResponse)
async def telefoni_log_page(request: Request):
return templates.TemplateResponse("modules/telefoni/templates/log.html", {"request": request})

View File

@ -0,0 +1,512 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Telefoni - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="mb-1"><i class="bi bi-telephone me-2"></i>Telefoni</h2>
<div class="text-muted small">Opkaldslog fra Yealink Action URL (Established/Terminated)</div>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-md-3">
<label class="form-label">Bruger</label>
<select id="filterUser" class="form-select">
<option value="">Alle</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Dato fra</label>
<input id="filterFrom" type="date" class="form-control" />
</div>
<div class="col-md-3">
<label class="form-label">Dato til</label>
<input id="filterTo" type="date" class="form-control" />
</div>
<div class="col-md-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="filterWithoutCase" />
<label class="form-check-label" for="filterWithoutCase">Kun uden sag</label>
</div>
</div>
<div class="col-md-1">
<button id="btnRefresh" class="btn btn-primary w-100">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
</div>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Dato</th>
<th>Bruger</th>
<th>Retning</th>
<th>Nummer</th>
<th>Kontakt</th>
<th>Sag</th>
<th class="text-end">Varighed</th>
</tr>
</thead>
<tbody id="telefoniRows">
<tr><td colspan="7" class="text-muted small">Indlæser...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="modal fade" id="linkSagModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Link opkald til sag</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div id="linkSagContext" class="small text-muted mb-3">Opkald: -</div>
<div class="mb-3">
<label for="linkSagSearch" class="form-label">Søg sag</label>
<input id="linkSagSearch" type="text" class="form-control" placeholder="Søg på titel, kunde eller ID" autocomplete="off" />
</div>
<div class="mb-3">
<label for="linkSagIdManual" class="form-label">Eller indtast sag-ID manuelt</label>
<input id="linkSagIdManual" type="number" min="1" step="1" class="form-control" placeholder="Fx 1234" />
</div>
<div id="linkSagSelected" class="alert alert-secondary py-2 mb-3">Ingen sag valgt</div>
<div id="linkSagResults" class="list-group"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" id="linkSagConfirm" class="btn btn-primary" disabled>Link sag</button>
</div>
</div>
</div>
</div>
<script>
function fmtDuration(sec, endedAt) {
if (sec === null || sec === undefined) return endedAt ? '-' : 'I gang';
const s = Number(sec);
if (!Number.isFinite(s) || s < 0) return endedAt ? '-' : 'I gang';
const mm = Math.floor(s / 60);
const ss = s % 60;
return `${mm}:${String(ss).padStart(2,'0')}`;
}
function escapeHtml(str) {
return String(str ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
let telefoniCurrentUserId = null;
const telefoniCallMap = new Map();
const linkSagState = {
callId: null,
selectedSagId: null,
selectedLabel: '',
searchTimer: null,
searchToken: 0,
modal: null
};
async function ensureCurrentUserId() {
if (telefoniCurrentUserId !== null) return telefoniCurrentUserId;
try {
const res = await fetch('/api/v1/auth/me', { credentials: 'include' });
if (!res.ok) return null;
const me = await res.json();
telefoniCurrentUserId = Number(me?.id) || null;
return telefoniCurrentUserId;
} catch (e) {
return null;
}
}
function getLinkSagModalInstance() {
if (!linkSagState.modal) {
const el = document.getElementById('linkSagModal');
if (!el || !window.bootstrap) return null;
linkSagState.modal = new bootstrap.Modal(el);
}
return linkSagState.modal;
}
function setLinkSagSelected(sagId, label) {
const selected = document.getElementById('linkSagSelected');
const confirmBtn = document.getElementById('linkSagConfirm');
const numericSagId = Number(sagId);
if (!Number.isInteger(numericSagId) || numericSagId <= 0) {
linkSagState.selectedSagId = null;
linkSagState.selectedLabel = '';
if (selected) {
selected.className = 'alert alert-secondary py-2 mb-3';
selected.textContent = 'Ingen sag valgt';
}
if (confirmBtn) confirmBtn.disabled = true;
return;
}
linkSagState.selectedSagId = numericSagId;
linkSagState.selectedLabel = String(label || `Sag #${numericSagId}`);
if (selected) {
selected.className = 'alert alert-success py-2 mb-3';
selected.textContent = `Valgt: ${linkSagState.selectedLabel} (ID: ${numericSagId})`;
}
if (confirmBtn) confirmBtn.disabled = false;
}
function renderLinkSagResults(results) {
const container = document.getElementById('linkSagResults');
if (!container) return;
if (!results || results.length === 0) {
container.innerHTML = '<div class="alert alert-light border mb-0">Ingen sager fundet</div>';
return;
}
container.innerHTML = (results || []).map(item => {
const sid = Number(item.id);
const title = String(item.titel || item.title || `Sag #${sid}`);
const customer = String(item.customer_name || 'Ukendt kunde');
const status = String(item.status || '-');
const label = `${title} — ${customer}`;
return `
<div class="list-group-item d-flex justify-content-between align-items-start gap-3">
<div>
<div class="fw-semibold">${escapeHtml(title)}</div>
<div class="small text-muted">${escapeHtml(customer)} · ${escapeHtml(status)} · ID: ${sid}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="setLinkSagSelected(${sid}, '${escapeHtml(label)}')">Vælg</button>
</div>
`;
}).join('');
}
function buildInitialSagQuery(call) {
if (!call) return '';
const number = String(call.display_number || call.ekstern_nummer || '').trim();
const contact = String(call.contact_name || '').trim();
const company = String(call.contact_company || '').trim();
if (contact && company) return `${contact} ${company}`;
if (contact) return contact;
if (company) return company;
if (number) return number;
return '';
}
async function searchSager(query) {
const token = ++linkSagState.searchToken;
const container = document.getElementById('linkSagResults');
if (container) {
container.innerHTML = '<div class="alert alert-light border mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Søger...</div>';
}
try {
const res = await fetch(`/api/v1/search/sag?q=${encodeURIComponent(query || '')}`, { credentials: 'include' });
if (token !== linkSagState.searchToken) return;
if (!res.ok) {
if (container) container.innerHTML = '<div class="alert alert-danger mb-0">Kunne ikke søge sager</div>';
return;
}
const data = await res.json();
const results = Array.isArray(data) ? data : (data?.items || data?.results || []);
renderLinkSagResults(results);
} catch (e) {
if (token !== linkSagState.searchToken) return;
if (container) container.innerHTML = '<div class="alert alert-danger mb-0">Fejl under søgning</div>';
}
}
function initLinkSagModalEvents() {
const searchInput = document.getElementById('linkSagSearch');
const manualInput = document.getElementById('linkSagIdManual');
const confirmBtn = document.getElementById('linkSagConfirm');
const modalEl = document.getElementById('linkSagModal');
if (!searchInput || !manualInput || !confirmBtn) return;
searchInput.addEventListener('input', () => {
if (linkSagState.searchTimer) clearTimeout(linkSagState.searchTimer);
const q = String(searchInput.value || '').trim();
linkSagState.searchTimer = setTimeout(() => {
if (!q) {
renderLinkSagResults([]);
return;
}
searchSager(q);
}, 250);
});
searchInput.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
const firstSelectBtn = document.querySelector('#linkSagResults .btn-outline-primary');
if (firstSelectBtn) {
firstSelectBtn.click();
return;
}
const manualVal = Number(manualInput.value);
if (Number.isInteger(manualVal) && manualVal > 0) {
setLinkSagSelected(manualVal, `Sag #${manualVal} (manuel)`);
}
});
manualInput.addEventListener('input', () => {
const val = Number(manualInput.value);
if (Number.isInteger(val) && val > 0) {
setLinkSagSelected(val, `Sag #${val} (manuel)`);
} else {
setLinkSagSelected(null, '');
}
});
confirmBtn.addEventListener('click', async () => {
if (!linkSagState.callId || !linkSagState.selectedSagId) return;
try {
const res = await fetch(`/api/v1/telefoni/calls/${linkSagState.callId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ sag_id: linkSagState.selectedSagId })
});
if (!res.ok) {
const t = await res.text();
alert('Kunne ikke linke sag: ' + t);
return;
}
const modal = getLinkSagModalInstance();
if (modal) modal.hide();
await loadCalls();
} catch (e) {
alert('Kunne ikke linke sag');
}
});
if (modalEl) {
modalEl.addEventListener('hidden.bs.modal', () => {
if (linkSagState.searchTimer) clearTimeout(linkSagState.searchTimer);
linkSagState.searchTimer = null;
linkSagState.searchToken++;
linkSagState.callId = null;
setLinkSagSelected(null, '');
searchInput.value = '';
manualInput.value = '';
const results = document.getElementById('linkSagResults');
const ctx = document.getElementById('linkSagContext');
if (results) results.innerHTML = '';
if (ctx) ctx.textContent = 'Opkald: -';
});
}
}
async function callViaYealink(number) {
const clean = String(number || '').trim();
if (!clean || clean === '-') {
alert('Intet gyldigt nummer at ringe til');
return;
}
const userId = await ensureCurrentUserId();
try {
const res = await fetch('/api/v1/telefoni/click-to-call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ number: clean, user_id: userId })
});
if (!res.ok) {
const t = await res.text();
alert('Ring ud fejlede: ' + t);
return;
}
alert('Ringer ud via Yealink...');
} catch (e) {
alert('Kunne ikke starte opkald');
}
}
async function loadUsers() {
const sel = document.getElementById('filterUser');
try {
const res = await fetch('/api/v1/telefoni/users', { credentials: 'include' });
if (!res.ok) return;
const users = await res.json();
(users || []).forEach(u => {
const opt = document.createElement('option');
opt.value = u.user_id;
opt.textContent = `${u.full_name || u.username || ('#' + u.user_id)}${u.telefoni_extension ? ' (' + u.telefoni_extension + ')' : ''}`;
sel.appendChild(opt);
});
} catch (e) {
console.error('Failed loading telefoni users', e);
}
}
async function loadCalls() {
const tbody = document.getElementById('telefoniRows');
tbody.innerHTML = '<tr><td colspan="7" class="text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser...</td></tr>';
const userId = document.getElementById('filterUser').value;
const from = document.getElementById('filterFrom').value;
const to = document.getElementById('filterTo').value;
const withoutCase = document.getElementById('filterWithoutCase').checked;
const qs = new URLSearchParams();
if (userId) qs.set('user_id', userId);
if (from) qs.set('date_from', from);
if (to) qs.set('date_to', to);
if (withoutCase) qs.set('without_case', '1');
try {
const res = await fetch('/api/v1/telefoni/calls?' + qs.toString(), { credentials: 'include' });
if (!res.ok) {
const t = await res.text();
tbody.innerHTML = `<tr><td colspan="7" class="text-danger small">Fejl: ${escapeHtml(t)}</td></tr>`;
return;
}
const rows = await res.json();
telefoniCallMap.clear();
(rows || []).forEach(r => telefoniCallMap.set(Number(r.id), r));
if (!rows || rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted small">Ingen opkald fundet</td></tr>';
return;
}
tbody.innerHTML = rows.map(r => {
const started = r.started_at ? new Date(r.started_at) : null;
const dateTxt = started ? started.toLocaleString('da-DK') : '-';
const userTxt = escapeHtml(r.full_name || r.username || '-');
const dirTxt = r.direction === 'outbound' ? 'Udgående' : 'Indgående';
const numberRaw = (r.display_number || r.ekstern_nummer || '').trim();
const numTxt = numberRaw
? `<div class="d-flex gap-2 align-items-center flex-wrap">
<span>${escapeHtml(numberRaw)}</span>
<button type="button" class="btn btn-sm btn-outline-success" onclick="callViaYealink('${escapeHtml(numberRaw)}')">Ring op</button>
</div>`
: '-';
const contactHtml = r.kontakt_id
? `<a href="/contacts/${r.kontakt_id}">${escapeHtml(r.contact_name || ('Kontakt #' + r.kontakt_id))}</a>${r.contact_company ? `<div class="text-muted small">${escapeHtml(r.contact_company)}</div>` : ''}`
: '<span class="text-muted">-</span>';
const numberForTitle = (r.display_number || r.ekstern_nummer || '').trim();
const createQs = new URLSearchParams();
if (r.kontakt_id) createQs.set('contact_id', String(r.kontakt_id));
createQs.set('telefoni_opkald_id', String(r.id));
createQs.set('title', `Telefonsamtale ${numberForTitle || 'ukendt nummer'}`);
const sagHtml = r.sag_id
? `<div class="d-flex gap-2 align-items-center flex-wrap">
<a href="/sag/${r.sag_id}">${escapeHtml(r.sag_titel || ('Sag #' + r.sag_id))}</a>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="linkExistingCase(${Number(r.id)})">Skift link</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkCase(${Number(r.id)})">Fjern link</button>
</div>`
: `<div class="d-flex gap-2">
<a class="btn btn-sm btn-outline-primary" href="/sag/new?${createQs.toString()}">Opret sag</a>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="linkExistingCase(${Number(r.id)})">Link sag</button>
</div>`;
return `
<tr>
<td>${escapeHtml(dateTxt)}</td>
<td>${userTxt}</td>
<td>${escapeHtml(dirTxt)}</td>
<td>${numTxt}</td>
<td>${contactHtml}</td>
<td>${sagHtml}</td>
<td class="text-end">${escapeHtml(fmtDuration(r.duration_sec, r.ended_at))}</td>
</tr>
`;
}).join('');
} catch (e) {
console.error('Failed loading calls', e);
tbody.innerHTML = '<tr><td colspan="7" class="text-danger small">Kunne ikke hente opkald</td></tr>';
}
}
async function linkExistingCase(callId) {
linkSagState.callId = Number(callId);
const call = telefoniCallMap.get(Number(callId));
const ctx = document.getElementById('linkSagContext');
const searchInput = document.getElementById('linkSagSearch');
const manualInput = document.getElementById('linkSagIdManual');
const results = document.getElementById('linkSagResults');
const label = call
? `${call.direction === 'outbound' ? 'Udgående' : 'Indgående'} · ${call.display_number || call.ekstern_nummer || '-'} · ${call.started_at ? new Date(call.started_at).toLocaleString('da-DK') : '-'}`
: `Opkald #${callId}`;
if (ctx) ctx.textContent = `Opkald: ${label}`;
const initialQuery = buildInitialSagQuery(call);
if (searchInput) searchInput.value = initialQuery;
if (manualInput) manualInput.value = '';
if (results) {
results.innerHTML = initialQuery
? '<div class="alert alert-light border mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Søger relevante sager...</div>'
: '<div class="alert alert-light border mb-0">Søg efter en sag eller indtast ID manuelt</div>';
}
setLinkSagSelected(null, '');
const modal = getLinkSagModalInstance();
if (modal) modal.show();
setTimeout(() => {
searchInput?.focus();
if (initialQuery) {
searchSager(initialQuery);
}
}, 200);
}
async function unlinkCase(callId) {
if (!confirm('Fjern link til sag for dette opkald?')) return;
try {
const res = await fetch(`/api/v1/telefoni/calls/${callId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ sag_id: null })
});
if (!res.ok) {
const t = await res.text();
alert('Kunne ikke fjerne link: ' + t);
return;
}
await loadCalls();
} catch (e) {
alert('Kunne ikke fjerne link');
}
}
document.addEventListener('DOMContentLoaded', async () => {
initLinkSagModalEvents();
await loadUsers();
document.getElementById('btnRefresh').addEventListener('click', loadCalls);
document.getElementById('filterUser').addEventListener('change', loadCalls);
document.getElementById('filterFrom').addEventListener('change', loadCalls);
document.getElementById('filterTo').addEventListener('change', loadCalls);
document.getElementById('filterWithoutCase').addEventListener('change', loadCalls);
await loadCalls();
});
</script>
{% endblock %}

View File

@ -4,7 +4,7 @@ ESET PROTECT Integration Service
import logging import logging
import time import time
import httpx import httpx
from typing import Dict, Optional, Any from typing import Dict, Optional, Any, List, Mapping, Sequence, Tuple, Union
from app.core.config import settings from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -80,7 +80,12 @@ class EsetService:
return self._access_token return self._access_token
return await self._authenticate(client) return await self._authenticate(client)
async def _get_json(self, client: httpx.AsyncClient, url: str, params: Optional[dict] = None) -> Optional[Dict[str, Any]]: async def _get_json(
self,
client: httpx.AsyncClient,
url: str,
params: Optional[Union[Mapping[str, Any], Sequence[Tuple[str, str]]]] = None,
) -> Optional[Dict[str, Any]]:
token = await self._get_access_token(client) token = await self._get_access_token(client)
if not token: if not token:
return None return None
@ -142,4 +147,94 @@ class EsetService:
logger.error(f"ESET API error: {str(e)}") logger.error(f"ESET API error: {str(e)}")
return None return None
async def get_devices_batch(self, device_uuids: List[str]) -> Optional[Dict[str, Any]]:
"""Fetch multiple devices in one call via /v1/devices:batchGet."""
if not self.enabled:
logger.warning("ESET not enabled")
return None
uuids = [str(u).strip() for u in (device_uuids or []) if str(u).strip()]
if not uuids:
return {"devices": []}
url = f"{self.base_url}/v1/devices:batchGet"
params = [("devicesUuids", u) for u in uuids]
async with httpx.AsyncClient(verify=self.verify_ssl, timeout=settings.ESET_TIMEOUT_SECONDS) as client:
payload = await self._get_json(client, url, params=params)
if not payload:
logger.warning("ESET batchGet payload empty")
return payload
@staticmethod
def extract_installed_software(device_payload: Dict[str, Any]) -> List[str]:
"""Extract installed software names/versions from ESET device payload."""
if not isinstance(device_payload, dict):
return []
device_raw = device_payload.get("device") if isinstance(device_payload.get("device"), dict) else device_payload
if not isinstance(device_raw, dict):
return []
def _normalize_version(value: Any) -> str:
if isinstance(value, dict):
name = str(value.get("name") or "").strip()
if name:
return name
version_id = str(value.get("id") or "").strip()
if version_id:
return version_id
major = value.get("major")
minor = value.get("minor")
patch = value.get("patch")
if major is not None and minor is not None and patch is not None:
return f"{major}.{minor}.{patch}"
return ""
if value is None:
return ""
return str(value).strip()
result: List[str] = []
def _add_item(name: Any, version: Any = None) -> None:
item_name = str(name or "").strip()
if not item_name:
return
item_version = _normalize_version(version)
result.append(f"{item_name} {item_version}".strip() if item_version else item_name)
for comp in device_raw.get("deployedComponents") or []:
if isinstance(comp, dict):
_add_item(comp.get("displayName") or comp.get("name"), comp.get("version"))
elif isinstance(comp, str):
_add_item(comp)
for key in ("installedSoftware", "applications", "applicationInventory", "softwareInventory", "activeProducts"):
for comp in device_raw.get(key) or []:
if isinstance(comp, dict):
_add_item(
comp.get("displayName")
or comp.get("name")
or comp.get("softwareName")
or comp.get("applicationName")
or comp.get("productName")
or comp.get("product"),
comp.get("version")
or comp.get("applicationVersion")
or comp.get("softwareVersion")
or comp.get("productVersion"),
)
elif isinstance(comp, str):
_add_item(comp)
# keep order, remove duplicates
deduped: List[str] = []
seen = set()
for item in result:
key = item.lower()
if key in seen:
continue
seen.add(key)
deduped.append(item)
return deduped
eset_service = EsetService() eset_service = EsetService()

View File

@ -0,0 +1,96 @@
import base64
import json
import logging
import re
from typing import Dict, Any
from urllib.error import HTTPError, URLError
from urllib.request import Request as UrlRequest, urlopen
from app.core.config import settings
logger = logging.getLogger(__name__)
class SmsService:
API_URL = "https://api.cpsms.dk/v2/send"
@staticmethod
def normalize_recipient(number: str) -> str:
cleaned = re.sub(r"[^0-9+]", "", (number or "").strip())
if not cleaned:
raise ValueError("Mobilnummer mangler")
if cleaned.startswith("+"):
cleaned = cleaned[1:]
if cleaned.startswith("00"):
cleaned = cleaned[2:]
if not cleaned.isdigit():
raise ValueError("Ugyldigt mobilnummer")
if len(cleaned) == 8:
cleaned = "45" + cleaned
if len(cleaned) < 8:
raise ValueError("Ugyldigt mobilnummer")
return cleaned
@staticmethod
def _authorization_header() -> str:
username = (settings.SMS_USERNAME or "").strip()
api_key = (settings.SMS_API_KEY or "").strip()
if not username or not api_key:
raise ValueError("SMS er ikke konfigureret (SMS_USERNAME/SMS_API_KEY mangler)")
raw = f"{username}:{api_key}".encode("utf-8")
token = base64.b64encode(raw).decode("ascii")
return f"Basic {token}"
@classmethod
def send_sms(cls, to: str, message: str, sender: str | None = None) -> Dict[str, Any]:
sms_message = (message or "").strip()
if not sms_message:
raise ValueError("SMS-besked må ikke være tom")
if len(sms_message) > 1530:
raise ValueError("SMS-besked er for lang (max 1530 tegn)")
recipient = cls.normalize_recipient(to)
sms_sender = (sender or settings.SMS_SENDER or "").strip()
if not sms_sender:
raise ValueError("SMS afsender mangler (SMS_SENDER)")
payload = {
"to": recipient,
"message": sms_message,
"from": sms_sender,
}
body = json.dumps(payload).encode("utf-8")
request = UrlRequest(cls.API_URL, data=body, method="POST")
request.add_header("Content-Type", "application/json")
request.add_header("Authorization", cls._authorization_header())
try:
with urlopen(request, timeout=15) as response:
response_body = response.read().decode("utf-8")
response_data = json.loads(response_body) if response_body else {}
return {
"http_status": getattr(response, "status", 200),
"provider": "cpsms",
"recipient": recipient,
"result": response_data,
}
except HTTPError as e:
error_body = ""
try:
error_body = e.read().decode("utf-8")
except Exception:
error_body = ""
logger.error("❌ CPSMS HTTP error: status=%s body=%s", e.code, error_body)
raise RuntimeError(f"CPSMS fejl ({e.code})")
except URLError as e:
logger.error("❌ CPSMS connection error: %s", e)
raise RuntimeError("Kunne ikke kontakte CPSMS")

View File

@ -83,6 +83,9 @@
<a class="nav-link" href="#integrations" data-tab="integrations"> <a class="nav-link" href="#integrations" data-tab="integrations">
<i class="bi bi-plugin me-2"></i>Integrationer <i class="bi bi-plugin me-2"></i>Integrationer
</a> </a>
<a class="nav-link" href="#telefoni" data-tab="telefoni">
<i class="bi bi-telephone me-2"></i>Telefoni
</a>
<a class="nav-link" href="#notifications" data-tab="notifications"> <a class="nav-link" href="#notifications" data-tab="notifications">
<i class="bi bi-bell me-2"></i>Notifikationer <i class="bi bi-bell me-2"></i>Notifikationer
</a> </a>
@ -200,6 +203,137 @@
</div> </div>
</div> </div>
<!-- Telefoni -->
<div class="tab-pane fade" id="telefoni">
<div class="card p-4 mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="mb-1 fw-bold">Click-to-Call (Action URL)</h5>
<p class="text-muted mb-0">Konfigurer URL-template til at starte opkald via telefon/PBX endpoint.</p>
</div>
</div>
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">Callback shared secret</label>
<div class="input-group">
<input type="text" class="form-control" id="telefoniSharedSecret" placeholder="Hemmelig token til Yealink callbacks">
<button type="button" class="btn btn-outline-secondary" onclick="generateTelefoniToken()">
<i class="bi bi-magic me-1"></i>Generér
</button>
</div>
<small class="text-muted">Bruges som <code>?token=...</code> på established/terminated callback URLs.</small>
</div>
<div class="col-md-4">
<label class="form-label">Aktivér click-to-call</label>
<div class="form-check form-switch mt-1">
<input class="form-check-input" type="checkbox" id="telefoniClickEnabled">
</div>
</div>
<div class="col-md-8">
<label class="form-label">Standard extension (valgfri)</label>
<input type="text" class="form-control" id="telefoniDefaultExtension" placeholder="fx 101">
<small class="text-muted">Bruges som fallback i test og i senere call-knapper.</small>
</div>
<div class="col-12">
<label class="form-label">Template preset</label>
<div class="d-flex flex-wrap gap-2 mb-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="applyTelefoniTemplatePreset('generic')">
Generic
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="applyTelefoniTemplatePreset('yealink-basic-auth')">
Yealink Basic Auth
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="applyTelefoniTemplatePreset('yealink-open')">
Yealink (uden auth)
</button>
</div>
</div>
<div class="col-12">
<label class="form-label">Action URL template</label>
<input type="text" class="form-control" id="telefoniActionTemplate" placeholder="http://PHONE_IP/servlet?number={number}&ext={extension}">
<small class="text-muted">Pladsholdere: <code>{number}</code> (påkrævet), <code>{raw_number}</code>, <code>{extension}</code>, <code>{phone_ip}</code>, <code>{phone_username}</code>, <code>{phone_password}</code>.</small>
</div>
<div class="col-12">
<label class="form-label">Preview</label>
<div class="form-control bg-light" id="telefoniActionPreview" style="min-height: 38px;">-</div>
</div>
<div class="col-12 d-flex justify-content-end">
<button class="btn btn-primary" onclick="saveTelefoniSettings()">
<i class="bi bi-save me-2"></i>Gem telefoni-indstillinger
</button>
</div>
</div>
</div>
<div class="card p-4">
<h5 class="mb-3 fw-bold">Yealink URL Builder</h5>
<p class="text-muted mb-3">Generér de præcise URL-strenge til telefonernes Action URL felter (Established + Terminated).</p>
<div class="alert alert-info py-2 small mb-3">
Brug Yealink variable values (fx <code>$call_id</code>, <code>$remote</code>, <code>$local</code>, <code>$active_user</code>). Ikke-dokumenterede placeholders som <code>$callid</code>/<code>$caller</code>/<code>$callee</code> bliver ofte ikke erstattet.
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Hub base URL</label>
<input type="text" class="form-control" id="yealinkBuilderBaseUrl" placeholder="http://hub.local">
<small class="text-muted">Eksempel: http://hub.local eller https://hub.bmcnetworks.dk</small>
</div>
<div class="col-md-6">
<label class="form-label">Shared secret token (valgfri)</label>
<input type="text" class="form-control" id="yealinkBuilderToken" placeholder="Samme token som TELEFONI_SHARED_SECRET">
<small class="text-muted">Hvis udfyldt, tilføjes <code>?token=...</code> automatisk.</small>
</div>
<div class="col-12">
<label class="form-label">Established URL</label>
<div class="input-group">
<input type="text" class="form-control" id="yealinkEstablishedUrl" readonly>
<button class="btn btn-outline-secondary" type="button" onclick="copyYealinkUrl('yealinkEstablishedUrl')">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
<div class="col-12">
<label class="form-label">Terminated URL</label>
<div class="input-group">
<input type="text" class="form-control" id="yealinkTerminatedUrl" readonly>
<button class="btn btn-outline-secondary" type="button" onclick="copyYealinkUrl('yealinkTerminatedUrl')">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
</div>
</div>
<div class="card p-4 mt-4">
<h5 class="mb-3 fw-bold">Test opkald</h5>
<div class="row g-3 align-items-end">
<div class="col-md-5">
<label class="form-label">Nummer</label>
<input type="text" class="form-control" id="telefoniTestNumber" placeholder="fx 22334455 eller +4522334455">
</div>
<div class="col-md-4">
<label class="form-label">Extension (valgfri)</label>
<input type="text" class="form-control" id="telefoniTestExtension" placeholder="fx 101">
</div>
<div class="col-md-3">
<button class="btn btn-outline-primary w-100" id="telefoniTestBtn" onclick="testTelefoniCall()">
<i class="bi bi-telephone-outbound me-2"></i>Start testopkald
</button>
</div>
</div>
<div class="mt-3 small text-muted" id="telefoniTestResult">-</div>
</div>
</div>
<!-- Notifications --> <!-- Notifications -->
<div class="tab-pane fade" id="notifications"> <div class="tab-pane fade" id="notifications">
<div class="card p-4"> <div class="card p-4">
@ -295,13 +429,18 @@
<th>Email</th> <th>Email</th>
<th>Grupper</th> <th>Grupper</th>
<th>Status</th> <th>Status</th>
<th>Telefoni ext.</th>
<th>Telefoni IP</th>
<th>Telefoni bruger</th>
<th>Telefoni kode</th>
<th>Telefoni aktiv</th>
<th>Oprettet</th> <th>Oprettet</th>
<th class="text-end">Handlinger</th> <th class="text-end">Handlinger</th>
</tr> </tr>
</thead> </thead>
<tbody id="usersTableBody"> <tbody id="usersTableBody">
<tr> <tr>
<td colspan="6" class="text-center py-5"> <td colspan="11" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div> <div class="spinner-border text-primary" role="status"></div>
</td> </td>
</tr> </tr>
@ -314,7 +453,7 @@
<div class="card p-4"> <div class="card p-4">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <div>
<h5 class="mb-1 fw-bold">Grupper</h5> <h5 class="mb-1 fw-bold">Grupper & Rettigheder</h5>
<p class="text-muted mb-0">Opret grupper og tildel rettigheder</p> <p class="text-muted mb-0">Opret grupper og tildel rettigheder</p>
</div> </div>
<button class="btn btn-outline-primary" onclick="showCreateGroupModal()"> <button class="btn btn-outline-primary" onclick="showCreateGroupModal()">
@ -1196,11 +1335,205 @@ let pipelineStagesCache = [];
let nextcloudInstancesCache = []; let nextcloudInstancesCache = [];
let customersCache = []; let customersCache = [];
function getSettingValue(key, fallback = '') {
const found = allSettings.find(s => s.key === key);
if (!found || found.value === null || found.value === undefined) return fallback;
return String(found.value);
}
function renderTelefoniSettings() {
const enabledEl = document.getElementById('telefoniClickEnabled');
const extEl = document.getElementById('telefoniDefaultExtension');
const templateEl = document.getElementById('telefoniActionTemplate');
const sharedSecretEl = document.getElementById('telefoniSharedSecret');
if (!enabledEl || !extEl || !templateEl || !sharedSecretEl) return;
enabledEl.checked = getSettingValue('telefoni_click_to_call_enabled', 'false') === 'true';
extEl.value = getSettingValue('telefoni_default_extension', '');
templateEl.value = getSettingValue('telefoni_action_url_template', '');
sharedSecretEl.value = getSettingValue('telefoni_shared_secret', '');
if (!document.getElementById('telefoniTestExtension').value) {
document.getElementById('telefoniTestExtension').value = extEl.value;
}
populateTelefoniTestUsers(usersCache || []);
const baseUrlEl = document.getElementById('yealinkBuilderBaseUrl');
if (baseUrlEl && !baseUrlEl.value) {
baseUrlEl.value = window.location.origin;
}
updateTelefoniActionPreview();
buildYealinkActionUrls();
}
function updateTelefoniActionPreview() {
const previewEl = document.getElementById('telefoniActionPreview');
const template = (document.getElementById('telefoniActionTemplate')?.value || '').trim();
const number = (document.getElementById('telefoniTestNumber')?.value || '22334455').trim();
const extension = (document.getElementById('telefoniTestExtension')?.value || document.getElementById('telefoniDefaultExtension')?.value || '').trim();
const userSelect = document.getElementById('telefoniTestUserId');
const selected = userSelect ? userSelect.options[userSelect.selectedIndex] : null;
const phoneIp = (selected?.dataset?.phoneIp || '').trim();
const phoneUsername = (selected?.dataset?.phoneUsername || '').trim();
const phonePassword = (selected?.dataset?.phonePassword || '').trim();
if (!previewEl) return;
if (!template) {
previewEl.textContent = '-';
return;
}
const resolved = template
.replaceAll('{number}', number)
.replaceAll('{raw_number}', number)
.replaceAll('{extension}', extension)
.replaceAll('{phone_ip}', phoneIp)
.replaceAll('{phone_username}', phoneUsername)
.replaceAll('{phone_password}', phonePassword);
previewEl.textContent = resolved;
}
function applyTelefoniTemplatePreset(preset) {
const templateEl = document.getElementById('telefoniActionTemplate');
if (!templateEl) return;
const presets = {
generic: 'http://{phone_ip}/servlet?number={number}&ext={extension}',
'yealink-basic-auth': 'http://{phone_username}:{phone_password}@{phone_ip}/servlet?key=number={raw_number}',
'yealink-open': 'http://{phone_ip}/servlet?key=number={raw_number}'
};
if (!presets[preset]) return;
templateEl.value = presets[preset];
updateTelefoniActionPreview();
}
function buildYealinkActionUrls() {
const baseRaw = (document.getElementById('yealinkBuilderBaseUrl')?.value || '').trim();
const manualToken = (document.getElementById('yealinkBuilderToken')?.value || '').trim();
const sharedToken = (document.getElementById('telefoniSharedSecret')?.value || '').trim();
const token = manualToken || sharedToken;
const estEl = document.getElementById('yealinkEstablishedUrl');
const termEl = document.getElementById('yealinkTerminatedUrl');
if (!estEl || !termEl) return;
if (!baseRaw) {
estEl.value = '';
termEl.value = '';
return;
}
const base = baseRaw.replace(/\/$/, '');
const tokenPart = token ? `token=${encodeURIComponent(token)}&` : '';
estEl.value = `${base}/api/v1/telefoni/established?${tokenPart}callid=$call_id&remote=$remote&local=$local&active_user=$active_user&called_number=$calledNumber`;
termEl.value = `${base}/api/v1/telefoni/terminated?${tokenPart}callid=$call_id&duration=$call_duration`;
}
async function copyYealinkUrl(inputId) {
const input = document.getElementById(inputId);
if (!input || !input.value) return;
try {
await navigator.clipboard.writeText(input.value);
showNotification('URL kopieret', 'success');
} catch (error) {
console.error('Copy failed:', error);
showNotification('Kunne ikke kopiere URL', 'error');
}
}
async function saveTelefoniSettings() {
const enabled = document.getElementById('telefoniClickEnabled')?.checked ? 'true' : 'false';
const extension = (document.getElementById('telefoniDefaultExtension')?.value || '').trim();
const template = (document.getElementById('telefoniActionTemplate')?.value || '').trim();
const sharedSecret = (document.getElementById('telefoniSharedSecret')?.value || '').trim();
if (!template.includes('{number}') && !template.includes('{raw_number}')) {
showNotification('Template skal indeholde {number} eller {raw_number}', 'error');
return;
}
await updateSetting('telefoni_click_to_call_enabled', enabled);
await updateSetting('telefoni_default_extension', extension);
await updateSetting('telefoni_action_url_template', template);
await updateSetting('telefoni_shared_secret', sharedSecret);
await loadSettings();
showNotification('Telefoni-indstillinger gemt', 'success');
}
function generateTelefoniToken() {
const input = document.getElementById('telefoniSharedSecret');
if (!input) return;
let token = '';
if (window.crypto && window.crypto.getRandomValues) {
const bytes = new Uint8Array(24);
window.crypto.getRandomValues(bytes);
token = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
} else {
token = `${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
}
input.value = token;
buildYealinkActionUrls();
showNotification('Nyt token genereret', 'success');
}
async function testTelefoniCall() {
const btn = document.getElementById('telefoniTestBtn');
const resultEl = document.getElementById('telefoniTestResult');
const number = (document.getElementById('telefoniTestNumber')?.value || '').trim();
const extension = (document.getElementById('telefoniTestExtension')?.value || '').trim();
const userIdRaw = (document.getElementById('telefoniTestUserId')?.value || '').trim();
const user_id = userIdRaw ? parseInt(userIdRaw, 10) : null;
if (!number) {
showNotification('Angiv et nummer til test', 'error');
return;
}
btn.disabled = true;
const original = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Ringer...';
resultEl.textContent = 'Sender Action URL...';
try {
const response = await fetch('/api/v1/telefoni/click-to-call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ number, extension: extension || null, user_id })
});
if (!response.ok) {
resultEl.textContent = await getErrorMessage(response, 'Testopkald fejlede');
showNotification('Testopkald fejlede', 'error');
return;
}
const data = await response.json();
resultEl.textContent = `✅ Kald sendt. HTTP ${data.http_status}. URL: ${data.action_url}`;
showNotification('Testopkald sendt', 'success');
} catch (error) {
console.error('Telefoni test call failed:', error);
resultEl.textContent = '❌ Kunne ikke sende testopkald';
showNotification('Kunne ikke sende testopkald', 'error');
} finally {
btn.disabled = false;
btn.innerHTML = original;
updateTelefoniActionPreview();
}
}
async function loadSettings() { async function loadSettings() {
try { try {
const response = await fetch('/api/v1/settings'); const response = await fetch('/api/v1/settings');
allSettings = await response.json(); allSettings = await response.json();
displaySettingsByCategory(); displaySettingsByCategory();
renderTelefoniSettings();
await loadCaseTypesSetting(); await loadCaseTypesSetting();
await loadNextcloudInstances(); await loadNextcloudInstances();
} catch (error) { } catch (error) {
@ -1634,10 +1967,11 @@ async function loadAdminUsers() {
if (!response.ok) throw new Error('Failed to load users'); if (!response.ok) throw new Error('Failed to load users');
usersCache = await response.json(); usersCache = await response.json();
displayUsers(usersCache); displayUsers(usersCache);
populateTelefoniTestUsers(usersCache);
} catch (error) { } catch (error) {
console.error('Error loading users:', error); console.error('Error loading users:', error);
const tbody = document.getElementById('usersTableBody'); const tbody = document.getElementById('usersTableBody');
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-5">Kunne ikke indlæse brugere</td></tr>'; tbody.innerHTML = '<tr><td colspan="11" class="text-center text-muted py-5">Kunne ikke indlæse brugere</td></tr>';
} }
} }
@ -1671,7 +2005,7 @@ function displayUsers(users) {
const tbody = document.getElementById('usersTableBody'); const tbody = document.getElementById('usersTableBody');
if (!users || users.length === 0) { if (!users || users.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-5">Ingen brugere fundet</td></tr>'; tbody.innerHTML = '<tr><td colspan="11" class="text-center text-muted py-5">Ingen brugere fundet</td></tr>';
return; return;
} }
@ -1701,15 +2035,71 @@ function displayUsers(users) {
${user.is_active ? 'Aktiv' : 'Inaktiv'} ${user.is_active ? 'Aktiv' : 'Inaktiv'}
</span> </span>
</td> </td>
<td style="min-width: 130px;">
<input
type="text"
class="form-control form-control-sm"
id="telefoni-extension-${user.user_id}"
value="${escapeHtml(user.telefoni_extension || '')}"
placeholder="fx 101"
maxlength="16"
>
</td>
<td style="min-width: 160px;">
<input
type="text"
class="form-control form-control-sm"
id="telefoni-phone-ip-${user.user_id}"
value="${escapeHtml(user.telefoni_phone_ip || '')}"
placeholder="fx 192.168.1.45"
maxlength="64"
>
</td>
<td style="min-width: 160px;">
<input
type="text"
class="form-control form-control-sm"
id="telefoni-phone-username-${user.user_id}"
value="${escapeHtml(user.telefoni_phone_username || '')}"
placeholder="fx admin"
maxlength="128"
>
</td>
<td style="min-width: 160px;">
<input
type="password"
class="form-control form-control-sm"
id="telefoni-phone-password-${user.user_id}"
value=""
placeholder="Lad tom for at beholde"
maxlength="255"
>
</td>
<td>
<div class="form-check d-flex justify-content-center">
<input
class="form-check-input"
type="checkbox"
id="telefoni-active-${user.user_id}"
${user.telefoni_aktiv ? 'checked' : ''}
>
</div>
</td>
<td>${user.created_at ? formatDate(user.created_at) : '<span class="text-muted">-</span>'}</td> <td>${user.created_at ? formatDate(user.created_at) : '<span class="text-muted">-</span>'}</td>
<td class="text-end"> <td class="text-end">
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
<button class="btn btn-light" onclick="saveUserTelefoni(${user.user_id})" title="Gem telefoni">
<i class="bi bi-telephone"></i>
</button>
<button class="btn btn-light" onclick="openUserGroupsModal(${user.user_id})" title="Tildel grupper"> <button class="btn btn-light" onclick="openUserGroupsModal(${user.user_id})" title="Tildel grupper">
<i class="bi bi-people"></i> <i class="bi bi-people"></i>
</button> </button>
<button class="btn btn-light" onclick="resetPassword(${user.user_id})" title="Nulstil adgangskode"> <button class="btn btn-light" onclick="resetPassword(${user.user_id})" title="Nulstil adgangskode">
<i class="bi bi-key"></i> <i class="bi bi-key"></i>
</button> </button>
<button class="btn btn-light" onclick="resetTwoFactor(${user.user_id})" title="Nulstil 2FA">
<i class="bi bi-shield-lock"></i>
</button>
<button class="btn btn-light" onclick="toggleUserActive(${user.user_id}, ${!user.is_active})" <button class="btn btn-light" onclick="toggleUserActive(${user.user_id}, ${!user.is_active})"
title="${user.is_active ? 'Deaktiver' : 'Aktiver'}"> title="${user.is_active ? 'Deaktiver' : 'Aktiver'}">
<i class="bi bi-${user.is_active ? 'pause' : 'play'}-circle"></i> <i class="bi bi-${user.is_active ? 'pause' : 'play'}-circle"></i>
@ -1721,6 +2111,77 @@ function displayUsers(users) {
}).join(''); }).join('');
} }
async function saveUserTelefoni(userId) {
const extInput = document.getElementById(`telefoni-extension-${userId}`);
const ipInput = document.getElementById(`telefoni-phone-ip-${userId}`);
const phoneUsernameInput = document.getElementById(`telefoni-phone-username-${userId}`);
const phonePasswordInput = document.getElementById(`telefoni-phone-password-${userId}`);
const activeInput = document.getElementById(`telefoni-active-${userId}`);
if (!extInput || !ipInput || !phoneUsernameInput || !phonePasswordInput || !activeInput) return;
const telefoni_extension = (extInput.value || '').trim() || null;
const telefoni_phone_ip = (ipInput.value || '').trim() || null;
const telefoni_phone_username = (phoneUsernameInput.value || '').trim() || null;
const telefoni_phone_password = (phonePasswordInput.value || '').trim() || null;
const telefoni_aktiv = !!activeInput.checked;
try {
const response = await fetch(`/api/v1/telefoni/admin/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
telefoni_extension,
telefoni_phone_ip,
telefoni_phone_username,
telefoni_phone_password,
telefoni_aktiv
})
});
if (!response.ok) {
alert(await getErrorMessage(response, 'Kunne ikke gemme telefoni-indstillinger'));
return;
}
await loadAdminUsers();
} catch (error) {
console.error('Error saving telefoni mapping:', error);
alert('Kunne ikke gemme telefoni-indstillinger');
}
}
function populateTelefoniTestUsers(users = []) {
const select = document.getElementById('telefoniTestUserId');
if (!select) return;
const current = select.value;
select.innerHTML = '<option value="">Ingen valgt</option>';
users.forEach(u => {
const option = document.createElement('option');
option.value = String(u.user_id);
const label = u.full_name || u.username || `User ${u.user_id}`;
const ext = u.telefoni_extension ? ` ext:${u.telefoni_extension}` : '';
const ip = u.telefoni_phone_ip ? ` ip:${u.telefoni_phone_ip}` : '';
const phoneUser = u.telefoni_phone_username ? ` user:${u.telefoni_phone_username}` : '';
option.textContent = `${label}${ext}${ip}${phoneUser}`;
option.dataset.extension = u.telefoni_extension || '';
option.dataset.phoneIp = u.telefoni_phone_ip || '';
option.dataset.phoneUsername = u.telefoni_phone_username || '';
select.appendChild(option);
});
if (current) select.value = current;
}
function onTelefoniTestUserChange() {
const select = document.getElementById('telefoniTestUserId');
const extInput = document.getElementById('telefoniTestExtension');
if (!select || !extInput) return;
const selected = select.options[select.selectedIndex];
if (selected && selected.dataset.extension && !extInput.value.trim()) {
extInput.value = selected.dataset.extension;
}
updateTelefoniActionPreview();
}
function displayGroups(groups) { function displayGroups(groups) {
const tbody = document.getElementById('groupsTableBody'); const tbody = document.getElementById('groupsTableBody');
@ -1997,6 +2458,34 @@ async function resetPassword(userId) {
} }
} }
async function resetTwoFactor(userId) {
const confirmed = confirm('Nulstil 2FA for denne bruger?');
if (!confirmed) return;
const reasonRaw = prompt('Begrundelse (valgfri):') || '';
const reason = reasonRaw.trim();
const payload = reason ? { reason } : {};
try {
const response = await fetch(`/api/v1/admin/users/${userId}/2fa/reset`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) {
alert('2FA nulstillet!');
await loadAdminUsers();
return;
}
alert(await getErrorMessage(response, 'Kunne ikke nulstille 2FA'));
} catch (error) {
console.error('Error resetting 2FA:', error);
alert('Kunne ikke nulstille 2FA');
}
}
function getInitials(name) { function getInitials(name) {
return name.split(' ').map(word => word[0]).join('').substring(0, 2).toUpperCase(); return name.split(' ').map(word => word[0]).join('').substring(0, 2).toUpperCase();
} }
@ -2257,6 +2746,8 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
// Load data for tab // Load data for tab
if (tab === 'users') { if (tab === 'users') {
loadUsers(); loadUsers();
} else if (tab === 'telefoni') {
renderTelefoniSettings();
} else if (tab === 'ai-prompts') { } else if (tab === 'ai-prompts') {
loadAIPrompts(); loadAIPrompts();
} else if (tab === 'modules') { } else if (tab === 'modules') {
@ -2963,6 +3454,20 @@ document.addEventListener('DOMContentLoaded', () => {
loadUsers(); loadUsers();
setupTagModalListeners(); setupTagModalListeners();
loadPipelineStages(); loadPipelineStages();
const telefoniTemplate = document.getElementById('telefoniActionTemplate');
const telefoniDefaultExt = document.getElementById('telefoniDefaultExtension');
const telefoniTestNumber = document.getElementById('telefoniTestNumber');
const telefoniTestExt = document.getElementById('telefoniTestExtension');
[telefoniTemplate, telefoniDefaultExt, telefoniTestNumber, telefoniTestExt].forEach(el => {
if (el) el.addEventListener('input', updateTelefoniActionPreview);
});
const yealinkBase = document.getElementById('yealinkBuilderBaseUrl');
const yealinkToken = document.getElementById('yealinkBuilderToken');
[yealinkBase, yealinkToken].forEach(el => {
if (el) el.addEventListener('input', buildYealinkActionUrls);
});
}); });
</script> </script>

View File

@ -231,6 +231,11 @@
<i class="bi bi-list-check me-2"></i>Sager <i class="bi bi-list-check me-2"></i>Sager
</a> </a>
</li> </li>
<li class="nav-item">
<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">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false"> <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 <i class="bi bi-headset me-2"></i>Support
@ -240,6 +245,7 @@
<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="/ticket/archived"><i class="bi bi-archive me-2"></i>Arkiverede Tickets</a></li>
<li><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>Hardware Assets</a></li> <li><a class="dropdown-item py-2" href="/hardware"><i class="bi bi-laptop me-2"></i>Hardware Assets</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><a class="dropdown-item py-2" href="/hardware/eset"><i class="bi bi-shield-check me-2"></i>ESET Oversigt</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="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></li> <li><a class="dropdown-item py-2" href="/app/locations"><i class="bi bi-map-fill me-2"></i>Lokaliteter</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></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="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
@ -517,6 +523,8 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/tag-picker.js?v=2.0"></script> <script src="/static/js/tag-picker.js?v=2.0"></script>
<script src="/static/js/notifications.js?v=1.0"></script> <script src="/static/js/notifications.js?v=1.0"></script>
<script src="/static/js/telefoni.js?v=1.0"></script>
<script src="/static/js/sms.js?v=1.0"></script>
<script> <script>
// Dark Mode Toggle Logic // Dark Mode Toggle Logic
const darkModeToggle = document.getElementById('darkModeToggle'); const darkModeToggle = document.getElementById('darkModeToggle');
@ -1261,6 +1269,39 @@
}); });
</script> </script>
<!-- SMS Modal -->
<div class="modal fade" id="smsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="smsModalTitle">Send SMS</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Modtager</label>
<input type="text" class="form-control" id="smsRecipientInput" readonly>
</div>
<div class="mb-3">
<label class="form-label">Afsender</label>
<input type="text" class="form-control" id="smsSenderInput" placeholder="Valgfrit">
</div>
<div class="mb-2">
<label class="form-label">Besked</label>
<textarea class="form-control" id="smsMessageInput" rows="4" maxlength="1530"></textarea>
<div class="d-flex justify-content-end mt-1">
<small class="text-muted" id="smsCharCounter">0/1530</small>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" id="smsSendBtn">Send</button>
</div>
</div>
</div>
</div>
<!-- Maintenance Mode Overlay --> <!-- Maintenance Mode Overlay -->
<div id="maintenance-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); z-index: 9999; backdrop-filter: blur(5px);"> <div id="maintenance-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); z-index: 9999; backdrop-filter: blur(5px);">
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; color: white; max-width: 500px; padding: 2rem;"> <div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; color: white; max-width: 500px; padding: 2rem;">

View File

@ -0,0 +1,57 @@
# ESET Support Request Full Software Inventory Access
## Background
BMC Hub can currently fetch device details from ESET Device Management, but only receives ESET component entries (typically Endpoint Security + Management Agent) via `deployedComponents`.
Goal: retrieve full installed programs list (OS-level software inventory) with version numbers per device.
## Environment
- Tenant/API base: `https://eu.device-management.eset.systems`
- IAM: `https://eu.business-account.iam.eset.systems`
- Integration uses OAuth and works for:
- `GET /v1/devices`
- `GET /v1/devices/{uuid}`
- `GET /v1/devices:batchGet`
## Observed limitation
For all tested devices in our dataset:
- `deployedComponents` contains only 12 ESET products
- no complete software inventory is present
Local dataset stats (sample):
- 50 devices with 2 components
- 1 device with 1 component
- 0 devices with >2 components
## Endpoint probing results
We tested candidate endpoint for software inventory:
- `GET /v1/devices/{uuid}:getSoftware` → HTTP 400
- `POST /v1/devices/{uuid}:getSoftware` → HTTP 404
No OpenAPI/Swagger endpoint was discoverable via common paths.
## Request IDs from ESET responses
Please use these IDs to trace calls in your logs:
- `7f68c8c1-1caf-4412-b57c-57735080995e` (GET :getSoftware)
- `fb367209-c619-49aa-88b4-2913802009b1` (GET :getSoftware with pageSize)
- `aab9206f-3ba2-4d1f-bb74-0fb525889c5e` (GET :getSoftware with limit)
- `6814a433-e240-4c65-b313-6cefb7c4e74d` (POST :getSoftware empty body)
- `1a261e22-8045-4b7a-a479-edecfe4e0b60` (POST :getSoftware page body)
- `ad166b80-5056-45de-9a8c-a6e8c4c2b0c2` (POST :getSoftware with deviceUuid)
## What we need from ESET
1. The correct endpoint(s) for full installed software inventory per device.
2. Required OAuth scopes/permissions/role assignments for this data.
3. Required HTTP method and request format (query/body, paging schema).
4. Confirmation whether this data is available in our tenant/region (`eu`).
## Expected response shape
We need a list like:
- app name
- version
- (optional) vendor/publisher
- (optional) install date
## Why this matters
Our UI already supports app + version table rendering, but source data is currently limited to ESET components.
Once full inventory endpoint/permissions are confirmed, we can integrate immediately.

12
main.py
View File

@ -86,6 +86,10 @@ from app.modules.nextcloud.backend import router as nextcloud_api
from app.modules.search.backend import router as search_api from app.modules.search.backend import router as search_api
from app.modules.wiki.backend import router as wiki_api from app.modules.wiki.backend import router as wiki_api
from app.fixed_price.backend import router as fixed_price_api from app.fixed_price.backend import router as fixed_price_api
from app.modules.telefoni.backend import router as telefoni_api
from app.modules.telefoni.frontend import views as telefoni_views
from app.modules.calendar.backend import router as calendar_api
from app.modules.calendar.frontend import views as calendar_views
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@ -184,6 +188,10 @@ async def auth_middleware(request: Request, call_next):
"/api/v1/auth/login" "/api/v1/auth/login"
} }
# Yealink Action URL callbacks (secured inside telefoni module by token/IP)
public_paths.add("/api/v1/telefoni/established")
public_paths.add("/api/v1/telefoni/terminated")
if settings.DEV_ALLOW_ARCHIVED_IMPORT: if settings.DEV_ALLOW_ARCHIVED_IMPORT:
public_paths.add("/api/v1/ticket/archived/simply/import") public_paths.add("/api/v1/ticket/archived/simply/import")
public_paths.add("/api/v1/ticket/archived/simply/modules") public_paths.add("/api/v1/ticket/archived/simply/modules")
@ -283,6 +291,8 @@ app.include_router(nextcloud_api.router, prefix="/api/v1/nextcloud", tags=["Next
app.include_router(search_api.router, prefix="/api/v1", tags=["Search"]) app.include_router(search_api.router, prefix="/api/v1", tags=["Search"])
app.include_router(wiki_api.router, prefix="/api/v1/wiki", tags=["Wiki"]) app.include_router(wiki_api.router, prefix="/api/v1/wiki", tags=["Wiki"])
app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devportal"]) app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devportal"])
app.include_router(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"])
app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
# Frontend Routers # Frontend Routers
app.include_router(dashboard_views.router, tags=["Frontend"]) app.include_router(dashboard_views.router, tags=["Frontend"])
@ -308,6 +318,8 @@ app.include_router(sag_views.router, tags=["Frontend"])
app.include_router(hardware_module_views.router, tags=["Frontend"]) app.include_router(hardware_module_views.router, tags=["Frontend"])
app.include_router(locations_views.router, tags=["Frontend"]) app.include_router(locations_views.router, tags=["Frontend"])
app.include_router(devportal_views.router, tags=["Frontend"]) app.include_router(devportal_views.router, tags=["Frontend"])
app.include_router(telefoni_views.router, tags=["Frontend"])
app.include_router(calendar_views.router, tags=["Frontend"])
# Serve static files (UI) # Serve static files (UI)
app.mount("/static", StaticFiles(directory="static", html=True), name="static") app.mount("/static", StaticFiles(directory="static", html=True), name="static")

View File

@ -0,0 +1,34 @@
-- Migration 120: Telefoni (Yealink) module
-- Dato: 12. februar 2026
-- Add telephony fields to users (chosen approach)
ALTER TABLE users
ADD COLUMN IF NOT EXISTS telefoni_extension VARCHAR(16),
ADD COLUMN IF NOT EXISTS telefoni_aktiv BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_users_telefoni_extension ON users(telefoni_extension);
CREATE INDEX IF NOT EXISTS idx_users_telefoni_aktiv ON users(telefoni_aktiv);
-- Call log table (isolated)
CREATE TABLE IF NOT EXISTS telefoni_opkald (
id BIGSERIAL PRIMARY KEY,
callid VARCHAR(128) NOT NULL,
bruger_id INTEGER,
direction VARCHAR(16) NOT NULL CHECK (direction IN ('inbound', 'outbound')),
ekstern_nummer VARCHAR(32),
intern_extension VARCHAR(16),
kontakt_id INTEGER,
sag_id INTEGER,
started_at TIMESTAMP NOT NULL,
ended_at TIMESTAMP,
duration_sec INTEGER,
raw_payload JSONB NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Ensure idempotency per call
CREATE UNIQUE INDEX IF NOT EXISTS ux_telefoni_opkald_callid ON telefoni_opkald(callid);
CREATE INDEX IF NOT EXISTS idx_telefoni_opkald_bruger_id ON telefoni_opkald(bruger_id);
CREATE INDEX IF NOT EXISTS idx_telefoni_opkald_ekstern_nummer ON telefoni_opkald(ekstern_nummer);
CREATE INDEX IF NOT EXISTS idx_telefoni_opkald_started_at ON telefoni_opkald(started_at);
CREATE INDEX IF NOT EXISTS idx_telefoni_opkald_sag_id ON telefoni_opkald(sag_id);

View File

@ -0,0 +1,8 @@
-- Migration 121: Telefoni settings (click-to-call)
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES
('telefoni_click_to_call_enabled', 'false', 'telefoni', 'Aktiver click-to-call via Action URL', 'boolean', true),
('telefoni_default_extension', '', 'telefoni', 'Standard extension til click-to-call', 'string', true),
('telefoni_action_url_template', '', 'telefoni', 'Action URL template ({number}, {raw_number}, {extension})', 'string', false)
ON CONFLICT (key) DO NOTHING;

View File

@ -0,0 +1,6 @@
-- Migration 122: Per-user phone IP for telephony
ALTER TABLE users
ADD COLUMN IF NOT EXISTS telefoni_phone_ip VARCHAR(64);
CREATE INDEX IF NOT EXISTS idx_users_telefoni_phone_ip ON users(telefoni_phone_ip);

View File

@ -0,0 +1,5 @@
-- Migration 123: Per-user phone credentials for telephony click-to-call
ALTER TABLE users
ADD COLUMN IF NOT EXISTS telefoni_phone_username VARCHAR(128),
ADD COLUMN IF NOT EXISTS telefoni_phone_password VARCHAR(255);

View File

@ -0,0 +1,6 @@
-- Migration 124: Telefoni shared secret setting
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES
('telefoni_shared_secret', '', 'telefoni', 'Shared secret token til Yealink callbacks', 'string', false)
ON CONFLICT (key) DO NOTHING;

View File

@ -0,0 +1,16 @@
-- Store SMS events linked to contacts for unified contact communication history
CREATE TABLE IF NOT EXISTS sms_messages (
id SERIAL PRIMARY KEY,
kontakt_id INTEGER NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
bruger_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
recipient VARCHAR(64) NOT NULL,
sender VARCHAR(64),
message TEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'sent',
provider_response JSONB,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sms_messages_kontakt_id ON sms_messages(kontakt_id);
CREATE INDEX IF NOT EXISTS idx_sms_messages_created_at ON sms_messages(created_at DESC);

View File

@ -0,0 +1,10 @@
-- Migration 126: Add event_type to sag_reminders
-- Dato: 13. februar 2026
ALTER TABLE sag_reminders
ADD COLUMN IF NOT EXISTS event_type VARCHAR(30) NOT NULL DEFAULT 'reminder'
CHECK (event_type IN ('reminder', 'meeting', 'technician_visit', 'obs', 'deadline'));
CREATE INDEX IF NOT EXISTS idx_sag_reminders_event_type
ON sag_reminders(event_type)
WHERE deleted_at IS NULL;

View File

@ -0,0 +1,18 @@
-- Add todo steps for cases
CREATE TABLE IF NOT EXISTS sag_todo_steps (
id SERIAL PRIMARY KEY,
sag_id INT NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
due_date DATE,
is_done BOOLEAN NOT NULL DEFAULT FALSE,
created_by_user_id INT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_by_user_id INT,
completed_at TIMESTAMP,
deleted_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_sag_todo_steps_sag_id ON sag_todo_steps (sag_id);
CREATE INDEX IF NOT EXISTS idx_sag_todo_steps_is_done ON sag_todo_steps (is_done);
CREATE INDEX IF NOT EXISTS idx_sag_todo_steps_due_date ON sag_todo_steps (due_date);

150
static/js/sms.js Normal file
View File

@ -0,0 +1,150 @@
let smsModalInstance = null;
function normalizeSmsNumber(number) {
const raw = String(number || '').trim();
if (!raw) return '';
let cleaned = raw.replace(/[^\d+]/g, '');
if (cleaned.startsWith('+')) cleaned = cleaned.slice(1);
if (cleaned.startsWith('00')) cleaned = cleaned.slice(2);
if (/^\d{8}$/.test(cleaned)) cleaned = `45${cleaned}`;
return cleaned;
}
async function sendSms(number, message, sender = null, contactId = null) {
const to = normalizeSmsNumber(number);
if (!to) {
alert('Ugyldigt mobilnummer');
return { ok: false };
}
const response = await fetch('/api/v1/sms/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ to, message, sender: sender || null, contact_id: contactId || null })
});
if (!response.ok) {
const text = await response.text();
alert('SMS fejlede: ' + text);
return { ok: false };
}
const data = await response.json();
alert('SMS sendt ✅');
return { ok: true, data };
}
function updateSmsCounter() {
const textarea = document.getElementById('smsMessageInput');
const counter = document.getElementById('smsCharCounter');
if (!textarea || !counter) return;
const len = String(textarea.value || '').length;
counter.textContent = `${len}/1530`;
counter.classList.toggle('text-danger', len > 1530);
}
function ensureSmsModal() {
const modalEl = document.getElementById('smsModal');
if (!modalEl || !window.bootstrap) return null;
if (!smsModalInstance) {
smsModalInstance = new bootstrap.Modal(modalEl);
}
return smsModalInstance;
}
async function submitSmsFromModal() {
const recipientInput = document.getElementById('smsRecipientInput');
const senderInput = document.getElementById('smsSenderInput');
const messageInput = document.getElementById('smsMessageInput');
const sendBtn = document.getElementById('smsSendBtn');
if (!recipientInput || !messageInput || !sendBtn) return;
const recipient = String(recipientInput.value || '').trim();
const message = String(messageInput.value || '').trim();
const sender = String(senderInput?.value || '').trim();
const contactIdRaw = String(recipientInput.dataset.contactId || '').trim();
const contactId = contactIdRaw ? Number(contactIdRaw) : null;
if (!recipient) {
alert('Modtager mangler');
return;
}
if (!message) {
alert('Besked må ikke være tom');
return;
}
if (message.length > 1530) {
alert('Besked er for lang (max 1530 tegn)');
return;
}
const original = sendBtn.innerHTML;
sendBtn.disabled = true;
sendBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Sender...';
try {
const result = await sendSms(recipient, message, sender || null, contactId);
if (result.ok) {
const modal = ensureSmsModal();
if (modal) modal.hide();
messageInput.value = '';
updateSmsCounter();
}
} finally {
sendBtn.disabled = false;
sendBtn.innerHTML = original;
}
}
async function openSmsPrompt(number, label = '', contactId = null) {
const recipient = String(number || '').trim();
if (!recipient || recipient === '-') {
alert('Intet gyldigt mobilnummer');
return;
}
const modal = ensureSmsModal();
const recipientInput = document.getElementById('smsRecipientInput');
const title = document.getElementById('smsModalTitle');
const messageInput = document.getElementById('smsMessageInput');
if (modal && recipientInput && title && messageInput) {
recipientInput.value = recipient;
recipientInput.dataset.contactId = contactId ? String(contactId) : '';
title.textContent = label ? `Send SMS til ${label}` : 'Send SMS';
if (!messageInput.value) {
messageInput.value = '';
}
updateSmsCounter();
modal.show();
setTimeout(() => messageInput.focus(), 200);
return;
}
const fallbackPrefix = label ? `SMS til ${label} (${recipient})` : `SMS til ${recipient}`;
const fallbackMessage = window.prompt(`${fallbackPrefix}\n\nSkriv besked:`);
if (fallbackMessage === null) return;
if (!String(fallbackMessage).trim()) {
alert('Besked må ikke være tom');
return;
}
try {
await sendSms(recipient, String(fallbackMessage), null, contactId);
} catch (error) {
console.error('SMS send failed:', error);
alert('Kunne ikke sende SMS');
}
}
document.addEventListener('DOMContentLoaded', () => {
const messageInput = document.getElementById('smsMessageInput');
const sendBtn = document.getElementById('smsSendBtn');
if (messageInput) {
messageInput.addEventListener('input', updateSmsCounter);
updateSmsCounter();
}
if (sendBtn) {
sendBtn.addEventListener('click', submitSmsFromModal);
}
});

169
static/js/telefoni.js Normal file
View File

@ -0,0 +1,169 @@
(() => {
let ws = null;
let reconnectTimer = null;
function normalizeToken(value) {
const token = String(value || '').trim();
if (!token) return '';
if (token.toLowerCase().startsWith('bearer ')) {
return token.slice(7).trim();
}
return token;
}
function getCookie(name) {
const cookie = document.cookie || '';
const parts = cookie.split(';').map(p => p.trim());
const match = parts.find(p => p.startsWith(`${name}=`));
if (!match) return '';
return decodeURIComponent(match.slice(name.length + 1));
}
function getToken() {
const fromLocal = normalizeToken(localStorage.getItem('access_token'));
if (fromLocal) return fromLocal;
const fromSession = normalizeToken(sessionStorage.getItem('access_token'));
if (fromSession) return fromSession;
const fromCookie = normalizeToken(getCookie('access_token'));
return fromCookie;
}
function ensureContainer() {
let container = document.getElementById('telefoni-toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'telefoni-toast-container';
container.setAttribute('aria-live', 'polite');
container.setAttribute('aria-atomic', 'true');
container.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
width: 420px;
max-width: 90%;
`;
document.body.appendChild(container);
}
return container;
}
function escapeHtml(str) {
return String(str ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function showIncomingCallToast(data) {
const container = ensureContainer();
const contact = data.contact || null;
const number = data.number || '';
const title = contact?.name ? contact.name : 'Ukendt nummer';
const company = contact?.company ? contact.company : '';
const callId = data.call_id;
const toastEl = document.createElement('div');
toastEl.className = 'toast align-items-stretch';
toastEl.setAttribute('role', 'alert');
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>`
: '';
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>` : ''}
<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>
</div>
</div>
`;
container.appendChild(toastEl);
const toast = new bootstrap.Toast(toastEl, { autohide: false });
toast.show();
toastEl.addEventListener('hidden.bs.toast', () => toastEl.remove());
toastEl.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const action = btn.getAttribute('data-action');
if (action === 'open-contact' && contact?.id) {
window.location.href = `/contacts/${contact.id}`;
}
if (action === 'create-case') {
const qs = new URLSearchParams();
if (contact?.id) qs.set('contact_id', String(contact.id));
qs.set('title', `Telefonsamtale ${number}`);
qs.set('telefoni_opkald_id', String(callId));
window.location.href = `/sag/new?${qs.toString()}`;
}
});
}
function scheduleReconnect() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, 5000);
}
function connect() {
if (ws && ws.readyState === WebSocket.OPEN) {
return;
}
const token = getToken();
if (!token) {
scheduleReconnect();
return;
}
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${proto}://${window.location.host}/api/v1/telefoni/ws?token=${encodeURIComponent(token)}`;
ws = new WebSocket(url);
ws.onopen = () => console.log('📞 Telefoni WS connected');
ws.onclose = (evt) => {
console.log('📞 Telefoni WS disconnected', evt.code, evt.reason || '');
scheduleReconnect();
};
ws.onerror = () => {
// onclose handles reconnect
};
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
if (msg?.event === 'incoming_call') {
showIncomingCallToast(msg.data || {});
}
} catch (e) {
console.warn('Telefoni WS message parse failed', e);
}
};
}
document.addEventListener('DOMContentLoaded', connect);
window.addEventListener('focus', connect);
window.addEventListener('storage', (evt) => {
if (evt.key === 'access_token') connect();
});
})();