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),),
|
(int(current_user_id),),
|
||||||
) or []
|
) or []
|
||||||
names = [str(r.get("name") or "") for r in rows]
|
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)
|
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.message_text,
|
||||||
m.requires_manual_ack,
|
m.requires_manual_ack,
|
||||||
m.created_at,
|
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(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
|
COALESCE(NULLIF(recipient.full_name, ''), recipient.username, ('Bruger #' || recipient.user_id::text)) AS recipient_name
|
||||||
FROM bottom_bar_messages m
|
FROM bottom_bar_messages m
|
||||||
JOIN users sender ON sender.user_id = m.sender_user_id
|
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 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
|
WHERE m.sender_user_id = %s
|
||||||
OR m.recipient_user_id = %s
|
OR m.recipient_user_id = %s
|
||||||
OR (m.recipient_user_id IS NULL AND EXISTS (
|
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
|
ORDER BY m.created_at DESC, m.id DESC
|
||||||
LIMIT %s
|
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 []
|
) or []
|
||||||
|
|
||||||
unread_row = execute_query_single(
|
unread_row = execute_query_single(
|
||||||
"""
|
"""
|
||||||
SELECT COUNT(*) AS count
|
SELECT COUNT(*) AS count
|
||||||
FROM bottom_bar_messages
|
FROM bottom_bar_messages m
|
||||||
WHERE read_at IS NULL
|
LEFT JOIN bottom_bar_message_receipts receipt
|
||||||
AND sender_user_id <> %s
|
ON receipt.message_id = m.id
|
||||||
|
AND receipt.user_id = %s
|
||||||
|
WHERE receipt.read_at IS NULL
|
||||||
|
AND m.sender_user_id <> %s
|
||||||
AND (
|
AND (
|
||||||
recipient_user_id = %s
|
m.recipient_user_id = %s
|
||||||
OR recipient_user_id IS NULL
|
OR m.recipient_user_id IS NULL
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
(int(user_id), int(user_id)),
|
(int(user_id), int(user_id), int(user_id)),
|
||||||
) or {}
|
) or {}
|
||||||
|
|
||||||
unread_count = _safe_count(unread_row)
|
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,
|
"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_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_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)]
|
params: List[Any] = [int(user_id)]
|
||||||
if partner_user_id is not None and int(partner_user_id) > 0:
|
if partner_user_id is not None and int(partner_user_id) > 0:
|
||||||
where_clause = """
|
where_clause = """
|
||||||
recipient_user_id = %s
|
(m.recipient_user_id = %s OR m.recipient_user_id IS NULL)
|
||||||
AND read_at IS NULL
|
AND COALESCE(m.requires_manual_ack, FALSE) = FALSE
|
||||||
AND COALESCE(requires_manual_ack, FALSE) = FALSE
|
AND m.sender_user_id <> %s
|
||||||
AND sender_user_id <> %s
|
|
||||||
"""
|
"""
|
||||||
params.append(int(user_id))
|
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))
|
params.append(int(partner_user_id))
|
||||||
else:
|
else:
|
||||||
where_clause = """
|
where_clause = """
|
||||||
(recipient_user_id = %s OR recipient_user_id IS NULL)
|
(m.recipient_user_id = %s OR m.recipient_user_id IS NULL)
|
||||||
AND read_at IS NULL
|
AND COALESCE(m.requires_manual_ack, FALSE) = FALSE
|
||||||
AND COALESCE(requires_manual_ack, FALSE) = FALSE
|
AND m.sender_user_id <> %s
|
||||||
AND sender_user_id <> %s
|
|
||||||
"""
|
"""
|
||||||
params.append(int(user_id))
|
params.append(int(user_id))
|
||||||
|
|
||||||
row = execute_query_single(
|
row = execute_query_single(
|
||||||
f"""
|
f"""
|
||||||
UPDATE bottom_bar_messages
|
WITH eligible AS (
|
||||||
SET read_at = CURRENT_TIMESTAMP
|
SELECT m.id
|
||||||
WHERE {where_clause}
|
FROM bottom_bar_messages m
|
||||||
RETURNING COUNT(*) OVER() AS affected_count
|
LEFT JOIN bottom_bar_message_receipts r
|
||||||
|
ON r.message_id = m.id AND r.user_id = %s
|
||||||
|
WHERE {where_clause}
|
||||||
|
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 {}
|
) or {}
|
||||||
|
|
||||||
return int(row.get("affected_count") or 0)
|
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()
|
ensure_bottom_bar_messages_schema()
|
||||||
row = execute_query_single(
|
row = execute_query_single(
|
||||||
"""
|
"""
|
||||||
UPDATE bottom_bar_messages
|
INSERT INTO bottom_bar_message_receipts (message_id, user_id, read_at, acknowledged_at)
|
||||||
SET read_at = CURRENT_TIMESTAMP
|
SELECT m.id, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||||
WHERE id = %s
|
FROM bottom_bar_messages m
|
||||||
AND recipient_user_id = %s
|
WHERE m.id = %s
|
||||||
AND read_at IS NULL
|
AND (m.recipient_user_id = %s OR m.recipient_user_id IS NULL)
|
||||||
RETURNING id
|
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 {}
|
) or {}
|
||||||
return bool(row.get("id"))
|
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)
|
group_names = _get_user_group_names(user_id)
|
||||||
if not group_names:
|
if not group_names:
|
||||||
# Fail-open for authenticated users if group mapping is missing.
|
return False
|
||||||
return True
|
|
||||||
|
|
||||||
leadership_tokens = (
|
leadership_tokens = (
|
||||||
"admin",
|
"admin",
|
||||||
"manager",
|
"manager",
|
||||||
"leder",
|
"leder",
|
||||||
"chef",
|
"chef",
|
||||||
"teknik",
|
|
||||||
"technician",
|
|
||||||
"support",
|
|
||||||
"drift",
|
|
||||||
"it",
|
|
||||||
)
|
)
|
||||||
return any(
|
return any(
|
||||||
any(token in group for token in leadership_tokens)
|
any(token in group for token in leadership_tokens)
|
||||||
|
|||||||
@ -289,8 +289,10 @@ async def sager_liste(
|
|||||||
placeholders = ", ".join(["%s"] * len(closed_statuses))
|
placeholders = ", ".join(["%s"] * len(closed_statuses))
|
||||||
query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})"
|
query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})"
|
||||||
params.extend(closed_statuses)
|
params.extend(closed_statuses)
|
||||||
if normalized_priority:
|
if normalized_priority == "urgent":
|
||||||
query += " AND LOWER(COALESCE(s.priority, 'normal')) = %s"
|
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)
|
params.append(normalized_priority)
|
||||||
if customer_id_int:
|
if customer_id_int:
|
||||||
query += " AND s.customer_id = %s"
|
query += " AND s.customer_id = %s"
|
||||||
@ -355,8 +357,10 @@ async def sager_liste(
|
|||||||
placeholders = ", ".join(["%s"] * len(closed_statuses))
|
placeholders = ", ".join(["%s"] * len(closed_statuses))
|
||||||
fallback_query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})"
|
fallback_query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})"
|
||||||
fallback_params.extend(closed_statuses)
|
fallback_params.extend(closed_statuses)
|
||||||
if normalized_priority:
|
if normalized_priority == "urgent":
|
||||||
fallback_query += " AND LOWER(COALESCE(s.priority, 'normal')) = %s"
|
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)
|
fallback_params.append(normalized_priority)
|
||||||
if customer_id_int:
|
if customer_id_int:
|
||||||
fallback_query += " AND s.customer_id = %s"
|
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/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.60"></script>
|
<script src="/static/js/bottom-bar.js?v=2.61"></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;
|
||||||
|
|||||||
@ -78,6 +78,33 @@ def _resolve_target_user_id(current_user: Optional[dict], payload_user_id: Any =
|
|||||||
return _resolve_current_user_id(current_user)
|
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]:
|
def _parse_iso_datetime(value: Optional[str]) -> Optional[datetime]:
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
@ -2800,6 +2827,8 @@ async def list_team_timer_status_v1(
|
|||||||
bruger_id = _resolve_current_user_id(current_user)
|
bruger_id = _resolve_current_user_id(current_user)
|
||||||
if not bruger_id:
|
if not bruger_id:
|
||||||
raise HTTPException(status_code=401, detail="Authentication required")
|
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 ""
|
employee_filter = "AND t.medarbejder_id = %s" if scope == "mine" else ""
|
||||||
params = (bruger_id,) if scope == "mine" else ()
|
params = (bruger_id,) if scope == "mine" else ()
|
||||||
rows = execute_query(
|
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 localNotes = loadLocalNotes();
|
||||||
const remoteList = Array.isArray(notes.list) ? notes.list : [];
|
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;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -768,6 +770,11 @@
|
|||||||
document.body.classList.toggle('bottom-bar-expanded', !!expanded);
|
document.body.classList.toggle('bottom-bar-expanded', !!expanded);
|
||||||
toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||||
panel.setAttribute('aria-hidden', expanded ? 'false' : 'true');
|
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() {
|
function syncBossTabVisibility() {
|
||||||
@ -776,7 +783,11 @@
|
|||||||
return;
|
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) {
|
function getCounts(sections) {
|
||||||
@ -892,7 +903,7 @@
|
|||||||
if (timer.active_count > 0) {
|
if (timer.active_count > 0) {
|
||||||
return (timer.list || []).map(t => {
|
return (timer.list || []).map(t => {
|
||||||
const elapsedText = t.elapsed_hhmmss || (String(t.elapsed || 0) + 's');
|
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>'];
|
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 === '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>';
|
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 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');
|
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('');
|
}).join('');
|
||||||
}
|
}
|
||||||
container.appendChild(list);
|
container.appendChild(list);
|
||||||
@ -1549,6 +1560,11 @@
|
|||||||
this.setAttribute('aria-selected', 'true');
|
this.setAttribute('aria-selected', 'true');
|
||||||
|
|
||||||
activeKey = this.getAttribute('data-bb-tab');
|
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();
|
renderTabPanel();
|
||||||
|
|
||||||
const detail = byId('bbCountDetail');
|
const detail = byId('bbCountDetail');
|
||||||
@ -1636,9 +1652,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onDriftPage = (window.location.pathname || '').toLowerCase().indexOf('/drift') === 0;
|
const onDriftPage = (window.location.pathname || '').toLowerCase().indexOf('/drift') === 0;
|
||||||
|
const previousTimer = (((latestSections || {}).timer || {}).active || {});
|
||||||
|
let timerIdentityChanged = false;
|
||||||
|
|
||||||
if (payload.event === 'timer_tick') {
|
if (payload.event === 'timer_tick') {
|
||||||
const timer = payload.data || {};
|
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 = latestSections.timer || {};
|
||||||
latestSections.timer.active = timer;
|
latestSections.timer.active = timer;
|
||||||
latestSections.timer.active_count = timer.active ? 1 : 0;
|
latestSections.timer.active_count = timer.active ? 1 : 0;
|
||||||
@ -1709,6 +1729,14 @@
|
|||||||
updateBar(latestSections);
|
updateBar(latestSections);
|
||||||
updateMessagesTabBadge();
|
updateMessagesTabBadge();
|
||||||
updateActivityZone();
|
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 focusedId = document.activeElement && document.activeElement.id;
|
||||||
const quickNoteFocused = activeKey === 'overview' && focusedId === 'bbQuickNoteInput';
|
const quickNoteFocused = activeKey === 'overview' && focusedId === 'bbQuickNoteInput';
|
||||||
const noteEditorFocused = activeKey === 'notes' && (focusedId === 'bbNoteTitleInput' || focusedId === 'bbNoteContentInput');
|
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() {
|
function stopActiveTimer() {
|
||||||
const active = ((latestSections || {}).timer || {}).active || {};
|
const active = ((latestSections || {}).timer || {}).active || {};
|
||||||
if (!active.time_entry_id) {
|
if (!active.time_entry_id) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
return stopTimer(active.time_entry_id);
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pauseActiveTimer() {
|
function pauseActiveTimer() {
|
||||||
@ -2650,7 +2686,7 @@
|
|||||||
timerChip.addEventListener('click', function () {
|
timerChip.addEventListener('click', function () {
|
||||||
const timer = (((latestSections || {}).timer || {}).active || {});
|
const timer = (((latestSections || {}).timer || {}).active || {});
|
||||||
const sagId = Number(timer.sag_id || 0);
|
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() {
|
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) {
|
document.addEventListener('click', function (e) {
|
||||||
const target = e.target;
|
const target = e.target;
|
||||||
const timerScopeButton = target && target.closest('[data-bb-timer-scope]');
|
const timerScopeButton = target && target.closest('[data-bb-timer-scope]');
|
||||||
@ -2993,16 +3039,13 @@
|
|||||||
|
|
||||||
const stopTimeId = Number(btn.getAttribute('data-bb-stop-time') || 0);
|
const stopTimeId = Number(btn.getAttribute('data-bb-stop-time') || 0);
|
||||||
if (stopTimeId > 0) {
|
if (stopTimeId > 0) {
|
||||||
fetch('/api/v1/timetracking/time/stop', {
|
btn.disabled = true;
|
||||||
method: 'POST',
|
stopTimer(stopTimeId)
|
||||||
credentials: 'include',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ time_id: stopTimeId })
|
|
||||||
})
|
|
||||||
.then(fetchBottomBarState)
|
.then(fetchBottomBarState)
|
||||||
.then(applyState)
|
.then(applyState)
|
||||||
.catch(function (err) {
|
.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;
|
return;
|
||||||
}
|
}
|
||||||
@ -3218,7 +3261,7 @@
|
|||||||
const taskTitle = task.title || 'Ingen opgave fundet';
|
const taskTitle = task.title || 'Ingen opgave fundet';
|
||||||
const caseId = task.case_id || '-';
|
const caseId = task.case_id || '-';
|
||||||
const freeMins = data && data.free_time_calculated ? data.free_time_calculated : 0;
|
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.add('btn-success');
|
||||||
btn.classList.remove('btn-primary');
|
btn.classList.remove('btn-primary');
|
||||||
})
|
})
|
||||||
@ -3346,7 +3389,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
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();
|
bindChipClicks();
|
||||||
bindChipHoverPreview();
|
bindChipHoverPreview();
|
||||||
bindSheetToggle();
|
bindSheetToggle();
|
||||||
@ -3355,6 +3411,12 @@
|
|||||||
|
|
||||||
bindSideTabs();
|
bindSideTabs();
|
||||||
|
|
||||||
|
try {
|
||||||
|
setExpanded(window.localStorage.getItem('bmc-bottom-bar-expanded') === '1');
|
||||||
|
} catch (_) {
|
||||||
|
setExpanded(false);
|
||||||
|
}
|
||||||
|
|
||||||
startPollingFallback();
|
startPollingFallback();
|
||||||
connectRealtime();
|
connectRealtime();
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user