From 88f7cec4788fe000851b9cefee185a8789eaa28a Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 17 Aug 2026 19:27:43 +0200 Subject: [PATCH] checkpoint: before bottom bar and side panel fixes --- app/auth/frontend/2fa_setup.html | 4 +- app/auth/frontend/login.html | 2 +- app/billing/frontend/supplier_invoices.html | 10 +- app/contacts/backend/router_simple.py | 210 +++++- app/contacts/frontend/contact_detail.html | 167 ++++- app/dashboard/backend/router.py | 4 +- app/dashboard/frontend/mission_control.html | 8 +- .../frontend/mission_control_legacy.html | 8 +- .../frontend/mission_control_v2.html | 14 +- app/economy/frontend/time_queue.html | 4 +- app/modules/bottom_bar/backend/service.py | 22 +- app/modules/calendar/templates/index.html | 4 +- app/modules/hardware/templates/detail.html | 18 +- .../hardware/templates/eset_import.html | 12 +- app/modules/links/templates/index.html | 2 +- app/modules/sag/frontend/views.py | 10 + app/modules/sag/templates/create.html | 4 +- app/modules/sag/templates/detail_v3.html | 701 +++++++++++++++--- app/modules/sag/templates/edit.html | 2 + app/modules/sag/templates/index.html | 2 +- app/prepaid/frontend/detail.html | 2 +- app/products/frontend/detail.html | 42 +- app/products/frontend/list.html | 56 +- app/settings/backend/router.py | 2 +- app/settings/frontend/migrations.html | 4 +- app/settings/frontend/settings.html | 76 +- app/shared/frontend/base.html | 195 +++-- app/subscriptions/frontend/list.html | 8 +- app/ticket/backend/models.py | 1 + app/ticket/frontend/ticket_detail.html | 3 +- app/ticket/frontend/worklog_review.html | 1 + app/timetracking/backend/order_service.py | 2 +- app/timetracking/backend/router.py | 313 +++++++- app/timetracking/frontend/employee_log.html | 6 +- app/timetracking/frontend/registrations.html | 127 +++- .../frontend/service_contract_report.html | 20 +- app/vendors/frontend/vendor_detail.html | 2 +- migrations/1016_contact_merge_history.sql | 14 + static/js/bottom-bar.js | 420 +++++++++-- static/js/task-template-selector.js | 40 +- tests/test_danish_ui_text.py | 44 ++ tests/test_global_search.py | 15 + tests/test_sag_module.py | 43 ++ 43 files changed, 2198 insertions(+), 446 deletions(-) create mode 100644 migrations/1016_contact_merge_history.sql create mode 100644 tests/test_danish_ui_text.py create mode 100644 tests/test_global_search.py diff --git a/app/auth/frontend/2fa_setup.html b/app/auth/frontend/2fa_setup.html index 97211bd..ddd40a8 100644 --- a/app/auth/frontend/2fa_setup.html +++ b/app/auth/frontend/2fa_setup.html @@ -10,11 +10,11 @@

2FA Setup

-

Opsaet tofaktor for din konto

+

Opsæt tofaktor for din konto

diff --git a/app/auth/frontend/login.html b/app/auth/frontend/login.html index 3462e35..4cafe8e 100644 --- a/app/auth/frontend/login.html +++ b/app/auth/frontend/login.html @@ -126,7 +126,7 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => { document.cookie = `access_token=${data.access_token};expires=${d.toUTCString()};path=/;SameSite=Lax`; if (data.requires_2fa_setup) { - const goSetup = confirm('2FA er ikke opsat. Vil du opsaette 2FA nu?'); + const goSetup = confirm('2FA er ikke opsat. Vil du opsætte 2FA nu?'); window.location.href = goSetup ? '/2fa/setup' : '/'; return; } diff --git a/app/billing/frontend/supplier_invoices.html b/app/billing/frontend/supplier_invoices.html index d7e3d8d..36c1d37 100644 --- a/app/billing/frontend/supplier_invoices.html +++ b/app/billing/frontend/supplier_invoices.html @@ -1,6 +1,6 @@ {% extends "shared/frontend/base.html" %} -{% block title %}Leverandør fakturaer - BMC Hub{% endblock %} +{% block title %}Leverandørfakturaer - BMC Hub{% endblock %} {% block extra_css %} {% endblock %} @@ -69,9 +88,10 @@

Tidsregistreringer

Søg og filtrer i alle registreringer

- +
+ + +
@@ -98,9 +118,32 @@ +
+ + +
+
+ + +
+
+ + +
+
+
@@ -111,6 +154,7 @@ Kunde Beskrivelse / Case Tekniker + Type Timer Fakt. Status @@ -118,7 +162,7 @@ - Henter data... + Henter data...
@@ -158,28 +202,36 @@ debounceTimer = setTimeout(loadData, 500); } + const workTypeLabels = { + support: 'Support', troubleshooting: 'Fejlsøgning', development: 'Udvikling', + maintenance: 'Vedligehold', on_site: 'Kørsel / On-site', meeting: 'Møde', other: 'Andet' + }; + + function buildReportParams(limit = 100) { + const params = new URLSearchParams({ limit, offset: 0 }); + const values = { + search: document.getElementById('filter-search').value, + status: document.getElementById('filter-status').value, + user_name: document.getElementById('filter-user').value, + work_type: document.getElementById('filter-work-type').value, + start_date: document.getElementById('filter-start-date').value, + end_date: document.getElementById('filter-end-date').value + }; + Object.entries(values).forEach(([key, value]) => { if (value) params.append(key, value); }); + return params; + } + async function loadData() { const tbody = document.getElementById('table-body'); - const search = document.getElementById('filter-search').value; - const status = document.getElementById('filter-status').value; - const user = document.getElementById('filter-user').value; - - // Build URL - const params = new URLSearchParams({ - limit: 100, // Hardcoded limit for now - offset: 0 - }); - - if (search) params.append('search', search); - if (status) params.append('status', status); - if (user) params.append('user_name', user); + const params = buildReportParams(); try { const response = await fetch(`/api/v1/timetracking/times?${params.toString()}`); const data = await response.json(); + renderReportSummary(data); if (data.times.length === 0) { - tbody.innerHTML = `Ingen resultater fundet`; + tbody.innerHTML = `Ingen resultater fundet`; return; } @@ -203,6 +255,7 @@ ${t.user_name || '-'} + ${workTypeLabels[t.work_type || 'support'] || t.work_type || 'Support'} ${parseFloat(t.original_hours).toFixed(2)} @@ -225,10 +278,46 @@ } catch (error) { console.error(error); - tbody.innerHTML = `Fejl ved hentning af data`; + tbody.innerHTML = `Fejl ved hentning af data`; } } + function renderReportSummary(data) { + const summary = data.summary || {}; + const cards = [ + ['Timer', Number(summary.total_hours || 0).toFixed(2)], + ['Fakturerbare timer', Number(summary.billable_hours || 0).toFixed(2)], + ['Registreringer', summary.total_entries || 0], + ['Medarbejdere', summary.total_employees || 0], + ['Sager', summary.total_cases || 0] + ]; + const typeText = (data.by_work_type || []) + .map(row => `${workTypeLabels[row.work_type] || row.work_type}: ${Number(row.hours || 0).toFixed(2)} t`) + .join(' · '); + document.getElementById('report-summary').innerHTML = cards.map(([label, value]) => ` +
${label}
${value}
+ `).join('') + (typeText ? `
Fordeling: ${typeText}
` : ''); + } + + async function exportReportCsv() { + const response = await fetch(`/api/v1/timetracking/times?${buildReportParams(10000).toString()}`); + const data = await response.json(); + if (!response.ok) return alert(data.detail || 'Kunne ikke eksportere rapporten'); + const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`; + const rows = [['Dato', 'Kunde', 'Sag', 'Medarbejder', 'Type', 'Beskrivelse', 'Timer', 'Fakturerbar', 'Status']]; + (data.times || []).forEach(t => rows.push([ + t.worked_date, t.customer_name, t.case_title, t.user_name, + workTypeLabels[t.work_type || 'support'] || t.work_type, + t.description, t.original_hours, t.billable ? 'Ja' : 'Nej', t.status + ])); + const csv = '\ufeff' + rows.map(row => row.map(quote).join(';')).join('\n'); + const link = document.createElement('a'); + link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' })); + link.download = `tidsrapport-${new Date().toISOString().slice(0, 10)}.csv`; + link.click(); + URL.revokeObjectURL(link.href); + } + function formatDate(dateStr) { if (!dateStr) return ''; return new Date(dateStr).toLocaleDateString('da-DK'); diff --git a/app/timetracking/frontend/service_contract_report.html b/app/timetracking/frontend/service_contract_report.html index e94dc31..ba5377b 100644 --- a/app/timetracking/frontend/service_contract_report.html +++ b/app/timetracking/frontend/service_contract_report.html @@ -126,7 +126,7 @@

Servicekontrakt Rapport

-

Vaelg kunde og servicekontrakt for at se relaterede cases og timelogs fra vTiger.

+

Vælg kunde og servicekontrakt for at se relaterede sager og tidsregistreringer fra vTiger.

@@ -134,13 +134,13 @@
@@ -155,7 +155,7 @@
-
Start med at vaelge en kunde.
+
Start med at vælge en kunde.
@@ -221,7 +221,7 @@ customerSelect.appendChild(option); } } catch (error) { - showError(error.message || 'Fejl ved indlaesning af kunder'); + showError(error.message || 'Fejl ved indlæsning af kunder'); } } @@ -229,14 +229,14 @@ state.selectedAccountId = event.target.value || ''; state.selectedContractId = ''; reportSection.classList.add('d-none'); - contractSelect.innerHTML = ''; + contractSelect.innerHTML = ''; contractSelect.disabled = !state.selectedAccountId; loadBtn.disabled = true; pdfBtn.disabled = true; excelBtn.disabled = true; if (!state.selectedAccountId) { - filterHint.textContent = 'Start med at vaelge en kunde.'; + filterHint.textContent = 'Start med at vælge en kunde.'; return; } @@ -260,7 +260,7 @@ } filterHint.textContent = state.contracts.length - ? 'Vaelg servicekontrakt og hent rapporten.' + ? 'Vælg servicekontrakt, og hent rapporten.' : 'Ingen servicekontrakter fundet for kunden.'; } catch (error) { showError(error.message || 'Fejl ved hentning af servicekontrakter'); @@ -312,7 +312,7 @@ document.getElementById('reportContractMeta').textContent = `${customer.account_name} (${customer.account_id})`; const contractLinkEl = document.getElementById('reportContractLink'); if (contract.vtiger_url) { - contractLinkEl.innerHTML = `Aabn servicekontrakt i vTiger`; + contractLinkEl.innerHTML = `Åbn servicekontrakt i vTiger`; } else { contractLinkEl.textContent = ''; } @@ -354,7 +354,7 @@
${escapeHtml(caseItem.title || '-')}
CC-nummer: ${escapeHtml(caseItem.cc_number || caseItem.id || '-')}
Kontaktperson: ${escapeHtml(caseItem.contact_person || '-')}
-
${caseItem.vtiger_url ? `Aabn case i vTiger` : ''}
+
${caseItem.vtiger_url ? `Åbn sag i vTiger` : ''}
${escapeHtml(caseItem.description || 'Ingen beskrivelse')}
diff --git a/app/vendors/frontend/vendor_detail.html b/app/vendors/frontend/vendor_detail.html index 49e424e..854c034 100644 --- a/app/vendors/frontend/vendor_detail.html +++ b/app/vendors/frontend/vendor_detail.html @@ -210,7 +210,7 @@
-
Leverandør Fakturaer
+
Leverandørfakturaer
0
diff --git a/migrations/1016_contact_merge_history.sql b/migrations/1016_contact_merge_history.sql new file mode 100644 index 0000000..9e42d31 --- /dev/null +++ b/migrations/1016_contact_merge_history.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS contact_merge_history ( + id BIGSERIAL PRIMARY KEY, + target_contact_id INTEGER NOT NULL REFERENCES contacts(id) ON DELETE RESTRICT, + source_contact_id INTEGER NOT NULL, + source_snapshot JSONB NOT NULL, + moved_relations JSONB NOT NULL DEFAULT '{}'::jsonb, + merged_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_contact_merge_history_target + ON contact_merge_history(target_contact_id, merged_at DESC); + +COMMENT ON TABLE contact_merge_history IS + 'Audit trail for destructive contact merges. Source contact snapshots and moved relation counts are retained.'; diff --git a/static/js/bottom-bar.js b/static/js/bottom-bar.js index f02721c..0e416ac 100644 --- a/static/js/bottom-bar.js +++ b/static/js/bottom-bar.js @@ -2,6 +2,7 @@ let latestSections = {}; let latestContextActions = { global: [], context: [] }; let activeKey = 'timer'; + let timerPanelState = { scope: 'mine', loading: false, loaded: false, rows: [], error: '' }; let overviewFilter = null; let ws = null; let pollTimer = null; @@ -17,7 +18,7 @@ let switchCaseState = { activeTimer: null, decision: 'unchanged', - timers: { active: [], paused: [] }, + timers: { active: [], paused: [], stopped: [] }, recentCases: [], unassignedCases: [] }; @@ -855,10 +856,10 @@ if (key === 'overview') { if (overviewFilter === 'urgent') return urgent.list ? urgent.list.map(u => '
Hastesag: ' + esc(u.title) + '
') : ['Ingen hastesager.']; - if (overviewFilter === 'drift') return drift.list ? drift.list.map(k => '
📉 ' + esc(k) + '
') : ['Alle systemer oppe.']; - if (overviewFilter === 'eset') return eset.list ? eset.list.map(e => '
🔐 ' + esc(e) + '
') : ['Ingen ESET incidents.']; + if (overviewFilter === 'drift') return drift.list ? drift.list.map(k => '
📉 ' + esc(k) + ' Håndter i Drift
') : ['Alle systemer oppe.']; + if (overviewFilter === 'eset') return eset.list ? eset.list.map(e => '
🔐 ' + esc(e) + ' Håndter
') : ['Ingen ESET incidents.']; if (overviewFilter === 'cases') return cases.list ? cases.list.map(c => '
' + esc(c.title) + '
') : ['Ingen åbne sager.']; - if (overviewFilter === 'mail') return ['
📧 ' + mail.unread + ' ulæste mails.
💬 ' + mail.customer_reply_needed + ' kræver kundesvar.
']; + if (overviewFilter === 'mail') return ['
📧 ' + mail.unread + ' ulæste mails.
💬 ' + mail.customer_reply_needed + ' kræver kundesvar. Åbn indbakke
']; if (overviewFilter === 'unassigned') return unassigned.list ? unassigned.list.map(u => '
' + esc(u.title || ('Sag #' + (u.id || ''))) + '
') : ['Ingen åbne sager uden ansvarlig.']; let out = []; @@ -871,6 +872,15 @@ if (out.length === 0) { out.push('
🎉 Alt ser grønt ud! Intet kritisk lige nu.
'); } + + const contextActions = (latestContextActions.context || []); + const globalActions = (latestContextActions.global || []); + const smartActions = contextActions.length ? contextActions : globalActions; + if (smartActions.length) { + out.push('
Smarte handlinger
' + smartActions.map(function (action) { + return ''; + }).join('') + '
'); + } // Add quick note button on overview out.push('
' + esc(quickNoteHintState.message || 'Tip: gemmer som kommentar på aktiv/åben sag.') + '
'); @@ -882,10 +892,10 @@ if (timer.active_count > 0) { return (timer.list || []).map(t => { const elapsedText = t.elapsed_hhmmss || (String(t.elapsed || 0) + 's'); - return '
' + esc(t.desc) + ' (' + esc(elapsedText) + ')
'; + return ''; }); } - return ['Ingen aktive timere lige nu.']; + return ['
Ingen aktiv timer
Brug “Skift sag” i bundlinjen for at starte arbejdet.
']; } if (key === 'messages') { @@ -929,9 +939,9 @@ if (key === 'tasks') { if (tasks.count > 0) { - return (tasks.list || []).map(t => '
' + esc(t.title) + ' ' + esc(t.deadline) + '
'); + return (tasks.list || []).map(t => '
' + esc(t.title) + 'Prioritet: ' + esc(t.deadline) + '
Aktuel'); } - return ['Ingen aktuelle opgaver.']; + return ['
Du er ajour
Der er ingen aktuelle opgaver eller påmindelser.
']; } if (key === 'notes') { @@ -1153,8 +1163,12 @@ const timerChip = byId('bbActiveTimerChip'); const timerText = byId('bbActiveTimerText'); const notifCount = byId('bbNotificationsCount'); + const pauseBtn = byId('bbTimerPauseBtn'); + const stopBtn = byId('bbTimerStopBtn'); const timer = ((latestSections || {}).timer || {}).active || {}; + const ownTimers = ((latestSections || {}).timer || {}).own || {}; + const hasPausedTimer = Array.isArray(ownTimers.paused) && ownTimers.paused.length > 0; const hasActiveTimer = !!timer.active; if (timerChip && timerText) { timerChip.classList.toggle('is-hidden', !hasActiveTimer); @@ -1170,6 +1184,15 @@ const computed = Number(latestNotificationCount || 0) + unreadMessages; notifCount.textContent = String(computed); } + if (pauseBtn) { + pauseBtn.disabled = !hasActiveTimer && !hasPausedTimer; + pauseBtn.title = hasActiveTimer ? 'Pause timer' : (hasPausedTimer ? 'Genoptag senest pausede timer' : 'Ingen timer at pause'); + pauseBtn.innerHTML = hasActiveTimer ? '' : ''; + } + if (stopBtn) { + stopBtn.disabled = !hasActiveTimer; + stopBtn.title = hasActiveTimer ? 'Stop timer' : 'Ingen aktiv timer'; + } } function renderTabPanel() { @@ -1193,6 +1216,7 @@ } const titleText = titleContainer.querySelector('.bb-tab-title-text'); + const descriptionEl = byId('bbTabDescription'); const titleByKey = { overview: 'Overblik', @@ -1212,12 +1236,24 @@ boss: 'bi-person-workspace' }; + const descriptionByKey = { + overview: 'Det vigtigste samlet ét sted.', + timer: 'Se aktiv tid, stop registreringen eller skift direkte til en anden sag.', + messages: 'Interne samtaler samlet efter modtager med tydelig læst-status.', + tasks: 'Prioritér næste handling ud fra deadlines og aktuelle påmindelser.', + notes: 'Skriv hurtigt til venstre og genbrug dine noter fra arkivet til højre.', + boss: 'Fordel supportkøen ud fra kapacitet, hast og ventetid.' + }; + const activeTitle = titleByKey[activeKey] || 'Info'; if (titleText) { titleText.textContent = activeTitle; } else { titleContainer.textContent = activeTitle; } + if (descriptionEl) { + descriptionEl.textContent = descriptionByKey[activeKey] || ''; + } const iconSpan = titleContainer.querySelector('.bi'); if (iconSpan) { @@ -1228,6 +1264,7 @@ const lines = listFor(activeKey, latestSections); const ul = document.createElement('ul'); ul.className = 'bb-tab-list'; + ul.classList.add('bb-panel-' + activeKey); lines.forEach(function (line) { const li = document.createElement('li'); @@ -1237,12 +1274,17 @@ }); innerContent.innerHTML = ''; + + if (activeKey === 'timer') { + renderTimerWorkQueue(innerContent); + return; + } // Add specific headers/controls based on active tab if (activeKey === 'tasks') { const topBar = document.createElement('div'); topBar.className = 'bb-task-actions mb-3'; - topBar.innerHTML = ''; + topBar.innerHTML = ''; innerContent.appendChild(topBar); } if (activeKey === 'messages') { @@ -1393,6 +1435,105 @@ } + function timerStateLabel(state) { + if (state === 'active') return ['Aktiv', 'success']; + if (state === 'paused') return ['Pauset', 'warning']; + return ['Klar til registrering', 'info']; + } + + function formatTimerSeconds(value) { + const seconds = Math.max(0, Number(value || 0)); + return [Math.floor(seconds / 3600), Math.floor((seconds % 3600) / 60), Math.floor(seconds % 60)] + .map(function (part) { return String(part).padStart(2, '0'); }).join(':'); + } + + function renderTimerWorkQueue(container) { + const scope = timerPanelState.scope; + const toolbar = document.createElement('div'); + toolbar.className = 'd-flex justify-content-between align-items-center gap-2 mb-3'; + toolbar.innerHTML = '
' + + '' + + '
' + + '' + timerPanelState.rows.length + ' vist'; + container.appendChild(toolbar); + + if (timerPanelState.loading || !timerPanelState.loaded) { + const loading = document.createElement('div'); + loading.className = 'bb-panel-empty'; + loading.innerHTML = '
Henter timere...
'; + container.appendChild(loading); + if (!timerPanelState.loading) loadTimerWorkQueue(scope); + return; + } + + if (timerPanelState.error) { + const errorBox = document.createElement('div'); + errorBox.className = 'alert alert-warning d-flex justify-content-between align-items-center gap-3'; + errorBox.innerHTML = '' + escapeHtml(timerPanelState.error) + '' + + ''; + container.appendChild(errorBox); + } + + const list = document.createElement('div'); + list.className = 'bb-timer-work-grid'; + if (!timerPanelState.rows.length) { + list.innerHTML = '
Ingen timere at vise
'; + } else { + list.innerHTML = timerPanelState.rows.map(function (row) { + const state = String(row.timer_state || 'pending_conversion'); + const label = timerStateLabel(state); + const timeId = Number(row.id || row.time_entry_id || 0); + const sagId = Number(row.sag_id || 0); + const isOwn = row.is_own_timer === true; + let action = ''; + if (isOwn && state === 'paused') action = ''; + if (isOwn && state === 'pending_conversion') action = ''; + if (isOwn && state === 'active') action = ''; + const elapsedValue = row.elapsed_hhmmss || formatTimerSeconds(row.live_elapsed_seconds || row.elapsed_seconds || row.elapsed); + const elapsed = state === 'active' ? '' + escapeHtml(elapsedValue) + ' · ' : ''; + const employeeName = row.employee_display_name || row.medarbejder_navn || row.user_name || (isOwn ? 'Min timer' : 'Ukendt medarbejder'); + return '
' + label[0] + '' + escapeHtml(row.sag_navn || ('Sag #' + sagId)) + 'Sag #' + sagId + '' + elapsed + escapeHtml(employeeName) + '
' + action + '
'; + }).join(''); + } + container.appendChild(list); + } + + function ownTimerRows(payload) { + const groups = normalizeSwitchableTimerPayload(payload); + return groups.active.map(function (row) { + return Object.assign({}, row, { timer_state: 'active', is_own_timer: true }); + }).concat(groups.paused.map(function (row) { + return Object.assign({}, row, { timer_state: 'paused', is_own_timer: true }); + }), groups.stopped.map(function (row) { + return Object.assign({}, row, { timer_state: 'pending_conversion', is_own_timer: true }); + })); + } + + async function loadTimerWorkQueue(scope) { + timerPanelState.scope = scope || 'mine'; + timerPanelState.loading = true; + timerPanelState.error = ''; + try { + if (timerPanelState.scope === 'mine') { + const ownTimers = await fetchSwitchableTimers(); + timerPanelState.rows = ownTimerRows(ownTimers); + switchCaseState.timers.stopped = timerPanelState.rows.filter(function (row) { return row.timer_state === 'pending_conversion'; }); + } else { + const response = await fetch('/api/v1/timetracking/time/team-status?scope=all', { credentials: 'include' }); + if (!response.ok) throw new Error('Kunne ikke hente alle medarbejderes timere'); + const payload = await response.json(); + timerPanelState.rows = Array.isArray(payload.rows) ? payload.rows : []; + } + timerPanelState.loaded = true; + } catch (err) { + timerPanelState.error = err && err.message ? err.message : 'Kunne ikke hente timeroversigten'; + timerPanelState.loaded = true; + } finally { + timerPanelState.loading = false; + if (activeKey === 'timer') renderTabPanel(); + } + } + function bindSideTabs() { const buttons = document.querySelectorAll('.bb-tab-btn'); for (let i = 0; i < buttons.length; i++) { @@ -1680,14 +1821,15 @@ } function normalizeSwitchableTimerPayload(payload) { - const out = { active: [], paused: [] }; + const out = { active: [], paused: [], stopped: [] }; if (!payload || typeof payload !== 'object') { return out; } - if (Array.isArray(payload.active) || Array.isArray(payload.paused)) { + if (Array.isArray(payload.active) || Array.isArray(payload.paused) || Array.isArray(payload.stopped)) { out.active = Array.isArray(payload.active) ? payload.active : []; out.paused = Array.isArray(payload.paused) ? payload.paused : []; + out.stopped = Array.isArray(payload.stopped) ? payload.stopped : []; return out; } @@ -1762,6 +1904,32 @@ return sagId > 0 ? ('Sag #' + sagId) : 'Ukendt sag'; } + function timerDurationLabel(timer) { + let seconds = Number((timer && (timer.live_elapsed_seconds || timer.elapsed_seconds || timer.elapsed)) || 0); + if (!seconds && timer && timer.start_tid && timer.slut_tid) { + const start = new Date(timer.start_tid).getTime(); + const end = new Date(timer.slut_tid).getTime(); + if (Number.isFinite(start) && Number.isFinite(end)) { + seconds = Math.max(0, Math.floor((end - start) / 1000) - Number(timer.pause_total_seconds || 0)); + } + } + if (!seconds && timer && timer.faktisk_tid_min) seconds = Number(timer.faktisk_tid_min) * 60; + return formatTimerSeconds(seconds); + } + + function timerCaseInfo(timer, includeDuration) { + const sagId = Number((timer && timer.sag_id) || 0); + const customer = String((timer && timer.customer_name) || 'Ingen kunde'); + const contact = String((timer && timer.contact_name) || 'Ingen kontakt'); + const status = String((timer && timer.case_status) || 'Ukendt status'); + return '' + timerDisplayName(timer) + '' + + '#' + sagId + '' + + '' + escapeHtml(customer) + '' + + '' + escapeHtml(contact) + '' + + '' + escapeHtml(status) + '' + + (includeDuration ? '' + timerDurationLabel(timer) + '' : ''); + } + function renderSwitchCaseLists() { const timersEl = byId('bbSwitchTimersList'); const recentEl = byId('bbSwitchRecentCasesList'); @@ -1772,43 +1940,54 @@ const active = Array.isArray(switchCaseState.timers.active) ? switchCaseState.timers.active : []; const paused = Array.isArray(switchCaseState.timers.paused) ? switchCaseState.timers.paused : []; + const stopped = Array.isArray(switchCaseState.timers.stopped) ? switchCaseState.timers.stopped : []; const recentCases = Array.isArray(switchCaseState.recentCases) ? switchCaseState.recentCases : []; const unassignedCases = Array.isArray(switchCaseState.unassignedCases) ? switchCaseState.unassignedCases : []; const showUnassigned = unassignedCases.length > 0; + const searchEl = byId('bbSwitchCaseSearch'); + const query = String((searchEl && searchEl.value) || '').trim().toLocaleLowerCase('da-DK'); if (actionsEl) { actionsEl.classList.toggle('d-none', !switchCaseState.activeTimer); } - if (!active.length && !paused.length) { - timersEl.innerHTML = '
Ingen aktive eller pausede timere.
'; + if (!active.length && !paused.length && !stopped.length) { + timersEl.innerHTML = '
Ingen tidligere timere at fortsætte.
'; } else { let timerItems = ''; active.forEach(function (t) { const timeId = Number((t && (t.id || t.time_entry_id)) || 0); timerItems += - '
' + - '
Aktiv' + timerDisplayName(t) + '
' + - '' + - '
'; - if (timeId > 0) { - timerItems += - '
Timer ID: ' + timeId + '
'; - } + '
Aktiv' + timerCaseInfo(t, true) + '
' + + '
'; }); paused.forEach(function (t) { + const timeId = Number((t && (t.time_entry_id || t.id)) || 0); timerItems += - '
' + - '
Pauset' + timerDisplayName(t) + '
' + - '' + - '
'; + '
Pauset' + timerCaseInfo(t, true) + '
' + + '
' + + '
'; + }); + + stopped.forEach(function (t) { + const sagId = Number((t && t.sag_id) || 0); + const timeId = Number((t && (t.time_entry_id || t.id)) || 0); + timerItems += + '
' + + '
' + timerCaseInfo(t, true) + '
' + + '
' + + '
'; }); timersEl.innerHTML = timerItems; } - const sourceCases = showUnassigned ? unassignedCases : recentCases; + const sourceCases = (showUnassigned ? unassignedCases : recentCases).filter(function (row) { + if (!query) return true; + const searchable = String((row && (row.sag_id || row.id)) || '') + ' ' + String((row && (row.titel || row.title)) || ''); + return searchable.toLocaleLowerCase('da-DK').includes(query); + }); const titleEl = byId('bbSwitchCaseModalLabel'); if (titleEl) { titleEl.innerHTML = showUnassigned @@ -1817,26 +1996,94 @@ } if (!sourceCases.length) { - recentEl.innerHTML = '
Ingen sager at vise.
'; + recentEl.innerHTML = '
' + (query ? 'Ingen sager matcher din søgning.' : 'Ingen sager at vise.') + '
'; return; } recentEl.innerHTML = sourceCases.map(function (row) { const caseId = Number((row && (row.sag_id || row.id)) || 0); const title = escapeHtml((row && (row.titel || row.title)) || (caseId > 0 ? ('Sag #' + caseId) : 'Ukendt sag')); - const prefix = showUnassigned ? 'Uden ansvarlig' : 'Senest'; + const meta = showUnassigned ? 'Uden ansvarlig' : 'Senest anvendt'; return ( - '
' + - '
' + prefix + title + '
' + - '
' + - '' + - '' + + '
' + + '
' + title + '
Sag #' + caseId + ' · ' + meta + '
' + + '
' + + '' + + '' + '
' + '
' ); }).join(''); } + async function openTimeConversion(timeId) { + const timer = (switchCaseState.timers.stopped || []).concat(timerPanelState.rows || []).find(function (row) { + return Number((row && (row.time_entry_id || row.id)) || 0) === Number(timeId); + }); + if (!timer) return; + + const panel = byId('bbConvertTimePanel'); + const select = byId('bbConvertBillingMethod'); + if (!panel || !select) return; + panel.dataset.timeId = String(timeId); + byId('bbConvertTimeName').textContent = String((timer.sag_navn || timer.title) || ('Sag #' + (timer.sag_id || ''))); + byId('bbConvertWorkType').value = timer.work_type || 'support'; + byId('bbConvertMinutes').value = Number(timer.fakturerbar_tid_min != null ? timer.fakturerbar_tid_min : (timer.faktisk_tid_min || 0)); + select.innerHTML = ''; + panel.classList.remove('d-none'); + + try { + const response = await fetch('/api/v1/timetracking/time/' + Number(timeId) + '/settlement-options', { credentials: 'include' }); + if (!response.ok) throw new Error('Kunne ikke hente afregningsmuligheder'); + const options = await response.json(); + const recommended = options.recommended || { method: 'invoice' }; + let html = ''; + (options.prepaid_cards || []).forEach(function (card) { + html += ''; + }); + (options.agreements || []).forEach(function (agreement) { + html += ''; + }); + html += ''; + select.innerHTML = html; + const recommendedId = recommended.prepaid_card_id || recommended.fixed_price_agreement_id; + select.value = recommended.method + (recommendedId ? ':' + recommendedId : ''); + byId('bbConvertRecommendation').textContent = 'Foreslået: ' + (recommended.reason || 'Faktura'); + } catch (err) { + select.innerHTML = ''; + byId('bbConvertRecommendation').textContent = err.message || 'Kunne ikke hente forslag.'; + } + } + + async function submitTimeConversion() { + const panel = byId('bbConvertTimePanel'); + const button = byId('bbConvertSubmit'); + const timeId = Number((panel && panel.dataset.timeId) || 0); + if (!timeId || !button) return; + const selected = String(byId('bbConvertBillingMethod').value || 'invoice').split(':'); + const payload = { + billing_method: selected[0], + work_type: byId('bbConvertWorkType').value, + fakturerbar_tid_min: Math.max(0, Number(byId('bbConvertMinutes').value || 0)), + entry_type: 'manuel' + }; + if (selected[0] === 'prepaid') payload.prepaid_card_id = Number(selected[1]); + if (selected[0] === 'subscription') payload.fixed_price_agreement_id = Number(selected[1]); + button.disabled = true; + try { + const response = await fetch('/api/v1/timetracking/time/' + timeId + '/approve', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); + const result = await response.json().catch(function () { return {}; }); + if (!response.ok) throw new Error(result.detail || 'Kunne ikke konvertere tiden'); + panel.classList.add('d-none'); + await loadSwitchCaseData(); + if (activeKey === 'timer') await loadTimerWorkQueue(timerPanelState.scope); + } catch (err) { + switchCaseStatusMessage('' + escapeHtml(err.message || 'Kunne ikke konvertere tiden.')); + } finally { + button.disabled = false; + } + } + async function loadSwitchCaseData(options) { const opts = options || {}; switchCaseState.decision = 'unchanged'; @@ -1845,7 +2092,7 @@ : null; switchCaseState.unassignedCases = []; switchCaseState.recentCases = []; - switchCaseState.timers = { active: [], paused: [] }; + switchCaseState.timers = { active: [], paused: [], stopped: [] }; if (opts.onlyUnassigned) { const unassigned = (((latestSections || {}).unassigned || {}).list || []); @@ -1937,7 +2184,7 @@ if (modal) { modal.hide(); } - window.location.href = '/sag/' + validCaseId; + window.location.href = '/sag/' + validCaseId + '/v3'; } catch (err) { switchCaseStatusMessage('' + escapeHtml(err.message || 'Kunne ikke starte timer for sag.')); } @@ -1952,11 +2199,41 @@ if (modal) { modal.hide(); } - window.location.href = '/sag/' + validCaseId; + window.location.href = '/sag/' + validCaseId + '/v3'; + } + + function executeBottomBarAction(action) { + if (!action) return false; + const command = String(action.command || ''); + if (command.indexOf('case_add:') === 0) { + const caseAction = command.slice('case_add:'.length); + if (typeof window.openCaseModuleAddPanel === 'function' && typeof window.openCaseAddAction === 'function') { + Promise.resolve(window.openCaseModuleAddPanel()).then(function () { + return window.openCaseAddAction(caseAction); + }); + setExpanded(false); + return true; + } + } + if (command === 'switch_timer') { + openSwitchCaseModal(); + return true; + } + if (command === 'open_notes') { + activeKey = 'notes'; + setExpanded(true); + renderTabPanel(); + return true; + } + if (action.action) { + window.location.href = action.action; + return true; + } + return false; } function resolveQuickNoteCaseId() { - const match = (window.location.pathname || '').match(/^\/sag\/(\d+)$/); + const match = (window.location.pathname || '').match(/^\/sag\/(\d+)(?:\/v3)?\/?$/); if (match && match[1]) { return Number(match[1]); } @@ -2319,13 +2596,6 @@ if (notificationsBtn) { notificationsBtn.addEventListener('click', function () { - if (latestNotifications.length > 0) { - const first = latestNotifications[0] || {}; - if (first.action) { - window.location.href = first.action; - return; - } - } const trigger = byId('globalRemindersBtn'); if (trigger) { trigger.click(); @@ -2378,7 +2648,9 @@ if (timerChip) { timerChip.addEventListener('click', function () { - window.location.href = '/timetracking'; + const timer = (((latestSections || {}).timer || {}).active || {}); + const sagId = Number(timer.sag_id || 0); + window.location.href = sagId > 0 ? ('/sag/' + sagId) : '/timetracking'; }); } @@ -2397,15 +2669,26 @@ return; } } - if (matched && matched.action) { - window.location.href = matched.action; - } + executeBottomBarAction(matched); }); } function bindDynamicActions() { document.addEventListener('click', function (e) { const target = e.target; + const timerScopeButton = target && target.closest('[data-bb-timer-scope]'); + if (timerScopeButton) { + timerPanelState.loaded = false; + loadTimerWorkQueue(timerScopeButton.getAttribute('data-bb-timer-scope') || 'mine'); + renderTabPanel(); + return; + } + const caseLink = target && target.closest('[data-bb-case-link]'); + if (caseLink && !target.closest('button, a, input, select, textarea')) { + const sagId = Number(caseLink.getAttribute('data-bb-case-link') || 0); + if (sagId > 0) openCaseDetail(sagId); + return; + } const btn = target && target.closest('button'); if (!btn) return; @@ -2661,12 +2944,47 @@ } } + if (btn.hasAttribute('data-bb-cancel-convert')) { + const panel = byId('bbConvertTimePanel'); + if (panel) panel.classList.add('d-none'); + return; + } + + if (btn.id === 'bbConvertSubmit') { + submitTimeConversion(); + return; + } + + const convertTimeId = Number(btn.getAttribute('data-bb-convert-time') || 0); + if (convertTimeId > 0) { + openTimeConversion(convertTimeId); + return; + } + const openCaseId = Number(btn.getAttribute('data-bb-open-case') || 0); if (openCaseId > 0) { openCaseDetail(openCaseId); return; } + const resumeTimeId = Number(btn.getAttribute('data-bb-resume-time') || 0); + if (resumeTimeId > 0) { + btn.disabled = true; + switchCaseStatusMessage('Genoptager timer...'); + resumeTimer(resumeTimeId) + .then(fetchBottomBarState) + .then(function (state) { + applyState(state); + switchCaseStatusMessage('Timeren er genoptaget.'); + return loadSwitchCaseData(); + }) + .catch(function (err) { + btn.disabled = false; + switchCaseStatusMessage('' + escapeHtml(err.message || 'Kunne ikke genoptage timer.')); + }); + return; + } + const startCaseId = Number(btn.getAttribute('data-bb-start-case') || 0); if (startCaseId > 0) { startTimerForCase(startCaseId); @@ -2878,7 +3196,7 @@ } if (bossAction === 'open_case') { const caseId = Number(btn.getAttribute('data-case-id') || 0); - window.location.href = caseId > 0 ? ('/sag/' + caseId) : '/sag'; + window.location.href = caseId > 0 ? ('/sag/' + caseId + '/v3') : '/sag'; return; } } @@ -2998,6 +3316,10 @@ document.addEventListener('input', function (e) { const target = e.target; + if (target && target.id === 'bbSwitchCaseSearch') { + renderSwitchCaseLists(); + return; + } if (!target || target.id !== 'bbQuickNoteInput') { if (target && target.id === 'bbNoteTitleInput') { noteEditorState.title = String(target.value || ''); diff --git a/static/js/task-template-selector.js b/static/js/task-template-selector.js index 2e87bb4..2374193 100644 --- a/static/js/task-template-selector.js +++ b/static/js/task-template-selector.js @@ -32,7 +32,7 @@