feat: manage case timers from time usage tab
This commit is contained in:
parent
97218d61a8
commit
0920f2ce30
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user