Compare commits
No commits in common. "main" and "v2.3.14" have entirely different histories.
@ -9,7 +9,7 @@ from typing import List, Dict, Optional
|
|||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single, table_has_column
|
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.economic_service import get_economic_service
|
from app.services.economic_service import get_economic_service
|
||||||
from app.services.ollama_service import ollama_service
|
from app.services.ollama_service import ollama_service
|
||||||
@ -710,29 +710,8 @@ async def list_supplier_invoices(
|
|||||||
params.append(vendor_id)
|
params.append(vendor_id)
|
||||||
|
|
||||||
if sag_id:
|
if sag_id:
|
||||||
if table_has_column("supplier_invoices", "sag_id"):
|
|
||||||
query += " AND si.sag_id = %s"
|
query += " AND si.sag_id = %s"
|
||||||
params.append(sag_id)
|
params.append(sag_id)
|
||||||
elif (
|
|
||||||
table_has_column("supplier_invoice_relations", "supplier_invoice_id")
|
|
||||||
and table_has_column("supplier_invoice_relations", "relation_type")
|
|
||||||
and table_has_column("supplier_invoice_relations", "relation_id")
|
|
||||||
):
|
|
||||||
query += """
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM supplier_invoice_relations sir
|
|
||||||
WHERE sir.supplier_invoice_id = si.id
|
|
||||||
AND sir.relation_type = 'sag'
|
|
||||||
AND sir.relation_id = %s
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
params.append(sag_id)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"⚠️ supplier invoice sag filter requested, but no schema link available (sag_id column/relation table missing)"
|
|
||||||
)
|
|
||||||
query += " AND 1 = 0"
|
|
||||||
|
|
||||||
if overdue_only:
|
if overdue_only:
|
||||||
query += " AND si.due_date < CURRENT_DATE AND si.paid_date IS NULL"
|
query += " AND si.due_date < CURRENT_DATE AND si.paid_date IS NULL"
|
||||||
|
|||||||
@ -88,26 +88,8 @@ async def get_contacts(
|
|||||||
params = []
|
params = []
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
where_clauses.append(
|
where_clauses.append("(c.first_name ILIKE %s OR c.last_name ILIKE %s OR c.email ILIKE %s)")
|
||||||
"""
|
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
|
||||||
(
|
|
||||||
c.first_name ILIKE %s
|
|
||||||
OR c.last_name ILIKE %s
|
|
||||||
OR c.email ILIKE %s
|
|
||||||
OR c.phone ILIKE %s
|
|
||||||
OR c.mobile ILIKE %s
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM contact_companies cc2
|
|
||||||
JOIN customers cu2 ON cu2.id = cc2.customer_id
|
|
||||||
WHERE cc2.contact_id = c.id
|
|
||||||
AND cu2.name ILIKE %s
|
|
||||||
)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
like = f"%{search}%"
|
|
||||||
params.extend([like, like, like, like, like, like])
|
|
||||||
|
|
||||||
if is_active is not None:
|
if is_active is not None:
|
||||||
where_clauses.append("c.is_active = %s")
|
where_clauses.append("c.is_active = %s")
|
||||||
|
|||||||
@ -113,27 +113,8 @@ async def get_contacts(
|
|||||||
params = []
|
params = []
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
where_clauses.append(
|
where_clauses.append("(c.first_name ILIKE %s OR c.last_name ILIKE %s OR c.email ILIKE %s)")
|
||||||
"""
|
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
|
||||||
(
|
|
||||||
c.first_name ILIKE %s
|
|
||||||
OR c.last_name ILIKE %s
|
|
||||||
OR c.email ILIKE %s
|
|
||||||
OR c.phone ILIKE %s
|
|
||||||
OR c.mobile ILIKE %s
|
|
||||||
OR c.user_company ILIKE %s
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM contact_companies cc2
|
|
||||||
JOIN customers cu2 ON cu2.id = cc2.customer_id
|
|
||||||
WHERE cc2.contact_id = c.id
|
|
||||||
AND cu2.name ILIKE %s
|
|
||||||
)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
like = f"%{search}%"
|
|
||||||
params.extend([like, like, like, like, like, like, like])
|
|
||||||
|
|
||||||
if is_active is not None:
|
if is_active is not None:
|
||||||
where_clauses.append("c.is_active = %s")
|
where_clauses.append("c.is_active = %s")
|
||||||
|
|||||||
@ -953,7 +953,7 @@ async function loadContacts() {
|
|||||||
totalContacts = data.total;
|
totalContacts = data.total;
|
||||||
currentContactsData = Array.isArray(data.contacts) ? data.contacts : [];
|
currentContactsData = Array.isArray(data.contacts) ? data.contacts : [];
|
||||||
displayContacts(currentContactsData);
|
displayContacts(currentContactsData);
|
||||||
updatePagination(data.total, currentContactsData.length);
|
updatePagination(data.total);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
@ -1182,12 +1182,11 @@ function persistTablePreferences() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePagination(total, rowsOnPage = 0) {
|
function updatePagination(total) {
|
||||||
const safeRowsOnPage = Number.isFinite(Number(rowsOnPage)) ? Math.max(0, Number(rowsOnPage)) : 0;
|
const start = currentPage * pageSize + 1;
|
||||||
const start = total > 0 ? (currentPage * pageSize + 1) : 0;
|
const end = Math.min((currentPage + 1) * pageSize, total);
|
||||||
const end = total > 0 ? Math.min(currentPage * pageSize + safeRowsOnPage, total) : 0;
|
|
||||||
|
|
||||||
document.getElementById('showingStart').textContent = start;
|
document.getElementById('showingStart').textContent = total > 0 ? start : 0;
|
||||||
document.getElementById('showingEnd').textContent = end;
|
document.getElementById('showingEnd').textContent = end;
|
||||||
document.getElementById('totalCount').textContent = total;
|
document.getElementById('totalCount').textContent = total;
|
||||||
|
|
||||||
|
|||||||
@ -595,37 +595,6 @@ class MissionService:
|
|||||||
(project_id,),
|
(project_id,),
|
||||||
) or []
|
) or []
|
||||||
|
|
||||||
project_open_todo_count = 0
|
|
||||||
project_open_todo_titles: list[str] = []
|
|
||||||
if MissionService._table_exists("sag_todo_steps"):
|
|
||||||
project_todo_row = execute_query_single(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
COUNT(*) FILTER (
|
|
||||||
WHERE t.deleted_at IS NULL
|
|
||||||
AND COALESCE(t.is_done, FALSE) = FALSE
|
|
||||||
) AS open_todo_count,
|
|
||||||
ARRAY_REMOVE(
|
|
||||||
ARRAY_AGG(
|
|
||||||
CASE
|
|
||||||
WHEN t.deleted_at IS NULL
|
|
||||||
AND COALESCE(t.is_done, FALSE) = FALSE
|
|
||||||
THEN t.title
|
|
||||||
END
|
|
||||||
ORDER BY COALESCE(t.due_date, DATE '9999-12-31') ASC, t.id ASC
|
|
||||||
),
|
|
||||||
NULL
|
|
||||||
) AS open_todo_titles
|
|
||||||
FROM sag_todo_steps t
|
|
||||||
WHERE t.sag_id = %s
|
|
||||||
""",
|
|
||||||
(project_id,),
|
|
||||||
) or {}
|
|
||||||
project_open_todo_count = int(project_todo_row.get("open_todo_count") or 0)
|
|
||||||
titles_raw = project_todo_row.get("open_todo_titles") or []
|
|
||||||
if isinstance(titles_raw, list):
|
|
||||||
project_open_todo_titles = [str(item).strip() for item in titles_raw if str(item or "").strip()]
|
|
||||||
|
|
||||||
# Fallback for case-backed projects: fetch directly related/under cases from relation table.
|
# Fallback for case-backed projects: fetch directly related/under cases from relation table.
|
||||||
# This is used when a project is a case of type project/projekt and tasks are linked as case relations.
|
# This is used when a project is a case of type project/projekt and tasks are linked as case relations.
|
||||||
if not tasks and MissionService._table_exists("sag_relationer"):
|
if not tasks and MissionService._table_exists("sag_relationer"):
|
||||||
@ -698,8 +667,6 @@ class MissionService:
|
|||||||
"milestones": [dict(row) for row in milestones],
|
"milestones": [dict(row) for row in milestones],
|
||||||
"blockers": [dict(row) for row in blockers],
|
"blockers": [dict(row) for row in blockers],
|
||||||
"tasks": [dict(row) for row in tasks],
|
"tasks": [dict(row) for row in tasks],
|
||||||
"project_open_todo_count": project_open_todo_count,
|
|
||||||
"project_open_todo_titles": project_open_todo_titles,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -805,16 +805,6 @@
|
|||||||
padding: 0.45rem 0.8rem;
|
padding: 0.45rem 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mc-day-actions {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mc-day-touch-btn {
|
|
||||||
min-height: 56px;
|
|
||||||
font-size: 1rem;
|
|
||||||
padding: 0.55rem 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mc-case-link,
|
.mc-case-link,
|
||||||
.mc-email-link {
|
.mc-email-link {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@ -839,46 +829,6 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mc-day-actions {
|
|
||||||
margin-top: 0.52rem;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 0.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mc-day-touch-btn {
|
|
||||||
border: 1px solid rgba(126, 194, 239, 0.45);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: rgba(15, 76, 117, 0.45);
|
|
||||||
color: #dff3ff;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-weight: 800;
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 0.42rem 0.58rem;
|
|
||||||
touch-action: manipulation;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mc-day-touch-btn.secondary {
|
|
||||||
border-color: rgba(157, 181, 210, 0.35);
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
color: #e3f1ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mc-day-touch-btn:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mc-day-modal-body {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mc-day-modal-meta {
|
|
||||||
font-size: 0.86rem;
|
|
||||||
color: var(--mc-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mc-day-agents {
|
.mc-day-agents {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@ -1175,50 +1125,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal fade" id="dayCaseQuickModal" tabindex="-1" aria-hidden="true">
|
|
||||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="dayCaseQuickTitle">Sag detaljer</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body mc-day-modal-body">
|
|
||||||
<div class="mc-day-modal-meta" id="dayCaseQuickMeta">Vaelg en sag fra listen.</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="dayCaseQuickUser" class="form-label">Ansvarlig medarbejder</label>
|
|
||||||
<select id="dayCaseQuickUser" class="form-select"></select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="dayCaseQuickGroup" class="form-label">Gruppe</label>
|
|
||||||
<select id="dayCaseQuickGroup" class="form-select"></select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label for="dayCaseQuickStart" class="form-label">Start</label>
|
|
||||||
<input id="dayCaseQuickStart" type="datetime-local" class="form-control">
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label for="dayCaseQuickDeadline" class="form-label">Deadline</label>
|
|
||||||
<input id="dayCaseQuickDeadline" type="datetime-local" class="form-control">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="dayCaseQuickDesc" class="form-label">Beskrivelse</label>
|
|
||||||
<textarea id="dayCaseQuickDesc" class="form-control" rows="4" readonly></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Luk</button>
|
|
||||||
<button type="button" class="btn btn-primary" id="dayCaseQuickSaveBtn">Gem aendringer</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="view-calls" class="mc-view">
|
<div id="view-calls" class="mc-view">
|
||||||
<div class="mc-view-grid">
|
<div class="mc-view-grid">
|
||||||
<div class="mc-card">
|
<div class="mc-card">
|
||||||
@ -1328,8 +1234,6 @@
|
|||||||
renderedCameraUrl: null,
|
renderedCameraUrl: null,
|
||||||
renderedCameraMode: null,
|
renderedCameraMode: null,
|
||||||
selectedProjectId: null,
|
selectedProjectId: null,
|
||||||
selectedDayCaseId: null,
|
|
||||||
dayCaseModal: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
@ -1424,10 +1328,6 @@
|
|||||||
const tasks = Array.isArray(detail?.tasks) ? detail.tasks : [];
|
const tasks = Array.isArray(detail?.tasks) ? detail.tasks : [];
|
||||||
const milestones = Array.isArray(detail?.milestones) ? detail.milestones : [];
|
const milestones = Array.isArray(detail?.milestones) ? detail.milestones : [];
|
||||||
const blockers = Array.isArray(detail?.blockers) ? detail.blockers : [];
|
const blockers = Array.isArray(detail?.blockers) ? detail.blockers : [];
|
||||||
const projectOpenTodoCount = Number(detail?.project_open_todo_count || 0);
|
|
||||||
const projectOpenTodoTitles = Array.isArray(detail?.project_open_todo_titles)
|
|
||||||
? detail.project_open_todo_titles.map((item) => String(item || '').trim()).filter(Boolean)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const grouped = { todo: [], doing: [], done: [] };
|
const grouped = { todo: [], doing: [], done: [] };
|
||||||
tasks.forEach((task) => {
|
tasks.forEach((task) => {
|
||||||
@ -1439,7 +1339,6 @@
|
|||||||
|
|
||||||
kpis.innerHTML = [
|
kpis.innerHTML = [
|
||||||
{ label: 'Opgaver', value: tasks.length },
|
{ label: 'Opgaver', value: tasks.length },
|
||||||
{ label: 'Projekt todo', value: projectOpenTodoCount },
|
|
||||||
{ label: 'Milepæle', value: milestones.length },
|
{ label: 'Milepæle', value: milestones.length },
|
||||||
{ label: 'Blockers', value: blockers.length },
|
{ label: 'Blockers', value: blockers.length },
|
||||||
{ label: 'Deadline', value: formatShortDate(detail?.ended_at || detail?.deadline) },
|
{ label: 'Deadline', value: formatShortDate(detail?.ended_at || detail?.deadline) },
|
||||||
@ -1456,29 +1355,11 @@
|
|||||||
{ key: 'done', label: 'Lukket' },
|
{ key: 'done', label: 'Lukket' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const projectTodoPreview = projectOpenTodoTitles.slice(0, 5)
|
|
||||||
.map((title) => `<div>• ${escapeHtml(title)}</div>`)
|
|
||||||
.join('');
|
|
||||||
const projectTodoMore = Math.max(projectOpenTodoCount - Math.min(projectOpenTodoTitles.length, 5), 0);
|
|
||||||
const projectTodoCard = projectOpenTodoCount > 0
|
|
||||||
? `
|
|
||||||
<div class="mc-kanban-card" style="border-left:3px solid #0f4c75;">
|
|
||||||
<div class="mc-kanban-title">Projekt todo (${projectOpenTodoCount})</div>
|
|
||||||
<div class="mc-kanban-meta">
|
|
||||||
${projectTodoPreview || '<div>-</div>'}
|
|
||||||
${projectTodoMore > 0 ? `<div>+${projectTodoMore} flere</div>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
board.innerHTML = laneMeta.map((lane) => {
|
board.innerHTML = laneMeta.map((lane) => {
|
||||||
const laneTasks = grouped[lane.key] || [];
|
const laneTasks = grouped[lane.key] || [];
|
||||||
const extraCard = lane.key === 'todo' ? projectTodoCard : '';
|
|
||||||
return `
|
return `
|
||||||
<div class="mc-kanban-col">
|
<div class="mc-kanban-col">
|
||||||
<h6>${escapeHtml(lane.label)} (${laneTasks.length + (lane.key === 'todo' && projectOpenTodoCount > 0 ? 1 : 0)})</h6>
|
<h6>${escapeHtml(lane.label)} (${laneTasks.length})</h6>
|
||||||
${extraCard}
|
|
||||||
${laneTasks.length ? laneTasks.map((task) => `
|
${laneTasks.length ? laneTasks.map((task) => `
|
||||||
<div class="mc-kanban-card">
|
<div class="mc-kanban-card">
|
||||||
<div class="mc-kanban-title">#${Number(task.id || 0)} ${escapeHtml(task.titel || task.title || 'Uden titel')}</div>
|
<div class="mc-kanban-title">#${Number(task.id || 0)} ${escapeHtml(task.titel || task.title || 'Uden titel')}</div>
|
||||||
@ -1527,14 +1408,6 @@
|
|||||||
return Number.isFinite(parsed) ? parsed : null;
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDatetimeLocalValue(value) {
|
|
||||||
if (!value) return '';
|
|
||||||
const d = new Date(value);
|
|
||||||
if (Number.isNaN(d.getTime())) return '';
|
|
||||||
const pad = (n) => String(n).padStart(2, '0');
|
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateText(value, maxLength = 180) {
|
function truncateText(value, maxLength = 180) {
|
||||||
const raw = String(value || '').trim();
|
const raw = String(value || '').trim();
|
||||||
if (!raw) return '';
|
if (!raw) return '';
|
||||||
@ -1542,23 +1415,6 @@
|
|||||||
return `${raw.slice(0, maxLength - 1)}...`;
|
return `${raw.slice(0, maxLength - 1)}...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDayCaseById(caseId) {
|
|
||||||
const id = Number(caseId || 0);
|
|
||||||
if (!Number.isFinite(id) || id <= 0) return null;
|
|
||||||
|
|
||||||
const source = Array.isArray(state.dayUnassignedCases) ? state.dayUnassignedCases : [];
|
|
||||||
const found = source.find((item) => Number(item.id || 0) === id);
|
|
||||||
return found || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrCreateDayCaseModal() {
|
|
||||||
if (state.dayCaseModal) return state.dayCaseModal;
|
|
||||||
const el = document.getElementById('dayCaseQuickModal');
|
|
||||||
if (!el || !window.bootstrap) return null;
|
|
||||||
state.dayCaseModal = new bootstrap.Modal(el);
|
|
||||||
return state.dayCaseModal;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEmailHref(emailId) {
|
function getEmailHref(emailId) {
|
||||||
const id = Number(emailId || 0);
|
const id = Number(emailId || 0);
|
||||||
if (!Number.isFinite(id) || id <= 0) return '/emails';
|
if (!Number.isFinite(id) || id <= 0) return '/emails';
|
||||||
@ -2157,12 +2013,7 @@
|
|||||||
<select class="mc-day-select" id="assignGroup-${caseId}">
|
<select class="mc-day-select" id="assignGroup-${caseId}">
|
||||||
${groupSelectOptions}
|
${groupSelectOptions}
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="mc-day-btn" onclick="quickAssignCase(${caseId})">Tildel begge</button>
|
<button type="button" class="mc-day-btn" onclick="quickAssignCase(${caseId})">Tildel</button>
|
||||||
</div>
|
|
||||||
<div class="mc-day-actions">
|
|
||||||
<button type="button" class="mc-day-touch-btn" onclick="quickAssignUser(${caseId})">Tildel medarbejder</button>
|
|
||||||
<button type="button" class="mc-day-touch-btn" onclick="quickAssignGroup(${caseId})">Tildel gruppe</button>
|
|
||||||
<button type="button" class="mc-day-touch-btn secondary" onclick="openDayCaseQuick(${caseId})">Mere info</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -2271,154 +2122,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function patchDayCase(caseId, payload, button, buttonText = 'Gemmer...') {
|
|
||||||
if (!payload || typeof payload !== 'object') return;
|
|
||||||
|
|
||||||
const btn = button || null;
|
|
||||||
const originalText = btn ? btn.textContent : null;
|
|
||||||
if (btn) {
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = buttonText;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/v1/sag/${caseId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(err?.detail || `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadInitialState();
|
|
||||||
} catch (error) {
|
|
||||||
alert(`Kunne ikke opdatere sag: ${error?.message || 'ukendt fejl'}`);
|
|
||||||
} finally {
|
|
||||||
if (btn) {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = originalText || 'Gem';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function quickAssignUser(caseId) {
|
|
||||||
const id = Number(caseId || 0);
|
|
||||||
if (!Number.isFinite(id) || id <= 0) return;
|
|
||||||
|
|
||||||
const userSelect = document.getElementById(`assignUser-${id}`);
|
|
||||||
const userId = toOptionalInt(userSelect?.value);
|
|
||||||
if (userId === null) {
|
|
||||||
alert('Vaelg en medarbejder foerst.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const card = document.getElementById(`dayCase-${id}`);
|
|
||||||
const button = card?.querySelector('.mc-day-actions .mc-day-touch-btn');
|
|
||||||
await patchDayCase(id, { ansvarlig_bruger_id: userId }, button, 'Tildeler...');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function quickAssignGroup(caseId) {
|
|
||||||
const id = Number(caseId || 0);
|
|
||||||
if (!Number.isFinite(id) || id <= 0) return;
|
|
||||||
|
|
||||||
const groupSelect = document.getElementById(`assignGroup-${id}`);
|
|
||||||
const groupId = toOptionalInt(groupSelect?.value);
|
|
||||||
if (groupId === null) {
|
|
||||||
alert('Vaelg en gruppe foerst.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const card = document.getElementById(`dayCase-${id}`);
|
|
||||||
const buttons = card?.querySelectorAll('.mc-day-actions .mc-day-touch-btn');
|
|
||||||
const button = buttons && buttons.length > 1 ? buttons[1] : null;
|
|
||||||
await patchDayCase(id, { assigned_group_id: groupId }, button, 'Tildeler...');
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDayCaseQuick(caseId) {
|
|
||||||
const id = Number(caseId || 0);
|
|
||||||
if (!Number.isFinite(id) || id <= 0) return;
|
|
||||||
|
|
||||||
const item = getDayCaseById(id);
|
|
||||||
if (!item) {
|
|
||||||
alert('Kunne ikke finde sag i Dagen-listen.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = document.getElementById('dayCaseQuickTitle');
|
|
||||||
const meta = document.getElementById('dayCaseQuickMeta');
|
|
||||||
const userSelect = document.getElementById('dayCaseQuickUser');
|
|
||||||
const groupSelect = document.getElementById('dayCaseQuickGroup');
|
|
||||||
const startInput = document.getElementById('dayCaseQuickStart');
|
|
||||||
const deadlineInput = document.getElementById('dayCaseQuickDeadline');
|
|
||||||
const descInput = document.getElementById('dayCaseQuickDesc');
|
|
||||||
|
|
||||||
if (!title || !meta || !userSelect || !groupSelect || !startInput || !deadlineInput || !descInput) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.selectedDayCaseId = id;
|
|
||||||
title.textContent = `#${id} ${item.titel || 'Uden titel'}`;
|
|
||||||
meta.textContent = `${item.customer_name || 'Ukendt kunde'} • Status: ${item.status || '-'} • Type: ${item.case_type || item.template_key || '-'}`;
|
|
||||||
descInput.value = String(item.beskrivelse || '').trim();
|
|
||||||
|
|
||||||
const userOptions = [
|
|
||||||
'<option value="">Ingen medarbejder</option>',
|
|
||||||
...(state.assignmentUsers || []).map((user) => (`
|
|
||||||
<option value="${Number(user.user_id)}">${escapeHtml(user.display_name || 'Ukendt')}</option>
|
|
||||||
`)),
|
|
||||||
];
|
|
||||||
const groupOptions = [
|
|
||||||
'<option value="">Ingen gruppe</option>',
|
|
||||||
...(state.assignmentGroups || []).map((group) => (`
|
|
||||||
<option value="${Number(group.id)}">${escapeHtml(group.name || 'Ukendt')}</option>
|
|
||||||
`)),
|
|
||||||
];
|
|
||||||
|
|
||||||
userSelect.innerHTML = userOptions.join('');
|
|
||||||
groupSelect.innerHTML = groupOptions.join('');
|
|
||||||
userSelect.value = Number(item.ansvarlig_bruger_id || 0) > 0 ? String(Number(item.ansvarlig_bruger_id)) : '';
|
|
||||||
groupSelect.value = Number(item.assigned_group_id || 0) > 0 ? String(Number(item.assigned_group_id)) : '';
|
|
||||||
|
|
||||||
startInput.value = toDatetimeLocalValue(item.start_date);
|
|
||||||
deadlineInput.value = toDatetimeLocalValue(item.deadline);
|
|
||||||
|
|
||||||
const modal = getOrCreateDayCaseModal();
|
|
||||||
if (modal) modal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveDayCaseQuick() {
|
|
||||||
const id = Number(state.selectedDayCaseId || 0);
|
|
||||||
if (!Number.isFinite(id) || id <= 0) return;
|
|
||||||
|
|
||||||
const userSelect = document.getElementById('dayCaseQuickUser');
|
|
||||||
const groupSelect = document.getElementById('dayCaseQuickGroup');
|
|
||||||
const startInput = document.getElementById('dayCaseQuickStart');
|
|
||||||
const deadlineInput = document.getElementById('dayCaseQuickDeadline');
|
|
||||||
const saveBtn = document.getElementById('dayCaseQuickSaveBtn');
|
|
||||||
|
|
||||||
if (!userSelect || !groupSelect || !startInput || !deadlineInput) return;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
ansvarlig_bruger_id: toOptionalInt(userSelect.value),
|
|
||||||
assigned_group_id: toOptionalInt(groupSelect.value),
|
|
||||||
start_date: startInput.value ? new Date(startInput.value).toISOString() : null,
|
|
||||||
deadline: deadlineInput.value ? new Date(deadlineInput.value).toISOString() : null,
|
|
||||||
};
|
|
||||||
|
|
||||||
await patchDayCase(id, payload, saveBtn, 'Gemmer...');
|
|
||||||
|
|
||||||
const modal = getOrCreateDayCaseModal();
|
|
||||||
if (modal) modal.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
window.quickAssignCase = quickAssignCase;
|
window.quickAssignCase = quickAssignCase;
|
||||||
window.quickAssignUser = quickAssignUser;
|
|
||||||
window.quickAssignGroup = quickAssignGroup;
|
|
||||||
window.openDayCaseQuick = openDayCaseQuick;
|
|
||||||
|
|
||||||
function renderEnvironmentReadings() {
|
function renderEnvironmentReadings() {
|
||||||
const container = document.getElementById('environmentReadings');
|
const container = document.getElementById('environmentReadings');
|
||||||
@ -2723,11 +2427,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('dayCaseQuickSaveBtn')?.addEventListener('click', () => {
|
|
||||||
saveDayCaseQuick();
|
|
||||||
resetIdleTimer();
|
|
||||||
});
|
|
||||||
|
|
||||||
['pointerdown', 'keydown', 'mousemove', 'touchstart'].forEach((name) => {
|
['pointerdown', 'keydown', 'mousemove', 'touchstart'].forEach((name) => {
|
||||||
document.addEventListener(name, resetIdleTimer, { passive: true });
|
document.addEventListener(name, resetIdleTimer, { passive: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@ -11,7 +11,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body
|
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response
|
||||||
from fastapi.responses import FileResponse, HTMLResponse
|
from fastapi.responses import FileResponse, HTMLResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from app.core.database import execute_query, execute_query_single, table_has_column
|
from app.core.database import execute_query, execute_query_single, table_has_column
|
||||||
@ -1273,7 +1273,7 @@ async def delete_todo_step(step_id: int):
|
|||||||
raise HTTPException(status_code=500, detail="Failed to delete todo step")
|
raise HTTPException(status_code=500, detail="Failed to delete todo step")
|
||||||
|
|
||||||
@router.patch("/sag/{sag_id:int}")
|
@router.patch("/sag/{sag_id:int}")
|
||||||
async def update_sag(sag_id: int, updates: dict = Body(...)):
|
async def update_sag(sag_id: int, updates: dict):
|
||||||
"""Update a case."""
|
"""Update a case."""
|
||||||
try:
|
try:
|
||||||
# Check if case exists
|
# Check if case exists
|
||||||
@ -2892,7 +2892,7 @@ async def get_sale_item(sag_id: int, item_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/sag/{sag_id}/sale-items/{item_id}")
|
@router.patch("/sag/{sag_id}/sale-items/{item_id}")
|
||||||
async def update_sale_item(sag_id: int, item_id: int, updates: dict = Body(...)):
|
async def update_sale_item(sag_id: int, item_id: int, updates: dict):
|
||||||
"""Update a sale item for a case."""
|
"""Update a sale item for a case."""
|
||||||
try:
|
try:
|
||||||
check = execute_query(
|
check = execute_query(
|
||||||
|
|||||||
@ -3461,7 +3461,7 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Status</label>
|
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Status</label>
|
||||||
<select id="topbarStatusSelect" class="form-select form-select-sm bg-light" style="width: 62%;" onchange="saveCaseStatusFromTopbar()">
|
<select id="topbarStatusSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
|
||||||
{% for st in status_options %}
|
{% for st in status_options %}
|
||||||
<option value="{{ st }}" {% if (case.status or '')|lower == st|lower %}selected{% endif %}>{{ st|capitalize }}</option>
|
<option value="{{ st }}" {% if (case.status or '')|lower == st|lower %}selected{% endif %}>{{ st|capitalize }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -3708,6 +3708,11 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
bindChange('topbarStatusSelect', async (el) => {
|
||||||
|
await patchCase({ status: el.value || 'åben' });
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
bindChange('topbarTypeSelect', async (el) => {
|
bindChange('topbarTypeSelect', async (el) => {
|
||||||
await patchCase({ type: String(el.value || 'ticket').toLowerCase() });
|
await patchCase({ type: String(el.value || 'ticket').toLowerCase() });
|
||||||
location.reload();
|
location.reload();
|
||||||
|
|||||||
@ -23,7 +23,6 @@ from .utils import (
|
|||||||
digits_only,
|
digits_only,
|
||||||
extract_extension,
|
extract_extension,
|
||||||
ip_in_whitelist,
|
ip_in_whitelist,
|
||||||
normalize_external_number,
|
|
||||||
is_outbound_call,
|
is_outbound_call,
|
||||||
normalize_e164,
|
normalize_e164,
|
||||||
phone_suffix_8,
|
phone_suffix_8,
|
||||||
@ -281,9 +280,8 @@ async def yealink_established(
|
|||||||
if candidate:
|
if candidate:
|
||||||
ekstern_raw = candidate
|
ekstern_raw = candidate
|
||||||
break
|
break
|
||||||
ekstern_normalized = normalize_external_number(ekstern_raw)
|
ekstern_e164 = normalize_e164(ekstern_raw)
|
||||||
ekstern_e164 = normalize_e164(ekstern_normalized or ekstern_raw)
|
ekstern_value = ekstern_e164 or ((ekstern_raw or "").strip() or None)
|
||||||
ekstern_value = ekstern_normalized or ((ekstern_raw or "").strip() or None)
|
|
||||||
|
|
||||||
user_ids = TelefoniService.find_user_by_extension(local_extension)
|
user_ids = TelefoniService.find_user_by_extension(local_extension)
|
||||||
|
|
||||||
@ -736,11 +734,6 @@ async def list_calls(
|
|||||||
params.extend([limit, offset])
|
params.extend([limit, offset])
|
||||||
|
|
||||||
rows = execute_query(query, tuple(params)) or []
|
rows = execute_query(query, tuple(params)) or []
|
||||||
for row in rows:
|
|
||||||
display_raw = row.get("display_number") or row.get("ekstern_nummer")
|
|
||||||
repaired = normalize_external_number(display_raw)
|
|
||||||
if repaired:
|
|
||||||
row["display_number"] = repaired
|
|
||||||
if rows:
|
if rows:
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|||||||
@ -39,39 +39,6 @@ def normalize_e164(number: Optional[str]) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def normalize_external_number(number: Optional[str]) -> Optional[str]:
|
|
||||||
"""Normalize external numbers and recover malformed concatenated callback values.
|
|
||||||
|
|
||||||
E.164 allows max 15 digits after '+'. If payload contains longer values,
|
|
||||||
we treat it as malformed concatenation and recover from the right-most part.
|
|
||||||
"""
|
|
||||||
if not number:
|
|
||||||
return None
|
|
||||||
|
|
||||||
raw = number.strip().replace(" ", "").replace("-", "")
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
|
|
||||||
d = digits_only(raw)
|
|
||||||
if not d:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if len(d) > 15:
|
|
||||||
# Malformed concatenation observed from callbacks.
|
|
||||||
# Prefer the leading Danish segment (user-confirmed cases like 53125928...),
|
|
||||||
# then fall back to right-most recovery.
|
|
||||||
if len(d) >= 10 and d[:2] == "45":
|
|
||||||
return "+" + d[:10]
|
|
||||||
if len(d) >= 8 and d[0] in "23456789":
|
|
||||||
return "+45" + d[:8]
|
|
||||||
if len(d) >= 10 and d[-10:].startswith("45"):
|
|
||||||
return "+" + d[-10:]
|
|
||||||
if len(d) >= 8:
|
|
||||||
return "+45" + d[-8:]
|
|
||||||
|
|
||||||
return normalize_e164(raw) or raw
|
|
||||||
|
|
||||||
|
|
||||||
def phone_suffix_8(number: Optional[str]) -> Optional[str]:
|
def phone_suffix_8(number: Optional[str]) -> Optional[str]:
|
||||||
d = digits_only(number)
|
d = digits_only(number)
|
||||||
if len(d) < 8:
|
if len(d) < 8:
|
||||||
|
|||||||
@ -4,7 +4,6 @@ from fastapi import APIRouter, Request
|
|||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from app.core.database import execute_query
|
from app.core.database import execute_query
|
||||||
from app.modules.telefoni.backend.utils import normalize_external_number
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -41,11 +40,6 @@ async def telefoni_log_page(request: Request):
|
|||||||
""",
|
""",
|
||||||
(),
|
(),
|
||||||
) or []
|
) or []
|
||||||
for row in initial_calls:
|
|
||||||
display_raw = row.get("display_number")
|
|
||||||
repaired = normalize_external_number(display_raw)
|
|
||||||
if repaired:
|
|
||||||
row["display_number"] = repaired
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("⚠️ Could not load initial telefoni calls for SSR fallback: %s", e)
|
logger.warning("⚠️ Could not load initial telefoni calls for SSR fallback: %s", e)
|
||||||
|
|
||||||
|
|||||||
@ -61,73 +61,23 @@
|
|||||||
<tbody id="telefoniRows">
|
<tbody id="telefoniRows">
|
||||||
{% if initial_calls and initial_calls|length > 0 %}
|
{% if initial_calls and initial_calls|length > 0 %}
|
||||||
{% for call in initial_calls %}
|
{% for call in initial_calls %}
|
||||||
<tr
|
<tr data-call-id="{{ call.id }}">
|
||||||
data-call-id="{{ call.id }}"
|
|
||||||
data-direction="{{ call.direction or '' }}"
|
|
||||||
data-display-number="{{ call.display_number or '' }}"
|
|
||||||
data-ekstern-nummer="{{ call.display_number or '' }}"
|
|
||||||
data-started-at="{{ call.started_at or '' }}"
|
|
||||||
data-ended-at="{{ call.ended_at or '' }}"
|
|
||||||
data-duration-sec="{{ call.duration_sec if call.duration_sec is not none else '' }}"
|
|
||||||
data-kontakt-id="{{ call.kontakt_id if call.kontakt_id is not none else '' }}"
|
|
||||||
data-contact-name="{{ call.contact_name or '' }}"
|
|
||||||
data-sag-id="{{ call.sag_id if call.sag_id is not none else '' }}"
|
|
||||||
data-sag-titel="{{ call.sag_titel or '' }}"
|
|
||||||
data-full-name="{{ call.full_name or '' }}"
|
|
||||||
data-username="{{ call.username or '' }}"
|
|
||||||
>
|
|
||||||
<td>{{ call.started_at or '-' }}</td>
|
<td>{{ call.started_at or '-' }}</td>
|
||||||
<td>{{ call.full_name or call.username or '-' }}</td>
|
<td>{{ call.full_name or call.username or '-' }}</td>
|
||||||
<td>{% if call.direction == 'outbound' %}Udgående{% else %}Indgående{% endif %}</td>
|
<td>{% if call.direction == 'outbound' %}Udgående{% else %}Indgående{% endif %}</td>
|
||||||
|
<td>{{ call.display_number or '-' }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if call.display_number %}
|
{% if call.kontakt_id %}
|
||||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
<a href="/contacts/{{ call.kontakt_id }}">{{ (call.contact_name or ('Kontakt #' ~ call.kontakt_id))|trim }}</a>
|
||||||
<span>{{ call.display_number }}</span>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-success" onclick="callViaYealink('{{ call.display_number|replace("'", "\\'") }}')">Ring op</button>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
-
|
-
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
{% if call.kontakt_id %}
|
|
||||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
|
||||||
<a href="/contacts/{{ call.kontakt_id }}">{{ (call.contact_name or ('Kontakt #' ~ call.kontakt_id))|trim }}</a>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal({{ call.id }})" title="Skift kontakt / opret kontakt">
|
|
||||||
<i class="bi bi-person-gear"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkContact({{ call.id }})" title="Fjern kontakt-link">
|
|
||||||
<i class="bi bi-x-circle"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal({{ call.id }})" title="Tilknyt eller opret kontakt">
|
|
||||||
<i class="bi bi-person-plus"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
{% if call.sag_id %}
|
{% if call.sag_id %}
|
||||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
|
||||||
<a href="/sag/{{ call.sag_id }}/v3">{{ call.sag_titel or ('Sag #' ~ call.sag_id) }}</a>
|
<a href="/sag/{{ call.sag_id }}/v3">{{ call.sag_titel or ('Sag #' ~ call.sag_id) }}</a>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="linkExistingCase({{ call.id }})" title="Skift sag-link">
|
|
||||||
<i class="bi bi-link-45deg"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkCase({{ call.id }})" title="Fjern sag-link">
|
|
||||||
<i class="bi bi-x-circle"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="d-flex gap-2">
|
-
|
||||||
<a class="btn btn-sm btn-outline-primary" href="/sag/new?telefoni_opkald_id={{ call.id }}{% if call.kontakt_id %}&contact_id={{ call.kontakt_id }}{% endif %}" title="Opret ny sag">
|
|
||||||
<i class="bi bi-folder-plus"></i>
|
|
||||||
</a>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="linkExistingCase({{ call.id }})" title="Tilknyt eksisterende sag">
|
|
||||||
<i class="bi bi-link-45deg"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">{{ call.duration_sec if call.duration_sec is not none else '-' }}</td>
|
<td class="text-end">{{ call.duration_sec if call.duration_sec is not none else '-' }}</td>
|
||||||
@ -253,45 +203,6 @@ function escapeHtml(str) {
|
|||||||
.replaceAll("'", ''');
|
.replaceAll("'", ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDisplayNumber(value) {
|
|
||||||
const raw = String(value || '').trim();
|
|
||||||
if (!raw) return '';
|
|
||||||
|
|
||||||
// Internal SIP identifiers should not be shown as external caller numbers.
|
|
||||||
if (/^sip:/i.test(raw)) {
|
|
||||||
const sipDigits = raw.replace(/\D+/g, '');
|
|
||||||
if (sipDigits.length <= 6) return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const digits = raw.replace(/\D+/g, '');
|
|
||||||
if (!digits) return raw;
|
|
||||||
|
|
||||||
// E.164 max is 15 digits (excluding +). Longer values are malformed/concatenated.
|
|
||||||
if (digits.length > 15) {
|
|
||||||
if (digits.length >= 10 && digits.slice(0, 2) === '45') {
|
|
||||||
return `+${digits.slice(0, 10)}`;
|
|
||||||
}
|
|
||||||
if (digits.length >= 8 && /[2-9]/.test(digits.charAt(0))) {
|
|
||||||
return `+45${digits.slice(0, 8)}`;
|
|
||||||
}
|
|
||||||
if (digits.length >= 10 && digits.slice(-10).startsWith('45')) {
|
|
||||||
return `+${digits.slice(-10)}`;
|
|
||||||
}
|
|
||||||
if (digits.length >= 8) {
|
|
||||||
return `+45${digits.slice(-8)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (raw.startsWith('+')) {
|
|
||||||
return digits.length >= 9 ? `+${digits}` : raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (digits.length === 8) return `+45${digits}`;
|
|
||||||
if (digits.length === 10 && digits.startsWith('45')) return `+${digits}`;
|
|
||||||
if (digits.length >= 9) return `+${digits}`;
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
let telefoniCurrentUserId = null;
|
let telefoniCurrentUserId = null;
|
||||||
const telefoniCallMap = new Map();
|
const telefoniCallMap = new Map();
|
||||||
const linkSagState = {
|
const linkSagState = {
|
||||||
@ -939,40 +850,6 @@ function hasExistingCallRows(tbody) {
|
|||||||
return Boolean(tbody && tbody.querySelector('tr[data-call-id]'));
|
return Boolean(tbody && tbody.querySelector('tr[data-call-id]'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function hydrateCallMapFromSsrRows() {
|
|
||||||
const rows = document.querySelectorAll('#telefoniRows tr[data-call-id]');
|
|
||||||
if (!rows.length) return;
|
|
||||||
|
|
||||||
rows.forEach((row) => {
|
|
||||||
const callId = Number(row.dataset.callId);
|
|
||||||
if (!Number.isInteger(callId) || callId <= 0) return;
|
|
||||||
|
|
||||||
const toIntOrNull = (value) => {
|
|
||||||
const n = Number(value);
|
|
||||||
return Number.isInteger(n) && n > 0 ? n : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const durationRaw = String(row.dataset.durationSec || '').trim();
|
|
||||||
const durationSec = durationRaw === '' ? null : Number(durationRaw);
|
|
||||||
|
|
||||||
telefoniCallMap.set(callId, {
|
|
||||||
id: callId,
|
|
||||||
direction: String(row.dataset.direction || '').trim() || null,
|
|
||||||
display_number: normalizeDisplayNumber(String(row.dataset.displayNumber || '').trim()) || null,
|
|
||||||
ekstern_nummer: normalizeDisplayNumber(String(row.dataset.eksternNummer || '').trim()) || null,
|
|
||||||
started_at: String(row.dataset.startedAt || '').trim() || null,
|
|
||||||
ended_at: String(row.dataset.endedAt || '').trim() || null,
|
|
||||||
duration_sec: Number.isFinite(durationSec) ? durationSec : null,
|
|
||||||
kontakt_id: toIntOrNull(row.dataset.kontaktId),
|
|
||||||
contact_name: String(row.dataset.contactName || '').trim() || null,
|
|
||||||
sag_id: toIntOrNull(row.dataset.sagId),
|
|
||||||
sag_titel: String(row.dataset.sagTitel || '').trim() || null,
|
|
||||||
full_name: String(row.dataset.fullName || '').trim() || null,
|
|
||||||
username: String(row.dataset.username || '').trim() || null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function cacheInitialSsrRows(tbody) {
|
function cacheInitialSsrRows(tbody) {
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
if (tbody.dataset.initialRowsCached === '1') return;
|
if (tbody.dataset.initialRowsCached === '1') return;
|
||||||
@ -1042,13 +919,7 @@ async function loadCalls(options = {}) {
|
|||||||
}
|
}
|
||||||
const rows = await res.json();
|
const rows = await res.json();
|
||||||
telefoniCallMap.clear();
|
telefoniCallMap.clear();
|
||||||
(rows || []).forEach(r => {
|
(rows || []).forEach(r => telefoniCallMap.set(Number(r.id), r));
|
||||||
const fixedDisplay = normalizeDisplayNumber(r.display_number || r.ekstern_nummer || '');
|
|
||||||
const fixedExternal = normalizeDisplayNumber(r.ekstern_nummer || r.display_number || '');
|
|
||||||
r.display_number = fixedDisplay || null;
|
|
||||||
r.ekstern_nummer = fixedExternal || null;
|
|
||||||
telefoniCallMap.set(Number(r.id), r);
|
|
||||||
});
|
|
||||||
if (!rows || rows.length === 0) {
|
if (!rows || rows.length === 0) {
|
||||||
if (preserveOnEmpty && hadRowsBeforeLoad && noActiveFilters) {
|
if (preserveOnEmpty && hadRowsBeforeLoad && noActiveFilters) {
|
||||||
console.warn('Telefoni: API returnerede 0 rækker, bevarer eksisterende SSR-visning');
|
console.warn('Telefoni: API returnerede 0 rækker, bevarer eksisterende SSR-visning');
|
||||||
@ -1069,7 +940,7 @@ async function loadCalls(options = {}) {
|
|||||||
const dateTxt = started ? started.toLocaleString('da-DK') : '-';
|
const dateTxt = started ? started.toLocaleString('da-DK') : '-';
|
||||||
const userTxt = escapeHtml(r.full_name || r.username || '-');
|
const userTxt = escapeHtml(r.full_name || r.username || '-');
|
||||||
const dirTxt = r.direction === 'outbound' ? 'Udgående' : 'Indgående';
|
const dirTxt = r.direction === 'outbound' ? 'Udgående' : 'Indgående';
|
||||||
const numberRaw = normalizeDisplayNumber(r.display_number || r.ekstern_nummer || '');
|
const numberRaw = (r.display_number || r.ekstern_nummer || '').trim();
|
||||||
const numTxt = numberRaw
|
const numTxt = numberRaw
|
||||||
? `<div class="d-flex gap-2 align-items-center flex-wrap">
|
? `<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||||
<span>${escapeHtml(numberRaw)}</span>
|
<span>${escapeHtml(numberRaw)}</span>
|
||||||
@ -1081,18 +952,18 @@ async function loadCalls(options = {}) {
|
|||||||
? `<div class="d-flex align-items-center gap-2 flex-wrap">
|
? `<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
<a href="/contacts/${r.kontakt_id}">${escapeHtml(r.contact_name || ('Kontakt #' + r.kontakt_id))}</a>
|
<a href="/contacts/${r.kontakt_id}">${escapeHtml(r.contact_name || ('Kontakt #' + r.kontakt_id))}</a>
|
||||||
${canMutateCall
|
${canMutateCall
|
||||||
? `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal(${callId})" title="Skift kontakt / opret kontakt"><i class="bi bi-person-gear"></i></button>
|
? `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal(${callId})">Skift</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkContact(${callId})" title="Fjern kontakt-link"><i class="bi bi-x-circle"></i></button>`
|
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkContact(${callId})">Fjern</button>`
|
||||||
: '<span class="badge bg-light text-muted border">Historik</span>'}
|
: '<span class="badge bg-light text-muted border">Historik</span>'}
|
||||||
</div>
|
</div>
|
||||||
${r.contact_company ? `<div class="text-muted small">${escapeHtml(r.contact_company)}</div>` : ''}`
|
${r.contact_company ? `<div class="text-muted small">${escapeHtml(r.contact_company)}</div>` : ''}`
|
||||||
: (canMutateCall
|
: (canMutateCall
|
||||||
? `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal(${callId})" title="Tilknyt eller opret kontakt">
|
? `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal(${callId})" title="Vælg kontakt/firma">
|
||||||
<i class="bi bi-person-plus"></i>
|
<i class="bi bi-three-dots"></i>
|
||||||
</button>`
|
</button>`
|
||||||
: '<span class="text-muted small">Historisk opkald</span>');
|
: '<span class="text-muted small">Historisk opkald</span>');
|
||||||
|
|
||||||
const numberForTitle = normalizeDisplayNumber(r.display_number || r.ekstern_nummer || '');
|
const numberForTitle = (r.display_number || r.ekstern_nummer || '').trim();
|
||||||
const createQs = new URLSearchParams();
|
const createQs = new URLSearchParams();
|
||||||
if (r.kontakt_id) createQs.set('contact_id', String(r.kontakt_id));
|
if (r.kontakt_id) createQs.set('contact_id', String(r.kontakt_id));
|
||||||
if (canMutateCall) createQs.set('telefoni_opkald_id', String(callId));
|
if (canMutateCall) createQs.set('telefoni_opkald_id', String(callId));
|
||||||
@ -1102,14 +973,14 @@ async function loadCalls(options = {}) {
|
|||||||
? `<div class="d-flex gap-2 align-items-center flex-wrap">
|
? `<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||||
<a href="/sag/${r.sag_id}/v3">${escapeHtml(r.sag_titel || ('Sag #' + r.sag_id))}</a>
|
<a href="/sag/${r.sag_id}/v3">${escapeHtml(r.sag_titel || ('Sag #' + r.sag_id))}</a>
|
||||||
${canMutateCall
|
${canMutateCall
|
||||||
? `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="linkExistingCase(${callId})" title="Skift sag-link"><i class="bi bi-link-45deg"></i></button>
|
? `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="linkExistingCase(${callId})">Skift link</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkCase(${callId})" title="Fjern sag-link"><i class="bi bi-x-circle"></i></button>`
|
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkCase(${callId})">Fjern link</button>`
|
||||||
: '<span class="badge bg-light text-muted border">Historik</span>'}
|
: '<span class="badge bg-light text-muted border">Historik</span>'}
|
||||||
</div>`
|
</div>`
|
||||||
: (canMutateCall
|
: (canMutateCall
|
||||||
? `<div class="d-flex gap-2">
|
? `<div class="d-flex gap-2">
|
||||||
<a class="btn btn-sm btn-outline-primary" href="/sag/new?${createQs.toString()}" title="Opret ny sag"><i class="bi bi-folder-plus"></i></a>
|
<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(${callId})" title="Tilknyt eksisterende sag"><i class="bi bi-link-45deg"></i></button>
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="linkExistingCase(${callId})">Link sag</button>
|
||||||
</div>`
|
</div>`
|
||||||
: '<span class="text-muted small">Ingen sag</span>');
|
: '<span class="text-muted small">Ingen sag</span>');
|
||||||
|
|
||||||
@ -1195,7 +1066,6 @@ async function unlinkCase(callId) {
|
|||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const telefoniRows = document.getElementById('telefoniRows');
|
const telefoniRows = document.getElementById('telefoniRows');
|
||||||
cacheInitialSsrRows(telefoniRows);
|
cacheInitialSsrRows(telefoniRows);
|
||||||
hydrateCallMapFromSsrRows();
|
|
||||||
initLinkContactModalEvents();
|
initLinkContactModalEvents();
|
||||||
initLinkSagModalEvents();
|
initLinkSagModalEvents();
|
||||||
const userFilter = document.getElementById('filterUser');
|
const userFilter = document.getElementById('filterUser');
|
||||||
|
|||||||
@ -1156,13 +1156,11 @@ class EmailWorkflowService:
|
|||||||
if sag_id_from_tag:
|
if sag_id_from_tag:
|
||||||
if sag_id and sag_id != sag_id_from_tag:
|
if sag_id and sag_id != sag_id_from_tag:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"⚠️ Email %s contains conflicting case hints (thread: SAG-%s, tag: SAG-%s). Using SAG tag.",
|
"⚠️ Email %s contains conflicting case hints (thread: SAG-%s, tag: SAG-%s). Using thread match.",
|
||||||
email_id,
|
email_id,
|
||||||
sag_id,
|
sag_id,
|
||||||
sag_id_from_tag
|
sag_id_from_tag
|
||||||
)
|
)
|
||||||
sag_id = sag_id_from_tag
|
|
||||||
routing_source = 'sag_tag'
|
|
||||||
elif not sag_id:
|
elif not sag_id:
|
||||||
sag_id = sag_id_from_tag
|
sag_id = sag_id_from_tag
|
||||||
routing_source = 'sag_tag'
|
routing_source = 'sag_tag'
|
||||||
|
|||||||
@ -1267,7 +1267,7 @@ window.addEventListener('unhandledrejection', function(event) {
|
|||||||
<script src="/static/js/tag-picker.js?v=2.2"></script>
|
<script src="/static/js/tag-picker.js?v=2.2"></script>
|
||||||
<script src="/static/js/task-template-selector.js?v=1.1"></script>
|
<script src="/static/js/task-template-selector.js?v=1.1"></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=2.3"></script>
|
<script src="/static/js/telefoni.js?v=2.2"></script>
|
||||||
<script src="/static/js/sms.js?v=1.0"></script>
|
<script src="/static/js/sms.js?v=1.0"></script>
|
||||||
<script src="/static/js/bug-report.js?v=1.0"></script>
|
<script src="/static/js/bug-report.js?v=1.0"></script>
|
||||||
<script src="/static/js/bottom-bar.js?v=2.23"></script>
|
<script src="/static/js/bottom-bar.js?v=2.23"></script>
|
||||||
|
|||||||
8
main.py
8
main.py
@ -536,18 +536,12 @@ if __name__ == "__main__":
|
|||||||
log_level="info"
|
log_level="info"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
api_workers_raw = os.getenv("API_WORKERS", "1").strip()
|
|
||||||
try:
|
|
||||||
api_workers = max(1, int(api_workers_raw))
|
|
||||||
except ValueError:
|
|
||||||
api_workers = 1
|
|
||||||
|
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"main:app",
|
"main:app",
|
||||||
host="0.0.0.0",
|
host="0.0.0.0",
|
||||||
port=8000,
|
port=8000,
|
||||||
reload=False,
|
reload=False,
|
||||||
workers=api_workers,
|
workers=2,
|
||||||
timeout_keep_alive=65,
|
timeout_keep_alive=65,
|
||||||
access_log=True,
|
access_log=True,
|
||||||
log_level="info"
|
log_level="info"
|
||||||
|
|||||||
@ -1,48 +0,0 @@
|
|||||||
-- Migration 190: Align sag_sager status constraint with current case status model
|
|
||||||
-- Fixes PATCH /api/v1/sag/{id} failures when using statuses beyond 'åben'/'lukket'.
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
DECLARE
|
|
||||||
constraint_row RECORD;
|
|
||||||
BEGIN
|
|
||||||
-- Drop legacy check constraints on sag_sager.status regardless of their generated name.
|
|
||||||
FOR constraint_row IN
|
|
||||||
SELECT c.conname
|
|
||||||
FROM pg_constraint c
|
|
||||||
JOIN pg_class t ON t.oid = c.conrelid
|
|
||||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
||||||
WHERE n.nspname = 'public'
|
|
||||||
AND t.relname = 'sag_sager'
|
|
||||||
AND c.contype = 'c'
|
|
||||||
AND pg_get_constraintdef(c.oid) ILIKE '%status%'
|
|
||||||
LOOP
|
|
||||||
EXECUTE format('ALTER TABLE public.sag_sager DROP CONSTRAINT IF EXISTS %I', constraint_row.conname);
|
|
||||||
END LOOP;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
UPDATE public.sag_sager
|
|
||||||
SET status = CASE
|
|
||||||
WHEN lower(trim(status)) IN ('aaben', 'open') THEN 'åben'
|
|
||||||
WHEN lower(trim(status)) IN ('i_gang', 'in_progress', 'under behandling') THEN 'under behandling'
|
|
||||||
WHEN lower(trim(status)) IN ('on_hold', 'waiting', 'afventer') THEN 'afventer'
|
|
||||||
WHEN lower(trim(status)) IN ('resolved', 'løst') THEN 'løst'
|
|
||||||
WHEN lower(trim(status)) IN ('closed', 'afsluttet', 'lukket') THEN 'lukket'
|
|
||||||
ELSE status
|
|
||||||
END;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM pg_constraint c
|
|
||||||
JOIN pg_class t ON t.oid = c.conrelid
|
|
||||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
||||||
WHERE n.nspname = 'public'
|
|
||||||
AND t.relname = 'sag_sager'
|
|
||||||
AND c.conname = 'sag_sager_status_check'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.sag_sager
|
|
||||||
ADD CONSTRAINT sag_sager_status_check
|
|
||||||
CHECK (status IN ('åben', 'under behandling', 'afventer', 'løst', 'lukket'));
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
@ -143,7 +143,6 @@
|
|||||||
<div class="d-flex gap-2 mt-3">
|
<div class="d-flex gap-2 mt-3">
|
||||||
${openContactBtn}
|
${openContactBtn}
|
||||||
<button type="button" class="btn btn-sm btn-primary" data-action="create-case">Opret sag</button>
|
<button type="button" class="btn btn-sm btn-primary" data-action="create-case">Opret sag</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" data-action="link-case">Link sag</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -154,30 +153,7 @@
|
|||||||
|
|
||||||
toastEl.addEventListener('hidden.bs.toast', () => toastEl.remove());
|
toastEl.addEventListener('hidden.bs.toast', () => toastEl.remove());
|
||||||
|
|
||||||
async function patchCallCase(caseId) {
|
toastEl.addEventListener('click', (e) => {
|
||||||
const parsedCaseId = Number(caseId);
|
|
||||||
if (!Number.isInteger(parsedCaseId) || parsedCaseId <= 0) {
|
|
||||||
throw new Error('Ugyldigt sag-ID');
|
|
||||||
}
|
|
||||||
if (!Number.isInteger(Number(callId)) || Number(callId) <= 0) {
|
|
||||||
throw new Error('Mangler call_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`/api/v1/telefoni/calls/${Number(callId)}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ sag_id: parsedCaseId })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const t = await res.text();
|
|
||||||
throw new Error(t || `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
return parsedCaseId;
|
|
||||||
}
|
|
||||||
|
|
||||||
toastEl.addEventListener('click', async (e) => {
|
|
||||||
const btn = e.target.closest('button[data-action]');
|
const btn = e.target.closest('button[data-action]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const action = btn.getAttribute('data-action');
|
const action = btn.getAttribute('data-action');
|
||||||
@ -192,28 +168,6 @@
|
|||||||
qs.set('telefoni_opkald_id', String(callId));
|
qs.set('telefoni_opkald_id', String(callId));
|
||||||
window.location.href = `/sag/new?${qs.toString()}`;
|
window.location.href = `/sag/new?${qs.toString()}`;
|
||||||
}
|
}
|
||||||
if (action === 'link-case') {
|
|
||||||
const answer = window.prompt('Indtast eksisterende sag-ID, som opkaldet skal linkes til:');
|
|
||||||
if (answer === null) return;
|
|
||||||
const caseId = Number(String(answer).trim());
|
|
||||||
if (!Number.isInteger(caseId) || caseId <= 0) {
|
|
||||||
window.alert('Ugyldigt sag-ID');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
btn.disabled = true;
|
|
||||||
const originalText = btn.textContent;
|
|
||||||
btn.textContent = 'Gemmer...';
|
|
||||||
try {
|
|
||||||
const linkedCaseId = await patchCallCase(caseId);
|
|
||||||
window.location.href = `/sag/${linkedCaseId}/v3`;
|
|
||||||
} catch (err) {
|
|
||||||
window.alert(`Kunne ikke linke sag: ${err?.message || 'ukendt fejl'}`);
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = originalText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -226,16 +180,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
// Fallback to cookie-auth websocket when token is HttpOnly and cannot be read by JS.
|
const url = `${proto}://${window.location.host}/api/v1/telefoni/ws?token=${encodeURIComponent(token)}`;
|
||||||
const url = token
|
|
||||||
? `${proto}://${window.location.host}/api/v1/telefoni/ws?token=${encodeURIComponent(token)}`
|
|
||||||
: `${proto}://${window.location.host}/api/v1/telefoni/ws`;
|
|
||||||
ws = new WebSocket(url);
|
ws = new WebSocket(url);
|
||||||
|
|
||||||
ws.onopen = () => console.log('📞 Telefoni WS connected');
|
ws.onopen = () => console.log('📞 Telefoni WS connected');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user