feat: manage case timers from time usage tab

This commit is contained in:
Christian 2026-08-18 06:56:36 +02:00
parent 97218d61a8
commit 0920f2ce30
4 changed files with 119 additions and 7 deletions

View File

@ -8601,6 +8601,16 @@
</div> </div>
</div> </div>
<div class="card mb-3" id="caseTimerWorkQueueCard">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-list-check me-2"></i>Timere på denne sag</h6>
<span class="badge bg-light text-dark" id="caseTimerWorkQueueCount">0</span>
</div>
<div class="card-body p-0" id="caseTimerWorkQueue">
<div class="text-muted text-center py-3">Henter timere på sagen...</div>
</div>
</div>
<div class="row g-3"> <div class="row g-3">
<div class="col-lg-9"> <div class="col-lg-9">
<div class="card"> <div class="card">
@ -11213,6 +11223,94 @@
return '<span class="badge bg-warning text-dark">Afventer</span>'; return '<span class="badge bg-warning text-dark">Afventer</span>';
} }
function caseTimerDuration(entry) {
let seconds = Number(entry.live_elapsed_seconds || 0);
if (!seconds && entry.faktisk_tid_min != null) seconds = Number(entry.faktisk_tid_min || 0) * 60;
if (!seconds && entry.start_tid && entry.slut_tid) {
const start = new Date(entry.start_tid).getTime();
const end = new Date(entry.slut_tid).getTime();
if (Number.isFinite(start) && Number.isFinite(end)) {
seconds = Math.max(0, Math.floor((end - start) / 1000) - Number(entry.pause_total_seconds || 0));
}
}
const hh = String(Math.floor(seconds / 3600)).padStart(2, '0');
const mm = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0');
const ss = String(Math.floor(seconds % 60)).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
function renderCaseTimerWorkQueue(entries) {
const container = document.getElementById('caseTimerWorkQueue');
const count = document.getElementById('caseTimerWorkQueueCount');
if (!container) return;
const rows = (entries || []).filter((entry) => {
const running = entry.aktiv_timer === true && !entry.slut_tid;
const paused = !entry.slut_tid && Boolean(entry.paused_at);
const pending = Boolean(entry.slut_tid)
&& String(entry.entry_status || 'afventer').toLowerCase() !== 'godkendt'
&& String(entry.status || 'pending').toLowerCase() === 'pending';
return running || paused || pending;
});
if (count) count.textContent = String(rows.length);
if (!rows.length) {
container.innerHTML = '<div class="text-muted text-center py-3"><i class="bi bi-check-circle me-1"></i>Ingen åbne eller afventende timere på sagen.</div>';
return;
}
container.innerHTML = '<div class="list-group list-group-flush">' + rows.map((entry) => {
const id = Number(entry.id || 0);
const own = entry.is_own_timer === true;
const running = entry.aktiv_timer === true && !entry.slut_tid;
const paused = !entry.slut_tid && Boolean(entry.paused_at);
const state = running ? 'Aktiv' : (paused ? 'Pauset' : 'Klar til registrering');
const badge = running ? 'success' : (paused ? 'warning text-dark' : 'info text-dark');
const employee = escapeHtml(entry.employee_display_name || entry.bruger_navn || entry.user_name || 'Ukendt medarbejder');
const workType = escapeHtml(entry.work_type || 'support');
let actions = '';
if (own && running) {
actions = `<button class="btn btn-sm btn-outline-warning" onclick="pauseLiveTimerV1()"><i class="bi bi-pause-fill me-1"></i>Pause</button>
<button class="btn btn-sm btn-outline-danger" onclick="stopLiveTimerV1({time_id:${id}, entry_status:'afventer'})"><i class="bi bi-stop-fill me-1"></i>Stop</button>`;
} else if (own && paused) {
actions = `<button class="btn btn-sm btn-primary" onclick="resumeCaseTimerByIdV1(${id})"><i class="bi bi-play-fill me-1"></i>Genoptag</button>`;
} else if (own) {
actions = `<button class="btn btn-sm btn-primary" onclick="openCaseTimerConversionV1(${id})"><i class="bi bi-check2-circle me-1"></i>Registrer</button>`;
}
return `<div class="list-group-item d-flex align-items-center gap-3 py-2">
<span class="badge bg-${badge}">${state}</span>
<div class="flex-grow-1 min-width-0">
<div class="fw-semibold text-truncate">${escapeHtml(entry.description || entry.beskrivelse || 'Arbejde på sagen')}</div>
<div class="small text-muted">${employee} · ${workType} · <span class="font-monospace">${caseTimerDuration(entry)}</span></div>
</div>
<div class="d-flex gap-2 flex-shrink-0">${actions}</div>
</div>`;
}).join('') + '</div>';
}
async function resumeCaseTimerByIdV1(timeId) {
try {
const res = await fetch('/api/v1/timetracking/time/resume', {
method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ time_id: Number(timeId) })
});
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new Error(error.detail || 'Kunne ikke genoptage timeren');
}
await loadTimeTrackingTab();
} catch (error) {
alert(error.message || 'Kunne ikke genoptage timeren');
}
}
function openCaseTimerConversionV1(timeId) {
if (typeof window.openBottomBarTimeConversion === 'function') {
window.openBottomBarTimeConversion(Number(timeId));
return;
}
alert('Konverteringsvinduet kunne ikke åbnes. Genindlæs siden og prøv igen.');
}
function renderTimeV1Timeline(entries) { function renderTimeV1Timeline(entries) {
const timeline = document.getElementById('timeTimelineColumns'); const timeline = document.getElementById('timeTimelineColumns');
if (!timeline) return; if (!timeline) return;
@ -11818,6 +11916,7 @@
ownTimers = await ownTimerRes.json().catch(() => null); ownTimers = await ownTimerRes.json().catch(() => null);
} }
updateCaseTimerControls(entries || [], ownTimers); updateCaseTimerControls(entries || [], ownTimers);
renderCaseTimerWorkQueue(entries || []);
timeV1EntriesById = Object.fromEntries((entries || []).map((entry) => [Number(entry.id), entry])); timeV1EntriesById = Object.fromEntries((entries || []).map((entry) => [Number(entry.id), entry]));
window.initialCaseTabCounts = Object.assign({}, window.initialCaseTabCounts || {}, { timetracking: (entries || []).length }); window.initialCaseTabCounts = Object.assign({}, window.initialCaseTabCounts || {}, { timetracking: (entries || []).length });
renderTimeV1Timeline(entries || []); renderTimeV1Timeline(entries || []);
@ -12092,6 +12191,7 @@
dateInput.valueAsDate = new Date(); dateInput.valueAsDate = new Date();
} }
}); });
window.addEventListener('bb:time-converted', () => loadTimeTrackingTab());
</script> </script>
<script> <script>

View File

@ -1586,7 +1586,7 @@ if (bmcOriginalFetch) {
<script src="/static/js/telefoni.js?v=2.4"></script> <script src="/static/js/telefoni.js?v=2.4"></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.63"></script> <script src="/static/js/bottom-bar.js?v=2.64"></script>
<script> <script>
// Dark Mode Toggle Logic // Dark Mode Toggle Logic
window.BMC_CAN_CLICK_TO_CALL = true; window.BMC_CAN_CLICK_TO_CALL = true;

View File

@ -2513,13 +2513,16 @@ async def stop_live_timer_v1(
try: try:
now = datetime.now() now = datetime.now()
time_id = payload.get("time_id") time_id = payload.get("time_id")
bruger_id = _resolve_target_user_id(current_user, payload.get("medarbejder_id")) bruger_id = _resolve_current_user_id(current_user)
if not bruger_id:
raise HTTPException(status_code=401, detail="Authentication required")
if time_id: if time_id:
entry = execute_query_single("SELECT * FROM tmodule_times WHERE id = %s", (time_id,)) entry = execute_query_single(
"SELECT * FROM tmodule_times WHERE id = %s AND medarbejder_id = %s",
(time_id, bruger_id),
)
else: else:
if not bruger_id:
raise HTTPException(status_code=400, detail="medarbejder_id could not be resolved")
entry = execute_query_single( entry = execute_query_single(
""" """
SELECT * SELECT *
@ -2532,7 +2535,7 @@ async def stop_live_timer_v1(
) )
if not entry: if not entry:
raise HTTPException(status_code=404, detail="No active timer found") raise HTTPException(status_code=404, detail="Timeren blev ikke fundet eller tilhører en anden medarbejder")
actual_minutes = payload.get("faktisk_tid_min") actual_minutes = payload.get("faktisk_tid_min")
if actual_minutes is None: if actual_minutes is None:
@ -3073,13 +3076,19 @@ async def approve_time_entry_v1(
if not entry: if not entry:
raise HTTPException(status_code=404, detail="Time entry not found") raise HTTPException(status_code=404, detail="Time entry not found")
current_user_id = _resolve_current_user_id(current_user)
if not current_user_id:
raise HTTPException(status_code=401, detail="Authentication required")
is_admin_approver = bool((current_user or {}).get("is_superadmin") or (current_user or {}).get("is_shadow_admin"))
if int(entry.get("medarbejder_id") or 0) != int(current_user_id) and not is_admin_approver:
raise HTTPException(status_code=403, detail="Du kan kun registrere dine egne timere")
_assert_prepaid_entry_editable(entry) _assert_prepaid_entry_editable(entry)
entry_type = payload.get("entry_type") or entry.get("entry_type") or "ukendt" entry_type = payload.get("entry_type") or entry.get("entry_type") or "ukendt"
work_type = str(payload.get("work_type") or entry.get("work_type") or "support") work_type = str(payload.get("work_type") or entry.get("work_type") or "support")
if work_type not in {"support", "troubleshooting", "development", "maintenance", "on_site", "meeting", "other"}: if work_type not in {"support", "troubleshooting", "development", "maintenance", "on_site", "meeting", "other"}:
raise HTTPException(status_code=400, detail="Invalid work_type") raise HTTPException(status_code=400, detail="Invalid work_type")
is_admin_approver = bool((current_user or {}).get("is_superadmin") or (current_user or {}).get("is_shadow_admin"))
if entry_type == "ukendt": if entry_type == "ukendt":
if not is_admin_approver: if not is_admin_approver:
raise HTTPException(status_code=400, detail="entry_type is required before approval") raise HTTPException(status_code=400, detail="entry_type is required before approval")

View File

@ -2158,6 +2158,7 @@
setTimeConversionMode(false); setTimeConversionMode(false);
const modal = getSwitchCaseModal(); const modal = getSwitchCaseModal();
if (modal) modal.hide(); if (modal) modal.hide();
window.dispatchEvent(new CustomEvent('bb:time-converted', { detail: { timeId: timeId } }));
} catch (err) { } catch (err) {
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke konvertere tiden.')); switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke konvertere tiden.'));
} finally { } finally {
@ -2232,6 +2233,8 @@
} }
} }
window.openBottomBarTimeConversion = openTimeConversion;
function openUnassignedCasesPanel() { function openUnassignedCasesPanel() {
window.location.href = '/sag?unassigned=1'; window.location.href = '/sag?unassigned=1';
} }