fix: harden bottom bar timers messages and access
This commit is contained in:
parent
88f7cec478
commit
9ac5b91f6f
@ -655,7 +655,7 @@ def _has_boss_access(current_user: dict) -> bool:
|
||||
(int(current_user_id),),
|
||||
) or []
|
||||
names = [str(r.get("name") or "") for r in rows]
|
||||
tokens = ("admin", "manager", "leder", "chef", "teknik", "technician", "support")
|
||||
tokens = ("admin", "manager", "leder", "chef")
|
||||
return any(any(token in name for token in tokens) for name in names)
|
||||
|
||||
|
||||
|
||||
@ -145,12 +145,16 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
|
||||
m.message_text,
|
||||
m.requires_manual_ack,
|
||||
m.created_at,
|
||||
m.read_at,
|
||||
receipt.read_at,
|
||||
receipt.acknowledged_at,
|
||||
COALESCE(NULLIF(sender.full_name, ''), sender.username, ('Bruger #' || sender.user_id::text)) AS sender_name,
|
||||
COALESCE(NULLIF(recipient.full_name, ''), recipient.username, ('Bruger #' || recipient.user_id::text)) AS recipient_name
|
||||
FROM bottom_bar_messages m
|
||||
JOIN users sender ON sender.user_id = m.sender_user_id
|
||||
LEFT JOIN users recipient ON recipient.user_id = m.recipient_user_id
|
||||
LEFT JOIN bottom_bar_message_receipts receipt
|
||||
ON receipt.message_id = m.id
|
||||
AND receipt.user_id = %s
|
||||
WHERE m.sender_user_id = %s
|
||||
OR m.recipient_user_id = %s
|
||||
OR (m.recipient_user_id IS NULL AND EXISTS (
|
||||
@ -162,21 +166,24 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
|
||||
ORDER BY m.created_at DESC, m.id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(int(user_id), int(user_id), int(user_id), safe_limit),
|
||||
(int(user_id), int(user_id), int(user_id), int(user_id), safe_limit),
|
||||
) or []
|
||||
|
||||
unread_row = execute_query_single(
|
||||
"""
|
||||
SELECT COUNT(*) AS count
|
||||
FROM bottom_bar_messages
|
||||
WHERE read_at IS NULL
|
||||
AND sender_user_id <> %s
|
||||
FROM bottom_bar_messages m
|
||||
LEFT JOIN bottom_bar_message_receipts receipt
|
||||
ON receipt.message_id = m.id
|
||||
AND receipt.user_id = %s
|
||||
WHERE receipt.read_at IS NULL
|
||||
AND m.sender_user_id <> %s
|
||||
AND (
|
||||
recipient_user_id = %s
|
||||
OR recipient_user_id IS NULL
|
||||
m.recipient_user_id = %s
|
||||
OR m.recipient_user_id IS NULL
|
||||
)
|
||||
""",
|
||||
(int(user_id), int(user_id)),
|
||||
(int(user_id), int(user_id), int(user_id)),
|
||||
) or {}
|
||||
|
||||
unread_count = _safe_count(unread_row)
|
||||
@ -197,7 +204,7 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
|
||||
"created_at": row.get("created_at").isoformat() if row.get("created_at") else None,
|
||||
"is_own": int(row.get("sender_user_id") or 0) == int(user_id),
|
||||
"is_unread": row.get("read_at") is None and int(row.get("sender_user_id") or 0) != int(user_id),
|
||||
"is_acknowledged": row.get("read_at") is not None,
|
||||
"is_acknowledged": row.get("acknowledged_at") is not None,
|
||||
}
|
||||
)
|
||||
|
||||
@ -215,31 +222,40 @@ def mark_user_messages_read(user_id: Optional[int], partner_user_id: Optional[in
|
||||
params: List[Any] = [int(user_id)]
|
||||
if partner_user_id is not None and int(partner_user_id) > 0:
|
||||
where_clause = """
|
||||
recipient_user_id = %s
|
||||
AND read_at IS NULL
|
||||
AND COALESCE(requires_manual_ack, FALSE) = FALSE
|
||||
AND sender_user_id <> %s
|
||||
(m.recipient_user_id = %s OR m.recipient_user_id IS NULL)
|
||||
AND COALESCE(m.requires_manual_ack, FALSE) = FALSE
|
||||
AND m.sender_user_id <> %s
|
||||
"""
|
||||
params.append(int(user_id))
|
||||
where_clause += " AND sender_user_id = %s"
|
||||
where_clause += " AND m.sender_user_id = %s"
|
||||
params.append(int(partner_user_id))
|
||||
else:
|
||||
where_clause = """
|
||||
(recipient_user_id = %s OR recipient_user_id IS NULL)
|
||||
AND read_at IS NULL
|
||||
AND COALESCE(requires_manual_ack, FALSE) = FALSE
|
||||
AND sender_user_id <> %s
|
||||
(m.recipient_user_id = %s OR m.recipient_user_id IS NULL)
|
||||
AND COALESCE(m.requires_manual_ack, FALSE) = FALSE
|
||||
AND m.sender_user_id <> %s
|
||||
"""
|
||||
params.append(int(user_id))
|
||||
|
||||
row = execute_query_single(
|
||||
f"""
|
||||
UPDATE bottom_bar_messages
|
||||
SET read_at = CURRENT_TIMESTAMP
|
||||
WITH eligible AS (
|
||||
SELECT m.id
|
||||
FROM bottom_bar_messages m
|
||||
LEFT JOIN bottom_bar_message_receipts r
|
||||
ON r.message_id = m.id AND r.user_id = %s
|
||||
WHERE {where_clause}
|
||||
RETURNING COUNT(*) OVER() AS affected_count
|
||||
AND r.read_at IS NULL
|
||||
), updated AS (
|
||||
INSERT INTO bottom_bar_message_receipts (message_id, user_id, read_at)
|
||||
SELECT id, %s, CURRENT_TIMESTAMP FROM eligible
|
||||
ON CONFLICT (message_id, user_id) DO UPDATE
|
||||
SET read_at = COALESCE(bottom_bar_message_receipts.read_at, EXCLUDED.read_at)
|
||||
RETURNING message_id
|
||||
)
|
||||
SELECT COUNT(*) AS affected_count FROM updated
|
||||
""",
|
||||
tuple(params),
|
||||
tuple([int(user_id)] + params + [int(user_id)]),
|
||||
) or {}
|
||||
|
||||
return int(row.get("affected_count") or 0)
|
||||
@ -252,14 +268,18 @@ def acknowledge_message(user_id: Optional[int], message_id: int) -> bool:
|
||||
ensure_bottom_bar_messages_schema()
|
||||
row = execute_query_single(
|
||||
"""
|
||||
UPDATE bottom_bar_messages
|
||||
SET read_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
AND recipient_user_id = %s
|
||||
AND read_at IS NULL
|
||||
RETURNING id
|
||||
INSERT INTO bottom_bar_message_receipts (message_id, user_id, read_at, acknowledged_at)
|
||||
SELECT m.id, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM bottom_bar_messages m
|
||||
WHERE m.id = %s
|
||||
AND (m.recipient_user_id = %s OR m.recipient_user_id IS NULL)
|
||||
AND m.sender_user_id <> %s
|
||||
ON CONFLICT (message_id, user_id) DO UPDATE
|
||||
SET read_at = COALESCE(bottom_bar_message_receipts.read_at, EXCLUDED.read_at),
|
||||
acknowledged_at = COALESCE(bottom_bar_message_receipts.acknowledged_at, EXCLUDED.acknowledged_at)
|
||||
RETURNING message_id AS id
|
||||
""",
|
||||
(int(message_id), int(user_id)),
|
||||
(int(user_id), int(message_id), int(user_id), int(user_id)),
|
||||
) or {}
|
||||
return bool(row.get("id"))
|
||||
|
||||
@ -333,19 +353,13 @@ def _can_view_boss_tab(user_id: Optional[int]) -> bool:
|
||||
|
||||
group_names = _get_user_group_names(user_id)
|
||||
if not group_names:
|
||||
# Fail-open for authenticated users if group mapping is missing.
|
||||
return True
|
||||
return False
|
||||
|
||||
leadership_tokens = (
|
||||
"admin",
|
||||
"manager",
|
||||
"leder",
|
||||
"chef",
|
||||
"teknik",
|
||||
"technician",
|
||||
"support",
|
||||
"drift",
|
||||
"it",
|
||||
)
|
||||
return any(
|
||||
any(token in group for token in leadership_tokens)
|
||||
|
||||
@ -289,8 +289,10 @@ async def sager_liste(
|
||||
placeholders = ", ".join(["%s"] * len(closed_statuses))
|
||||
query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})"
|
||||
params.extend(closed_statuses)
|
||||
if normalized_priority:
|
||||
query += " AND LOWER(COALESCE(s.priority, 'normal')) = %s"
|
||||
if normalized_priority == "urgent":
|
||||
query += " AND LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')"
|
||||
elif normalized_priority:
|
||||
query += " AND LOWER(COALESCE(s.priority::text, 'normal')) = %s"
|
||||
params.append(normalized_priority)
|
||||
if customer_id_int:
|
||||
query += " AND s.customer_id = %s"
|
||||
@ -355,8 +357,10 @@ async def sager_liste(
|
||||
placeholders = ", ".join(["%s"] * len(closed_statuses))
|
||||
fallback_query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})"
|
||||
fallback_params.extend(closed_statuses)
|
||||
if normalized_priority:
|
||||
fallback_query += " AND LOWER(COALESCE(s.priority, 'normal')) = %s"
|
||||
if normalized_priority == "urgent":
|
||||
fallback_query += " AND LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')"
|
||||
elif normalized_priority:
|
||||
fallback_query += " AND LOWER(COALESCE(s.priority::text, 'normal')) = %s"
|
||||
fallback_params.append(normalized_priority)
|
||||
if customer_id_int:
|
||||
fallback_query += " AND s.customer_id = %s"
|
||||
|
||||
@ -1578,7 +1578,7 @@ if (bmcOriginalFetch) {
|
||||
<script src="/static/js/telefoni.js?v=2.4"></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/bottom-bar.js?v=2.60"></script>
|
||||
<script src="/static/js/bottom-bar.js?v=2.61"></script>
|
||||
<script>
|
||||
// Dark Mode Toggle Logic
|
||||
window.BMC_CAN_CLICK_TO_CALL = true;
|
||||
|
||||
@ -78,6 +78,33 @@ def _resolve_target_user_id(current_user: Optional[dict], payload_user_id: Any =
|
||||
return _resolve_current_user_id(current_user)
|
||||
|
||||
|
||||
def _can_view_all_team_timers(current_user: Optional[dict]) -> bool:
|
||||
"""Restrict company-wide timer visibility to explicit leadership groups."""
|
||||
if not current_user:
|
||||
return False
|
||||
if bool(current_user.get("is_superadmin") or current_user.get("is_shadow_admin")):
|
||||
return True
|
||||
|
||||
user_id = _resolve_current_user_id(current_user)
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
rows = execute_query(
|
||||
"""
|
||||
SELECT LOWER(COALESCE(g.name, '')) AS name
|
||||
FROM user_groups ug
|
||||
JOIN groups g ON g.id = ug.group_id
|
||||
WHERE ug.user_id = %s
|
||||
""",
|
||||
(user_id,),
|
||||
) or []
|
||||
leadership_tokens = ("admin", "manager", "leder", "chef")
|
||||
return any(
|
||||
any(token in str(row.get("name") or "") for token in leadership_tokens)
|
||||
for row in rows
|
||||
)
|
||||
|
||||
|
||||
def _parse_iso_datetime(value: Optional[str]) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
@ -2800,6 +2827,8 @@ async def list_team_timer_status_v1(
|
||||
bruger_id = _resolve_current_user_id(current_user)
|
||||
if not bruger_id:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
if scope == "all" and not _can_view_all_team_timers(current_user):
|
||||
raise HTTPException(status_code=403, detail="Du har ikke rettighed til at se alle medarbejderes timere")
|
||||
employee_filter = "AND t.medarbejder_id = %s" if scope == "mine" else ""
|
||||
params = (bruger_id,) if scope == "mine" else ()
|
||||
rows = execute_query(
|
||||
|
||||
23
migrations/231_bottom_bar_message_receipts.sql
Normal file
23
migrations/231_bottom_bar_message_receipts.sql
Normal file
@ -0,0 +1,23 @@
|
||||
-- Per-user delivery state for direct and broadcast bottom-bar messages.
|
||||
CREATE TABLE IF NOT EXISTS bottom_bar_message_receipts (
|
||||
message_id INTEGER NOT NULL REFERENCES bottom_bar_messages(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
read_at TIMESTAMP NULL,
|
||||
acknowledged_at TIMESTAMP NULL,
|
||||
PRIMARY KEY (message_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bottom_bar_message_receipts_user_unread
|
||||
ON bottom_bar_message_receipts (user_id, read_at, message_id);
|
||||
|
||||
-- Preserve the state of existing direct messages. Broadcast read_at cannot be
|
||||
-- migrated safely because the old schema stored one shared value for everyone.
|
||||
INSERT INTO bottom_bar_message_receipts (message_id, user_id, read_at, acknowledged_at)
|
||||
SELECT id,
|
||||
recipient_user_id,
|
||||
read_at,
|
||||
CASE WHEN requires_manual_ack THEN read_at ELSE NULL END
|
||||
FROM bottom_bar_messages
|
||||
WHERE recipient_user_id IS NOT NULL
|
||||
ON CONFLICT (message_id, user_id) DO NOTHING;
|
||||
|
||||
@ -489,7 +489,9 @@
|
||||
const localNotes = loadLocalNotes();
|
||||
const remoteList = Array.isArray(notes.list) ? notes.list : [];
|
||||
|
||||
if (!notesApiUnavailable && remoteList.length > 0) {
|
||||
// An empty response from a healthy API is authoritative. Falling back
|
||||
// merely because the list is empty resurrects stale/deleted local notes.
|
||||
if (!notesApiUnavailable) {
|
||||
return target;
|
||||
}
|
||||
|
||||
@ -768,6 +770,11 @@
|
||||
document.body.classList.toggle('bottom-bar-expanded', !!expanded);
|
||||
toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||
panel.setAttribute('aria-hidden', expanded ? 'false' : 'true');
|
||||
try {
|
||||
window.localStorage.setItem('bmc-bottom-bar-expanded', expanded ? '1' : '0');
|
||||
} catch (_) {
|
||||
// Storage may be disabled in private browsing.
|
||||
}
|
||||
}
|
||||
|
||||
function syncBossTabVisibility() {
|
||||
@ -776,7 +783,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
bossBtn.classList.remove('d-none');
|
||||
const canView = Boolean((((latestSections || {}).boss || {}).can_view));
|
||||
bossBtn.classList.toggle('d-none', !canView);
|
||||
if (!canView && activeKey === 'boss') {
|
||||
activeKey = 'overview';
|
||||
}
|
||||
}
|
||||
|
||||
function getCounts(sections) {
|
||||
@ -892,7 +903,7 @@
|
||||
if (timer.active_count > 0) {
|
||||
return (timer.list || []).map(t => {
|
||||
const elapsedText = t.elapsed_hhmmss || (String(t.elapsed || 0) + 's');
|
||||
return '<div class="d-flex justify-content-between align-items-center gap-3 bb-timer-case-link" data-bb-case-link="' + Number(t.sag_id || 0) + '" role="link" tabindex="0"><div class="min-w-0"><div class="fw-semibold text-truncate"><span class="bb-live-indicator"></span>' + esc(t.desc) + '</div><div class="small text-muted mt-1">Aktiv tid · <span class="font-monospace fw-semibold">' + esc(elapsedText) + '</span></div></div><div class="d-flex gap-2"><button class="btn btn-sm btn-outline-primary" data-bb-open-case="' + Number(t.sag_id || 0) + '">Åbn sag</button><button class="btn btn-sm btn-danger" data-bb-stop-time="' + Number(t.id || t.time_entry_id || 0) + '"><i class="bi bi-stop-fill me-1"></i>Stop</button></div></div>';
|
||||
return '<div class="d-flex justify-content-between align-items-center gap-3 bb-timer-case-link" data-bb-case-link="' + Number(t.sag_id || 0) + '" role="link" tabindex="0"><div class="min-w-0"><div class="fw-semibold text-truncate"><span class="bb-live-indicator"></span>' + esc(t.desc) + '</div><div class="small text-muted mt-1">Aktiv tid · <span class="font-monospace fw-semibold" data-bb-live-elapsed="' + Number(t.id || t.time_entry_id || 0) + '">' + esc(elapsedText) + '</span></div></div><div class="d-flex gap-2"><button class="btn btn-sm btn-outline-primary" data-bb-open-case="' + Number(t.sag_id || 0) + '">Åbn sag</button><button class="btn btn-sm btn-danger" data-bb-stop-time="' + Number(t.id || t.time_entry_id || 0) + '"><i class="bi bi-stop-fill me-1"></i>Stop</button></div></div>';
|
||||
});
|
||||
}
|
||||
return ['<div class="bb-panel-empty"><div><i class="bi bi-stopwatch"></i><strong>Ingen aktiv timer</strong><div class="small mt-1">Brug “Skift sag” i bundlinjen for at starte arbejdet.</div></div></div>'];
|
||||
@ -1490,9 +1501,9 @@
|
||||
if (isOwn && state === 'pending_conversion') action = '<button class="btn btn-sm btn-primary" data-bb-convert-time="' + timeId + '"><i class="bi bi-arrow-repeat me-1"></i>Konverter</button>';
|
||||
if (isOwn && state === 'active') action = '<button class="btn btn-sm btn-danger" data-bb-stop-time="' + timeId + '"><i class="bi bi-stop-fill me-1"></i>Stop</button>';
|
||||
const elapsedValue = row.elapsed_hhmmss || formatTimerSeconds(row.live_elapsed_seconds || row.elapsed_seconds || row.elapsed);
|
||||
const elapsed = state === 'active' ? '<span class="font-monospace">' + escapeHtml(elapsedValue) + '</span> · ' : '';
|
||||
const elapsed = state === 'active' ? '<span class="font-monospace" data-bb-live-elapsed="' + timeId + '">' + escapeHtml(elapsedValue) + '</span> · ' : '';
|
||||
const employeeName = row.employee_display_name || row.medarbejder_navn || row.user_name || (isOwn ? 'Min timer' : 'Ukendt medarbejder');
|
||||
return '<div class="bb-timer-work-card" data-bb-case-link="' + sagId + '"><div class="bb-timer-work-main"><span class="badge text-bg-' + label[1] + '">' + label[0] + '</span><strong class="bb-timer-work-title">' + escapeHtml(row.sag_navn || ('Sag #' + sagId)) + '</strong><span class="bb-timer-work-meta">Sag #' + sagId + '</span><span class="bb-timer-work-meta">' + elapsed + escapeHtml(employeeName) + '</span></div><div class="bb-timer-work-actions"><button class="btn btn-sm btn-outline-secondary" data-bb-open-case="' + sagId + '" title="Åbn sag"><i class="bi bi-box-arrow-up-right"></i></button>' + action + '</div></div>';
|
||||
return '<div class="bb-timer-work-card" data-bb-case-link="' + sagId + '" role="link" tabindex="0"><div class="bb-timer-work-main"><span class="badge text-bg-' + label[1] + '">' + label[0] + '</span><strong class="bb-timer-work-title">' + escapeHtml(row.sag_navn || ('Sag #' + sagId)) + '</strong><span class="bb-timer-work-meta">Sag #' + sagId + '</span><span class="bb-timer-work-meta">' + elapsed + escapeHtml(employeeName) + '</span></div><div class="bb-timer-work-actions"><button class="btn btn-sm btn-outline-secondary" data-bb-open-case="' + sagId + '" title="Åbn sag" aria-label="Åbn sag #' + sagId + '"><i class="bi bi-box-arrow-up-right"></i></button>' + action + '</div></div>';
|
||||
}).join('');
|
||||
}
|
||||
container.appendChild(list);
|
||||
@ -1549,6 +1560,11 @@
|
||||
this.setAttribute('aria-selected', 'true');
|
||||
|
||||
activeKey = this.getAttribute('data-bb-tab');
|
||||
try {
|
||||
window.localStorage.setItem('bmc-bottom-bar-active-tab', activeKey);
|
||||
} catch (_) {
|
||||
// Storage may be disabled in private browsing.
|
||||
}
|
||||
renderTabPanel();
|
||||
|
||||
const detail = byId('bbCountDetail');
|
||||
@ -1636,9 +1652,13 @@
|
||||
}
|
||||
|
||||
const onDriftPage = (window.location.pathname || '').toLowerCase().indexOf('/drift') === 0;
|
||||
const previousTimer = (((latestSections || {}).timer || {}).active || {});
|
||||
let timerIdentityChanged = false;
|
||||
|
||||
if (payload.event === 'timer_tick') {
|
||||
const timer = payload.data || {};
|
||||
timerIdentityChanged = Boolean(previousTimer.active) !== Boolean(timer.active)
|
||||
|| Number(previousTimer.time_entry_id || 0) !== Number(timer.time_entry_id || 0);
|
||||
latestSections.timer = latestSections.timer || {};
|
||||
latestSections.timer.active = timer;
|
||||
latestSections.timer.active_count = timer.active ? 1 : 0;
|
||||
@ -1709,6 +1729,14 @@
|
||||
updateBar(latestSections);
|
||||
updateMessagesTabBadge();
|
||||
updateActivityZone();
|
||||
if (payload.event === 'timer_tick' && !timerIdentityChanged) {
|
||||
const timer = payload.data || {};
|
||||
const elapsed = timer.elapsed_hhmmss || formatTimerSeconds(timer.elapsed || 0);
|
||||
document.querySelectorAll('[data-bb-live-elapsed="' + Number(timer.time_entry_id || 0) + '"]').forEach(function (node) {
|
||||
node.textContent = elapsed;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const focusedId = document.activeElement && document.activeElement.id;
|
||||
const quickNoteFocused = activeKey === 'overview' && focusedId === 'bbQuickNoteInput';
|
||||
const noteEditorFocused = activeKey === 'notes' && (focusedId === 'bbNoteTitleInput' || focusedId === 'bbNoteContentInput');
|
||||
@ -1771,20 +1799,28 @@
|
||||
}
|
||||
|
||||
|
||||
async function stopTimer(timeId) {
|
||||
const safeTimeId = Number(timeId || 0);
|
||||
if (!(safeTimeId > 0)) return {};
|
||||
|
||||
const response = await fetch('/api/v1/timetracking/time/stop', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ time_id: safeTimeId })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response, 'Kunne ikke stoppe timeren.'));
|
||||
}
|
||||
return response.json().catch(function () { return {}; });
|
||||
}
|
||||
|
||||
function stopActiveTimer() {
|
||||
const active = ((latestSections || {}).timer || {}).active || {};
|
||||
if (!active.time_entry_id) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return fetch('/api/v1/timetracking/time/stop', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ time_id: active.time_entry_id })
|
||||
}).catch(function (err) {
|
||||
console.warn('Failed stopping active timer', err);
|
||||
});
|
||||
return stopTimer(active.time_entry_id);
|
||||
}
|
||||
|
||||
function pauseActiveTimer() {
|
||||
@ -2650,7 +2686,7 @@
|
||||
timerChip.addEventListener('click', function () {
|
||||
const timer = (((latestSections || {}).timer || {}).active || {});
|
||||
const sagId = Number(timer.sag_id || 0);
|
||||
window.location.href = sagId > 0 ? ('/sag/' + sagId) : '/timetracking';
|
||||
window.location.href = sagId > 0 ? ('/sag/' + sagId + '/v3') : '/timetracking';
|
||||
});
|
||||
}
|
||||
|
||||
@ -2674,6 +2710,16 @@
|
||||
}
|
||||
|
||||
function bindDynamicActions() {
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
const caseLink = e.target && e.target.closest('[data-bb-case-link][role="link"]');
|
||||
if (!caseLink || e.target.closest('button, a, input, select, textarea')) return;
|
||||
const sagId = Number(caseLink.getAttribute('data-bb-case-link') || 0);
|
||||
if (sagId > 0) {
|
||||
e.preventDefault();
|
||||
openCaseDetail(sagId);
|
||||
}
|
||||
});
|
||||
document.addEventListener('click', function (e) {
|
||||
const target = e.target;
|
||||
const timerScopeButton = target && target.closest('[data-bb-timer-scope]');
|
||||
@ -2993,16 +3039,13 @@
|
||||
|
||||
const stopTimeId = Number(btn.getAttribute('data-bb-stop-time') || 0);
|
||||
if (stopTimeId > 0) {
|
||||
fetch('/api/v1/timetracking/time/stop', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ time_id: stopTimeId })
|
||||
})
|
||||
btn.disabled = true;
|
||||
stopTimer(stopTimeId)
|
||||
.then(fetchBottomBarState)
|
||||
.then(applyState)
|
||||
.catch(function (err) {
|
||||
console.warn('Failed stopping timer from list', err);
|
||||
btn.disabled = false;
|
||||
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke stoppe timeren.'));
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -3218,7 +3261,7 @@
|
||||
const taskTitle = task.title || 'Ingen opgave fundet';
|
||||
const caseId = task.case_id || '-';
|
||||
const freeMins = data && data.free_time_calculated ? data.free_time_calculated : 0;
|
||||
btn.innerHTML = '<i class="bi bi-magic me-2"></i>Du fik tildelt: ' + taskTitle + ' (Sag #' + caseId + ') <span class="badge bg-light text-dark ms-2">' + freeMins + 'm fri</span>';
|
||||
btn.innerHTML = '<i class="bi bi-magic me-2"></i>Du fik tildelt: ' + escapeHtml(taskTitle) + ' (Sag #' + escapeHtml(caseId) + ') <span class="badge bg-light text-dark ms-2">' + escapeHtml(freeMins) + 'm fri</span>';
|
||||
btn.classList.add('btn-success');
|
||||
btn.classList.remove('btn-primary');
|
||||
})
|
||||
@ -3346,7 +3389,20 @@
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
activeKey = 'overview'; // Default overview state
|
||||
try {
|
||||
activeKey = window.localStorage.getItem('bmc-bottom-bar-active-tab') || 'overview';
|
||||
} catch (_) {
|
||||
activeKey = 'overview';
|
||||
}
|
||||
const allowedTabs = ['overview', 'timer', 'messages', 'tasks', 'notes', 'boss'];
|
||||
if (allowedTabs.indexOf(activeKey) === -1) activeKey = 'overview';
|
||||
|
||||
const selectedTab = document.querySelector('.bb-tab-btn[data-bb-tab="' + activeKey + '"]');
|
||||
document.querySelectorAll('.bb-tab-btn').forEach(function (button) {
|
||||
const selected = button === selectedTab;
|
||||
button.classList.toggle('is-active', selected);
|
||||
button.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||||
});
|
||||
bindChipClicks();
|
||||
bindChipHoverPreview();
|
||||
bindSheetToggle();
|
||||
@ -3355,6 +3411,12 @@
|
||||
|
||||
bindSideTabs();
|
||||
|
||||
try {
|
||||
setExpanded(window.localStorage.getItem('bmc-bottom-bar-expanded') === '1');
|
||||
} catch (_) {
|
||||
setExpanded(false);
|
||||
}
|
||||
|
||||
startPollingFallback();
|
||||
connectRealtime();
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user