/* pto.js - Paid Time Off feature * Renders the PTO panel inside the Time Tracking tab. * Members see: balance summary, request form, their own request history. * Admins (Dee, Alyssa) ALSO see: pending approvals queue, hire-date editor, * and balance override editor for every active team member. * NOTE: Shauna is explicitly excluded from admin features by _pto_is_admin * which only accepts usernames 'dee' or 'alyssa' (server-side enforced). */ (function () { 'use strict'; const ADMIN_USERNAMES = ['dee', 'alyssa']; function getUsername() { // #who displays "Full Name (username)" - extract username const el = document.getElementById('who'); if (!el) return null; const txt = el.textContent || ''; const m = txt.match(/\(([^)]+)\)\s*$/); if (m) return m[1].trim().toLowerCase(); return null; } function isAdmin(username) { return !!username && ADMIN_USERNAMES.includes(username.toLowerCase()); } async function rpc(fn, params) { try { const r = await window.rpc(fn, params || {}); return { ok: true, data: r }; } catch (e) { console.error('[PTO] rpc', fn, e); return { ok: false, error: e && e.message ? e.message : String(e) }; } } // Cache team_member_id and dashboard snapshot to map username -> team_member uuid let _cache = { username: null, teamMemberId: null, fullName: null }; async function resolveTeamMember(username) { if (_cache.username === username && _cache.teamMemberId) return _cache; const res = await rpc('app_get_dashboard_data', { p_username: username }); if (!res.ok) return _cache; const d = res.data || {}; const users = d.all_app_users || []; const tms = d.team_members || []; const user = users.find(u => u.username && u.username.toLowerCase() === username); if (!user) return _cache; const tm = tms.find(t => t.full_name && user.full_name && t.full_name.toLowerCase() === user.full_name.toLowerCase()); if (!tm) return _cache; _cache = { username, teamMemberId: tm.id, fullName: tm.full_name }; return _cache; } function el(tag, attrs, children) { const n = document.createElement(tag); if (attrs) for (const k in attrs) { if (k === 'style') n.style.cssText = attrs[k]; else if (k === 'cls') n.className = attrs[k]; else if (k.startsWith('on')) n.addEventListener(k.slice(2), attrs[k]); else if (k === 'html') n.innerHTML = attrs[k]; else n.setAttribute(k, attrs[k]); } if (children) [].concat(children).forEach(c => { if (c == null) return; if (typeof c === 'string') n.appendChild(document.createTextNode(c)); else n.appendChild(c); }); return n; } function fmtDate(d) { if (!d) return ''; try { return new Date(d + 'T00:00:00').toLocaleDateString(); } catch (e) { return d; } } function buildPtoPanel() { const username = getUsername(); if (!username) return null; const admin = isAdmin(username); const root = el('div', { id: 'pto-panel', cls: 'card', style: 'margin-top:16px;padding:16px;' }, [ el('h3', { style: 'margin:0 0 8px 0;' }, 'Paid Time Off (PTO)'), el('div', { id: 'pto-summary', style: 'margin-bottom:12px;color:#bbb;' }, 'Loading PTO summary...'), // Request form el('div', { cls: 'pto-section', style: 'border-top:1px solid #333;padding-top:12px;' }, [ el('h4', { style: 'margin:0 0 8px 0;' }, 'Request Time Off'), el('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:8px;' }, [ el('label', null, [ el('div', { style: 'font-size:11px;color:#888;' }, 'START DATE'), el('input', { id: 'pto-start', type: 'date', style: 'width:100%;' }) ]), el('label', null, [ el('div', { style: 'font-size:11px;color:#888;' }, 'END DATE'), el('input', { id: 'pto-end', type: 'date', style: 'width:100%;' }) ]), ]), el('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px;align-items:end;' }, [ el('label', null, [ el('div', { style: 'font-size:11px;color:#888;' }, 'DAYS REQUESTED (0.5 INCREMENTS)'), el('input', { id: 'pto-days', type: 'number', step: '0.5', min: '0.5', placeholder: 'e.g. 1 or 0.5', style: 'width:100%;' }) ]), el('label', { style: 'display:flex;align-items:center;gap:6px;' }, [ el('input', { id: 'pto-half', type: 'checkbox' }), el('span', { style: 'font-size:12px;color:#888;' }, 'HALF DAY ONLY'), ]), ]), el('label', { style: 'display:block;margin-top:8px;' }, [ el('div', { style: 'font-size:11px;color:#888;' }, 'REASON / NOTES (OPTIONAL)'), el('textarea', { id: 'pto-reason', rows: 2, style: 'width:100%;' }), ]), el('button', { id: 'pto-submit', cls: 'btn', style: 'margin-top:8px;', onclick: onSubmitRequest }, 'Submit Request'), el('div', { id: 'pto-msg', style: 'margin-top:8px;font-size:13px;' }), ]), // My requests el('div', { cls: 'pto-section', style: 'border-top:1px solid #333;padding-top:12px;margin-top:12px;' }, [ el('h4', { style: 'margin:0 0 8px 0;' }, 'My PTO Requests (this year)'), el('div', { id: 'pto-my-list', style: 'font-size:13px;color:#bbb;' }, 'Loading...'), ]), ]); // Admin-only sections if (admin) { root.appendChild(el('div', { cls: 'pto-section', style: 'border-top:1px solid #333;padding-top:12px;margin-top:12px;' }, [ el('h4', { style: 'margin:0 0 8px 0;color:#f5a623;' }, 'Pending Approvals (Admin)'), el('div', { id: 'pto-pending-list', style: 'font-size:13px;color:#bbb;' }, 'Loading...'), ])); root.appendChild(el('div', { cls: 'pto-section', style: 'border-top:1px solid #333;padding-top:12px;margin-top:12px;' }, [ el('h4', { style: 'margin:0 0 8px 0;color:#f5a623;' }, 'Team Roster - Hire Dates & PTO Balances (Admin)'), el('div', { id: 'pto-roster', style: 'font-size:13px;color:#bbb;' }, 'Loading...'), ])); } return root; } function mountPanel(panel) { if (!panel) return; // Remove any prior PTO panel first const prior = document.getElementById('pto-panel'); if (prior) prior.remove(); const hostIds = ['panel-time-tracking', 'panel-manage', 'panel-settings', 'app']; for (const id of hostIds) { const host = document.getElementById(id); if (host) { host.appendChild(panel); return; } } document.body.appendChild(panel); } // -------- Data loaders -------- async function loadSummary() { const username = getUsername(); if (!username) return; const sumEl = document.getElementById('pto-summary'); if (!sumEl) return; const ctx = await resolveTeamMember(username); if (!ctx.teamMemberId) { sumEl.textContent = 'Could not find your team member record. Ask Dee or Alyssa to verify your account.'; return; } const res = await rpc('app_get_pto_summary', { p_team_member_id: ctx.teamMemberId }); if (!res.ok) { sumEl.textContent = 'PTO summary unavailable. (' + res.error + ')'; return; } const row = (res.data || [])[0]; if (!row) { sumEl.textContent = 'No PTO record yet.'; return; } if (!row.hire_date) { sumEl.innerHTML = 'Hire date not set. Ask Dee or Alyssa to set your hire date to start earning PTO.'; return; } if (!row.eligible) { sumEl.innerHTML = 'PTO eligibility starts 90 days after hire date (' + fmtDate(row.hire_date) + '). Not yet eligible.'; return; } sumEl.innerHTML = '' + (row.entitled_days || 0) + ' entitled / ' + '' + (row.used_days || 0) + ' used / ' + '' + (row.pending_days || 0) + ' pending / ' + '' + (row.remaining_days || 0) + ' remaining' + '   (hire date: ' + fmtDate(row.hire_date) + ')'; } function renderRequestRow(r, opts) { opts = opts || {}; const statusColor = { pending: '#f5a623', approved: '#5dd5a8', denied: '#e26565', cancelled: '#888' }[r.status] || '#888'; const row = el('div', { style: 'padding:6px 0;border-bottom:1px solid #2a2a2a;display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;' }, [ el('div', null, [ el('span', { style: 'color:#ddd;' }, (opts.showName ? (r.full_name + ' - ') : '') + fmtDate(r.start_date) + (r.start_date === r.end_date ? '' : ' to ' + fmtDate(r.end_date)) + ' (' + r.days_requested + ' day' + (r.days_requested === 1 ? '' : 's') + (r.half_day ? ', half' : '') + ')'), r.reason ? el('div', { style: 'color:#999;font-size:12px;' }, r.reason) : null, ]), el('div', { style: 'display:flex;gap:6px;align-items:center;' }, [ el('span', { style: 'color:' + statusColor + ';font-weight:600;text-transform:uppercase;font-size:12px;' }, r.status), opts.adminActions ? el('button', { cls: 'btn-sm', style: 'background:#1f7a4d;color:#fff;', onclick: () => doReview(r.id, 'approved') }, 'Approve') : null, opts.adminActions ? el('button', { cls: 'btn-sm', style: 'background:#a13b3b;color:#fff;', onclick: () => doReview(r.id, 'denied') }, 'Deny') : null, ]), ]); return row; } async function loadMyRequests() { const username = getUsername(); if (!username) return; const listEl = document.getElementById('pto-my-list'); if (!listEl) return; const ctx = await resolveTeamMember(username); if (!ctx.teamMemberId) { listEl.textContent = 'No team member record.'; return; } const res = await rpc('app_get_pto_requests', { p_username: username, p_team_member_id: ctx.teamMemberId }); if (!res.ok) { listEl.textContent = 'Could not load requests. (' + res.error + ')'; return; } const rows = (res.data || []).filter(r => r.team_member_id === ctx.teamMemberId); listEl.innerHTML = ''; if (rows.length === 0) { listEl.textContent = 'No requests yet this year.'; return; } rows.forEach(r => listEl.appendChild(renderRequestRow(r, { showName: false }))); } async function loadPending() { const username = getUsername(); if (!username || !isAdmin(username)) return; const listEl = document.getElementById('pto-pending-list'); if (!listEl) return; const ctx = await resolveTeamMember(username); const res = await rpc('app_get_pto_requests', { p_username: username, p_team_member_id: ctx.teamMemberId || '00000000-0000-0000-0000-000000000000', p_status: 'pending' }); if (!res.ok) { listEl.textContent = 'Could not load pending. (' + res.error + ')'; return; } const rows = (res.data || []).filter(r => r.status === 'pending'); listEl.innerHTML = ''; if (rows.length === 0) { listEl.textContent = 'No pending requests.'; return; } rows.forEach(r => listEl.appendChild(renderRequestRow(r, { showName: true, adminActions: true }))); } async function loadRoster() { const username = getUsername(); if (!username || !isAdmin(username)) return; const listEl = document.getElementById('pto-roster'); if (!listEl) return; const res = await rpc('app_get_pto_roster', { p_username: username }); if (!res.ok) { listEl.textContent = 'Could not load roster. (' + res.error + ')'; return; } const rows = res.data || []; listEl.innerHTML = ''; const head = el('div', { style: 'display:grid;grid-template-columns:1.4fr 1.1fr 0.8fr 0.8fr 1.6fr 1.2fr;gap:6px;padding:6px 0;border-bottom:1px solid #333;font-weight:600;color:#bbb;font-size:12px;' }, [ el('div', null, 'Name'), el('div', null, 'Hire Date'), el('div', null, 'Entitled'), el('div', null, 'Remaining'), el('div', null, 'Set Hire Date'), el('div', null, 'Override Balance'), ]); listEl.appendChild(head); rows.forEach(tm => { const hireInput = el('input', { type: 'date', value: tm.hire_date || '', style: 'width:130px;' }); const hireBtn = el('button', { cls: 'btn-sm', style: 'background:#3b6db2;color:#fff;margin-left:4px;', onclick: () => setHireDate(tm.id, hireInput.value) }, 'Save'); const balInput = el('input', { type: 'number', step: '0.5', min: '0', value: (tm.override_days != null && tm.override_year === new Date().getFullYear()) ? tm.override_days : '', placeholder: 'e.g. 7', style: 'width:80px;' }); const balBtn = el('button', { cls: 'btn-sm', style: 'background:#3b6db2;color:#fff;margin-left:4px;', onclick: () => setBalance(tm.id, balInput.value) }, 'Save'); listEl.appendChild(el('div', { style: 'display:grid;grid-template-columns:1.4fr 1.1fr 0.8fr 0.8fr 1.6fr 1.2fr;gap:6px;padding:6px 0;border-bottom:1px solid #222;align-items:center;font-size:13px;' }, [ el('div', { style: 'color:#ddd;' }, tm.full_name), el('div', { style: 'color:' + (tm.hire_date ? '#ddd' : '#f5a623') + ';' }, tm.hire_date ? fmtDate(tm.hire_date) : 'not set'), el('div', null, String(tm.entitled_days || 0)), el('div', { style: 'color:#5dd5a8;' }, String(tm.remaining_days || 0)), el('div', null, [hireInput, hireBtn]), el('div', null, [balInput, balBtn]), ])); }); } // -------- Actions -------- async function onSubmitRequest() { const username = getUsername(); const msg = document.getElementById('pto-msg'); msg.style.color = '#888'; msg.textContent = 'Submitting...'; const start = document.getElementById('pto-start').value; const end = document.getElementById('pto-end').value || start; const days = parseFloat(document.getElementById('pto-days').value); const half = document.getElementById('pto-half').checked; const reason = document.getElementById('pto-reason').value || null; if (!start || !days) { msg.style.color = '#e26565'; msg.textContent = 'Start date and days are required.'; return; } const ctx = await resolveTeamMember(username); if (!ctx.teamMemberId) { msg.style.color = '#e26565'; msg.textContent = 'Could not find your team member record.'; return; } const res = await rpc('app_request_pto', { p_team_member_id: ctx.teamMemberId, p_start_date: start, p_end_date: end, p_days: days, p_half_day: !!half, p_reason: reason }); if (!res.ok) { msg.style.color = '#e26565'; msg.textContent = 'Error: ' + res.error; return; } msg.style.color = '#5dd5a8'; msg.textContent = 'Request submitted (pending approval).'; document.getElementById('pto-start').value = ''; document.getElementById('pto-end').value = ''; document.getElementById('pto-days').value = ''; document.getElementById('pto-half').checked = false; document.getElementById('pto-reason').value = ''; await loadSummary(); await loadMyRequests(); await loadPending(); } async function doReview(requestId, decision) { const username = getUsername(); const notes = decision === 'denied' ? (prompt('Optional notes for denial:') || null) : null; const res = await rpc('app_review_pto', { p_username: username, p_request_id: requestId, p_decision: decision, p_notes: notes }); if (!res.ok) { alert('Error: ' + res.error); return; } await loadPending(); await loadMyRequests(); await loadSummary(); await loadRoster(); } async function setHireDate(targetTmId, hireDate) { const username = getUsername(); if (!hireDate) { alert('Pick a hire date first.'); return; } const res = await rpc('app_set_hire_date', { p_username: username, p_target_tm_id: targetTmId, p_hire_date: hireDate }); if (!res.ok) { alert('Error: ' + res.error); return; } await loadRoster(); await loadSummary(); } async function setBalance(targetTmId, balance) { const username = getUsername(); const b = parseFloat(balance); if (isNaN(b) || b < 0) { alert('Enter a valid balance (>= 0, in 0.5 increments).'); return; } const res = await rpc('app_set_pto_balance', { p_username: username, p_target_tm_id: targetTmId, p_new_balance: b }); if (!res.ok) { alert('Error: ' + res.error); return; } await loadRoster(); await loadSummary(); } // -------- Boot -------- async function init() { if (!window.rpc) { setTimeout(init, 300); return; } if (!getUsername()) { setTimeout(init, 300); return; } const panel = buildPtoPanel(); mountPanel(panel); await loadSummary(); await loadMyRequests(); if (isAdmin(getUsername())) { await loadPending(); await loadRoster(); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => setTimeout(init, 500)); } else { setTimeout(init, 500); } })();