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:
parent
7eda0ce58b
commit
0831715d3a
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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):
|
||||||
@ -71,6 +72,11 @@ async def login(request: Request, credentials: LoginRequest, response: Response)
|
|||||||
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",
|
||||||
value=access_token,
|
value=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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
)
|
||||||
|
|||||||
145
app/auth/frontend/2fa_setup.html
Normal file
145
app/auth/frontend/2fa_setup.html
Normal 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 %}
|
||||||
@ -125,6 +125,12 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
|||||||
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 {
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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>
|
||||||
<a class="btn btn-outline-secondary btn-sm" id="hardwareCustomerLink" href="#">
|
<div class="d-flex gap-2">
|
||||||
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn kundedetalje
|
<a class="btn btn-outline-secondary btn-sm" href="/hardware/new">
|
||||||
|
<i class="bi bi-plus-lg me-2"></i>Nyt hardware
|
||||||
</a>
|
</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>
|
</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() {
|
||||||
|
|||||||
@ -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}`);
|
||||||
|
|||||||
@ -140,6 +140,23 @@ class AuthService:
|
|||||||
)
|
)
|
||||||
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(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
@ -213,6 +214,10 @@ class Settings(BaseSettings):
|
|||||||
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
|
||||||
ESET_API_URL: str = "https://eu.device-management.eset.systems"
|
ESET_API_URL: str = "https://eu.device-management.eset.systems"
|
||||||
|
|||||||
@ -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"""
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
@ -1190,6 +1214,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}, { 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"]');
|
||||||
if (subscriptionsTab) {
|
if (subscriptionsTab) {
|
||||||
@ -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>';
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
341
app/modules/calendar/backend/router.py
Normal file
341
app/modules/calendar/backend/router.py
Normal 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",
|
||||||
|
)
|
||||||
26
app/modules/calendar/frontend/views.py
Normal file
26
app/modules/calendar/frontend/views.py
Normal 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,
|
||||||
|
},
|
||||||
|
)
|
||||||
888
app/modules/calendar/templates/index.html
Normal file
888
app/modules/calendar/templates/index.html
Normal 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 %}
|
||||||
@ -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."""
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -299,7 +299,15 @@
|
|||||||
</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">
|
||||||
|
<div class="btn-group btn-group-sm" role="group" aria-label="Visning">
|
||||||
|
<button type="button" class="btn btn-outline-secondary active" id="viewCardsBtn" onclick="setHardwareView('cards')">Kort</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" id="viewTableBtn" onclick="setHardwareView('table')">Tabel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="hardwareCardsView">
|
||||||
|
<div class="hardware-grid">
|
||||||
{% for item in hardware %}
|
{% for item in hardware %}
|
||||||
<div class="hardware-card" onclick="window.location.href='/hardware/{{ item.id }}'">
|
<div class="hardware-card" onclick="window.location.href='/hardware/{{ item.id }}'">
|
||||||
<div class="hardware-header">
|
<div class="hardware-header">
|
||||||
@ -367,6 +375,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% 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>
|
</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 %}
|
||||||
|
|||||||
@ -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'],
|
||||||
@ -431,6 +438,10 @@ async def update_sag_reminder(reminder_id: int, update: ReminderUpdate):
|
|||||||
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")
|
||||||
params.append(update.notify_mattermost)
|
params.append(update.notify_mattermost)
|
||||||
|
|||||||
@ -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:
|
||||||
@ -52,8 +74,17 @@ async def list_sager(
|
|||||||
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))
|
||||||
|
|
||||||
# If tag filter, filter in Python after fetch
|
# If tag filter, filter in Python after fetch
|
||||||
@ -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)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 =>
|
||||||
|
|||||||
@ -741,6 +741,113 @@
|
|||||||
</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 -->
|
||||||
@ -789,8 +896,6 @@
|
|||||||
</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 %}
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
<a href="tel:{{ hovedkontakt.mobile }}" style="color: var(--accent);">
|
<a href="tel:{{ hovedkontakt.mobile }}" style="color: var(--accent);">
|
||||||
<i class="bi bi-phone me-1"></i>{{ hovedkontakt.mobile }}
|
<i class="bi bi-phone me-1"></i>{{ hovedkontakt.mobile }}
|
||||||
</a>
|
</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);
|
||||||
|
container.innerHTML = '<div class="p-3 text-center text-danger">Fejl ved hentning af emails</div>';
|
||||||
|
setModuleContentState('emails', true);
|
||||||
}
|
}
|
||||||
} catch(e) { console.error(e); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
app/modules/telefoni/__init__.py
Normal file
1
app/modules/telefoni/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Telefoni module package."""
|
||||||
1
app/modules/telefoni/backend/__init__.py
Normal file
1
app/modules/telefoni/backend/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Telefoni backend package."""
|
||||||
667
app/modules/telefoni/backend/router.py
Normal file
667
app/modules/telefoni/backend/router.py
Normal 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"}
|
||||||
28
app/modules/telefoni/backend/schemas.py
Normal file
28
app/modules/telefoni/backend/schemas.py
Normal 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
|
||||||
111
app/modules/telefoni/backend/service.py
Normal file
111
app/modules/telefoni/backend/service.py
Normal 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)
|
||||||
102
app/modules/telefoni/backend/utils.py
Normal file
102
app/modules/telefoni/backend/utils.py
Normal 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
|
||||||
67
app/modules/telefoni/backend/websocket.py
Normal file
67
app/modules/telefoni/backend/websocket.py
Normal 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()
|
||||||
1
app/modules/telefoni/frontend/__init__.py
Normal file
1
app/modules/telefoni/frontend/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Telefoni frontend package."""
|
||||||
14
app/modules/telefoni/frontend/views.py
Normal file
14
app/modules/telefoni/frontend/views.py
Normal 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})
|
||||||
512
app/modules/telefoni/templates/log.html
Normal file
512
app/modules/telefoni/templates/log.html
Normal 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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 %}
|
||||||
@ -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()
|
||||||
|
|||||||
96
app/services/sms_service.py
Normal file
96
app/services/sms_service.py
Normal 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")
|
||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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;">
|
||||||
|
|||||||
57
docs/ESET_SOFTWARE_INVENTORY_SUPPORT_REQUEST.md
Normal file
57
docs/ESET_SOFTWARE_INVENTORY_SUPPORT_REQUEST.md
Normal 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 1–2 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
12
main.py
@ -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")
|
||||||
|
|||||||
34
migrations/120_telefoni_module.sql
Normal file
34
migrations/120_telefoni_module.sql
Normal 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);
|
||||||
8
migrations/121_telefoni_settings.sql
Normal file
8
migrations/121_telefoni_settings.sql
Normal 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;
|
||||||
6
migrations/122_telefoni_user_phone_ip.sql
Normal file
6
migrations/122_telefoni_user_phone_ip.sql
Normal 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);
|
||||||
5
migrations/123_telefoni_user_phone_credentials.sql
Normal file
5
migrations/123_telefoni_user_phone_credentials.sql
Normal 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);
|
||||||
6
migrations/124_telefoni_shared_secret_setting.sql
Normal file
6
migrations/124_telefoni_shared_secret_setting.sql
Normal 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;
|
||||||
16
migrations/125_sms_messages.sql
Normal file
16
migrations/125_sms_messages.sql
Normal 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);
|
||||||
10
migrations/126_sag_reminders_event_type.sql
Normal file
10
migrations/126_sag_reminders_event_type.sql
Normal 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;
|
||||||
18
migrations/127_sag_todo_steps.sql
Normal file
18
migrations/127_sag_todo_steps.sql
Normal 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
150
static/js/sms.js
Normal 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
169
static/js/telefoni.js
Normal 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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
Loading…
Reference in New Issue
Block a user