/* 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);
}
})();