release: v2.5.1
This commit is contained in:
parent
761753e577
commit
6906568dc2
34
MDfile/RELEASE_NOTES_v2.5.1.md
Normal file
34
MDfile/RELEASE_NOTES_v2.5.1.md
Normal 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.
|
||||||
@ -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:
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 på navn og opdaterer telefon, hvis den kun mangler nummer.</div>
|
<div class="form-text mt-0">Finder først eksisterende kontakt på 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() {
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
88
tests/test_timetracking_manual_time.py
Normal file
88
tests/test_timetracking_manual_time.py
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user