release: v2.5.1

This commit is contained in:
Christian 2026-08-18 20:57:49 +02:00
parent 761753e577
commit 6906568dc2
12 changed files with 585 additions and 48 deletions

View File

@ -0,0 +1,34 @@
# Release Notes: v2.5.1
**Dato:** 18. august 2026
## Overblik
Version 2.5.1 retter de vigtigste fejl fundet efter 2.5-udgivelsen i tidsregistrering, VoIP og oprettelse af nye sager.
## Tidsregistrering
- Manuel tidsregistrering bruger nu den indtastede lokale dato og tid uden utilsigtet tidszoneforskydning.
- Flere medarbejdere kan vælges i samme registrering, og der oprettes én tidsregistrering under hver valgt medarbejder.
- Medarbejdervælgeren viser sagens tilgængelige brugere tydeligt og er adskilt fra live tracking.
- Registreringer, der krydser midnat, får korrekt slutdato.
- Sagsvisningen leveres uden browsercache, så de seneste ændringer i tidsregistreringen vises med det samme.
## VoIP
- Ved et ukendt telefonnummer kan en ny kontakt knyttes til et valgt firma direkte i VoIP-popupen.
- De viste åbne sager kan åbnes og knyttes direkte til opkaldet.
- Click-to-call accepterer nu en godkendt bruger på Hubens lokale eller interne adresse, selv når en proxy skjuler klientens lokale IP-adresse.
- VoIP-klientens cacheversion er opdateret.
## Opret ny sag
- Når et firma vælges først, hentes og vises firmaets kontaktpersoner automatisk.
- Kontaktsøgningen afgrænses til det valgte firmas kontakter og søger på navn, rolle, e-mail og telefon.
- Fjernes firmaet, skifter kontaktfeltet tilbage til den globale kontaktsøgning.
- Det er fortsat muligt at vælge flere kontaktpersoner på sagen.
## Verifikation
- Målrettede tests for sager, tidsregistrering og VoIP: **36 bestået**.
- Python- og diff-kontrol samt lokal API-health-check er gennemført.

View File

@ -1 +1 @@
2.5.0 2.5.1

View File

@ -1242,7 +1242,7 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
status_options.append(current_status) status_options.append(current_status)
is_deadline_overdue = _is_deadline_overdue(sag.get("deadline")) is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
return templates.TemplateResponse("modules/sag/templates/detail_v3.html", { response = templates.TemplateResponse("modules/sag/templates/detail_v3.html", {
"request": request, "request": request,
"case": sag, "case": sag,
"customer": customer, "customer": customer,
@ -1268,6 +1268,8 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
"assignment_users": _fetch_assignment_users(), "assignment_users": _fetch_assignment_users(),
"assignment_groups": _fetch_assignment_groups(), "assignment_groups": _fetch_assignment_groups(),
}) })
response.headers["Cache-Control"] = "no-store, max-age=0"
return response
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:

View File

@ -181,6 +181,7 @@
</div> </div>
<div id="contactResults" class="search-results shadow-sm d-none"></div> <div id="contactResults" class="search-results shadow-sm d-none"></div>
</div> </div>
<div id="contactSearchContext" class="form-text">Søg blandt alle kontakter.</div>
<div id="selectedContacts" class="mt-2 text-wrap"></div> <div id="selectedContacts" class="mt-2 text-wrap"></div>
</div> </div>
@ -381,6 +382,8 @@
let selectedContactsCompanies = {}; let selectedContactsCompanies = {};
let customerSearchTimeout; let customerSearchTimeout;
let contactSearchTimeout; let contactSearchTimeout;
let selectedCustomerContacts = [];
let customerContactsLoadToken = 0;
let successAlertTimeout; let successAlertTimeout;
let orderLineCounter = 0; let orderLineCounter = 0;
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null }; let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null };
@ -569,6 +572,11 @@
const contactInput = document.getElementById('contactSearch'); const contactInput = document.getElementById('contactSearch');
if (contactInput) { if (contactInput) {
contactInput.addEventListener('input', (e) => handleSearch(e, 'contact')); contactInput.addEventListener('input', (e) => handleSearch(e, 'contact'));
contactInput.addEventListener('focus', () => {
if (selectedCustomer) {
renderCustomerContactResults(contactInput.value);
}
});
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!e.target.closest('.search-position-relative')) { if (!e.target.closest('.search-position-relative')) {
const cr = document.getElementById('contactResults'); const cr = document.getElementById('contactResults');
@ -589,6 +597,11 @@
clearTimeout(timeoutVar); clearTimeout(timeoutVar);
if (type === 'contact' && selectedCustomer) {
renderCustomerContactResults(query);
return;
}
if (query.length < 2) { if (query.length < 2) {
resultsDiv.classList.add('d-none'); resultsDiv.classList.add('d-none');
return; return;
@ -653,6 +666,104 @@
else contactSearchTimeout = timeout; else contactSearchTimeout = timeout;
} }
function contactDisplayName(contact) {
return `${contact.first_name || ''} ${contact.last_name || ''}`.trim()
|| contact.name
|| `Kontakt #${contact.id}`;
}
function contactDisplayMeta(contact) {
const details = [];
if (contact.role) details.push(contact.role);
if (contact.email) details.push(contact.email);
const phone = contact.mobile || contact.phone;
if (phone) details.push(phone);
return details.join(' · ') || 'Ingen kontaktoplysninger';
}
function renderCustomerContactResults(query = '') {
const resultsDiv = document.getElementById('contactResults');
if (!resultsDiv || !selectedCustomer) return;
const normalizedQuery = String(query || '').trim().toLocaleLowerCase('da-DK');
const contacts = selectedCustomerContacts.filter((contact) => {
if (!normalizedQuery) return true;
return [
contactDisplayName(contact),
contact.email,
contact.mobile,
contact.phone,
contact.role
].some((value) => String(value || '').toLocaleLowerCase('da-DK').includes(normalizedQuery));
});
if (!contacts.length) {
resultsDiv.innerHTML = `<div class="p-3 text-muted small">${
selectedCustomerContacts.length
? 'Ingen af firmaets kontakter matcher søgningen.'
: 'Firmaet har ingen registrerede kontakter.'
}</div>`;
} else {
resultsDiv.innerHTML = contacts.map((contact) => {
const name = contactDisplayName(contact);
return `
<div class="search-result-item" data-contact-id="${Number(contact.id)}">
<div class="search-result-name">${escapeTopAlertHtml(name)}</div>
<div class="search-result-meta">${escapeTopAlertHtml(contactDisplayMeta(contact))}</div>
</div>
`;
}).join('');
resultsDiv.querySelectorAll('[data-contact-id]').forEach((row) => {
row.addEventListener('click', () => {
const contact = contacts.find((item) => Number(item.id) === Number(row.dataset.contactId));
if (contact) selectContact(contact.id, contactDisplayName(contact));
});
});
}
resultsDiv.classList.remove('d-none');
}
async function loadSelectedCustomerContacts(customerId) {
const contactInput = document.getElementById('contactSearch');
const context = document.getElementById('contactSearchContext');
const resultsDiv = document.getElementById('contactResults');
const loadToken = ++customerContactsLoadToken;
selectedCustomerContacts = [];
contactInput.placeholder = `Søg blandt kontakter hos ${selectedCustomer.name}...`;
context.textContent = `Viser kontakter hos ${selectedCustomer.name}.`;
resultsDiv.innerHTML = '<div class="p-3 text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Henter firmaets kontakter...</div>';
resultsDiv.classList.remove('d-none');
try {
const response = await fetch(`/api/v1/customers/${customerId}/contacts`, { credentials: 'include' });
if (loadToken !== customerContactsLoadToken) return;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const contacts = await response.json();
selectedCustomerContacts = Array.isArray(contacts) ? contacts : [];
renderCustomerContactResults(contactInput.value);
} catch (error) {
if (loadToken !== customerContactsLoadToken) return;
console.error('Failed to load customer contacts:', error);
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Firmaets kontakter kunne ikke hentes.</div>';
resultsDiv.classList.remove('d-none');
}
}
function resetCustomerContactSearch() {
customerContactsLoadToken += 1;
selectedCustomerContacts = [];
const contactInput = document.getElementById('contactSearch');
const context = document.getElementById('contactSearchContext');
const resultsDiv = document.getElementById('contactResults');
contactInput.value = '';
contactInput.placeholder = 'Søg kontakt...';
context.textContent = 'Søg blandt alle kontakter.';
resultsDiv.innerHTML = '';
resultsDiv.classList.add('d-none');
}
// --- Selection Logic --- // --- Selection Logic ---
function selectCustomer(id, name, skipAlert = false) { function selectCustomer(id, name, skipAlert = false) {
selectedCustomer = { id, name }; selectedCustomer = { id, name };
@ -661,6 +772,7 @@
document.getElementById('customerResults').classList.add('d-none'); document.getElementById('customerResults').classList.add('d-none');
renderSelections(); renderSelections();
loadCreateTopAlertsForCustomer(id); loadCreateTopAlertsForCustomer(id);
loadSelectedCustomerContacts(id);
// Show notification // Show notification
if (!skipAlert) { if (!skipAlert) {
@ -671,6 +783,7 @@
function removeCustomer() { function removeCustomer() {
selectedCustomer = null; selectedCustomer = null;
document.getElementById('customer_id').value = ''; document.getElementById('customer_id').value = '';
resetCustomerContactSearch();
renderSelections(); renderSelections();
loadCreateTopAlertsForCustomer(null); loadCreateTopAlertsForCustomer(null);
} }

View File

@ -2557,6 +2557,17 @@
background: color-mix(in srgb, var(--module-accent, var(--accent)) 7%, var(--bg-card)); background: color-mix(in srgb, var(--module-accent, var(--accent)) 7%, var(--bg-card));
} }
/* Employee multi-select must be allowed to extend beyond the compact time card. */
#timetracking .time-v1-entry-card {
overflow: visible !important;
position: relative;
z-index: 20;
}
#timeV1EmployeePickerMenu {
z-index: 1080;
}
.left-module-card, .left-module-card,
.right-module-card { .right-module-card {
border: 2px solid rgba(15, 76, 117, 0.28) !important; border: 2px solid rgba(15, 76, 117, 0.28) !important;
@ -8525,7 +8536,7 @@
<!-- Tidsforbrug Tab --> <!-- Tidsforbrug Tab -->
<div class="tab-pane fade" id="timetracking" role="tabpanel" tabindex="0" data-has-content="unknown"> <div class="tab-pane fade" id="timetracking" role="tabpanel" tabindex="0" data-has-content="unknown">
<div class="card mb-3"> <div class="card mb-3 time-v1-entry-card">
<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-stopwatch me-2"></i>Live tracking</h6> <h6 class="mb-0 text-primary"><i class="bi bi-stopwatch me-2"></i>Live tracking</h6>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
@ -8543,8 +8554,30 @@
<div class="card-body"> <div class="card-body">
<form id="timeManualFormV1" class="row g-2 align-items-end" onsubmit="createManualTimeV1(event); return false;"> <form id="timeManualFormV1" class="row g-2 align-items-end" onsubmit="createManualTimeV1(event); return false;">
<div class="col-xl-2 col-md-3 col-12"> <div class="col-xl-2 col-md-3 col-12">
<label class="form-label small mb-1">Medarbejder</label> <label class="form-label small mb-1">
<select class="form-select form-select-sm" id="timeV1EmployeeId"> Manuel registrering for
<span class="text-muted fw-normal">({{ assignment_users|length }} brugere)</span>
</label>
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle w-100 text-start text-truncate"
id="timeV1EmployeePickerButton" type="button" data-bs-toggle="dropdown"
data-bs-auto-close="outside" aria-expanded="false">
Mig · vælg flere
</button>
<div class="dropdown-menu w-100 p-2 shadow-sm" id="timeV1EmployeePickerMenu" style="max-height: 16rem; overflow-y: auto;">
<label class="dropdown-item px-2 py-1 d-flex align-items-center gap-2">
<input class="form-check-input mt-0 time-v1-employee-option" type="checkbox" value="" data-label="Mig" checked>
<span>Mig (nuværende bruger)</span>
</label>
{% for user in assignment_users %}
<label class="dropdown-item px-2 py-1 d-flex align-items-center gap-2">
<input class="form-check-input mt-0 time-v1-employee-option" type="checkbox" value="{{ user.user_id }}" data-label="{{ user.display_name }}">
<span class="text-truncate">{{ user.display_name }}</span>
</label>
{% endfor %}
</div>
</div>
<select class="d-none" id="timeV1EmployeeId" aria-hidden="true" tabindex="-1">
<option value="">Mig (nuværende bruger)</option> <option value="">Mig (nuværende bruger)</option>
{% for user in assignment_users %} {% for user in assignment_users %}
<option value="{{ user.user_id }}">{{ user.display_name }}</option> <option value="{{ user.user_id }}">{{ user.display_name }}</option>
@ -10246,7 +10279,7 @@
} }
async function registerQuickTimeFromComment(content) { async function registerQuickTimeFromComment(content) {
const dateValue = document.getElementById('commentQuickTimeDate')?.value || new Date().toISOString().slice(0, 10); const dateValue = document.getElementById('commentQuickTimeDate')?.value || toLocalDateInputValue(new Date());
const includeStart = !!document.getElementById('commentQuickTimeIncludeStart')?.checked; const includeStart = !!document.getElementById('commentQuickTimeIncludeStart')?.checked;
const startValue = includeStart ? (document.getElementById('commentQuickTimeStart')?.value || '') : ''; const startValue = includeStart ? (document.getElementById('commentQuickTimeStart')?.value || '') : '';
const minutes = Number(document.getElementById('commentQuickTimeMinutes')?.value || 0); const minutes = Number(document.getElementById('commentQuickTimeMinutes')?.value || 0);
@ -10259,12 +10292,8 @@
let startIso = null; let startIso = null;
let endIso = null; let endIso = null;
if (startValue) { if (startValue) {
const startDate = new Date(`${dateValue}T${startValue}:00`); startIso = `${dateValue}T${startValue}:00`;
if (!Number.isNaN(startDate.getTime())) { endIso = addMinutesToTimeV1LocalIso(startIso, minutes);
const endDate = new Date(startDate.getTime() + (minutes * 60000));
startIso = startDate.toISOString();
endIso = endDate.toISOString();
}
} }
const response = await fetch('/api/v1/timetracking/time/manual', { const response = await fetch('/api/v1/timetracking/time/manual', {
@ -11865,10 +11894,7 @@
if (endVal) { if (endVal) {
endIso = `${dateVal}T${endVal}:00`; endIso = `${dateVal}T${endVal}:00`;
} else { } else {
const startDate = new Date(startIso); endIso = addMinutesToTimeV1LocalIso(startIso, minutes);
if (!Number.isNaN(startDate.getTime())) {
endIso = new Date(startDate.getTime() + (minutes * 60000)).toISOString();
}
} }
} }
@ -12108,6 +12134,42 @@
return val ? Number(val) : null; return val ? Number(val) : null;
} }
function getTimeV1EmployeeIds() {
return Array.from(document.querySelectorAll('.time-v1-employee-option:checked')).map((option) => {
const value = option.value;
return value ? Number(value) : null;
});
}
function updateTimeV1EmployeePickerLabel() {
const button = document.getElementById('timeV1EmployeePickerButton');
if (!button) return;
const selected = Array.from(document.querySelectorAll('.time-v1-employee-option:checked'));
if (selected.length === 0) {
button.textContent = 'Vælg medarbejdere';
} else if (selected.length === 1) {
button.textContent = selected[0].value
? (selected[0].dataset.label || '1 medarbejder')
: 'Mig · vælg flere';
} else {
button.textContent = `${selected.length} medarbejdere valgt`;
}
}
function addDaysToTimeV1Date(dateValue, days) {
const [year, month, day] = String(dateValue || '').split('-').map(Number);
if (!year || !month || !day) return dateValue;
const value = new Date(year, month - 1, day + days, 12, 0, 0);
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
}
function addMinutesToTimeV1LocalIso(localIso, minutes) {
const value = new Date(localIso);
if (Number.isNaN(value.getTime())) return null;
value.setMinutes(value.getMinutes() + Number(minutes || 0));
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}T${String(value.getHours()).padStart(2, '0')}:${String(value.getMinutes()).padStart(2, '0')}:00`;
}
async function createManualTimeV1(event) { async function createManualTimeV1(event) {
event.preventDefault(); event.preventDefault();
const minutes = Number(document.getElementById('timeV1Minutes')?.value || 0); const minutes = Number(document.getElementById('timeV1Minutes')?.value || 0);
@ -12120,30 +12182,28 @@
const dateVal = document.getElementById('timeV1Date')?.value || null; const dateVal = document.getElementById('timeV1Date')?.value || null;
const tStart = document.getElementById('timeV1Start')?.value; const tStart = document.getElementById('timeV1Start')?.value;
const tEnd = document.getElementById('timeV1End')?.value; const tEnd = document.getElementById('timeV1End')?.value;
const employeeIds = getTimeV1EmployeeIds();
if (employeeIds.length === 0) {
alert('Vælg mindst én medarbejder');
return;
}
let startObj = null; let startObj = null;
let endObj = null; let endObj = null;
if (dateVal && tStart) { if (dateVal && tStart) {
try { startObj = `${dateVal}T${tStart}:00`;
const l = new Date(`${dateVal}T${tStart}:00`);
startObj = l.toISOString();
} catch(e){}
} }
if (dateVal && tEnd) { if (dateVal && tEnd) {
try { const endDate = tStart && tEnd < tStart ? addDaysToTimeV1Date(dateVal, 1) : dateVal;
const l = new Date(`${dateVal}T${tEnd}:00`); endObj = `${endDate}T${tEnd}:00`;
if (startObj && new Date(startObj) > l) {
l.setDate(l.getDate() + 1);
}
endObj = l.toISOString();
} catch(e){}
} }
const payload = { const payload = {
sag_id: timeCaseId, sag_id: timeCaseId,
medarbejder_id: getTimeV1EmployeeId(), medarbejder_ids: employeeIds,
faktisk_tid_min: minutes, faktisk_tid_min: minutes,
worked_date: dateVal, worked_date: dateVal,
entry_type: document.getElementById('timeV1Type')?.value || 'manuel', entry_type: document.getElementById('timeV1Type')?.value || 'manuel',
@ -12185,6 +12245,10 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
bindTimeV1Calculations(); bindTimeV1Calculations();
document.querySelectorAll('.time-v1-employee-option').forEach((option) => {
option.addEventListener('change', updateTimeV1EmployeePickerLabel);
});
updateTimeV1EmployeePickerLabel();
loadTimeTrackingTab(); loadTimeTrackingTab();
const dateInput = document.getElementById('timeV1Date'); const dateInput = document.getElementById('timeV1Date');
if (dateInput && !dateInput.value) { if (dateInput && !dateInput.value) {
@ -13515,19 +13579,11 @@
let startObj = null; let startObj = null;
let endObj = null; let endObj = null;
if (dateVal && tStart) { if (dateVal && tStart) {
try { startObj = `${dateVal}T${tStart}:00`;
const l = new Date(`${dateVal}T${tStart}:00`);
startObj = l.toISOString();
} catch(e){}
} }
if (dateVal && tEnd) { if (dateVal && tEnd) {
try { const endDate = tStart && tEnd < tStart ? addDaysToTimeV1Date(dateVal, 1) : dateVal;
const l = new Date(`${dateVal}T${tEnd}:00`); endObj = `${endDate}T${tEnd}:00`;
if (startObj && new Date(startObj) > l) {
l.setDate(l.getDate() + 1);
}
endObj = l.toISOString();
} catch(e){}
} }
const sagId = document.getElementById('time_sag_id').value; const sagId = document.getElementById('time_sag_id').value;

View File

@ -178,6 +178,17 @@ def _is_internal_bmc_supernet_ip(client_ip: str) -> bool:
return ip_obj in ipaddress.ip_network("172.16.0.0/12") return ip_obj in ipaddress.ip_network("172.16.0.0/12")
def _is_internal_request_target(request: Request) -> bool:
"""Allow the local/internal Hub URL when a desktop proxy masks the LAN client IP."""
hostname = (request.url.hostname or "").strip().lower()
if hostname in {"localhost", "127.0.0.1", "::1"}:
return True
try:
return ipaddress.ip_address(hostname) in ipaddress.ip_network("172.16.31.0/24")
except ValueError:
return False
def _validate_yealink_request(request: Request, token: Optional[str]) -> None: def _validate_yealink_request(request: Request, token: Optional[str]) -> None:
env_secret = (getattr(settings, "TELEFONI_SHARED_SECRET", "") or "").strip() env_secret = (getattr(settings, "TELEFONI_SHARED_SECRET", "") or "").strip()
db_secret = (_get_setting_value("telefoni_shared_secret", "") or "").strip() db_secret = (_get_setting_value("telefoni_shared_secret", "") or "").strip()
@ -609,9 +620,22 @@ def _get_setting_value(key: str, default: Optional[str] = None) -> Optional[str]
@router.post("/telefoni/click-to-call") @router.post("/telefoni/click-to-call")
async def click_to_call(payload: TelefoniClickToCallRequest, request: Request): async def click_to_call(payload: TelefoniClickToCallRequest, request: Request):
client_ip = _get_client_ip(request) client_ip = _get_client_ip(request)
if not _is_internal_bmc_ip(client_ip): authenticated_user_id = getattr(request.state, "user_id", None)
logger.warning("⚠️ Click-to-call blocked for non-internal IP: %s", client_ip or "unknown") internal_target_fallback = bool(authenticated_user_id) and _is_internal_request_target(request)
if not _is_internal_bmc_ip(client_ip) and not internal_target_fallback:
logger.warning(
"⚠️ Click-to-call blocked for non-internal IP: client=%s target=%s",
client_ip or "unknown",
request.url.hostname or "unknown",
)
raise HTTPException(status_code=403, detail="Click-to-call is only available on internal network") raise HTTPException(status_code=403, detail="Click-to-call is only available on internal network")
if internal_target_fallback and not _is_internal_bmc_ip(client_ip):
logger.info(
"📞 Click-to-call accepted via authenticated internal target: user_id=%s client=%s target=%s",
authenticated_user_id,
client_ip or "unknown",
request.url.hostname,
)
enabled = (_get_setting_value("telefoni_click_to_call_enabled", "false") or "false").lower() == "true" enabled = (_get_setting_value("telefoni_click_to_call_enabled", "false") or "false").lower() == "true"
if not enabled: if not enabled:

View File

@ -1583,7 +1583,7 @@ if (bmcOriginalFetch) {
<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.4"></script> <script src="/static/js/telefoni.js?v=2.5"></script>
<script src="/static/js/sms.js?v=1.1"></script> <script src="/static/js/sms.js?v=1.1"></script>
<script src="/static/js/bug-report.js?v=1.4"></script> <script src="/static/js/bug-report.js?v=1.4"></script>
<script src="/static/js/bottom-bar.js?v=2.64"></script> <script src="/static/js/bottom-bar.js?v=2.64"></script>

View File

@ -2882,7 +2882,22 @@ async def create_manual_time_v1(
detail="Kunde er ikke linked til tidsmodulet. Kør kundesync/linking for kunden før tidsregistrering.", detail="Kunde er ikke linked til tidsmodulet. Kør kundesync/linking for kunden før tidsregistrering.",
) )
bruger_id = _resolve_target_user_id(current_user, payload.get("medarbejder_id")) requested_user_ids = payload.get("medarbejder_ids")
is_bulk_request = requested_user_ids is not None
if requested_user_ids is None:
requested_user_ids = [payload.get("medarbejder_id")]
if not isinstance(requested_user_ids, list) or not requested_user_ids:
raise HTTPException(status_code=400, detail="Vælg mindst én medarbejder")
if len(requested_user_ids) > 50:
raise HTTPException(status_code=400, detail="Der kan højst vælges 50 medarbejdere ad gangen")
bruger_ids: List[Optional[int]] = []
for requested_user_id in requested_user_ids:
resolved_user_id = _resolve_target_user_id(current_user, requested_user_id)
if requested_user_id not in (None, "") and resolved_user_id is None:
raise HTTPException(status_code=400, detail="Ugyldigt medarbejder-id")
if resolved_user_id not in bruger_ids:
bruger_ids.append(resolved_user_id)
default_user_name = ( default_user_name = (
(current_user or {}).get("username") (current_user or {}).get("username")
or (current_user or {}).get("full_name") or (current_user or {}).get("full_name")
@ -2928,15 +2943,16 @@ async def create_manual_time_v1(
entry_type, kilde, entry_status, medarbejder_id, entry_type, kilde, entry_status, medarbejder_id,
aktiv_timer, round_block_min, ikke_placeret, aktiv_timer, round_block_min, ikke_placeret,
approved_hours, rounded_to, work_type approved_hours, rounded_to, work_type
) VALUES ( ) SELECT
%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, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, target.medarbejder_id,
%s, %s, %s, %s, %s, %s,
%s, %s, %s %s, %s, %s
) RETURNING * FROM UNNEST(%s::bigint[]) AS target(medarbejder_id)
RETURNING *
""" """
inserted = execute_query( inserted = execute_query(
@ -2959,15 +2975,17 @@ async def create_manual_time_v1(
payload.get("entry_type") or "manuel", payload.get("entry_type") or "manuel",
payload.get("kilde") or "manuel", payload.get("kilde") or "manuel",
entry_status, entry_status,
bruger_id,
False, False,
round_block_min, round_block_min,
not_placed, not_placed,
(billable_minutes / 60.0) if billable else None, (billable_minutes / 60.0) if billable else None,
(round_block_min / 60.0) if billable else None, (round_block_min / 60.0) if billable else None,
payload.get("work_type") or "support", payload.get("work_type") or "support",
bruger_ids,
) )
) )
if is_bulk_request:
return {"count": len(inserted or []), "created": inserted or []}
return inserted[0] if inserted else None return inserted[0] if inserted else None
except HTTPException: except HTTPException:
raise raise

View File

@ -207,10 +207,30 @@
return Array.isArray(payload) ? payload : []; return Array.isArray(payload) ? payload : [];
} }
async function searchCustomers(query) {
const qs = new URLSearchParams({
search: String(query || '').trim(),
limit: '20',
is_active: 'true',
});
const res = await fetch(`/api/v1/customers?${qs.toString()}`, {
credentials: 'include'
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
const payload = await res.json();
return Array.isArray(payload?.customers) ? payload.customers : [];
}
function showIncomingCallToast(data) { function showIncomingCallToast(data) {
const container = ensureContainer(); const container = ensureContainer();
let currentData = { ...data }; let currentData = { ...data };
let contact = currentData.contact || null; let contact = currentData.contact || null;
let selectedQuickCompany = null;
let companySearchTimer = null;
let companySearchToken = 0;
const number = data.number || ''; const number = data.number || '';
const callId = data.call_id; const callId = data.call_id;
@ -519,7 +539,16 @@
if (recentCases.length > 0) { if (recentCases.length > 0) {
casesHtml = '<div class="mt-2 mb-2"><small class="text-muted fw-semibold">Åbne sager:</small>'; casesHtml = '<div class="mt-2 mb-2"><small class="text-muted fw-semibold">Åbne sager:</small>';
recentCases.forEach(c => { recentCases.forEach(c => {
casesHtml += `<div class="small"><a href="/sag/${c.id}" class="text-decoration-none" target="_blank">${escapeHtml(c.titel)}</a></div>`; const caseId = Number(c.id);
casesHtml += `
<div class="d-flex align-items-center justify-content-between gap-2 border rounded px-2 py-1 mt-1 small">
<a href="/sag/${caseId}/v3" class="text-decoration-none text-truncate" target="_blank" title="Åbn sag #${caseId}">
<span class="text-muted">#${caseId}</span> ${escapeHtml(c.titel)}
</a>
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-2 flex-shrink-0" data-action="link-recent-case" data-case-id="${caseId}">
<i class="bi bi-link-45deg me-1"></i>Link til sag
</button>
</div>`;
}); });
casesHtml += '</div>'; casesHtml += '</div>';
} }
@ -568,6 +597,16 @@
<div class="col-12"> <div class="col-12">
<input type="email" class="form-control form-control-sm" data-role="quick-email" placeholder="E-mail (valgfri)"> <input type="email" class="form-control form-control-sm" data-role="quick-email" placeholder="E-mail (valgfri)">
</div> </div>
<div class="col-12 position-relative">
<label class="form-label small text-muted mb-1">Firma</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-building"></i></span>
<input type="search" class="form-control" data-role="quick-company-search" placeholder="Søg og vælg firma" autocomplete="off">
<button type="button" class="btn btn-outline-secondary d-none" data-action="clear-quick-company" title="Fjern valgt firma"><i class="bi bi-x-lg"></i></button>
</div>
<div class="list-group position-absolute start-0 end-0 mt-1 shadow-sm d-none" data-role="quick-company-results" style="z-index: 10010; max-height: 180px; overflow-y: auto;"></div>
<div class="form-text" data-role="quick-company-help">Skriv mindst 2 tegn og vælg firmaet.</div>
</div>
<div class="col-12"> <div class="col-12">
<div class="form-text mt-0">Finder først eksisterende kontakt navn og opdaterer telefon, hvis den kun mangler nummer.</div> <div class="form-text mt-0">Finder først eksisterende kontakt navn og opdaterer telefon, hvis den kun mangler nummer.</div>
</div> </div>
@ -658,6 +697,7 @@
const firstName = String(firstNameInput?.value || '').trim(); const firstName = String(firstNameInput?.value || '').trim();
const lastName = String(lastNameInput?.value || '').trim(); const lastName = String(lastNameInput?.value || '').trim();
const email = String(emailInput?.value || '').trim(); const email = String(emailInput?.value || '').trim();
const companyId = Number(selectedQuickCompany?.id || 0);
if (!firstName) { if (!firstName) {
window.alert('Fornavn er påkrævet'); window.alert('Fornavn er påkrævet');
@ -700,6 +740,19 @@
} }
savedContactId = Number(existing.id); savedContactId = Number(existing.id);
if (companyId > 0) {
const linkRes = await fetch(`/api/v1/contacts/${savedContactId}/companies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ customer_id: companyId, is_primary: true })
});
if (!linkRes.ok) {
const text = await linkRes.text();
throw new Error(text || `HTTP ${linkRes.status}`);
}
}
} else { } else {
const createRes = await fetch('/api/v1/contacts', { const createRes = await fetch('/api/v1/contacts', {
method: 'POST', method: 'POST',
@ -711,6 +764,8 @@
email: email || null, email: email || null,
phone: phoneValue, phone: phoneValue,
title: null, title: null,
company_id: companyId > 0 ? companyId : null,
is_primary: companyId > 0,
}) })
}); });
if (!createRes.ok) { if (!createRes.ok) {
@ -768,10 +823,91 @@
if (action === 'link-case') { if (action === 'link-case') {
openCaseSmartModal(); openCaseSmartModal();
} }
if (action === 'link-recent-case') {
const caseId = Number(btn.getAttribute('data-case-id'));
btn.disabled = true;
const originalHtml = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Linker';
try {
await patchCallCase(caseId);
btn.className = 'btn btn-sm btn-success py-0 px-2 flex-shrink-0';
btn.innerHTML = '<i class="bi bi-check-lg me-1"></i>Linket';
} catch (err) {
btn.disabled = false;
btn.innerHTML = originalHtml;
window.alert(`Kunne ikke linke opkaldet til sagen: ${err?.message || 'ukendt fejl'}`);
}
}
if (action === 'select-quick-company') {
selectedQuickCompany = {
id: Number(btn.getAttribute('data-company-id')),
name: btn.getAttribute('data-company-name') || '',
};
const input = toastEl.querySelector('[data-role="quick-company-search"]');
const results = toastEl.querySelector('[data-role="quick-company-results"]');
const help = toastEl.querySelector('[data-role="quick-company-help"]');
const clearBtn = toastEl.querySelector('[data-action="clear-quick-company"]');
if (input) input.value = selectedQuickCompany.name;
results?.classList.add('d-none');
if (help) help.innerHTML = `<span class="text-success"><i class="bi bi-check-circle me-1"></i>Valgt: ${escapeHtml(selectedQuickCompany.name)}</span>`;
clearBtn?.classList.remove('d-none');
}
if (action === 'clear-quick-company') {
selectedQuickCompany = null;
const input = toastEl.querySelector('[data-role="quick-company-search"]');
const results = toastEl.querySelector('[data-role="quick-company-results"]');
const help = toastEl.querySelector('[data-role="quick-company-help"]');
if (input) {
input.value = '';
input.focus();
}
results?.classList.add('d-none');
if (help) help.textContent = 'Skriv mindst 2 tegn og vælg firmaet.';
btn.classList.add('d-none');
}
if (action === 'quick-create-contact') { if (action === 'quick-create-contact') {
await createOrUpdateContactFromToast(); await createOrUpdateContactFromToast();
} }
}); });
toastEl.addEventListener('input', (event) => {
const input = event.target.closest('[data-role="quick-company-search"]');
if (!input) return;
selectedQuickCompany = null;
toastEl.querySelector('[data-action="clear-quick-company"]')?.classList.add('d-none');
const resultsEl = toastEl.querySelector('[data-role="quick-company-results"]');
const helpEl = toastEl.querySelector('[data-role="quick-company-help"]');
const query = String(input.value || '').trim();
if (companySearchTimer) clearTimeout(companySearchTimer);
if (query.length < 2) {
resultsEl?.classList.add('d-none');
if (helpEl) helpEl.textContent = 'Skriv mindst 2 tegn og vælg firmaet.';
return;
}
const token = ++companySearchToken;
if (helpEl) helpEl.textContent = 'Søger firmaer...';
companySearchTimer = setTimeout(async () => {
try {
const customers = await searchCustomers(query);
if (token !== companySearchToken || !resultsEl) return;
resultsEl.innerHTML = customers.length ? customers.map((customer) => {
const companyId = Number(customer.id);
const companyName = String(customer.name || `Firma #${companyId}`);
const meta = [customer.cvr_number ? `CVR ${customer.cvr_number}` : '', customer.city || ''].filter(Boolean).join(' · ');
return `<button type="button" class="list-group-item list-group-item-action py-2" data-action="select-quick-company" data-company-id="${companyId}" data-company-name="${escapeHtml(companyName)}">
<div class="fw-semibold small">${escapeHtml(companyName)}</div>
${meta ? `<div class="text-muted" style="font-size: .72rem">${escapeHtml(meta)}</div>` : ''}
</button>`;
}).join('') : '<div class="list-group-item text-muted small">Ingen firmaer fundet</div>';
resultsEl.classList.remove('d-none');
if (helpEl) helpEl.textContent = customers.length ? 'Vælg et firma fra listen.' : 'Ingen firmaer fundet.';
} catch (err) {
if (token !== companySearchToken) return;
resultsEl?.classList.add('d-none');
if (helpEl) helpEl.textContent = `Firmaer kunne ikke hentes: ${err?.message || 'ukendt fejl'}`;
}
}, 250);
});
} }
function scheduleReconnect() { function scheduleReconnect() {

View File

@ -138,6 +138,33 @@ def test_case_email_forward_supports_latest_mail_and_full_thread_as_new_thread()
assert "linkedEmailsCache\n .filter" in template assert "linkedEmailsCache\n .filter" in template
def test_case_detail_disables_browser_cache_for_fresh_inline_ui():
source = Path("app/modules/sag/frontend/views.py").read_text()
assert 'response.headers["Cache-Control"] = "no-store, max-age=0"' in source
def test_case_create_lists_contacts_for_selected_customer():
template = Path("app/modules/sag/templates/create.html").read_text()
assert 'id="contactSearchContext"' in template
assert "loadSelectedCustomerContacts(id);" in template
assert "`/api/v1/customers/${customerId}/contacts`" in template
assert "if (type === 'contact' && selectedCustomer)" in template
assert "renderCustomerContactResults(contactInput.value);" in template
assert "resetCustomerContactSearch();" in template
def test_time_employee_picker_is_clearly_separate_from_live_tracking():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert "Manuel registrering for" in template
assert "Mig · vælg flere" in template
assert "assignment_users|length" in template
assert "#timetracking .time-v1-entry-card" in template
assert "overflow: visible !important" in template
def test_case_email_snippet_removes_embedded_css_and_scripts(): def test_case_email_snippet_removes_embedded_css_and_scripts():
template = Path("app/modules/sag/templates/detail_v3.html").read_text() template = Path("app/modules/sag/templates/detail_v3.html").read_text()

View File

@ -3,9 +3,20 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from starlette.requests import Request
from app.modules.telefoni.backend.router import _is_internal_request_target
from app.modules.telefoni.backend.service import TelefoniService from app.modules.telefoni.backend.service import TelefoniService
def _telefoni_javascript() -> str:
return (Path(__file__).resolve().parents[1] / "static/js/telefoni.js").read_text(encoding="utf-8")
def _request_for_host(host: str) -> Request:
return Request({"type": "http", "method": "POST", "path": "/", "headers": [], "server": (host, 8001)})
def test_terminate_call_creates_placeholder_row_when_missing(monkeypatch): def test_terminate_call_creates_placeholder_row_when_missing(monkeypatch):
calls = [] calls = []
@ -28,3 +39,31 @@ def test_terminate_call_creates_placeholder_row_when_missing(monkeypatch):
assert result is True assert result is True
assert any("INSERT INTO telefoni_opkald" in query for query, _ in calls) assert any("INSERT INTO telefoni_opkald" in query for query, _ in calls)
def test_unknown_caller_quick_contact_can_select_and_save_company():
source = _telefoni_javascript()
assert 'data-role="quick-company-search"' in source
assert "async function searchCustomers" in source
assert "company_id: companyId > 0 ? companyId : null" in source
assert "body: JSON.stringify({ customer_id: companyId, is_primary: true })" in source
def test_recent_open_case_can_be_linked_to_call_from_popup():
source = _telefoni_javascript()
assert 'data-action="link-recent-case"' in source
assert "await patchCallCase(caseId)" in source
assert "Link til sag" in source
def test_click_to_call_recognizes_local_and_internal_hub_targets():
assert _is_internal_request_target(_request_for_host("localhost")) is True
assert _is_internal_request_target(_request_for_host("127.0.0.1")) is True
assert _is_internal_request_target(_request_for_host("172.16.31.183")) is True
def test_click_to_call_rejects_external_hub_target_fallback():
assert _is_internal_request_target(_request_for_host("example.com")) is False
assert _is_internal_request_target(_request_for_host("172.16.30.183")) is False

View File

@ -0,0 +1,88 @@
import asyncio
import importlib
from datetime import datetime
from pathlib import Path
timetracking_router = importlib.import_module("app.timetracking.backend.router")
def test_manual_time_keeps_local_wall_clock_and_creates_one_entry_per_employee(monkeypatch):
captured = {}
monkeypatch.setattr(timetracking_router, "_resolve_case_customer_id", lambda *_args: 77)
def fake_execute_query(query, params):
captured["query"] = query
captured["params"] = params
employee_ids = params[-1]
return [{"id": index + 1, "medarbejder_id": employee_id} for index, employee_id in enumerate(employee_ids)]
monkeypatch.setattr(timetracking_router, "execute_query", fake_execute_query)
result = asyncio.run(
timetracking_router.create_manual_time_v1(
{
"sag_id": 88,
"medarbejder_ids": [12, 34],
"worked_date": "2026-08-18",
"start_tid": "2026-08-18T10:00:00",
"slut_tid": "2026-08-18T11:00:00",
"faktisk_tid_min": 60,
},
current_user={"id": 9, "username": "tester"},
)
)
assert result["count"] == 2
assert [row["medarbejder_id"] for row in result["created"]] == [12, 34]
assert captured["params"][-1] == [12, 34]
assert captured["params"][10] == datetime(2026, 8, 18, 10, 0)
assert captured["params"][11] == datetime(2026, 8, 18, 11, 0)
assert "UNNEST(%s::bigint[])" in captured["query"]
def test_manual_time_bulk_resolves_current_user_and_removes_duplicates(monkeypatch):
captured = {}
monkeypatch.setattr(timetracking_router, "_resolve_case_customer_id", lambda *_args: 77)
def fake_execute_query(_query, params):
captured["employee_ids"] = params[-1]
return [{"id": 1, "medarbejder_id": employee_id} for employee_id in params[-1]]
monkeypatch.setattr(timetracking_router, "execute_query", fake_execute_query)
result = asyncio.run(
timetracking_router.create_manual_time_v1(
{
"sag_id": 88,
"medarbejder_ids": [None, 9, 12, 12],
"faktisk_tid_min": 30,
},
current_user={"id": 9, "username": "tester"},
)
)
assert captured["employee_ids"] == [9, 12]
assert result["count"] == 2
def test_case_time_form_sends_local_time_and_multiple_employees():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
manual_form_script = template.split("async function createManualTimeV1(event)", 1)[1].split(
"document.addEventListener('DOMContentLoaded'", 1
)[0]
assert 'class="form-check-input mt-0 time-v1-employee-option"' in template
assert "{% for user in assignment_users %}" in template
assert "loadTimeV1EmployeeOptions" not in template
assert "medarbejder_ids: employeeIds" in manual_form_script
assert "startObj = `${dateVal}T${tStart}:00`;" in manual_form_script
assert "endObj = `${endDate}T${tEnd}:00`;" in manual_form_script
assert ".toISOString()" not in manual_form_script
quick_time_script = template.split("async function registerQuickTimeFromComment(content)", 1)[1].split(
"function resolveCommentAwaitCustomerStatus", 1
)[0]
assert "startIso = `${dateValue}T${startValue}:00`;" in quick_time_script
assert "endIso = addMinutesToTimeV1LocalIso(startIso, minutes);" in quick_time_script
assert ".toISOString()" not in quick_time_script