/**
 * WP Roster — Frontend Application
 * Vanilla JS (no build step). Runs in WP Admin and on public pages via shortcode.
 * @version 1.0.0
 * @license GPL-2.0+
 */
(function (W) {
  'use strict';

  if (!W || !W.ajaxUrl) return;

  const AJAX  = W.ajaxUrl;
  let   NONCE = W.nonce;
  const USER  = W.user  || {};
  let   SET   = W.settings || {};
  // Used to gate roster-wide data views.
  const CAN_VIEW_ALL = USER.isManager;
  // Strict === true, matching every other enable flag below. A lenient `!== false`
  // would resolve an ABSENT key to true — the opposite of what an omitted feature
  // key must mean. Builds that do not ship payroll drop payrollEnabled from for_js()
  // entirely, and payroll has to be OFF there. Do not loosen this.
  let PAY_ON      = SET.payrollEnabled === true;
  // True when the current viewer is allowed to see pay/earnings data.
  // Managers and WP admins always see pay when PAY_ON is true.
  let CAN_SEE_PAY = PAY_ON && (USER.isManager || USER.isAdmin);
  let PENALTY_ON = SET.penaltyRateEnabled === true;
  let SAT_RATE   = typeof SET.saturdayRate === 'number' ? SET.saturdayRate : 25;
  let SUN_RATE   = typeof SET.sundayRate   === 'number' ? SET.sundayRate   : 50;
  const PENDING_COUNT   = Number(W.pendingRequestsCount   !== undefined ? W.pendingRequestsCount   : 0);
  const WEEK_START      = 'weekStart' in SET ? SET.weekStart : 1;
  // Analytics dashboard card ordering. Default order: Hours, Budget, Payroll,
  // Leave, Attendance. ANALYTICS_CARD_ORDER caches the live per-user order so it
  // survives tab switches instantly; the server (user meta) is the source of truth.
  const ANALYTICS_DEFAULT_ORDER = ['hours', 'budget', 'payroll', 'leave', 'attendance'];
  let   ANALYTICS_CARD_ORDER    = null;

  /**
   * Apply fresh settings returned by the server (e.g. from get_settings). Updates the
   * derived feature flags so the next view render uses the current values. The optional fragment
   * redeclares onSettings() to also recompute its own flags (see the optional views fragment).
   */
  function applySettings(data) {
    if (!data) return;
    Object.assign(SET, data);
    PAY_ON      = SET.payrollEnabled === true;
    CAN_SEE_PAY = PAY_ON && (USER.isManager || USER.isAdmin);
    PENALTY_ON = SET.penaltyRateEnabled === true;
    SAT_RATE   = typeof SET.saturdayRate === 'number' ? SET.saturdayRate : 25;
    SUN_RATE   = typeof SET.sundayRate   === 'number' ? SET.sundayRate   : 50;
    if (SET.primaryColor) applyBrandColor(SET.primaryColor);
  }

  // Injects (or updates) a <style> tag that overrides the three brand colour tokens
  // for the entire plugin UI — admin SPA, shortcode frontend, and settings page.
  // brand-h  = 15% darker  (hover/active states)
  // brand-pale = 92% white + 8% brand (light tinted backgrounds)
  function applyBrandColor(hex) {
    if (!hex || !/^#[0-9A-Fa-f]{6}$/i.test(hex)) return;
    const r = parseInt(hex.slice(1, 3), 16);
    const g = parseInt(hex.slice(3, 5), 16);
    const b = parseInt(hex.slice(5, 7), 16);
    const ch = v => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0');
    const brandH    = '#' + ch(r * .85)              + ch(g * .85)              + ch(b * .85);
    const brandPale = '#' + ch(r * .08 + 255 * .92)  + ch(g * .08 + 255 * .92)  + ch(b * .08 + 255 * .92);
    let el = document.getElementById('emr-brand-vars');
    if (!el) { el = document.createElement('style'); el.id = 'emr-brand-vars'; document.head.appendChild(el); }
    el.textContent = `:root{--wpr-brand:${hex};--wpr-brand-h:${brandH};--wpr-brand-pale:${brandPale}}`;
  }

  /* ═══════════════ UTILITY ═══════════════ */

  /**
   * AJAX helper — uses wp_ajax_ actions instead of REST API.
   * This works on ALL WordPress sites regardless of REST API restrictions.
   *
   * GET  requests: params go in query string
   * POST requests: body goes as JSON in request body, action/nonce in query string
   */
  async function http(path, method = 'GET', body = null) {
    // Convert REST-style path to AJAX action name
    // e.g. '/shifts'            → 'empr_get_shifts'   (GET)
    // e.g. '/shifts'            → 'empr_create_shift' (POST)
    // e.g. '/shifts/5'          → 'empr_update_shift' (PUT) or 'empr_delete_shift' (DELETE)
    // e.g. '/shifts/5/sync'     → 'empr_sync_assignments'
    // e.g. '/attendance/clock-in' → 'empr_clock_in'
    const action = pathToAction(path, method, body);

    let url = AJAX + '?action=' + action + '&nonce=' + encodeURIComponent(NONCE);

    // Append GET params from path query string
    const qIdx = path.indexOf('?');
    if (qIdx !== -1) {
      url += '&' + path.slice(qIdx + 1);
    }

    const opt = { method: method === 'GET' ? 'GET' : 'POST' };

    if (method !== 'GET' && body) {
      opt.headers = { 'Content-Type': 'application/json' };
      opt.body    = JSON.stringify(body);
    }

    // For DELETE we pass id in POST body since we don't send a body normally
    if (method === 'DELETE') {
      const idMatch = path.match(/\/([0-9]+)(?:\/|$)/);
      if (idMatch) {
        opt.headers = { 'Content-Type': 'application/json' };
        opt.body    = JSON.stringify({ id: parseInt(idMatch[1]) });
      }
    }

    // ── Network layer ────────────────────────────────────────────────────────
    let res;
    try {
      res = await fetch(url, opt);
    } catch (netErr) {
      // fetch() itself threw — network failure, server unreachable, CORS, mixed-content, etc.
      throw new Error('Network error — could not reach the server. (' + netErr.message + ')');
    }

    // ── Response parsing ─────────────────────────────────────────────────────
    let data;
    try {
      const text = await res.text();
      // WordPress returns plain-text '0' for unregistered actions and '-1' for
      // bad nonces when the action dies without wp_send_json_*.
      if (text === '-1') {
        throw new Error('Session expired — please reload the page to continue.');
      }
      if (text === '0') {
        throw new Error('Action not registered — try deactivating and reactivating the plugin.');
      }
      data = JSON.parse(text);
    } catch (parseErr) {
      // Re-throw our own messages as-is; wrap JSON parse failures.
      if (parseErr.message.includes('Session expired') || parseErr.message.includes('Action not registered')) {
        throw parseErr;
      }
      throw new Error('Unexpected server response. A PHP error may be interfering — check your server logs.');
    }

    if (!data.success) {
      const msg  = (data.data && data.data.message) ? data.data.message : 'Request failed';
      const err  = new Error(msg);
      if (data.data && data.data.code) err.code = data.data.code;
      throw err;
    }
    return data.data;
  }


  /**
   * Map a REST-style path + method to a wp_ajax action name.
   */
  function pathToAction(path, method, body) {
    // Strip query string
    const p = path.split('?')[0].replace(/\/+$/, '');

    // Specific sub-routes first
    if (p === '/dashboard')               return 'empr_get_dashboard';
    if (p === '/settings')                return 'empr_get_settings';
    if (p === '/availability/all')        return 'empr_all_availability';
    if (p === '/attendance/status')       return 'empr_clock_status';
    if (p === '/attendance/clock-in')     return 'empr_clock_in';
    if (p === '/attendance/clock-out')    return 'empr_clock_out';
    if (p === '/attendance/mine')         return 'empr_get_my_attendance';
    if (p.match(/^\/attendance\/\d+$/))  { if (method === 'PUT') return 'empr_update_attendance'; }
    if (p === '/attendance')              return method === 'GET' ? 'empr_get_attendance' : 'empr_get_attendance';
    if (p === '/timesheets')             return 'empr_get_timesheets';
    if (p.match(/^\/timesheets\/\d+$/)) return 'empr_get_timesheet_user';
    if (p === '/profile')                 return method === 'GET' ? 'empr_get_profile' : 'empr_update_profile';
    if (p === '/profile/password')        return 'empr_change_password';
    if (p === '/availability')            return method === 'GET' ? 'empr_get_availability' : 'empr_save_availability';
    if (p === '/staff')                          return method === 'GET' ? 'empr_get_staff' : 'empr_create_staff';
    if (p === '/staff/deactivated')              return 'empr_get_deactivated_staff';
    if (p.match(/^\/staff\/\d+\/deactivate$/))    return 'empr_deactivate_staff';
    if (p.match(/^\/staff\/\d+\/reactivate$/))   return 'empr_reactivate_staff';
    if (p.match(/^\/staff\/\d+\/reset-account$/)) return 'empr_reset_account';
    if (p.match(/^\/staff\/\d+$/)) {
      if (method === 'PUT') return 'empr_update_staff';
    }

    // Shift stats (active count)

    // Open shifts
    if (p === '/shifts/open')                             return 'empr_get_open_shifts';
    if (p === '/shifts/self-assign')                      return 'empr_self_assign';
    if (p === '/assignments/pending')                     return 'empr_get_pending_assignments';
    if (p === '/assignments/history')                     return 'empr_get_assignment_history';
    if (p.match(/^\/assignments\/\d+\/review$/))          return 'empr_review_assignment';

    // Job sheet attachment
    if (p.match(/^\/shifts\/\d+\/job-sheet$/) && method === 'DELETE') return 'empr_delete_job_sheet';

    // Shift sub-actions
    if (p.match(/^\/shifts\/\d+\/sync$/))     return 'empr_sync_assignments';
    if (p.match(/^\/shifts\/\d+\/assign$/))   return 'empr_sync_assignments';
    if (p.match(/^\/shifts\/\d+\/unassign$/)) return 'empr_sync_assignments';

    // Shift CRUD
    if (p.match(/^\/shifts\/\d+$/)) {
      if (method === 'GET')    return 'empr_get_shifts';
      if (method === 'PUT')    return 'empr_update_shift';
      if (method === 'DELETE') return 'empr_delete_shift';
    }
    if (p === '/shifts') {
      return method === 'GET' ? 'empr_get_shifts' : 'empr_create_shift';
    }

    // Leave sub-actions
    if (p.match(/^\/leave\/\d+\/review$/)) return 'empr_review_leave';
    if (p.match(/^\/leave\/\d+$/)) {
      if (method === 'DELETE') return 'empr_delete_leave';
      if (method === 'PUT')    return 'empr_update_leave';
    }
    if (p === '/leave/balances/adjust') return 'empr_adjust_leave_balance';
    if (p === '/leave/balances')        return 'empr_get_leave_balances';
    if (p === '/leave/accrual')         return 'empr_run_leave_accrual';
    if (p === '/leave') {
      return method === 'GET' ? 'empr_get_leave' : 'empr_create_leave';
    }

    // Swap request sub-actions
    if (p.match(/^\/swaps\/\d+\/review$/))   return 'empr_review_swap';
    if (p.match(/^\/swaps\/\d+\/withdraw$/)) return 'empr_withdraw_swap';
    if (p.match(/^\/swaps\/\d+$/)) {
      if (method === 'PUT')    return 'empr_update_swap';
      if (method === 'DELETE') return 'empr_delete_swap';
    }
    if (p === '/swaps') {
      return method === 'GET' ? 'empr_get_swaps' : 'empr_create_swap';
    }
    if (p === '/colleagues')   return 'empr_get_colleagues';
    if (p === '/analytics/layout')    return 'empr_save_analytics_layout';
    if (p === '/analytics')           return 'empr_get_analytics';
    if (p === '/roster/copy')         return 'empr_copy_roster';
    if (p === '/export/csv')          return 'empr_export_csv';
    if (p === '/export/pdf')          return 'empr_export_pdf';
    if (p === '/login-logs')          return method === 'DELETE' ? 'empr_delete_login_logs' : 'empr_get_login_logs';
    if (p === '/login-logs/delete')   return 'empr_delete_login_logs';

    // No match — surface the problem immediately rather than silently using the wrong handler.
    throw new Error('No AJAX action mapped for: ' + method + ' ' + path);
  }

  const DAY_NAMES    = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
  const DAY_SHORT    = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
  const LEAVE_TYPE   = { annual:'Annual Leave', sick:'Sick Leave', personal:'Personal Leave', unpaid:'Unpaid Leave', other:'Other' };
  const JOB_TYPE_LABEL = { fulltime: 'Full-time', parttime: 'Part-time', casual: 'Casual' };
  // Casual staff accrue no paid leave — mirrors EMPR_Roles::CASUAL_BLOCKED_LEAVE,
  // which rejects these server-side. Filtering the picker just avoids the round trip.
  const CASUAL_BLOCKED_LEAVE = ['annual','sick'];

  function leaveTypeOptions(selected) {
    return Object.entries(LEAVE_TYPE)
      .filter(([v]) => USER.jobType !== 'casual' || !CASUAL_BLOCKED_LEAVE.includes(v))
      .map(([v,l]) => `<option value="${v}" ${selected===v?'selected':''}>${l}</option>`)
      .join('');
  }

  function fmtDT(dt)   { return dt ? new Date(dt.replace(' ', 'T')).toLocaleString('en-AU',{dateStyle:'short',timeStyle:'short'}) : '—'; }
  function fmtDate(d)  { return d  ? new Date(d + 'T00:00').toLocaleDateString('en-AU')  : '—'; }
  function fmtTime(dt) { return dt ? new Date(dt).toLocaleTimeString('en-AU',{hour:'2-digit',minute:'2-digit',hour12:false}) : '—'; }
  function fmtDay(dt)  { return dt ? new Date(dt).toLocaleDateString('en-AU',{weekday:'short',day:'numeric',month:'short'}) : '—'; }
  // Request submission timestamp — fixed "dd/mm/yy, h:mm am/pm" (e.g. 03/06/26, 12:00 am).
  function fmtReq(dt) {
    if (!dt) return '—';
    const d = new Date(dt.replace(' ', 'T'));
    if (isNaN(d)) return '—';
    const p = n => String(n).padStart(2, '0');
    let h = d.getHours();
    const ampm = h < 12 ? 'am' : 'pm';
    h = h % 12 || 12;
    return `${p(d.getDate())}/${p(d.getMonth()+1)}/${p(d.getFullYear()%100)}, ${h}:${p(d.getMinutes())} ${ampm}`;
  }

  /** Parse a date-time string (MySQL or datetime-local) to a JS day-of-week (0=Sun). */
  function parseDayOfWeek(dtStr) {
    if (!dtStr) return null;
    const datePart = String(dtStr).replace('T', ' ').split(' ')[0];
    const parts = datePart.split('-').map(Number);
    if (parts.length < 3 || isNaN(parts[0])) return null;
    return new Date(parts[0], parts[1] - 1, parts[2]).getDay();
  }

  /**
   * Build a map of { userId: Set<availableDayOfWeek> } from the /availability/all response.
   * Only includes users who have at least one availability row (i.e. have configured availability).
   */
  function buildAvailMap(allAvail) {
    const map = {};
    (allAvail || []).forEach(a => {
      if (!a.availability || !a.availability.length) return;
      map[a.user_id] = new Set(
        a.availability.filter(s => parseInt(s.is_available) === 1).map(s => parseInt(s.day_of_week))
      );
    });
    return map;
  }

  /**
   * Returns true if the user has configured availability AND is not marked available that day.
   * Returns false if the user has no availability configured (no flag shown).
   */
  function isUnavailableOnDay(availMap, userId, dayOfWeek) {
    if (dayOfWeek === null || !(userId in availMap)) return false;
    return !availMap[userId].has(dayOfWeek);
  }

  /**
   * Returns slot availability info for an open / open-with-approval shift,
   * or null when no live indicator should be rendered.
   *
   * Per spec the indicator is shown only when slot_limit > 1. `taken` reflects
   * approved (confirmed) assignments — pending requests are excluded — so the
   * remaining count is correct immediately after approvals, declines,
   * cancellations, and assignment changes on the next data reload.
   */
  function openShiftSlotInfo(s) {
    const lim = parseInt(s.slot_limit);
    if (!lim || lim <= 1) return null;
    const taken = parseInt(s.confirmed_count || 0);
    const left  = Math.max(0, lim - taken);
    return { lim: lim, taken: taken, left: left, full: left === 0 };
  }

  // Compact chip for inline calendar / list contexts. Empty string when no indicator.
  function openShiftSlotsChip(s) {
    const i = openShiftSlotInfo(s);
    if (!i) return '';
    if (i.full) return '<span class="wpr-rshift__slots wpr-rshift__slots--full">Filled</span>';
    return '<span class="wpr-rshift__slots">' + i.left + ' slot' + (i.left === 1 ? '' : 's') + ' remaining</span>';
  }

  // Longer label for modals / tables. Empty string when no indicator.
  function openShiftSlotsLabel(s) {
    const i = openShiftSlotInfo(s);
    if (!i) return '';
    if (i.full) return 'Filled (' + i.taken + ' / ' + i.lim + ')';
    return i.left + ' slot' + (i.left === 1 ? '' : 's') + ' remaining (' + i.taken + ' of ' + i.lim + ' filled)';
  }
  function toLocal(dt) {
    if (!dt) return '';
    const d = new Date(dt);
    return [d.getFullYear(), pad(d.getMonth()+1), pad(d.getDate())].join('-') + 'T' + [pad(d.getHours()), pad(d.getMinutes())].join(':');
  }
  function pad(n)    { return String(n).padStart(2,'0'); }
  function locColor(name) {
    if (!name) return null;
    const loc = (SET.locations || []).find(l => l.name === name);
    return loc ? loc.color : null;
  }
  const DEPT_PALETTE = ['#2563EB','#16A34A','#DC2626','#D97706','#7C3AED','#0891B2','#BE185D','#374151'];
  function deptColor(name) {
    if (!name) return null;
    let h = 0;
    for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
    return DEPT_PALETTE[Math.abs(h) % DEPT_PALETTE.length];
  }
  function shiftColor(location, department) {
    return locColor(location) || '#94a3b8';
  }
  function weekRange() {
    const now = new Date(), dow = now.getDay();
    const mon = new Date(now); mon.setDate(now.getDate() - ((dow-WEEK_START+7)%7)); mon.setHours(0,0,0,0);
    const sun = new Date(mon); sun.setDate(mon.getDate()+6); sun.setHours(23,59,59);
    // Use local date parts — NOT toISOString() which returns UTC and can give wrong date
    const fmt = d => [d.getFullYear(), pad(d.getMonth()+1), pad(d.getDate())].join('-');
    return { start: fmt(mon), end: fmt(sun) };
  }
  function shiftDuration(s) { // returns decimal hours
    return (new Date(s.end_datetime) - new Date(s.start_datetime)) / 3600000;
  }
  // Resolves the daily budget target for a given Date. When per-day budgeting is
  // enabled, returns that weekday's target (dailyBudgetByDay is indexed by
  // Date.getDay(): 0=Sun … 6=Sat); otherwise falls back to the single target.
  function dayBudgetTarget(date) {
    if (SET.dailyBudgetPerDayEnabled && Array.isArray(SET.dailyBudgetByDay)) {
      return SET.dailyBudgetByDay[date.getDay()] || 0;
    }
    return SET.dailyBudget || 0;
  }
  function fmtHours(h) {
    const hrs = Math.floor(h), mins = Math.round((h - hrs) * 60);
    return mins > 0 ? `${hrs}h ${mins}m` : `${hrs}h`;
  }
  function elapsedStr(from) {
    const mins = Math.round((Date.now() - new Date(from).getTime()) / 60000);
    if (mins < 60) return `${mins}m`;
    return `${Math.floor(mins/60)}h ${mins%60}m`;
  }
  function hoursFromSession(s, elapsedSeconds) {
    const secs = (typeof elapsedSeconds === 'number') ? elapsedSeconds : 0;
    const mins = Math.round(secs / 60);
    return `${Math.floor(mins / 60)}h ${mins % 60}m`;
  }

  /* ═══════════════ UI PRIMITIVES ═══════════════ */

  function toast(msg, type = 'ok', duration = 3500) {
    document.querySelectorAll('.wpr-toast').forEach(e => e.remove());
    const el = document.createElement('div');
    el.className = `wpr-toast wpr-toast--${type}`;
    const icon = { ok:'✓', err:'✕', info:'ℹ', warn:'⚠' }[type] || '●';
    el.innerHTML = `<span class="wpr-toast__icon">${icon}</span><span>${msg}</span>`;
    document.body.appendChild(el);
    requestAnimationFrame(() => el.classList.add('wpr-toast--in'));
    setTimeout(() => { el.classList.remove('wpr-toast--in'); setTimeout(() => el.remove(), 400); }, duration);
  }

  function spin(el) { el.innerHTML = '<div class="wpr-spin"><div></div><div></div><div></div><div></div></div>'; }

  /**
   * Copy each column's header text onto the cells beneath it as data-label, so
   * the phone card-stack layout in responsive.css can render "Label   value"
   * rows via td::before { content: attr(data-label) }.
   *
   * Cells under an empty header (the checkbox and row-action columns) are left
   * unlabelled on purpose, and cells that span columns — empty-state rows like
   * "No records" — are skipped so they stay full width.
   *
   * @param {ParentNode} root Subtree to scan.
   */
  function stampTableLabels(root) {
    root.querySelectorAll('table.wpr-table:not(.wpr-table--nostack)').forEach(table => {
      const heads = Array.from(table.querySelectorAll('thead th')).map(th => th.textContent.trim());
      if (!heads.length) return;
      table.querySelectorAll('tbody tr').forEach(row => {
        Array.from(row.children).forEach((cell, i) => {
          if (cell.hasAttribute('colspan')) return;
          if (heads[i]) cell.setAttribute('data-label', heads[i]);
        });
      });
    });
  }

  /**
   * Keep data-label in sync with whatever is on screen. Views rewrite innerHTML
   * from ~50 places and there is no single render choke point, so watch the
   * document rather than hooking every call site. Setting an attribute does not
   * itself trigger a childList record, so this cannot feed back on itself.
   */
  function initTableLabels() {
    stampTableLabels(document);
    let queued = false;
    new MutationObserver(() => {
      if (queued) return;
      queued = true;
      requestAnimationFrame(() => { queued = false; stampTableLabels(document); });
    }).observe(document.body, { childList: true, subtree: true });
  }

  /**
   * Put a button into a loading state: shows an inline spinner, disables it,
   * and locks its width to prevent layout shift.
   * Returns a reset() function that restores the original label and re-enables it.
   *
   * @param  {HTMLElement} btn    The button element.
   * @param  {string}     [label] Optional text shown next to the spinner.
   * @returns {Function}          Call to restore the button.
   */
  function btnLoad(btn, label) {
    const orig = btn.innerHTML;
    btn.style.minWidth = btn.offsetWidth + 'px';
    btn.disabled  = true;
    btn.innerHTML = '<span class="wpr-btn-spin"></span>' + (label ? '<span>' + label + '</span>' : '');
    return function() {
      btn.disabled      = false;
      btn.style.minWidth = '';
      btn.innerHTML     = orig;
    };
  }

  function modal({ title, body, actions = [], size = 'md' }) {
    document.querySelector('.wpr-overlay')?.remove();
    const ov = document.createElement('div');
    ov.className = 'wpr-overlay';
    ov.innerHTML = `
      <div class="wpr-modal wpr-modal--${size}" role="dialog" aria-modal="true">
        <header class="wpr-modal__head">
          <span class="wpr-modal__title">${title}</span>
          <button class="wpr-modal__x" aria-label="Close">✕</button>
        </header>
        <div class="wpr-modal__body">${body}</div>
        <footer class="wpr-modal__foot">
          ${actions.map(a => `<button class="wpr-btn ${a.cls||''}" data-action="${a.id}">${a.label}</button>`).join('')}
        </footer>
      </div>`;
    document.body.appendChild(ov);
    ov.querySelector('.wpr-modal__x').onclick = () => ov.remove();
    ov.addEventListener('mousedown', e => { if (e.target === ov) ov.remove(); });
    actions.forEach(a => {
      if (a.fn) ov.querySelector(`[data-action="${a.id}"]`).addEventListener('click', () => a.fn(ov));
    });
    setTimeout(() => ov.classList.add('wpr-overlay--in'), 10);
    return ov;
  }

  function badge(text, variant = 'neutral') {
    return `<span class="wpr-badge wpr-badge--${variant}">${text}</span>`;
  }
  function leaveBadge(s) {
    return badge(s, { pending:'amber', approved:'green', declined:'red' }[s] || 'neutral');
  }
  function swapBadge(s) {
    return badge(s, { pending:'amber', approved:'green', declined:'red', withdrawn:'neutral' }[s] || 'neutral');
  }
  function statusBadge(s) {
    return badge(s, { active:'green', completed:'blue', missed:'red', draft:'neutral', published:'green', cancelled:'red' }[s] || 'neutral');
  }

  /* ═══════════════ INFO TOOLTIPS ═══════════════ */
  /**
   * Markup for a small, subtle "i" help trigger placed in the corner of a card.
   * Drop it in as the LAST child of any position:relative card; pair with the
   * shared popover wired up by initInfoTips(). Help text is URI-encoded so quotes
   * and newlines survive the template literal, and rendered via textContent on show
   * (no HTML injection). Returns '' for empty text so callers can pass optional keys.
   * @param {string} text  Plain-text explanation of the card/metric.
   * @returns {string}
   */
  function infoTip(text) {
    if (!text) return '';
    return `<button type="button" class="wpr-info" data-wpr-tip="${encodeURIComponent(text)}" aria-label="About this metric">i</button>`;
  }

  let _infoTipsInit = false;
  /**
   * Wires up a single shared, body-level popover for every .wpr-info trigger via
   * delegated listeners. Shown on hover (desktop), focus (keyboard) and click/tap
   * (touch). The popover is position:fixed at document.body level so it is never
   * clipped by a card's overflow:hidden, and is clamped to the viewport. Safe to
   * call multiple times — only initialises once.
   */
  function initInfoTips() {
    if (_infoTipsInit) return;
    _infoTipsInit = true;

    let pop = null;
    let current = null;        // the trigger the popover currently describes
    let frame = 0;

    function ensurePop() {
      if (pop) return pop;
      pop = document.createElement('div');
      pop.className = 'wpr-tip-pop';
      pop.setAttribute('role', 'tooltip');
      document.body.appendChild(pop);
      return pop;
    }

    function position(trigger) {
      const r = trigger.getBoundingClientRect();
      const pw = pop.offsetWidth;
      const ph = pop.offsetHeight;
      const margin = 8;
      // Horizontal: right-align to the icon, clamped inside the viewport.
      let left = r.right - pw;
      left = Math.max(margin, Math.min(left, window.innerWidth - pw - margin));
      // Vertical: below the icon by default; flip above if it would overflow.
      let top = r.bottom + margin;
      if (top + ph > window.innerHeight - margin) {
        top = r.top - ph - margin;
      }
      pop.style.left = Math.round(left) + 'px';
      pop.style.top  = Math.round(top) + 'px';
    }

    function show(trigger) {
      ensurePop();
      current = trigger;
      pop.textContent = decodeURIComponent(trigger.dataset.wprTip || '');
      pop.classList.add('wpr-tip-pop--in');
      position(trigger);
    }

    function hide() {
      current = null;
      if (pop) pop.classList.remove('wpr-tip-pop--in');
    }

    function reposition() {
      if (!current) return;
      cancelAnimationFrame(frame);
      frame = requestAnimationFrame(() => { if (current) position(current); });
    }

    document.addEventListener('pointerover', e => {
      const t = e.target.closest && e.target.closest('.wpr-info');
      if (t) show(t);
    });
    document.addEventListener('pointerout', e => {
      const t = e.target.closest && e.target.closest('.wpr-info');
      // Ignore moves within the same trigger; only hide on hover (mouse), not touch.
      if (t && t === current && e.pointerType === 'mouse') hide();
    });
    document.addEventListener('focusin', e => {
      const t = e.target.closest && e.target.closest('.wpr-info');
      if (t) show(t);
    });
    document.addEventListener('focusout', e => {
      const t = e.target.closest && e.target.closest('.wpr-info');
      if (t && t === current) hide();
    });
    document.addEventListener('click', e => {
      const t = e.target.closest && e.target.closest('.wpr-info');
      if (t) {
        e.preventDefault();
        if (t === current) { hide(); } else { show(t); }
        return;
      }
      if (current) hide();   // tap/click outside dismisses
    });
    document.addEventListener('keydown', e => {
      if (e.key === 'Escape' && current) {
        const t = current;
        hide();
        if (t && t.focus) t.focus();
      }
    });
    window.addEventListener('scroll', reposition, true);
    window.addEventListener('resize', reposition);
  }

  /* ═══════════════ SKELETON LOADING ═══════════════ */
  function skeletonRows(n = 4) {
    return Array.from({length: n}, () =>
      `<tr>${Array.from({length:5},()=>'<td><div class="wpr-skel"></div></td>').join('')}</tr>`
    ).join('');
  }

  /* ═══════════════ VIEWS ═══════════════ */

  /* ── DASHBOARD ── */
  async function viewDashboard(el) {
    spin(el);
    try {
      const d = await http('/dashboard');
      const upNext = (d.my_upcoming_shifts || []).slice(0, 6);

      // Help text for each KPI card. Add a key here to give a future card its tooltip.
      const KPI_TIPS = {
        clock:        "Whether you are currently clocked in. 'Live' means you have an open attendance session; the subtext shows time elapsed since clock-in.",
        hours:        "Total hours you have worked so far this week, summed from your attendance sessions.",
        upcoming:     'Your shifts scheduled from today through the end of this week.',
        pendingLeave: 'Leave requests from all staff that are awaiting manager approval (pending status).',
        openShifts:   'Open-shift claim requests from staff awaiting your approval.',
        swaps:        'Shift-swap requests across all staff that are awaiting review (pending status).',
        activeShifts: 'Published shifts from today onward that are either open or staffed by an active employee.',
        shiftsToday:  'Count of published shifts scheduled for today.',
        totalStaff:   'Number of active roster members (deactivated accounts excluded).',
      };

      // Keep the nav badge in sync with fresh server data.
      updateNavBadge(pendingBadgeCount(d));

      el.innerHTML = `
        <div class="wpr-view wpr-view--dashboard">
          <div class="wpr-page-header">
            <div>
              <h2 class="wpr-page-title">Dashboard</h2>
              <p class="wpr-page-sub">Welcome back, <strong>${USER.name}</strong></p>
            </div>
          </div>

          <div class="wpr-kpi-grid">
            ${dashboardClockKpi(d, KPI_TIPS)}
            <div class="wpr-kpi">
              <div class="wpr-kpi__label">My Hours</div>
              <div class="wpr-kpi__val">${d.hours_this_week || 0}<span class="wpr-kpi__unit">h</span></div>
              <div class="wpr-kpi__sub">This Week</div>
              ${infoTip(KPI_TIPS.hours)}
            </div>
            <div class="wpr-kpi">
              <div class="wpr-kpi__label">My Upcoming Shifts</div>
              <div class="wpr-kpi__val">${upNext.length}</div>
              <div class="wpr-kpi__sub">This week</div>
              ${infoTip(KPI_TIPS.upcoming)}
            </div>
            ${CAN_VIEW_ALL ? `
            ${dashboardManagerKpis(d, KPI_TIPS)}
            <div class="wpr-kpi">
              <div class="wpr-kpi__label">Active Shifts</div>
              <div class="wpr-kpi__val">${d.shifts_now || 0}</div>
              <div class="wpr-kpi__sub">Total staff active shifts</div>
              ${infoTip(KPI_TIPS.activeShifts)}
            </div>
            <div class="wpr-kpi">
              <div class="wpr-kpi__label">Shifts Today</div>
              <div class="wpr-kpi__val">${d.shifts_today || 0}</div>
              <div class="wpr-kpi__sub">Published</div>
              ${infoTip(KPI_TIPS.shiftsToday)}
            </div>
            <div class="wpr-kpi">
              <div class="wpr-kpi__label">Total Staff</div>
              <div class="wpr-kpi__val">${d.total_staff || 0}</div>
              <div class="wpr-kpi__sub">Active roster members</div>
              ${infoTip(KPI_TIPS.totalStaff)}
            </div>` : ''}
          </div>

          ${(!USER.isManager || upNext.length) ? `
          <div class="wpr-section-title">Upcoming Shifts This Week</div>
          ${upNext.length
            ? `<div class="wpr-shift-cards">${upNext.map(s => shiftCard(s, [], true)).join('')}</div>`
            : `<div class="wpr-empty"><span>📭</span><p>No shifts this week.</p></div>`
          }` : ''}
        </div>`;

      // Analytics section — optional-seam; the default seam renders nothing.
      dashboardAnalytics(el);
    } catch (e) { el.innerHTML = errBox(e.message); }
  }


  /* ── SHIFTS ── */
  async function viewShifts(el) {
    spin(el);
    const now = new Date();

    // ── View state ───────────────────────────────────────────────────────────
    let viewMode = 'week'; // 'day' | 'week' | 'month'
    let locationFilter   = '';   // '' = all locations
    let locationOptions  = [];   // unique locations from the last fetch
    let skillFilter      = '';   // '' = all skills
    let skillOptions     = [];   // unique skills from the last staff fetch
    let allAvail         = [];   // availability data for all staff (used in shift modals)
    // Anchor starts on Monday of the current week
    let anchor = new Date(now);
    anchor.setDate(now.getDate() - ((now.getDay() - WEEK_START + 7) % 7));
    anchor.setHours(0, 0, 0, 0);

    // ── Utilities ────────────────────────────────────────────────────────────
    function fmtD(d) {
      return [d.getFullYear(), pad(d.getMonth()+1), pad(d.getDate())].join('-');
    }
    const todayKey = fmtD(now);
    // Returns true when a shift's start time is in the past — used to suppress join/request UI.
    const isPastShift = s => new Date(s.start_datetime.replace(' ', 'T')) < new Date();

    function getRange() {
      if (viewMode === 'month') {
        const firstOfMonth = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
        const startDow     = (firstOfMonth.getDay() - WEEK_START + 7) % 7;
        const calStart     = new Date(firstOfMonth); calStart.setDate(1 - startDow);
        const calEnd       = new Date(calStart);     calEnd.setDate(calStart.getDate() + 41);
        return { start: calStart, end: calEnd, days: [] };
      }
      const count = viewMode === 'day' ? 1 : 7;
      const days  = [];
      for (let i = 0; i < count; i++) {
        const d = new Date(anchor); d.setDate(anchor.getDate() + i); days.push(d);
      }
      return { start: days[0], end: days[days.length - 1], days };
    }

    function navLabel() {
      if (viewMode === 'month') {
        return new Date(anchor.getFullYear(), anchor.getMonth(), 1)
          .toLocaleDateString('en-AU', { month:'long', year:'numeric' });
      }
      if (viewMode === 'day') {
        return anchor.toLocaleDateString('en-AU', { weekday:'long', day:'numeric', month:'long', year:'numeric' });
      }
      const end = new Date(anchor); end.setDate(anchor.getDate() + 6);
      const f = d => d.toLocaleDateString('en-AU', { day:'numeric', month:'short' });
      return anchor.getMonth() === end.getMonth()
        ? `${anchor.getDate()} – ${end.getDate()} ${end.toLocaleDateString('en-AU',{month:'short'})} ${end.getFullYear()}`
        : `${f(anchor)} – ${f(end)} ${end.getFullYear()}`;
    }

    function navigate(dir) {
      if      (viewMode === 'month') anchor = new Date(anchor.getFullYear(), anchor.getMonth() + dir, 1);
      else if (viewMode === 'week')  { anchor = new Date(anchor); anchor.setDate(anchor.getDate() + dir * 7); }
      else                           { anchor = new Date(anchor); anchor.setDate(anchor.getDate() + dir); }
      load();
    }

    // ── Data fetch ───────────────────────────────────────────────────────────
    async function load() {
      spin(el);
      try {
        const range = getRange();
        let allShifts, staff, leave, openShifts;
        [allShifts, staff, leave, openShifts, allAvail] = await Promise.all([
          // Core: shifts are required — let this propagate if it fails.
          http(`/shifts?start=${fmtD(range.start)}%2000%3A00%3A00&end=${fmtD(range.end)}%2023%3A59%3A59&status=published`),
          // Staff list: required for manager assignment UI; fall back to empty on error.
          CAN_VIEW_ALL ? http('/staff').catch(() => []) : Promise.resolve([]),
          // Leave: supplementary overlay; optional-seam fetch, else empty.
          rosterLeaveFetch(range, fmtD),
          // Open shifts: optional-seam fetch, else empty.
          rosterOpenShiftsFetch(range, fmtD),
          // Availability hints: purely advisory; never block the roster.
          CAN_VIEW_ALL ? http('/availability/all').catch(() => []) : Promise.resolve([]),
        ]);

        // Build location list from configured locations; fall back to shift data for any unrecognised names
        const configuredLocs = (SET.locations || []).map(l => l.name).filter(Boolean);
        const shiftLocs = new Set(configuredLocs);
        allShifts.forEach(s => { if (s.location && s.location.trim()) shiftLocs.add(s.location.trim()); });
        locationOptions = configuredLocs.length ? configuredLocs : Array.from(shiftLocs).sort();

        // Build unique skill list from staff
        const skillSet = new Set();
        (staff || []).forEach(m => (m.skills || []).forEach(s => skillSet.add(s)));
        skillOptions = Array.from(skillSet).sort();

        // Apply location filter (managers only)
        const shifts = (CAN_VIEW_ALL && locationFilter)
          ? allShifts.filter(s => s.location === locationFilter)
          : allShifts;

        // Apply skill filter to staff rows (managers only)
        const filteredStaff = (CAN_VIEW_ALL && skillFilter)
          ? staff.filter(m => (m.skills || []).includes(skillFilter))
          : staff;

        viewMode === 'month' ? renderMonth(shifts, filteredStaff, leave, openShifts) : renderRoster(shifts, filteredStaff, leave, openShifts);
      } catch(e) { el.innerHTML = errBox(e.message); }
    }

    // ── Build leave index (expands multi-day spans into per-day entries) ─────
    function buildLeaveIdx(leave, perUser) {
      const idx = {};
      leave.forEach(l => {
        const start = new Date(l.start_date + 'T00:00');
        const end   = new Date(l.end_date   + 'T00:00');
        for (let d = new Date(start); d.getTime() <= end.getTime(); d.setDate(d.getDate() + 1)) {
          const key = perUser ? `${fmtD(d)}:${l.user_id}` : fmtD(d);
          (idx[key] = idx[key] || []).push(l);
        }
      });
      return idx;
    }

    // ── Shared toolbar HTML ──────────────────────────────────────────────────
    function toolbarHTML() {
      const locationSelect = (CAN_VIEW_ALL && locationOptions.length > 0) ? `
        <select class="wpr-input wpr-input--sm" id="wpr-location-filter" title="Filter by location">
          <option value="">All Locations</option>
          ${locationOptions.map(l => `<option value="${l}"${locationFilter===l?' selected':''}>${l}</option>`).join('')}
        </select>` : '';

      const skillSelect = (CAN_VIEW_ALL && skillOptions.length > 0) ? `
        <select class="wpr-input wpr-input--sm" id="wpr-skill-filter" title="Filter by skill">
          <option value="">All Skills</option>
          ${skillOptions.map(s => `<option value="${s}"${skillFilter===s?' selected':''}>${s.charAt(0).toUpperCase()+s.slice(1)}</option>`).join('')}
        </select>` : '';

      return `
        <div class="wpr-page-header">
          <h2 class="wpr-page-title">Shifts</h2>
          <div class="wpr-page-header__actions">
            ${locationSelect}
            ${skillSelect}
            <div class="wpr-view-toggle">
              <button class="wpr-vt-btn${viewMode==='day'?' wpr-vt-btn--active':''}" data-vt="day">Day</button>
              <button class="wpr-vt-btn${viewMode==='week'?' wpr-vt-btn--active':''}" data-vt="week">Week</button>
              <button class="wpr-vt-btn${viewMode==='month'?' wpr-vt-btn--active':''}" data-vt="month">Month</button>
            </div>
            <div class="wpr-cal-nav">
              <button class="wpr-btn wpr-btn--ghost wpr-btn--sm" id="wpr-cal-prev">&#8249;</button>
              <span class="wpr-cal-month">${navLabel()}</span>
              <button class="wpr-btn wpr-btn--ghost wpr-btn--sm" id="wpr-cal-next">&#8250;</button>
            </div>
            ${USER.isManager ? `
              ${rosterToolbarButtons(viewMode)}
              <button class="wpr-btn wpr-btn--primary" id="wpr-new-shift">+ New Shift</button>` : ''}
          </div>
        </div>`;
    }


    // ── Wire toolbar interactions ────────────────────────────────────────────
    function wireToolbar(shifts, staff) {
      el.querySelectorAll('[data-vt]').forEach(btn => {
        btn.addEventListener('click', () => {
          viewMode = btn.dataset.vt;
          // Always reset to today's context when switching views
          const today = new Date();
          if (viewMode === 'day') {
            anchor = new Date(today); anchor.setHours(0, 0, 0, 0);
          } else if (viewMode === 'week') {
            anchor = new Date(today);
            anchor.setDate(today.getDate() - ((today.getDay() - WEEK_START + 7) % 7));
            anchor.setHours(0, 0, 0, 0);
          } else if (viewMode === 'month') {
            anchor = new Date(today.getFullYear(), today.getMonth(), 1);
          }
          load();
        });
      });
      el.querySelector('#wpr-cal-prev').addEventListener('click', () => navigate(-1));
      el.querySelector('#wpr-cal-next').addEventListener('click', () => navigate(1));
      el.querySelector('#wpr-location-filter')?.addEventListener('change', e => {
        locationFilter = e.target.value;
        load();
      });
      el.querySelector('#wpr-skill-filter')?.addEventListener('change', e => {
        skillFilter = e.target.value;
        load();
      });
      if (USER.isManager) {
        el.querySelector('#wpr-new-shift')?.addEventListener('click', () => shiftModal(null, staff, allAvail, load));
        el.querySelector('#wpr-copy-roster')?.addEventListener('click', () => copyRosterModal(load));
        el.querySelector('#wpr-print-roster')?.addEventListener('click', () => printRoster(el, navLabel()));
      }
      el.querySelectorAll('[data-shift-id]').forEach(btn => {
        const s = shifts.find(x => x.id == btn.dataset.shiftId);
        if (s) btn.addEventListener('click', () => shiftDetailModal(s, staff, allAvail, load));
      });
    }

    // ── Roster grid (Day / Week) ─────────────────────────────────────────────
    function renderRoster(shifts, staff, leave, openShifts = []) {
      const range    = getRange();
      const leaveIdx = buildLeaveIdx(leave, true); // keyed 'YYYY-MM-DD:userId'

      // Employees get a single synthetic row using their own USER context.
      // Roster-wide view: show staff with at least one assigned shift in range,
      // plus anyone on approved leave in range — otherwise a leave-only week (common in
      // future weeks, where no shifts are rostered yet) would hide the leave entirely,
      // diverging from the Month view which is keyed by date and always shows it.
      const leaveUserIds = new Set();
      range.days.forEach(d => {
        leave.forEach(l => {
          if (leaveIdx[`${fmtD(d)}:${l.user_id}`]) leaveUserIds.add(String(l.user_id));
        });
      });

      let rows;
      if (CAN_VIEW_ALL) {
        const hasShift = u => shifts.some(s => (s.assigned_ids || '').split(',').filter(Boolean).includes(String(u.id)));
        rows = staff.filter(u => hasShift(u) || leaveUserIds.has(String(u.id)));
        // Leave for someone missing from the staff list (e.g. an account no longer
        // returned by /staff) still gets a row, mirroring Month view. Skipped while a
        // skill filter is active, since such a row has no skills to filter on.
        if (!skillFilter) {
          const known = new Set(rows.map(u => String(u.id)));
          leaveUserIds.forEach(uid => {
            if (known.has(uid)) return;
            if ((staff || []).some(u => String(u.id) === uid)) return; // filtered out by design
            const l = leave.find(x => String(x.user_id) === uid);
            rows.push({ id: uid, display_name: (l && l.display_name) || 'Staff member', roles: [], pay_rate: 0 });
            known.add(uid);
          });
        }
      } else {
        rows = [{ id: USER.id, display_name: USER.name, roles: [], pay_rate: USER.pay_rate || 0 }];
      }

      // Build userId→payRate map for daily budget calculation
      const staffPayMap = {};
      (staff || []).forEach(u => { staffPayMap[String(u.id)] = Number(u.pay_rate) || 0; });

      // Index shifts by date for budget calculation (date string → shift[])
      const shiftsByDate = {};
      shifts.forEach(s => {
        const k = s.start_datetime.slice(0, 10);
        (shiftsByDate[k] = shiftsByDate[k] || []).push(s);
      });

      // Index shifts by 'YYYY-MM-DD:userId'
      const idx = {};
      shifts.forEach(s => {
        const date = s.start_datetime.slice(0, 10);
        (s.assigned_ids || '').split(',').filter(Boolean).forEach(uid => {
          const k = `${date}:${uid}`;
          (idx[k] = idx[k] || []).push(s);
        });
      });

      const memberHours = uid => shifts
        .filter(s => (s.assigned_ids || '').split(',').map(Number).includes(Number(uid)))
        .reduce((sum, s) => sum + shiftDuration(s), 0);

      // Day column headers
      const dayHeaders = range.days.map(d => {
        const isToday  = fmtD(d) === todayKey;
        return `<th class="wpr-roster-th${isToday?' wpr-roster-th--today':''}">
          <span class="wpr-th-dow">${isToday ? 'Today' : d.toLocaleDateString('en-AU',{weekday:'short'})}</span>
          <span class="wpr-th-date${isToday?' wpr-th-date--today':''}">${d.getDate()} <em>${d.toLocaleDateString('en-AU',{month:'short'})}</em></span>
        </th>`;
      }).join('');

      // Budget footer row — optional-seam; free renders nothing.
      const budgetRowHTML = rosterBudgetRow(range, shiftsByDate, staffPayMap, todayKey, fmtD);

      // Staff body rows
      const bodyRows = rows.length
        ? rows.map(u => {
            const totalH        = memberHours(u.id);
            const roleLabel     = (u.roles || []).map(r => EMR_ROLE_LABEL[r]).filter(Boolean).join(' · ');
            const totalEarnings = (CAN_SEE_PAY && u.pay_rate && totalH > 0)
              ? totalH * Number(u.pay_rate)
              : null;
            const jobLimit    = jobHoursLimit(u);
            const isOverHours = jobLimit !== null && jobLimit > 0 && totalH > jobLimit;

            const cells = range.days.map(d => {
              const dayShifts = idx[`${fmtD(d)}:${u.id}`] || [];
              const dayLeave  = leaveIdx[`${fmtD(d)}:${u.id}`] || [];
              const isToday   = fmtD(d) === todayKey;
              const dayH      = dayShifts.reduce((sum, s) => sum + shiftDuration(s), 0);
              const isEmpty   = !dayShifts.length && !dayLeave.length;
              const onLeave   = !dayShifts.length && dayLeave.length > 0;
              return `<td class="wpr-roster-cell${isToday?' wpr-roster-cell--today':''}${isEmpty?' wpr-roster-cell--off':''}${onLeave?' wpr-roster-cell--on-leave':''}">
                ${dayShifts.map(s => {
                  const isFuture = new Date(s.start_datetime) > new Date();
                  const swapBtn  = shiftSwapBtn(u, s, isFuture);
                  const sc = shiftColor(s.location, s.department);
                  return `<div class="wpr-roster-shift" style="--sc:${sc}" data-shift-id="${s.id}">
                    <span class="wpr-rshift__title">${s.department || s.title || 'Shift'}</span>
                    <span class="wpr-rshift__time">${fmtTime(s.start_datetime)} – ${fmtTime(s.end_datetime)}</span>
                    ${s.location ? `<span class="wpr-rshift__loc" style="color:${sc}">📍 ${s.location}</span>` : ''}
                    <span class="wpr-rshift__dur">${fmtHours(shiftDuration(s))}</span>
                    ${jobSheetClip(s)}
                    ${swapBtn}
                  </div>`;
                }).join('')}
                ${dayShifts.length > 1 ? `<div class="wpr-cell-total">${fmtHours(dayH)} total</div>` : ''}
                ${dayLeave.map(l => USER.isManager
                  ? `<button class="wpr-roster-leave" data-leave-id="${l.id}">
                       <span class="wpr-rleave__type">${LEAVE_TYPE[l.leave_type]||l.leave_type}</span>
                       ${l.reason ? `<span class="wpr-rleave__reason">${l.reason}</span>` : ''}
                     </button>`
                  : `<div class="wpr-roster-leave">
                       <span class="wpr-rleave__type">${LEAVE_TYPE[l.leave_type]||l.leave_type}</span>
                       ${l.reason ? `<span class="wpr-rleave__reason">${l.reason}</span>` : ''}
                     </div>`
                ).join('')}
              </td>`;
            }).join('');

            return `<tr class="wpr-roster-row">
              <td class="wpr-roster-member-cell">
                <span class="wpr-rmember__name">${u.display_name}</span>
                ${(roleLabel || u.job_type || (CAN_SEE_PAY && u.pay_rate)) ? `<div class="wpr-rmember__meta">${roleLabel ? `<span class="wpr-rmember__role">${roleLabel}</span>` : ''}${u.job_type ? `<span class="wpr-rmember__jt">${JOB_TYPE_LABEL[u.job_type]||u.job_type}</span>` : ''}${(CAN_SEE_PAY && u.pay_rate) ? `<span class="wpr-rmember__rate">$${Number(u.pay_rate).toFixed(2)}/hr</span>` : ''}</div>` : ''}
                ${(totalH > 0 || totalEarnings !== null) ? `<div class="wpr-rmember__stats">${totalH > 0 ? `<span class="wpr-rmember__hours${isOverHours ? ' wpr-rmember__hours--over' : ''}">${fmtHours(totalH)}${isOverHours ? ' ⚠' : ''}</span>` : ''}${totalEarnings !== null ? `<span class="wpr-rmember__earnings">$${totalEarnings.toFixed(2)}</span>` : ''}</div>` : ''}
                ${skillBadgesHTML(u.skills)}
              </td>
              ${cells}
            </tr>`;
          }).join('')
        : `<tr><td colspan="${range.days.length + 1}">
             <div class="wpr-empty"><span>👥</span><p>No staff members to display.</p></div>
           </td></tr>`;

      // ── Open shifts row — always rendered, pinned above employee rows ───────
      // Source: managers use the already-fetched shifts (all types visible to them).
      //         Includes non-direct shifts (open/self-assign) AND direct shifts with
      //         no assigned employees yet (unassigned).
      //         Employees use the dedicated openShifts fetch, minus confirmed ones
      //         (those already appear in the employee's own assigned-shifts row).
      const isUnassigned = s => !(s.assigned_ids || '').split(',').filter(Boolean).length;
      const isFull = s => { const lim = parseInt(s.slot_limit); return lim > 0 && parseInt(s.confirmed_count || 0) >= lim; };
      const openRowShifts = USER.isManager
        ? shifts.filter(s => (s.assignment_type !== 'direct' || isUnassigned(s)) && !isFull(s))
        : openShifts.filter(s => s.my_status !== 'confirmed' && !isPastShift(s));

      const openByDate = {};
      openRowShifts.forEach(s => {
        const k = s.start_datetime.slice(0, 10);
        (openByDate[k] = openByDate[k] || []).push(s);
      });

      const openCells = range.days.map(d => {
        const dayOpen = openByDate[fmtD(d)] || [];
        const isToday = fmtD(d) === todayKey;
        return `<td class="wpr-roster-cell wpr-roster-cell--open${isToday ? ' wpr-roster-cell--today' : ''}">
          ${dayOpen.map(s => {
            const isPending  = s.my_status === 'pending';
            const showUnassignedBadge = USER.isManager && s.assignment_type === 'direct' && isUnassigned(s);
            const actionHtml = USER.isManager
              ? (showUnassignedBadge ? `<span class="wpr-badge wpr-badge--slate" style="font-size:.62rem;margin-top:.25rem">Unassigned</span>` : '')
              : `<div class="wpr-rshift__open-action">
                ${isPending
                  ? `<span class="wpr-badge wpr-badge--amber" style="font-size:.62rem">Pending</span>`
                  : `<button class="wpr-btn wpr-btn--xs wpr-btn--primary wpr-open-join" data-sid="${s.id}">${s.assignment_type === 'open' ? 'Join' : 'Request'}</button>`}
              </div>`;
            const sc2 = shiftColor(s.location, s.department);
            return `<div class="wpr-roster-shift wpr-roster-shift--open" style="--sc:${sc2}" data-shift-id="${s.id}">
              <span class="wpr-rshift__title">${s.department || s.title || 'Shift'}</span>
              <span class="wpr-rshift__time">${fmtTime(s.start_datetime)}–${fmtTime(s.end_datetime)}</span>
              ${s.location ? `<span class="wpr-rshift__loc" style="color:${sc2}">📍 ${s.location}</span>` : ''}
              ${openShiftSlotsChip(s)}
              ${jobSheetClip(s)}
              ${actionHtml}
            </div>`;
          }).join('')}
        </td>`;
      }).join('');

      // Open Shifts row — optional-seam; the default seam renders nothing.
      const openRowLabel = USER.isManager
        ? (openRowShifts.length ? 'Open & unassigned' : 'None this period')
        : (openRowShifts.length ? 'Available to join' : 'None this period');
      const openRowHTML = rosterOpenRow(openCells, openRowLabel);

      el.innerHTML = `
        <div class="wpr-view">
          ${toolbarHTML()}
          <div class="wpr-roster-wrap">
            <table class="wpr-roster-table wpr-roster-table--${viewMode}">
              <thead><tr>
                <th class="wpr-roster-th wpr-roster-th--member">Team Member</th>
                ${dayHeaders}
              </tr></thead>
              <tbody>${budgetRowHTML}${openRowHTML}${bodyRows}</tbody>
            </table>
          </div>
        </div>`;

      wireToolbar(shifts, staff);
      el.querySelectorAll('.wpr-open-join').forEach(btn => {
        btn.addEventListener('click', async () => {
          const sid   = parseInt(btn.dataset.sid);
          const reset = btnLoad(btn);
          try {
            const res = await http('/shifts/self-assign', 'POST', { shift_id: sid });
            toast(res.message || 'Done.');
            load();
          } catch(e) { toast(e.message, 'err'); reset(); }
        });
      });
      el.querySelectorAll('.wpr-propose-swap').forEach(btn => {
        btn.addEventListener('click', e => {
          e.stopPropagation();
          swapModal(parseInt(btn.dataset.sid), btn.dataset.sname, btn.dataset.sstart, btn.dataset.send, load);
        });
      });
      if (USER.isManager) {
        el.querySelectorAll('[data-leave-id]').forEach(btn => {
          const l = leave.find(x => parseInt(x.id) === parseInt(btn.dataset.leaveId));
          if (l) btn.addEventListener('click', e => { e.stopPropagation(); leaveEditModal(l, load); });
        });
      }
    }

    // ── Month calendar grid ──────────────────────────────────────────────────
    function renderMonth(shifts, staff, leave, openShifts = []) {
      const firstOfMonth = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
      const startDow     = (firstOfMonth.getDay() - WEEK_START + 7) % 7;
      const calStart     = new Date(firstOfMonth); calStart.setDate(1 - startDow);

      const days = [];
      const cur  = new Date(calStart);
      for (let i = 0; i < 42; i++) { days.push(new Date(cur)); cur.setDate(cur.getDate() + 1); }

      const byDate   = {};
      shifts.forEach(s => {
        const key = s.start_datetime.slice(0, 10);
        (byDate[key] = byDate[key] || []).push(s);
      });

      // Build userId→payRate map for daily budget calculation
      const staffPayMap = {};
      (staff || []).forEach(u => { staffPayMap[String(u.id)] = Number(u.pay_rate) || 0; });

      // Leave indexed by date only (month view has no staff rows)
      const leaveByDate = buildLeaveIdx(leave, false);

      // Staff lookup for assigned-names display
      const staffById = {};
      (staff || []).forEach(u => { staffById[String(u.id)] = u.display_name; });
      // Employees don't receive the staff list — seed with own user so ID lookups resolve
      if (!CAN_VIEW_ALL) staffById[String(USER.id)] = USER.name;

      // Open shifts indexed by date (for viewers who do not manage the roster)
      const openByDate = {};
      if (!USER.isManager) {
        openShifts.filter(s => !isPastShift(s)).forEach(s => {
          const k = s.start_datetime.slice(0, 10);
          (openByDate[k] = openByDate[k] || []).push(s);
        });
      }

      el.innerHTML = `
        <div class="wpr-view">
          ${toolbarHTML()}
          <div class="wpr-calendar">
            <div class="wpr-calendar__scroll">
            <div class="wpr-calendar__head">
              ${Array.from({length:7},(_,i)=>DAY_SHORT[(WEEK_START+i)%7]).map(d=>`<div class="wpr-cal-dow">${d}</div>`).join('')}
            </div>
            <div class="wpr-calendar__grid">
              ${days.map(d => {
                const key       = fmtD(d);
                const inMonth   = d.getMonth() === anchor.getMonth();
                const isToday   = key === todayKey;
                const dayShifts = byDate[key] || [];
                const dayLeave  = leaveByDate[key] || [];
                const chipHTML  = rosterDayBudget(dayShifts, d, staffPayMap);
                return `
                  <div class="wpr-cal-cell${!inMonth?' wpr-cal-cell--out':''}${isToday?' wpr-cal-cell--today':''}">
                    <div style="display:flex;align-items:center;gap:0;flex-shrink:0">
                      <span class="wpr-cal-cell__num">${d.getDate()}</span>
                      ${isToday ? '<span class="wpr-cal-today-label">Today</span>' : ''}
                    </div>
                    ${chipHTML}
                    <div class="wpr-cal-cell__shifts">
                      ${dayShifts.map(s => {
                        const ids      = s.assigned_ids ? s.assigned_ids.split(',').filter(Boolean) : [];
                        const names    = ids.map(id => staffById[id]).filter(Boolean);
                        const staffLine = USER.isManager
                          ? (names.length
                              ? names.slice(0, 2).join(', ') + (names.length > 2 ? ` +${names.length - 2} more` : '')
                              : 'Unassigned')
                          : '';
                        const sc3 = shiftColor(s.location, s.department);
                        return `<button class="wpr-roster-shift" style="--sc:${sc3}" data-shift-id="${s.id}"
                          title="${[s.department||s.title||'Shift', fmtTime(s.start_datetime)+'–'+fmtTime(s.end_datetime), s.location, staffLine].filter(Boolean).join(' · ')}">
                          <span class="wpr-rshift__title">${s.department || s.title || 'Shift'}</span>
                          <span class="wpr-rshift__time">${fmtTime(s.start_datetime)}–${fmtTime(s.end_datetime)}</span>
                          ${s.location ? `<span class="wpr-rshift__loc" style="color:${sc3}">📍 ${s.location}</span>` : ''}
                          <span class="wpr-rshift__dur">${fmtHours(shiftDuration(s))}</span>
                          ${jobSheetClip(s)}
                          ${staffLine ? `<span class="wpr-rshift__dept">${staffLine}</span>` : ''}
                        </button>`;
                      }).join('')}
                      ${dayLeave.map(l => {
                        const leaveTitle = `${LEAVE_TYPE[l.leave_type]||l.leave_type}${l.reason?' — '+l.reason:''}`;
                        const inner = `<span class="wpr-rleave__type">${LEAVE_TYPE[l.leave_type]||l.leave_type}</span>
                          ${USER.isManager ? `<span class="wpr-rleave__name">${l.display_name}</span>` : ''}
                          ${l.reason ? `<span class="wpr-rleave__reason">${l.reason}</span>` : ''}`;
                        return USER.isManager
                          ? `<button class="wpr-roster-leave" data-leave-id="${l.id}" title="${leaveTitle}">${inner}</button>`
                          : `<div class="wpr-roster-leave" title="${leaveTitle}">${inner}</div>`;
                      }).join('')}
                      ${(openByDate[key]||[]).map(s => {
                        const isJoined  = s.my_status === 'confirmed';
                        const isPending = s.my_status === 'pending';
                        const prefix    = isJoined ? '✓ ' : (isPending ? '⏳ ' : '+ ');
                        const stateCls  = isJoined ? ' wpr-cal-pill--joined' : (isPending ? ' wpr-cal-pill--pending' : ' wpr-cal-pill--open');
                        const pillColor = shiftColor(s.location, s.department);
                        return `<button class="wpr-cal-pill${stateCls}" style="--pill-color:${pillColor}" data-open-shift-id="${s.id}"
                          title="${[s.department||s.title||'Shift', fmtTime(s.start_datetime)+'–'+fmtTime(s.end_datetime), s.location, openShiftSlotsLabel(s)].filter(Boolean).join(' · ')}">
                          <span class="wpr-cal-pill__text">${prefix}${s.department || s.title || 'Shift'}</span>
                          <span class="wpr-cal-pill__meta">${fmtTime(s.start_datetime)}–${fmtTime(s.end_datetime)}${s.location ? ' · '+s.location : ''}</span>
                          ${openShiftSlotsChip(s)}
                        </button>`;
                      }).join('')}
                    </div>
                  </div>`;
              }).join('')}
            </div>
            </div>
          </div>
        </div>`;

      wireToolbar(shifts, staff);
      el.querySelectorAll('[data-open-shift-id]').forEach(btn => {
        const sid = parseInt(btn.dataset.openShiftId);
        const s   = openShifts.find(x => parseInt(x.id) === sid);
        if (s) btn.addEventListener('click', () => openShiftDetailModal(s, load));
      });
      if (USER.isManager) {
        el.querySelectorAll('[data-leave-id]').forEach(btn => {
          const l = leave.find(x => parseInt(x.id) === parseInt(btn.dataset.leaveId));
          if (l) btn.addEventListener('click', e => { e.stopPropagation(); leaveEditModal(l, load); });
        });
      }
    }

    // ── Open shift detail + action modal (month view pill click) ────────────
    function openShiftDetailModal(s, onJoin) {
      const isJoined  = s.my_status === 'confirmed';
      const isPending = s.my_status === 'pending';
      const typeLabel = s.assignment_type === 'open' ? 'Open' : 'Open with Approval';
      const color     = shiftColor(s.location, s.department);
      const actions   = [];
      if (!isJoined && !isPending && !isPastShift(s)) {
        actions.push({
          id:    'join',
          label: s.assignment_type === 'open' ? 'Join Shift' : 'Request to Join',
          cls:   'wpr-btn--primary',
          fn:    async ov => {
            const btn   = ov.querySelector('[data-action="join"]');
            const reset = btn ? btnLoad(btn) : () => {};
            try {
              const res = await http('/shifts/self-assign', 'POST', { shift_id: parseInt(s.id) });
              toast(res.message || 'Done.');
              ov.remove();
              onJoin();
            } catch(e) { toast(e.message, 'err'); reset(); }
          },
        });
      }
      actions.push({ id:'close', label:'Close', cls:'wpr-btn--ghost', fn: ov => ov.remove() });
      modal({
        title: s.title,
        body: `
          <div class="wpr-shift-detail">
            <div class="wpr-shift-detail__stripe" style="background:${color}"></div>
            <div class="wpr-shift-detail__rows">
              <div class="wpr-shift-detail__row"><span>Date</span><strong>${fmtDay(s.start_datetime)}</strong></div>
              <div class="wpr-shift-detail__row"><span>Time</span><strong>${fmtTime(s.start_datetime)} – ${fmtTime(s.end_datetime)}</strong></div>
              ${s.break_minutes != null ? `<div class="wpr-shift-detail__row"><span>Break</span><strong>${parseInt(s.break_minutes) > 0 ? parseInt(s.break_minutes) + ' min' : 'No break'}</strong></div>` : ''}
              ${s.location   ? `<div class="wpr-shift-detail__row"><span>Location</span><strong>${s.location}</strong></div>` : ''}
              ${s.department ? `<div class="wpr-shift-detail__row"><span>Department</span><strong>${s.department}</strong></div>` : ''}
              <div class="wpr-shift-detail__row"><span>Type</span><strong><span class="wpr-badge wpr-badge--blue">${typeLabel}</span></strong></div>
              ${openShiftSlotsLabel(s) ? `<div class="wpr-shift-detail__row"><span>Slots</span><strong>${openShiftSlotsLabel(s)}</strong></div>` : (s.slot_limit ? `<div class="wpr-shift-detail__row"><span>Slots</span><strong>${s.confirmed_count || 0} / ${s.slot_limit} filled</strong></div>` : '')}
              ${isJoined  ? `<div class="wpr-shift-detail__row"><span>Status</span><strong><span class="wpr-badge wpr-badge--green">Joined</span></strong></div>` : ''}
              ${isPending ? `<div class="wpr-shift-detail__row"><span>Status</span><strong><span class="wpr-badge wpr-badge--amber">Pending approval</span></strong></div>` : ''}
            </div>
          </div>`,
        actions,
      });
    }

    await load();
  }

  function shiftCard(shift, staff = [], compact = false) {
    const ids   = shift.assigned_ids ? shift.assigned_ids.split(',').map(Number) : [];
    const names = staff.filter(u => ids.includes(Number(u.id))).map(u => u.display_name);
    const color = shiftColor(shift.location, shift.department);
    const typeLabel = {direct:'Direct',open:'Open',open_approval:'Open with Approval'}[shift.assignment_type||'direct'];

    return `
      <article class="wpr-scard" style="--scard-color:${color}">
        <div class="wpr-scard__stripe"></div>
        <div class="wpr-scard__body">
          <div class="wpr-scard__top">
            <strong class="wpr-scard__title">${shift.department || shift.title || 'Shift'}</strong>
          </div>
          <div class="wpr-scard__meta">
            <span>📅 ${fmtDay(shift.start_datetime)}</span>
            <span>⏰ ${fmtTime(shift.start_datetime)}–${fmtTime(shift.end_datetime)}</span>
            ${shift.location ? `<span style="color:${color}">📍 ${shift.location}</span>` : ''}
          </div>
          <div class="wpr-scard__staff">
            <span class="wpr-badge wpr-badge--slate" style="font-size:.7rem">${typeLabel}</span>
            ${ids.length ? `<span class="wpr-scard__names">${ids.length} staff${names.length ? ': ' + names.join(', ') : ''}</span>` : ''}
          </div>
          ${shift.notes && !compact ? `<p class="wpr-scard__notes">${shift.notes}</p>` : ''}
          ${USER.isManager && !compact ? `
          <div class="wpr-scard__actions">
            <button class="wpr-btn wpr-btn--xs" data-shift-assign="${shift.id}">Assign</button>
            <button class="wpr-btn wpr-btn--xs wpr-btn--ghost" data-shift-edit="${shift.id}">Edit</button>
            <button class="wpr-btn wpr-btn--xs wpr-btn--danger" data-shift-del="${shift.id}">Delete</button>
          </div>` : ''}
        </div>
      </article>`;
  }

  function shiftModal(shift, staff, allAvail, onSave) {
    const depts  = (SET.departments || []).map(r => `<option value="${r}" ${shift && shift.department===r?'selected':''}>${r}</option>`).join('');
    const locs    = (SET.locations || []).map(l => `<option value="${l.name}" ${shift && shift.location===l.name?'selected':''}>${l.name}</option>`).join('');
    const curType = (shift && shift.assignment_type) ? shift.assignment_type : 'direct';
    const initColor = shiftColor(shift && shift.location, shift && shift.department);

    // Default start/end for new shifts: tomorrow 09:00 – 17:00
    const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1);
    const tomorrowStr = [tomorrow.getFullYear(), pad(tomorrow.getMonth()+1), pad(tomorrow.getDate())].join('-');
    const defaultStart = tomorrowStr + 'T09:00';
    const defaultEnd   = tomorrowStr + 'T17:00';
    const assignedIds = shift && shift.assigned_ids ? shift.assigned_ids.split(',').map(Number) : [];

    const availMap = buildAvailMap(allAvail);

    const staffList = (staff || []).map(u => `
      <label class="wpr-assign-row" data-name="${u.display_name.toLowerCase()}">
        <input type="checkbox" class="sf-staff-cb" value="${u.id}" ${assignedIds.includes(Number(u.id))?'checked':''}>
        <span class="wpr-assign-info">
          <span class="wpr-assign-name">${u.display_name}</span>
          <span class="wpr-assign-role">${(u.roles||[]).map(r => r.replace('emr_','')).join(', ')}</span>
          ${skillBadgesHTML(u.skills)}
          ${(u.id in availMap) ? `<span class="wpr-avail-hint" data-uid="${u.id}" style="display:none">⚠ May not be available this day</span>` : ''}
        </span>
      </label>`).join('');

    modal({
      title: shift ? 'Edit Shift' : 'New Shift',
      size: 'lg',
      body: `
        <div class="wpr-form-grid">
          <div class="wpr-field wpr-field--full"><label>Department</label><select id="sf-r" class="wpr-input"><option value="">— Select department —</option>${depts}</select></div>
          <div class="wpr-field"><label>Location</label><select id="sf-l" class="wpr-input"><option value="">Select…</option>${locs}</select></div>
          <div class="wpr-field"><label>Start *</label><input id="sf-s" class="wpr-input" type="datetime-local" value="${shift ? toLocal(shift.start_datetime) : defaultStart}"></div>
          <div class="wpr-field"><label>End *</label><input id="sf-e" class="wpr-input" type="datetime-local" value="${shift ? toLocal(shift.end_datetime) : defaultEnd}"></div>
          <div class="wpr-field">
            <label>Assignment Type</label>
            <select id="sf-at" class="wpr-input">
              <option value="direct"       ${curType==='direct'?'selected':''}>Direct — assign staff directly</option>
              ${shiftTypeOptions(curType)}
            </select>
          </div>
          <div id="sf-slot-wrap" class="wpr-field" style="${(curType==='open'||curType==='open_approval')?'':'display:none'}">
            <label>Slot Limit</label>
            <select id="sf-slot" class="wpr-input">
              <option value="">Unlimited</option>
              ${Array.from({length:20},(_,i)=>i+1).map(n=>`<option value="${n}" ${(shift ? parseInt(shift.slot_limit) : 1)===n?'selected':''}>${n}</option>`).join('')}
            </select>
          </div>
          <div class="wpr-field">
            <label>Break Duration</label>
            <select id="sf-brk" class="wpr-input">
              ${Array.from({length:25},(_,i)=>i*5).map(v=>`<option value="${v}" ${(shift&&shift.break_minutes!=null?shift.break_minutes:30)==v?'selected':''}>${v===0?'No break':v+' min'}</option>`).join('')}
            </select>
          </div>
          <input type="hidden" id="sf-c" value="${initColor}">
          <div class="wpr-field wpr-field--full"><label>Notes</label><textarea id="sf-n" class="wpr-input" rows="3" maxlength="256" placeholder="Optional notes visible to assigned staff">${shift ? shift.notes||'' : ''}</textarea><small id="sf-n-count" class="wpr-muted" style="font-size:.72rem;float:right;margin-top:.2rem">${(shift && shift.notes ? shift.notes.length : 0)}/256</small></div>
          ${jobSheetField(shift)}
          <div id="sf-staff-wrap" class="wpr-field wpr-field--full" style="${curType==='direct'?'':'display:none'}">
            <label>Assign Staff</label>
            <div id="sf-avail-warn" class="wpr-avail-warn"></div>
            ${(staff && staff.length > 4) ? `<div class="wpr-staff-search-wrap"><input type="text" id="sf-staff-search" class="wpr-input wpr-input--sm wpr-staff-search" placeholder="Search by name…" autocomplete="off" spellcheck="false"></div>` : ''}
            <div class="wpr-assign-list" id="sf-assign-list">${staffList || '<p class="wpr-muted">No staff members found.</p>'}</div>
          </div>
        </div>`,
      actions: [
        { id:'cancel', label:'Cancel', cls:'wpr-btn--ghost', fn: ov => ov.remove() },
        { id:'save',   label: shift ? 'Update Shift' : 'Create Shift', cls:'wpr-btn--primary', fn: async ov => {
          const v = ov.querySelector.bind(ov);
          const assignType = v('#sf-at').value;
          const slotRaw = v('#sf-slot') ? v('#sf-slot').value : '';
          const selected = assignType === 'direct'
            ? [...ov.querySelectorAll('.sf-staff-cb:checked')].map(c => Number(c.value))
            : [];

          // ── Availability check (non-blocking) ──────────────────────────
          if (assignType === 'direct' && selected.length > 0) {
            const saveDay = parseDayOfWeek(v('#sf-s') ? v('#sf-s').value : '');
            const warnEl  = ov.querySelector('#sf-avail-warn');
            if (saveDay !== null && warnEl && !warnEl.dataset.confirmed) {
              const unavailNames = selected
                .filter(uid => isUnavailableOnDay(availMap, uid, saveDay))
                .map(uid => { const u = (staff||[]).find(m => Number(m.id) === uid); return u ? u.display_name : null; })
                .filter(Boolean);
              if (unavailNames.length > 0) {
                const nameList = unavailNames.map(n => `<strong>${n}</strong>`).join(', ');
                warnEl.innerHTML = `⚠ ${nameList} ${unavailNames.length === 1 ? 'is' : 'are'} not available on ${DAY_NAMES[saveDay]}s based on their availability settings. Click <strong>Save</strong> again to assign anyway.`;
                warnEl.style.display = '';
                warnEl.dataset.confirmed = '1';
                return; // pause — next click will skip this check
              }
            }
          }

          const data = {
            department:      v('#sf-r').value,
            title:           v('#sf-r').value || 'Shift',
            start_datetime:  v('#sf-s').value.replace('T',' ') + ':00',
            end_datetime:    v('#sf-e').value.replace('T',' ') + ':00',
            location:        v('#sf-l').value,
            assignment_type: assignType,
            slot_limit:      (assignType !== 'direct' && slotRaw !== '') ? parseInt(slotRaw) : null,
            break_minutes:   parseInt(v('#sf-brk').value)||0,
            color:           v('#sf-c').value,
            notes:           v('#sf-n').value.trim(),
          };
          const saveBtn  = ov.querySelector('[data-action="save"]');
          const resetBtn = saveBtn ? btnLoad(saveBtn) : () => {};
          try {
            let shiftId;
            let shiftIds = []; // all IDs created/edited — used for job sheet upload
            if (shift) {
              // ── Edit existing shift ──────────────────────────────────────
              await http(`/shifts/${shift.id}`, 'PUT', Object.assign({}, data, { id: shift.id }));
              shiftId  = shift.id;
              shiftIds = [shiftId];
              if (assignType === 'direct') {
                // Split selected into: users already on this record vs genuinely new additions.
                // Already-assigned users stay on this shift record (keeps their ID stable).
                // Newly-added users get their own independent shift copies so each record
                // is fully isolated — editing or deleting one cannot affect the others.
                const alreadyAssigned = selected.filter(uid => assignedIds.includes(uid));
                const newlyAdded      = selected.filter(uid => !assignedIds.includes(uid));
                // Pure reassignment (original assignee unchecked, different person checked):
                // sync the new selection to this record rather than creating a copy.
                const keepIds = alreadyAssigned.length > 0 ? alreadyAssigned : selected;
                try {
                  await http(`/shifts/${shiftId}/sync`, 'POST', { shift_id: shiftId, user_ids: keepIds });
                } catch(syncErr) {
                  toast('Shift updated — assignment conflict: ' + syncErr.message, 'warn', 10000);
                  ov.remove(); onSave(); return;
                }
                // Create independent copies for any employees added alongside the original assignee.
                if (alreadyAssigned.length > 0 && newlyAdded.length > 0) {
                  try {
                    const res = await http('/shifts', 'POST', Object.assign({}, data, { user_ids: newlyAdded }));
                    const newIds = res.ids ? res.ids : (res.id ? [res.id] : []);
                    shiftIds = shiftIds.concat(newIds);
                  } catch(createErr) {
                    toast('Shift updated — some new staff could not be assigned: ' + createErr.message, 'warn', 10000);
                    ov.remove(); onSave(); return;
                  }
                }
              }
            } else {
              // ── Create new shift ─────────────────────────────────────────
              // Send user_ids with the create request so the server can validate
              // time conflicts and block creation before anything is persisted.
              if (assignType === 'direct' && selected.length === 0) {
                toast('Please assign at least one employee to this shift.', 'err');
                resetBtn(); return;
              }
              const res = await http('/shifts', 'POST', Object.assign({}, data, { user_ids: selected }));
              // Direct create with >1 employee returns res.ids (one independent record per
              // employee). Single-employee and open shifts still return res.id as before.
              shiftIds = res.ids ? res.ids : (res.id ? [res.id] : []);
              shiftId  = shiftIds[0];
              // Assignments are handled server-side atomically; no separate sync needed.
            }
            if (!(await jobSheetSave(ov, shiftIds, onSave))) return;
            toast(shift ? 'Shift updated.' : 'Shift created.'); ov.remove(); onSave();
          } catch(e) { toast(e.message, 'err'); resetBtn(); }
        }},
      ],
    });

    // Character counter for shift notes textarea
    const sfNotesEl = document.querySelector('#sf-n');
    const sfNotesCount = document.querySelector('#sf-n-count');
    if (sfNotesEl && sfNotesCount) {
      sfNotesEl.addEventListener('input', () => { sfNotesCount.textContent = sfNotesEl.value.length + '/256'; });
    }

    // Wire remove-attachment button (edit mode only) — optional-seam.
    jobSheetWireRemove(shift);

    // Toggle staff panel and slot limit when assignment type changes
    const atSelect  = document.querySelector('#sf-at');
    const staffWrap = document.querySelector('#sf-staff-wrap');
    const slotWrap  = document.querySelector('#sf-slot-wrap');
    if (atSelect) {
      atSelect.addEventListener('change', () => {
        const isOpen = atSelect.value === 'open' || atSelect.value === 'open_approval';
        if (staffWrap) staffWrap.style.display = atSelect.value === 'direct' ? '' : 'none';
        if (slotWrap)  slotWrap.style.display  = isOpen ? '' : 'none';
      });
    }

    // Real-time staff search — filters rows by display_name as the manager types
    const sfSearch     = document.querySelector('#sf-staff-search');
    const sfAssignList = document.querySelector('#sf-assign-list');
    if (sfSearch && sfAssignList) {
      sfSearch.addEventListener('input', () => {
        const q = sfSearch.value.toLowerCase().trim();
        let visible = 0;
        sfAssignList.querySelectorAll('.wpr-assign-row').forEach(row => {
          const match = !q || (row.dataset.name || '').includes(q);
          row.style.display = match ? '' : 'none';
          if (match) visible++;
        });
        let empty = sfAssignList.querySelector('.sf-search-empty');
        if (visible === 0) {
          if (!empty) {
            empty = document.createElement('p');
            empty.className = 'sf-search-empty';
            empty.style.cssText = 'margin:.25rem 0 0;padding:.5rem .75rem;font-size:var(--wpr-text-13);color:var(--wpr-muted);';
            sfAssignList.appendChild(empty);
          }
          empty.textContent = `No staff match "${sfSearch.value.trim()}".`;
          empty.style.display = '';
        } else if (empty) {
          empty.style.display = 'none';
        }
      });
      // Pressing Escape clears the search
      sfSearch.addEventListener('keydown', e => {
        if (e.key === 'Escape') {
          sfSearch.value = '';
          sfSearch.dispatchEvent(new Event('input'));
          e.stopPropagation();
        }
      });
    }

    // Keep the hidden colour in sync with location / department selection
    function syncShiftColor() {
      const locEl  = document.querySelector('#sf-l');
      const deptEl = document.querySelector('#sf-r');
      const colorEl = document.querySelector('#sf-c');
      if (colorEl) colorEl.value = shiftColor(locEl && locEl.value, deptEl && deptEl.value);
    }
    const locSelEl  = document.querySelector('#sf-l');
    const deptSelEl = document.querySelector('#sf-r');
    if (locSelEl)  locSelEl.addEventListener('change',  syncShiftColor);
    if (deptSelEl) deptSelEl.addEventListener('change', syncShiftColor);

    // Reset the availability warning whenever the manager changes their selection or start date,
    // so the prompt re-evaluates against the new state on the next save attempt.
    function clearAvailWarn() {
      const warnEl = document.querySelector('#sf-avail-warn');
      if (warnEl) { warnEl.style.display = 'none'; delete warnEl.dataset.confirmed; }
    }
    document.querySelectorAll('.sf-staff-cb').forEach(cb => cb.addEventListener('change', clearAvailWarn));

    // Update availability hints whenever the start date changes
    function refreshAvailHints(dtVal) {
      const day = parseDayOfWeek(dtVal);
      document.querySelectorAll('.wpr-avail-hint[data-uid]').forEach(hint => {
        const uid = parseInt(hint.dataset.uid);
        const unavailable = day !== null && isUnavailableOnDay(availMap, uid, day);
        hint.style.display = unavailable ? '' : 'none';
        if (unavailable) hint.textContent = `⚠ May not be available on ${DAY_NAMES[day]}s`;
      });
    }
    const startInput = document.querySelector('#sf-s');
    if (startInput) {
      startInput.addEventListener('change', e => { clearAvailWarn(); refreshAvailHints(e.target.value); });
      refreshAvailHints(startInput.value); // apply immediately on open
    }
  }


  function assignModal(shift, staff, allAvail, onSave) {
    const ids = shift.assigned_ids ? shift.assigned_ids.split(',').map(Number) : [];
    const availMap = buildAvailMap(allAvail);
    const shiftDay = parseDayOfWeek(shift.start_datetime);
    modal({
      title: `Assign Staff — ${shift.title}`,
      size: 'md',
      body: `
        <p class="wpr-modal-meta">${fmtDay(shift.start_datetime)} · ${fmtTime(shift.start_datetime)}–${fmtTime(shift.end_datetime)}</p>
        <div id="assign-avail-warn" class="wpr-avail-warn"></div>
        <div class="wpr-assign-list">
          ${staff.map(u => `
            <label class="wpr-assign-row">
              <input type="checkbox" class="assign-cb" value="${u.id}" ${ids.includes(Number(u.id))?'checked':''}>
              <span class="wpr-assign-info">
                <span class="wpr-assign-name">${u.display_name}</span>
                <span class="wpr-assign-role">${(u.roles||[]).join(', ')}</span>
                ${skillBadgesHTML(u.skills)}
                ${isUnavailableOnDay(availMap, u.id, shiftDay) ? `<span class="wpr-avail-hint">⚠ May not be available on ${DAY_NAMES[shiftDay]}s</span>` : ''}
              </span>
            </label>`).join('')}
        </div>`,
      actions: [
        { id:'cancel', label:'Cancel', cls:'wpr-btn--ghost', fn: ov => ov.remove() },
        { id:'save', label:'Save Assignments', cls:'wpr-btn--primary', fn: async ov => {
          const selected = [...ov.querySelectorAll('.wpr-assign-list input:checked')].map(c => Number(c.value));

          // ── Availability check (non-blocking) ──────────────────────────
          const warnEl = ov.querySelector('#assign-avail-warn');
          if (shiftDay !== null && warnEl && !warnEl.dataset.confirmed) {
            const unavailNames = selected
              .filter(uid => isUnavailableOnDay(availMap, uid, shiftDay))
              .map(uid => { const u = (staff||[]).find(m => Number(m.id) === uid); return u ? u.display_name : null; })
              .filter(Boolean);
            if (unavailNames.length > 0) {
              const nameList = unavailNames.map(n => `<strong>${n}</strong>`).join(', ');
              warnEl.innerHTML = `⚠ ${nameList} ${unavailNames.length === 1 ? 'is' : 'are'} not available on ${DAY_NAMES[shiftDay]}s based on their availability settings. Click <strong>Save</strong> again to assign anyway.`;
              warnEl.style.display = '';
              warnEl.dataset.confirmed = '1';
              // Reset if selections change
              ov.querySelectorAll('.assign-cb').forEach(cb => cb.addEventListener('change', () => {
                warnEl.style.display = 'none'; delete warnEl.dataset.confirmed;
              }, { once: true }));
              return; // pause — next click proceeds
            }
          }

          try {
            // Open / Open with Approval shifts are a single shared record whose
            // slot_limit defines how many confirmed users may join. Every selected
            // user becomes a confirmed slot on this same shift — no independent
            // copies — so the remaining-slots counter reflects the assignment.
            const isOpenSlot = shift.assignment_type === 'open' || shift.assignment_type === 'open_approval';
            if (isOpenSlot) {
              await http(`/shifts/${shift.id}/sync`, 'POST', { shift_id: shift.id, user_ids: selected });
            } else {
              // Direct shifts: users already on this record stay on it; newly added
              // users each get their own independent shift copy so editing or
              // deleting one record cannot affect the others.
              const alreadyAssigned = selected.filter(uid => ids.includes(uid));
              const newlyAdded      = selected.filter(uid => !ids.includes(uid));
              // If the manager removed all original assignees (pure reassignment), sync
              // the selected users directly to this record — no independent copy needed.
              const keepIds = alreadyAssigned.length > 0 ? alreadyAssigned : selected;
              await http(`/shifts/${shift.id}/sync`, 'POST', { shift_id: shift.id, user_ids: keepIds });
              if (alreadyAssigned.length > 0 && newlyAdded.length > 0) {
                const copyData = {
                  title:           shift.title || shift.department || 'Shift',
                  department:      shift.department || '',
                  start_datetime:  shift.start_datetime,
                  end_datetime:    shift.end_datetime,
                  location:        shift.location || '',
                  assignment_type: 'direct',
                  break_minutes:   parseInt(shift.break_minutes) || 0,
                  color:           shift.color || '#2563EB',
                  notes:           shift.notes || '',
                  status:          shift.status || 'published',
                  slot_limit:      null,
                  user_ids:        newlyAdded,
                };
                await http('/shifts', 'POST', copyData);
              }
            }
            toast('Assignments saved.'); ov.remove(); onSave();
          } catch(e) {
            const isConflict = e.code === 'overlap' || e.code === 'leave_overlap' || e.code === 'conflict' || e.code === 'slot_full';
            toast(e.message, isConflict ? 'warn' : 'err', 8000);
          }
        }},
      ],
    });
  }

  function shiftDetailModal(shift, staff, allAvail, onSave) {
    const ids   = shift.assigned_ids ? shift.assigned_ids.split(',').map(Number) : [];
    let names = staff.filter(u => ids.includes(Number(u.id))).map(u => u.display_name);
    // Employee context: staff list is empty — fall back to own USER name if assigned
    if (!names.length && ids.includes(USER.id)) names = [USER.name];
    const color = shiftColor(shift.location, shift.department);

    const actions = [];
    if (USER.isManager) {
      actions.push({ id:'edit',   label:'Edit',   cls:'wpr-btn--ghost',  fn: ov => { ov.remove(); shiftModal(shift, staff, allAvail, onSave); } });
      actions.push({ id:'assign', label:'Assign', cls:'wpr-btn--ghost',  fn: ov => { ov.remove(); assignModal(shift, staff, allAvail, onSave); } });
      actions.push({ id:'del',    label:'Delete', cls:'wpr-btn--danger', fn: async ov => {
        if (!confirm('Delete this shift and all assignments?')) return;
        try { await http(`/shifts/${shift.id}`, 'DELETE', {id:parseInt(shift.id)}); ov.remove(); toast('Shift deleted.'); onSave(); }
        catch(e) { toast(e.message, 'err'); }
      }});
    }
    actions.push({ id:'close', label:'Close', cls:'wpr-btn--ghost', fn: ov => ov.remove() });

    modal({
      title: shift.title,
      body: `
        <div class="wpr-shift-detail">
          <div class="wpr-shift-detail__stripe" style="background:${color}"></div>
          <div class="wpr-shift-detail__rows">
            <div class="wpr-shift-detail__row"><span>Date</span><strong>${fmtDay(shift.start_datetime)}</strong></div>
            <div class="wpr-shift-detail__row"><span>Time</span><strong>${fmtTime(shift.start_datetime)} – ${fmtTime(shift.end_datetime)}</strong></div>
            ${shift.break_minutes != null ? `<div class="wpr-shift-detail__row"><span>Break</span><strong>${parseInt(shift.break_minutes) > 0 ? parseInt(shift.break_minutes) + ' min' : 'No break'}</strong></div>` : ''}
            ${shift.location ? `<div class="wpr-shift-detail__row"><span>Location</span><strong>${shift.location}</strong></div>` : ''}
            ${shift.department ? `<div class="wpr-shift-detail__row"><span>Department</span><strong>${shift.department}</strong></div>` : ''}
            <div class="wpr-shift-detail__row"><span>Type</span><strong>${{direct:'Direct',open:'Open',open_approval:'Open with Approval'}[shift.assignment_type||'direct']}</strong></div>
            ${ids.length ? `<div class="wpr-shift-detail__row"><span>Staff</span><strong>${names.join(', ')}</strong></div>` : ''}
            ${shift.notes ? `<div class="wpr-shift-detail__row wpr-shift-detail__row--notes"><span>Notes</span><em>${shift.notes}</em></div>` : ''}
            ${jobSheetDetailRow(shift)}
          </div>
        </div>`,
      actions,
    });
  }








  /* ── PROFILE ── */
  async function viewProfile(el) {
    spin(el);

    async function load() {
      spin(el);
      try {
        const [profile, avail] = await Promise.all([
          http('/profile'),
          http('/availability'),
        ]);
        render(profile, avail);
      } catch(e) { el.innerHTML = errBox(e.message); }
    }

    function render(profile, avail) {
      const roleLabel = (profile.roles || []).map(r => EMR_ROLE_LABEL[r]).filter(Boolean).join(', ') || '—';
      const initials  = (profile.display_name || '?').split(' ').map(w => w[0]).filter(Boolean).join('').slice(0, 2).toUpperCase();

      const byDay = {};
      avail.forEach(a => { byDay[a.day_of_week] = a; });

      el.innerHTML = `
        <div class="wpr-view">
          <div class="wpr-page-header">
            <h2 class="wpr-page-title">My Profile</h2>
          </div>

          <div class="wpr-profile-hcard">
            <div class="wpr-profile-hcard__upper">
              <div class="wpr-profile-hcard__identity">
                <div class="wpr-profile-avatar wpr-profile-avatar--xl">${initials}</div>
                <div class="wpr-profile-identity">
                  <h3 class="wpr-profile-name">${profile.display_name}</h3>
                  ${roleLabel !== '—' ? `<div class="wpr-profile-pills"><span class="wpr-pill wpr-pill--blue">${roleLabel}</span></div>` : ''}
                </div>
              </div>
              <button class="wpr-btn wpr-btn--ghost" id="wpr-edit-profile">Edit Profile</button>
            </div>
            <div class="wpr-profile-hcard__lower">
              <div class="wpr-profile-detail-item">
                <span class="wpr-profile-detail-label">Email</span>
                <span class="wpr-profile-detail-value">${profile.email}</span>
              </div>
              ${profile.job_type ? `
              <div class="wpr-profile-detail-item">
                <span class="wpr-profile-detail-label">Job Type</span>
                <span class="wpr-profile-detail-value">${JOB_TYPE_LABEL[profile.job_type] || profile.job_type}</span>
              </div>` : ''}
              ${(PAY_ON && profile.pay_rate) ? `
              <div class="wpr-profile-detail-item">
                <span class="wpr-profile-detail-label">Pay Rate</span>
                <div class="wpr-pay-row">
                  <span class="wpr-pay-val wpr-pay-masked" id="wpr-pay-val">&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;</span>
                  <button class="wpr-pay-reveal" id="wpr-pay-reveal">Reveal</button>
                </div>
              </div>` : ''}
              ${profileSkillsView(profile)}
            </div>
          </div>

          <div class="wpr-section-card" style="margin-top:1.5rem">
            <div class="wpr-section-card__head">
              <div>
                <h3 class="wpr-section-card__title">My Availability</h3>
                <p class="wpr-section-card__desc">Set the days and hours you are available to work. Managers use this when scheduling shifts.</p>
              </div>
              <div class="wpr-avail-head-actions">
                <button class="wpr-btn wpr-btn--ghost wpr-btn--sm" id="wpr-avail-apply-all">Apply to all days</button>
                <button class="wpr-btn wpr-btn--primary wpr-btn--sm" id="wpr-save-avail">Save Availability</button>
              </div>
            </div>
            <div class="wpr-avail-grid2">
              ${DAY_NAMES.map((day, i) => {
                const a   = byDay[i];
                const on  = a ? a.is_available == 1 : true;
                const from = a && a.start_time ? a.start_time.slice(0,5) : '09:00';
                const to   = a && a.end_time   ? a.end_time.slice(0,5)   : '17:00';
                return `
                  <div class="wpr-avail-card${on?' wpr-avail-card--on':''}" data-day="${i}">
                    <div class="wpr-avail-card__head">
                      <label class="wpr-toggle">
                        <input type="checkbox" class="wpr-avail-cb"${on?' checked':''}>
                        <span class="wpr-toggle__track"><span class="wpr-toggle__thumb"></span></span>
                      </label>
                      <span class="wpr-avail-day-name">${day}</span>
                    </div>
                    <div class="wpr-avail-card__body">
                      <div class="wpr-avail-times-row">
                        <input type="time" class="wpr-input wpr-input--sm wpr-avail-from" value="${from}"${!on?' disabled':''}>
                        <span class="wpr-avail-sep">—</span>
                        <input type="time" class="wpr-input wpr-input--sm wpr-avail-to" value="${to}"${!on?' disabled':''}>
                      </div>
                      <div class="wpr-avail-off-label">Unavailable</div>
                    </div>
                  </div>`;
              }).join('')}
            </div>
          </div>
        </div>`;

      // Pay rate reveal toggle
      const payReveal = el.querySelector('#wpr-pay-reveal');
      const payVal    = el.querySelector('#wpr-pay-val');
      if (payReveal && payVal) {
        const actualRate = `$${Number(profile.pay_rate).toFixed(2)}/hr`;
        payReveal.addEventListener('click', () => {
          const visible = payReveal.textContent.trim() === 'Hide';
          payVal.textContent = visible ? '••••••' : actualRate;
          payVal.classList.toggle('wpr-pay-masked', visible);
          payReveal.textContent = visible ? 'Reveal' : 'Hide';
        });
      }

      // Edit profile modal (includes optional password change section)
      el.querySelector('#wpr-edit-profile').addEventListener('click', () => {
        const ov = modal({
          title: 'Edit Profile',
          size:  'md',
          body: `
            <div class="wpr-form-grid">
              <div class="wpr-field"><label>First Name</label><input id="pf-fn" class="wpr-input" value="${profile.first_name || ''}"></div>
              <div class="wpr-field"><label>Last Name</label><input id="pf-ln" class="wpr-input" value="${profile.last_name || ''}"></div>
              <div class="wpr-field wpr-field--full"><label>Email Address</label><input id="pf-em" class="wpr-input" type="email" value="${profile.email}"></div>
            </div>
            ${profileSkillsEditor(profile)}
            <div class="wpr-modal-section-divider">Change Password <span>— leave blank to keep current password</span></div>
            <div class="wpr-form-grid">
              <div class="wpr-field wpr-field--full"><label>Current Password</label><input id="pw-cur" class="wpr-input" type="password" autocomplete="current-password"></div>
              <div class="wpr-field"><label>New Password <span style="font-weight:400;color:var(--wpr-muted)">(min. 8 chars)</span></label><input id="pw-new" class="wpr-input" type="password" autocomplete="new-password"></div>
              <div class="wpr-field"><label>Confirm New Password</label><input id="pw-cfm" class="wpr-input" type="password" autocomplete="new-password"></div>
            </div>`,
          actions: [
            { id:'cancel', label:'Cancel', cls:'wpr-btn--ghost', fn: ov => ov.remove() },
            { id:'save', label:'Save Changes', cls:'wpr-btn--primary', fn: async (ov) => {
              const v   = id => ov.querySelector(id);
              const cur = v('#pw-cur').value;
              const nw  = v('#pw-new').value;
              const cfm = v('#pw-cfm').value;
              const anyPw = cur || nw || cfm;
              if (anyPw) {
                if (!cur || !nw || !cfm) { toast('Fill in all three password fields to change your password.', 'err'); return; }
                if (nw.length < 8)       { toast('New password must be at least 8 characters.', 'err'); return; }
                if (nw !== cfm)          { toast('New passwords do not match.', 'err'); return; }
              }
              const btn   = ov.querySelector('[data-action="save"]');
              const reset = btnLoad(btn);
              try {
                await http('/profile', 'PUT', {
                  first_name: v('#pf-fn').value.trim(),
                  last_name:  v('#pf-ln').value.trim(),
                  email:      v('#pf-em').value.trim(),
                  skills:     getSkillsFromInput(ov, 'pf-skills'),
                });
                if (anyPw) {
                  await http('/profile/password', 'POST', { current_password: cur, new_password: nw, confirm_password: cfm });
                  // wp_set_password() invalidates the current auth cookie and nonce.
                  // Store a flag so the post-reload boot() can show a success toast,
                  // then reload immediately to pick up the new cookie + fresh nonce.
                  sessionStorage.setItem('emr_pw_changed', '1');
                  window.location.reload();
                  return;
                }
                toast('Profile updated.'); ov.remove(); load();
              } catch(e) { toast(e.message, 'err'); reset(); }
            }},
          ],
        });
        profileSkillsWire(ov);
      });

      // Availability toggles
      el.querySelectorAll('.wpr-avail-cb').forEach(cb => {
        cb.addEventListener('change', () => {
          const card = cb.closest('.wpr-avail-card');
          card.classList.toggle('wpr-avail-card--on', cb.checked);
          card.querySelectorAll('input[type=time]').forEach(i => i.disabled = !cb.checked);
        });
      });

      // Apply to all days quick action
      el.querySelector('#wpr-avail-apply-all').addEventListener('click', () => {
        modal({
          title: 'Apply to All Days',
          size: 'sm',
          body: `
            <p class="wpr-help" style="margin-top:0">Set the same hours for every day of the week and mark all days as available.</p>
            <div class="wpr-form-grid">
              <div class="wpr-field"><label>From</label><input id="aa-from" type="time" class="wpr-input" value="09:00"></div>
              <div class="wpr-field"><label>To</label><input id="aa-to" type="time" class="wpr-input" value="17:00"></div>
            </div>`,
          actions: [
            { id:'cancel', label:'Cancel', cls:'wpr-btn--ghost', fn: ov => ov.remove() },
            { id:'apply', label:'Apply to All Days', cls:'wpr-btn--primary', fn: ov => {
              const from = ov.querySelector('#aa-from').value;
              const to   = ov.querySelector('#aa-to').value;
              el.querySelectorAll('.wpr-avail-card').forEach(card => {
                const cb = card.querySelector('.wpr-avail-cb');
                cb.checked = true;
                card.classList.add('wpr-avail-card--on');
                card.querySelectorAll('input[type=time]').forEach(i => i.disabled = false);
                card.querySelector('.wpr-avail-from').value = from;
                card.querySelector('.wpr-avail-to').value   = to;
              });
              ov.remove();
              toast('Applied to all days — don\'t forget to save.');
            }},
          ],
        });
      });

      // Save availability
      el.querySelector('#wpr-save-avail').addEventListener('click', async () => {
        const btn   = el.querySelector('#wpr-save-avail');
        const reset = btnLoad(btn);
        const slots = [...el.querySelectorAll('.wpr-avail-card')].map(card => ({
          day_of_week:  parseInt(card.dataset.day),
          start_time:   card.querySelector('.wpr-avail-from').value + ':00',
          end_time:     card.querySelector('.wpr-avail-to').value   + ':00',
          is_available: card.querySelector('.wpr-avail-cb').checked ? 1 : 0,
        }));
        try { await http('/availability', 'POST', {slots}); toast('Availability saved!'); reset(); }
        catch(e) { toast(e.message, 'err'); reset(); }
      });
    }

    await load();
  }

  /* ── STAFF ── */
  const EMR_ROLE_LABEL = {
    emr_employee:   'Employee',
  };

  function staffRoleBadge(roles) {
    return (roles || [])
      .filter(r => EMR_ROLE_LABEL[r])
      .map(r => badge(EMR_ROLE_LABEL[r], 'neutral'))
      .join(' ') || badge('—', 'neutral');
  }

  function staffJobTypeDropdown(selected) {
    return [
      { val: 'fulltime', label: 'Full-time' },
      { val: 'parttime', label: 'Part-time' },
      { val: 'casual',   label: 'Casual'    },
    ].map(o => `<option value="${o.val}"${selected === o.val ? ' selected' : ''}>${o.label}</option>`).join('');
  }

  async function viewStaff(el) {
    spin(el);
    let selectedIds  = new Set();
    let searchTerm   = '';
    let cachedStaff  = [];
    let cachedDeact  = [];

    function applySearch(staff) {
      if (!searchTerm) return staff;
      const q = searchTerm.toLowerCase();
      return staff.filter(function(u) {
        return (u.display_name || '').toLowerCase().includes(q) ||
               (u.email        || '').toLowerCase().includes(q) ||
               (JOB_TYPE_LABEL[u.job_type] || u.job_type || '').toLowerCase().includes(q) ||
               (u.roles || []).some(function(r) { return (EMR_ROLE_LABEL[r] || r).toLowerCase().includes(q); });
      });
    }

    async function load() {
      spin(el);
      try {
        const [staff, deactivated] = await Promise.all([
          http('/staff'),
          USER.isManager ? http('/staff/deactivated') : Promise.resolve([]),
        ]);
        cachedStaff = staff;
        cachedDeact = deactivated;
        render(staff, deactivated);
      } catch(e) { el.innerHTML = errBox(e.message); }
    }

    function render(staff, deactivated) {
      const filtered    = applySearch(staff);
      const colCount    = USER.isManager ? 6 : 5;
      el.innerHTML = `
        <div class="wpr-view">
          <div class="wpr-page-header">
            <h2 class="wpr-page-title">Staff <span class="wpr-count">${searchTerm ? filtered.length + ' / ' + staff.length : staff.length}</span></h2>
            <div class="wpr-page-header__actions">
              <input type="search" id="wpr-staff-search" class="wpr-input wpr-input--sm" placeholder="Search staff…" value="${searchTerm.replace(/"/g,'&quot;')}" style="width:180px">
              ${staffExportBtn()}
              ${USER.isManager ? `<button class="wpr-btn wpr-btn--primary" id="wpr-add-staff">+ Add Staff</button>` : ''}
            </div>
          </div>
          <div class="wpr-table-wrap">
            <table class="wpr-table">
              <thead><tr>
                ${USER.isManager ? '<th><input type="checkbox" id="wpr-staff-check-all" title="Select all"></th>' : ''}
                <th>Name</th><th>Email</th><th>Job Type</th><th>Role</th>
                <th></th>
              </tr></thead>
              <tbody>
                ${filtered.length ? filtered.map(u => `
                  <tr>
                    ${USER.isManager ? `<td><input type="checkbox" class="wpr-staff-check" value="${u.id}" ${selectedIds.has(u.id) ? 'checked' : ''}></td>` : ''}
                    <td><strong>${u.display_name}</strong></td>
                    <td>${u.email}</td>
                    <td>${u.job_type ? (JOB_TYPE_LABEL[u.job_type] || u.job_type) : '<span style="color:var(--wpr-muted)">—</span>'}</td>
                    <td>${staffRoleBadge(u.roles)}</td>
                    <td class="wpr-table__actions">
                      <button class="wpr-btn wpr-btn--xs wpr-btn--ghost" data-view-staff="${u.id}">View</button>
                      ${USER.isManager ? `<button class="wpr-btn wpr-btn--xs wpr-btn--ghost" data-edit-staff="${u.id}">Edit</button>` : ''}
                    </td>
                  </tr>`).join('')
                  : `<tr><td colspan="${colCount}"><div class="wpr-empty"><span>👥</span><p>${searchTerm ? 'No staff match your search.' : 'No staff members yet.'}</p></div></td></tr>`}
              </tbody>
            </table>
          </div>

          ${USER.isManager && deactivated.length ? `
          <details class="wpr-deactivated-section">
            <summary class="wpr-deactivated-summary">
              <span class="wpr-deactivated-summary__label">Deactivated Employees</span>
              <span class="wpr-count">${deactivated.length}</span>
              <span class="wpr-deactivated-summary__chevron" aria-hidden="true"></span>
            </summary>
            <div class="wpr-table-wrap" style="margin-top:.75rem">
              <table class="wpr-table">
                <thead><tr><th>Name</th><th>Email</th><th>Role</th><th></th></tr></thead>
                <tbody>
                  ${deactivated.map(u => `
                    <tr>
                      <td><strong>${u.display_name}</strong></td>
                      <td>${u.email}</td>
                      <td>${staffRoleBadge(u.roles)}</td>
                      <td class="wpr-table__actions">
                        <button class="wpr-btn wpr-btn--xs wpr-btn--ghost" data-reactivate-staff="${u.id}" data-name="${u.display_name}" data-email="${u.email}">Reactivate</button>
                      </td>
                    </tr>`).join('')}
                </tbody>
              </table>
            </div>
          </details>` : ''}
        </div>`;

      el.querySelector('#wpr-add-staff')?.addEventListener('click', () => {
        staffAddModal(load);
      });

      const searchInput = el.querySelector('#wpr-staff-search');
      if (searchInput) {
        searchInput.addEventListener('input', function() {
          searchTerm = this.value.trim();
          render(cachedStaff, cachedDeact);
          const s = el.querySelector('#wpr-staff-search');
          if (s) { s.focus(); s.setSelectionRange(s.value.length, s.value.length); }
        });
      }

      el.querySelector('#wpr-staff-check-all')?.addEventListener('change', function(e) {
        el.querySelectorAll('.wpr-staff-check').forEach(function(cb) {
          cb.checked = e.target.checked;
          const id = parseInt(cb.value);
          e.target.checked ? selectedIds.add(id) : selectedIds.delete(id);
        });
      });
      el.querySelectorAll('.wpr-staff-check').forEach(function(cb) {
        cb.addEventListener('change', function() {
          const id = parseInt(cb.value);
          cb.checked ? selectedIds.add(id) : selectedIds.delete(id);
        });
      });
      el.querySelector('#wpr-staff-export-sel')?.addEventListener('click', () => {
        const sel = filtered.filter(u => selectedIds.has(u.id));
        if (!sel.length) { toast('Select at least one staff member.', 'info'); return; }
        exportStaffData(sel, []);
      });

      el.querySelectorAll('[data-view-staff]').forEach(b => {
        const u = staff.find(x => x.id == b.dataset.viewStaff);
        b.addEventListener('click', () => staffProfileModal(u));
      });

      el.querySelectorAll('[data-edit-staff]').forEach(b => {
        const u = staff.find(x => x.id == b.dataset.editStaff);
        b.addEventListener('click', () => {
          staffEditModal(u, load);
        });
      });

      el.querySelectorAll('[data-reactivate-staff]').forEach(b => {
        b.addEventListener('click', () => {
          const uid   = parseInt(b.dataset.reactivateStaff);
          const name  = b.dataset.name;
          const email = b.dataset.email;
          modal({
            title: 'Reactivate ' + name + '?',
            size:  'sm',
            body:  `<p style="margin:0">This will restore their front-end access and return them to the active staff list. A new welcome email with login details will be sent to <strong>${email}</strong>.</p>`,
            actions: [
              { id:'cancel',  label:'Cancel',     cls:'wpr-btn--ghost',   fn: ov => ov.remove() },
              { id:'confirm', label:'Reactivate', cls:'wpr-btn--primary', fn: async (confirmOv) => {
                const btn   = confirmOv.querySelector('[data-action="confirm"]');
                const reset = btnLoad(btn);
                try {
                  const res = await http('/staff/' + uid + '/reactivate', 'POST', { user_id: uid });
                  confirmOv.remove();
                  load();
                  staffPasswordModal(name, email, res.password);
                } catch(e) { toast(e.message, 'err'); reset(); }
              }},
            ],
          });
        });
      });
    }

    await load();
  }


  function staffAddModal(onSave, hideSupervisor) {
    const ov = modal({
      title: 'Add Staff Member',
      size:  'md',
      body: `
        <div class="wpr-form-grid">
          <div class="wpr-field"><label>First Name *</label><input id="st-fn" class="wpr-input" placeholder="Jane"></div>
          <div class="wpr-field"><label>Last Name</label><input id="st-ln" class="wpr-input" placeholder="Smith"></div>
          <div class="wpr-field wpr-field--full"><label>Email Address *</label><input id="st-em" class="wpr-input" type="email" placeholder="jane@example.com"></div>
          ${staffAddRoleField(hideSupervisor)}
          <div class="wpr-field"><label>Job Type</label><select id="st-jt" class="wpr-input">${staffJobTypeDropdown('casual')}</select></div>
        </div>
        ${staffSkillsEditor('st-skills', [])}`,
      actions: [
        { id:'cancel', label:'Cancel', cls:'wpr-btn--ghost', fn: ov => ov.remove() },
        { id:'save', label:'Create Staff', cls:'wpr-btn--primary', fn: async ov => {
          const v = id => ov.querySelector(id);
          const data = {
            first_name: v('#st-fn').value.trim(),
            last_name:  v('#st-ln').value.trim(),
            email:      v('#st-em').value.trim(),
            job_type:   v('#st-jt').value,
            skills:     getSkillsFromInput(ov, 'st-skills'),
          };
          if (!data.first_name) return toast('First name is required.', 'err');
          if (!data.email)      return toast('Email address is required.', 'err');
          const btn   = ov.querySelector('[data-action="save"]');
          const reset = btnLoad(btn);
          try {
            const res = await http('/staff', 'POST', data);
            ov.remove();
            onSave();
            staffCreatedFeedback(data, res);
          } catch(e) { toast(e.message, 'err'); reset(); }
        }},
      ],
    });
    staffSkillsWire(ov);
  }


  // ── Skill tag helpers ─────────────────────────────────────────────────────

  function skillBadgesHTML() { return ''; }



  function getSkillsFromInput(container, id) {
    const wrap = container.querySelector('#' + id);
    if (!wrap) return [];
    return Array.from(wrap.querySelectorAll('.wpr-skill-tag')).map(t => t.dataset.skill);
  }


  function staffEditModal(user, onSave, hideSupervisor) {
    const currentJT   = user.job_type || 'casual';
    const ov = modal({
      title: 'Edit — ' + user.display_name,
      size:  'md',
      body: `
        <div class="wpr-form-grid">
          <div class="wpr-field"><label>First Name</label><input id="st-fn" class="wpr-input" value="${user.first_name || ''}"></div>
          <div class="wpr-field"><label>Last Name</label><input id="st-ln" class="wpr-input" value="${user.last_name || ''}"></div>
          <div class="wpr-field wpr-field--full"><label>Email Address</label><input id="st-em" class="wpr-input" type="email" value="${user.email}"></div>
          <div class="wpr-field"><label>Job Type</label><select id="st-jt" class="wpr-input">${staffJobTypeDropdown(currentJT)}</select></div>
        </div>
        ${staffSkillsEditor('st-skills', user.skills || [])}
        <div class="wpr-modal-section-divider wpr-modal-section-divider--danger">Danger Zone</div>
        <div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.25rem 0">
          <p style="margin:0;font-size:var(--wpr-text-sm);color:var(--wpr-muted);line-height:var(--wpr-lh-base)">Deactivating this staff member will revoke their front-end access and remove them from the staff list and shift assignments. All historical data is preserved.</p>
          <button class="wpr-btn wpr-btn--ghost wpr-btn--sm" id="st-deactivate" style="white-space:nowrap;flex-shrink:0;border-color:var(--wpr-red);color:var(--wpr-red)">Deactivate</button>
        </div>`,
      actions: [
        { id:'cancel', label:'Cancel', cls:'wpr-btn--ghost', fn: ov => ov.remove() },
        ...staffResetAccountAction(user),
        { id:'save', label:'Save Changes', cls:'wpr-btn--primary', fn: async ov => {
          const v = id => ov.querySelector(id);
          const data = {
            id:         user.id,
            first_name: v('#st-fn').value.trim(),
            last_name:  v('#st-ln').value.trim(),
            email:      v('#st-em').value.trim(),
            job_type:   v('#st-jt').value,
            skills:     getSkillsFromInput(ov, 'st-skills'),
          };
          const btn   = ov.querySelector('[data-action="save"]');
          const reset = btnLoad(btn);
          try {
            await http('/staff/' + user.id, 'PUT', data);
            toast('Staff member updated.'); ov.remove(); onSave();
          } catch(e) { toast(e.message, 'err'); reset(); }
        }},
      ],
    });
    staffSkillsWire(ov);

    ov.querySelector('#st-deactivate').addEventListener('click', () => {
      modal({
        title: 'Deactivate ' + user.display_name + '?',
        size:  'sm',
        body: `<p style="margin:0">This will revoke their front-end access and remove them from the staff list and shift assignments. Any pending leave or swap requests will be cancelled. Historical data is preserved.</p>`,
        actions: [
          { id:'cancel',  label:'Cancel',     cls:'wpr-btn--ghost',  fn: ov => ov.remove() },
          { id:'confirm', label:'Deactivate', cls:'wpr-btn--danger', fn: async (confirmOv) => {
            const btn   = confirmOv.querySelector('[data-action="confirm"]');
            const reset = btnLoad(btn);
            try {
              await http('/staff/' + user.id + '/deactivate', 'POST', { user_id: user.id });
              confirmOv.remove();
              toast(user.display_name + ' has been deactivated.');
              onSave();
            } catch(e) { toast(e.message, 'err'); reset(); }
          }},
        ],
      });
    });
  }

  async function staffProfileModal(user) {
    let avail = [];
    try { avail = await http('/availability?user_id=' + user.id); } catch(e) { /* non-fatal */ }
    const byDay = {};
    avail.forEach(a => { byDay[a.day_of_week] = a; });
    const roleLabel = (user.roles || []).map(r => EMR_ROLE_LABEL[r]).filter(Boolean).join(', ') || '—';
    modal({
      title: user.display_name,
      size:  'md',
      body: `
        <div class="wpr-shift-detail" style="margin-bottom:1.25rem">
          <div class="wpr-shift-detail__rows" style="padding-left:0">
            <div class="wpr-shift-detail__row"><span>Email</span><strong>${user.email}</strong></div>
            <div class="wpr-shift-detail__row"><span>Role</span><strong>${roleLabel}</strong></div>
            <div class="wpr-shift-detail__row"><span>Job Type</span><strong>${JOB_TYPE_LABEL[user.job_type] || 'Casual'}</strong></div>
            ${(CAN_SEE_PAY && user.pay_rate !== null) ? `<div class="wpr-shift-detail__row"><span>Pay Rate</span><strong>$${Number(user.pay_rate).toFixed(2)}/hr</strong></div>` : ''}
            ${staffDetailSkillsRow(user)}
          </div>
        </div>
        <p class="wpr-profile__section-title">Weekly Availability</p>
        <div class="wpr-table-wrap">
          <table class="wpr-table">
            <thead><tr><th>Day</th><th>Available</th><th>Hours</th></tr></thead>
            <tbody>
              ${DAY_SHORT.map((d, i) => {
                const a  = byDay[i];
                const on = !a || a.is_available == 1; // no saved row → default available
                return `<tr>
                  <td><strong>${d}</strong></td>
                  <td>${on ? '✅ Yes' : '❌ No'}</td>
                  <td>${on ? (a ? a.start_time.slice(0,5) + ' – ' + a.end_time.slice(0,5) : '09:00 – 17:00') : '—'}</td>
                </tr>`;
              }).join('')}
            </tbody>
          </table>
        </div>`,
      actions: [{ id:'close', label:'Close', cls:'wpr-btn--ghost', fn: ov => ov.remove() }],
    });
  }

  /* ═══════════════ TIMESHEETS ═══════════════ */

  async function viewTimesheets(el) {
    if (!CAN_VIEW_ALL) { return viewMyTimesheets(el); }
    spin(el);
    let periodType    = 'week';
    let anchor        = new Date();
    let selectedIds   = new Set();
    let payRateVisible = false;
    // Tax columns mirror the convention used in detail modals & exports:
    // shown only when pay is visible AND tax is enabled with a non-zero rate.
    const taxEnabled = CAN_SEE_PAY && SET.taxEnabled && (Number(SET.taxRate) || 0) > 0;
    const taxPct     = taxEnabled ? Number(SET.taxRate) : 0;

    function applyPayRateVisibility() {
      el.querySelectorAll('.wpr-ts-pay-col').forEach(function(c) {
        c.style.display = payRateVisible ? '' : 'none';
      });
      const btn = el.querySelector('#wpr-ts-toggle-pay');
      if (btn) btn.textContent = payRateVisible ? 'Hide Pay' : 'Reveal Pay';
    }

    function fmtD(d) { return [d.getFullYear(), pad(d.getMonth()+1), pad(d.getDate())].join('-'); }

    function getRange() {
      const d = new Date(anchor);
      if (periodType === 'week') {
        const mon = new Date(d); mon.setDate(d.getDate() - ((d.getDay()-WEEK_START+7)%7)); mon.setHours(0,0,0,0);
        const sun = new Date(mon); sun.setDate(mon.getDate()+6);
        return {
          start: fmtD(mon), end: fmtD(sun),
          label: mon.toLocaleDateString('en-AU',{day:'numeric',month:'short'}) + ' – ' +
                 sun.toLocaleDateString('en-AU',{day:'numeric',month:'short',year:'numeric'}),
        };
      }
      if (periodType === 'month') {
        const y = d.getFullYear(), m = d.getMonth();
        const last = new Date(y, m+1, 0).getDate();
        return {
          start: `${y}-${pad(m+1)}-01`, end: `${y}-${pad(m+1)}-${pad(last)}`,
          label: d.toLocaleDateString('en-AU',{month:'long',year:'numeric'}),
        };
      }
      return {
        start: `${d.getFullYear()}-01-01`, end: `${d.getFullYear()}-12-31`,
        label: `${d.getFullYear()}`,
      };
    }

    function navigate(dir) {
      if (periodType === 'week')  anchor.setDate(anchor.getDate() + dir*7);
      if (periodType === 'month') anchor.setMonth(anchor.getMonth() + dir);
      if (periodType === 'year')  anchor.setFullYear(anchor.getFullYear() + dir);
      load();
    }


    async function load() {
      spin(el);
      try {
        const range  = getRange();
        const staff  = await http(`/timesheets?start=${range.start}&end=${range.end}`);
        render(staff, range);
      } catch(e) { el.innerHTML = errBox(e.message); }
    }

    function render(staff, range) {
      el.innerHTML = `
        <div class="wpr-view">
          <div class="wpr-page-header">
            <div>
              <h2 class="wpr-page-title">Timesheets</h2>
              <p class="wpr-page-sub">${range.label}</p>
            </div>
            <div class="wpr-ts-export-group">
              ${timesheetExportBtns()}
            </div>
          </div>

          <div class="wpr-ts-toolbar">
            <div class="wpr-ts-toolbar-left">
              <div class="wpr-view-toggle">
                ${['week','month','year'].map(p => `
                  <button class="wpr-vt-btn ${p===periodType?'wpr-vt-btn--active':''}" data-period="${p}">
                    ${p.charAt(0).toUpperCase()+p.slice(1)}
                  </button>`).join('')}
              </div>
              <div class="wpr-ts-nav">
                <button class="wpr-ts-nav-btn" data-dir="-1">‹</button>
                <span class="wpr-ts-period-label">${range.label}</span>
                <button class="wpr-ts-nav-btn" data-dir="1">›</button>
              </div>
            </div>
          </div>

          ${staff.length ? `
          <div class="wpr-table-wrap" style="border-radius:var(--wpr-r-lg);border:1px solid var(--wpr-slate-200);overflow-x:auto;overflow-y:hidden">
            <table class="wpr-table wpr-ts-list-table">
              <thead><tr>
                <th><input type="checkbox" id="wpr-ts-check-all" title="Select all"></th>
                <th>Employee</th>
                <th>Role</th>
                <th>Work Hours</th>
                ${timesheetLeaveHead()}
                <th></th>
              </tr></thead>
              <tbody>
                ${staff.map(u => {
                  const leaveDays = u.leave_days || 0;       // all approved leave types
                  const leaveH    = u.paid_leave_hours || 0; // paid types only — feeds earnings
                  const totalH    = u.total_hours + leaveH;
                  const gross   = (CAN_SEE_PAY && u.pay_rate > 0 && totalH > 0) ? (u.pay_rate * totalH) : null;
                  const tax     = (gross !== null && taxEnabled) ? gross * (taxPct / 100) : null;
                  const net     = (gross !== null && taxEnabled) ? (gross - tax) : null;
                  const muted   = '<span class="wpr-c-muted">—</span>';
                  const grossCell = gross !== null ? `<strong class="wpr-c-green">$${gross.toFixed(2)}</strong>` : muted;
                  const taxCell   = tax   !== null ? `$${tax.toFixed(2)}` : muted;
                  const netCell   = net   !== null ? `<strong class="wpr-c-green">$${net.toFixed(2)}</strong>` : muted;
                  return `<tr>
                    <td><input type="checkbox" class="wpr-ts-check" value="${u.user_id}" ${selectedIds.has(u.user_id)?'checked':''}></td>
                    <td><strong>${u.display_name}</strong></td>
                    <td>${staffRoleBadge(u.roles)}</td>
                    <td>${u.total_hours > 0 ? `${u.total_hours.toFixed(2)}<span class="wpr-c-muted">h</span>` : '<span class="wpr-c-muted">—</span>'}</td>
                    ${timesheetLeaveCell(u)}
                    <td>
                      <button class="wpr-btn wpr-btn--ghost wpr-btn--xs" data-view-ts="${u.user_id}" data-ts-name="${u.display_name}" data-ts-roles='${JSON.stringify(u.roles)}' data-ts-pay="${CAN_SEE_PAY ? (u.pay_rate||0) : 0}">Details →</button>
                    </td>
                  </tr>`;
                }).join('')}
              </tbody>
            </table>
          </div>` : `
          <div class="wpr-ts-empty">
            <div class="wpr-ts-empty__icon">⊟</div>
            <p class="wpr-ts-empty__msg">No staff records found for this period.</p>
          </div>`}
        </div>`;

      // Period toggle
      el.querySelectorAll('[data-period]').forEach(b => b.addEventListener('click', () => {
        periodType = b.dataset.period; load();
      }));
      // Navigation
      el.querySelectorAll('.wpr-ts-nav-btn').forEach(b => b.addEventListener('click', () => navigate(parseInt(b.dataset.dir))));
      // Select-all checkbox
      el.querySelector('#wpr-ts-check-all')?.addEventListener('change', e => {
        el.querySelectorAll('.wpr-ts-check').forEach(cb => {
          cb.checked = e.target.checked;
          const id = parseInt(cb.value);
          e.target.checked ? selectedIds.add(id) : selectedIds.delete(id);
        });
      });
      // Individual checkboxes
      el.querySelectorAll('.wpr-ts-check').forEach(cb => cb.addEventListener('change', () => {
        const id = parseInt(cb.value);
        cb.checked ? selectedIds.add(id) : selectedIds.delete(id);
      }));
      // Export all (button may be absent — use ?. to guard)
      el.querySelector('#wpr-ts-export-all')?.addEventListener('click', () =>
        exportCSV(staff.map(u => u.user_id), staff, range));
      // Export selected
      el.querySelector('#wpr-ts-export-sel')?.addEventListener('click', () => {
        const ids = [...selectedIds];
        if (!ids.length) { toast('Select at least one employee.', 'info'); return; }
        exportCSV(ids, staff, range);
      });
      // View individual
      el.querySelectorAll('[data-view-ts]').forEach(b => b.addEventListener('click', () => {
        const uid     = parseInt(b.dataset.viewTs);
        const name    = b.dataset.tsName;
        const roles   = JSON.parse(b.dataset.tsRoles || '[]');
        const payRate = parseFloat(b.dataset.tsPay) || 0;
        viewTimesheetDetail(el, uid, name, roles, payRate, getRange(), () => viewTimesheets(el));
      }));
      // Pay rate toggle
      el.querySelector('#wpr-ts-toggle-pay')?.addEventListener('click', () => {
        payRateVisible = !payRateVisible;
        applyPayRateVisibility();
      });
      applyPayRateVisibility();
    }

    await load();
  }

  async function viewMyTimesheets(el) {
    spin(el);
    const uid        = USER.id;
    const payRate    = PAY_ON ? (USER.pay_rate || 0) : 0;
    const taxEnabled = PAY_ON && SET.taxEnabled && payRate > 0;
    const taxPct     = taxEnabled ? (SET.taxRate || 0) : 0;
    let periodType = 'week';
    let anchor     = new Date();
    let gran       = 'weekly';

    function fmtD(d) { return [d.getFullYear(), pad(d.getMonth()+1), pad(d.getDate())].join('-'); }

    function getRange() {
      const d = new Date(anchor);
      if (periodType === 'week') {
        const mon = new Date(d); mon.setDate(d.getDate() - ((d.getDay()-WEEK_START+7)%7)); mon.setHours(0,0,0,0);
        const sun = new Date(mon); sun.setDate(mon.getDate()+6);
        return {
          start: fmtD(mon), end: fmtD(sun),
          label: mon.toLocaleDateString('en-AU',{day:'numeric',month:'short'}) + ' – ' +
                 sun.toLocaleDateString('en-AU',{day:'numeric',month:'short',year:'numeric'}),
        };
      }
      if (periodType === 'month') {
        const y = d.getFullYear(), m = d.getMonth();
        const last = new Date(y, m+1, 0).getDate();
        return {
          start: `${y}-${pad(m+1)}-01`, end: `${y}-${pad(m+1)}-${pad(last)}`,
          label: d.toLocaleDateString('en-AU',{month:'long',year:'numeric'}),
        };
      }
      return {
        start: `${d.getFullYear()}-01-01`, end: `${d.getFullYear()}-12-31`,
        label: `${d.getFullYear()}`,
      };
    }

    function navigate(dir) {
      if (periodType === 'week')  anchor.setDate(anchor.getDate() + dir*7);
      if (periodType === 'month') anchor.setMonth(anchor.getMonth() + dir);
      if (periodType === 'year')  anchor.setFullYear(anchor.getFullYear() + dir);
      load();
    }

    function calcNet(s) {
      if (!s.clock_in || !s.clock_out) return null;
      const h = (new Date(s.clock_out.replace(' ','T')) - new Date(s.clock_in.replace(' ','T'))) / 3600000;
      return Math.max(0, h - (s.break_minutes||0)/60);
    }
    function penaltyMult(clockIn) {
      if (!PENALTY_ON || !clockIn) return 1;
      const dow = new Date(clockIn.replace(' ', 'T')).getDay();
      return dow === 6 ? 1 + SAT_RATE / 100 : (dow === 0 ? 1 + SUN_RATE / 100 : 1);
    }
    function sessionEarnings(s) {
      const net = calcNet(s);
      if (net === null) return null;
      return net * payRate * penaltyMult(s.clock_in);
    }
    function totalEarningsForSessions(arr) {
      return arr.reduce((sum, s) => { const e = sessionEarnings(s); return sum + (e !== null ? e : 0); }, 0);
    }

    function groupByGran(sessions) {
      sessions = sessions.filter(s => s.clock_in);
      if (gran === 'daily') {
        return sessions.map(s => {
          const d = s.clock_in ? new Date(s.clock_in.replace(' ','T')) : null;
          return {
            label: d ? d.toLocaleDateString('en-AU',{weekday:'short',day:'numeric',month:'short',year:'numeric'}) : '—',
            total: calcNet(s), sessions: [s],
          };
        });
      }
      const groups = {};
      sessions.forEach(s => {
        const d = s.clock_in ? new Date(s.clock_in.replace(' ','T')) : null;
        let key, label;
        if (!d) { key = 'unknown'; label = 'Unknown'; }
        else if (gran === 'weekly') {
          const mon = new Date(d); mon.setDate(d.getDate()-((d.getDay()-WEEK_START+7)%7));
          const sun = new Date(mon); sun.setDate(mon.getDate()+6);
          key   = fmtD(mon);
          label = mon.toLocaleDateString('en-AU',{day:'numeric',month:'short'}) + ' – ' +
                  sun.toLocaleDateString('en-AU',{day:'numeric',month:'short',year:'numeric'});
        } else if (gran === 'monthly') {
          key   = `${d.getFullYear()}-${pad(d.getMonth()+1)}`;
          label = d.toLocaleDateString('en-AU',{month:'long',year:'numeric'});
        } else {
          key = `${d.getFullYear()}`; label = key;
        }
        if (!groups[key]) groups[key] = { key, label, sessions:[], total:0 };
        groups[key].sessions.push(s);
        const net = calcNet(s); if (net !== null) groups[key].total += net;
      });
      return Object.values(groups).sort((a,b) => a.key > b.key ? 1 : -1);
    }

    function exportMyCSV(sessions, paidLeave, range) {
      try {
        const groups  = groupByGran(sessions);
        const hasPay  = PAY_ON && payRate > 0;
        const leaveH  = paidLeave.reduce((s, l) => s + (l.hours || 0), 0);
        const leaveDays = paidLeave.reduce((s, l) => s + (l.days || 0), 0);
        const taxCols = hasPay && taxEnabled && taxPct > 0
          ? [`Tax Amount (${taxPct}%)`, 'Net Earnings'] : [];
        const showLeave = timesheetShowLeave();
        const rows    = [['Period', 'Hours Worked', ...(showLeave ? ['Leave (Days)'] : []),
          ...(hasPay ? ['Pay Rate ($/hr)', 'Gross Earnings', ...taxCols] : [])]];
        groups.forEach(g => {
          const gross = hasPay ? totalEarningsForSessions(g.sessions) : null;
          const tax   = (hasPay && taxEnabled && taxPct > 0) ? gross * (taxPct / 100) : null;
          rows.push([
            g.label, g.total.toFixed(2), ...(showLeave ? [''] : []),
            ...(hasPay ? [
              payRate.toFixed(2),
              gross.toFixed(2),
              ...(tax !== null ? [tax.toFixed(2), (gross - tax).toFixed(2)] : []),
            ] : []),
          ]);
        });
        if (showLeave) {
          paidLeave.forEach(l => {
            const label  = `${LEAVE_TYPE[l.leave_type]||l.leave_type}: ${fmtDate(l.start_date)} – ${fmtDate(l.end_date)}`;
            const lGross = hasPay ? l.hours * payRate : null;
            const lTax   = (hasPay && taxEnabled && taxPct > 0) ? lGross * (taxPct / 100) : null;
            rows.push([label, '', String(l.days),
              ...(hasPay ? [
                payRate.toFixed(2),
                lGross.toFixed(2),
                ...(lTax !== null ? [lTax.toFixed(2), (lGross - lTax).toFixed(2)] : []),
              ] : [])]);
          });
        }
        const totalH  = sessions.reduce((s,r) => { const n=calcNet(r); return s+(n||0); }, 0);
        const tGross  = hasPay ? (totalEarningsForSessions(sessions) + leaveH * payRate) : null;
        const tTax    = (hasPay && taxEnabled && taxPct > 0) ? tGross * (taxPct / 100) : null;
        rows.push(['Total', totalH.toFixed(2), ...(showLeave ? [String(leaveDays)] : []),
          ...(hasPay ? [
            payRate.toFixed(2),
            tGross.toFixed(2),
            ...(tTax !== null ? [tTax.toFixed(2), (tGross - tTax).toFixed(2)] : []),
          ] : [])]);
        const csv  = rows.map(r => r.map(v => `"${String(v).replace(/"/g,'""')}"`).join(',')).join('\n');
        const blob = new Blob([csv], {type:'text/csv'});
        const url  = URL.createObjectURL(blob);
        const a    = document.createElement('a');
        a.href = url; a.download = `my-timesheet-${range.start}-to-${range.end}.csv`; a.click();
        URL.revokeObjectURL(url);
        toast('Export ready.', 'ok');
      } catch(e) { toast(e.message, 'err'); }
    }

    async function load() {
      spin(el);
      try {
        const range     = getRange();
        const data      = await http(`/timesheets/${uid}?user_id=${uid}&start=${range.start}&end=${range.end}`);
        const sessions  = Array.isArray(data) ? data : (data.sessions || []);
        const paidLeave = Array.isArray(data) ? [] : (data.paid_leave || []);
        render(sessions, paidLeave, range);
      } catch(e) { el.innerHTML = errBox(e.message); }
    }

    function render(sessions, paidLeave, range) {
      const groups        = groupByGran(sessions);
      const workedH       = groups.reduce((s, g) => s + (g.total || 0), 0);
      const leaveH        = paidLeave.reduce((s, l) => s + (l.hours || 0), 0);
      const leaveDays     = paidLeave.reduce((s, l) => s + (l.days  || 0), 0);
      const totalH        = workedH + leaveH;
      const hasPay         = PAY_ON && payRate > 0;
      const workedEarnings = hasPay ? groups.reduce((s, g) => s + totalEarningsForSessions(g.sessions), 0) : null;
      const grossEarnings  = hasPay ? (workedEarnings + leaveH * payRate) : null;
      const taxAmount      = (hasPay && taxEnabled) ? grossEarnings * (taxPct / 100) : null;
      const netEarnings    = (hasPay && taxEnabled) ? grossEarnings - taxAmount : null;
      const totalEarnings  = grossEarnings; // gross (used in non-tax grand total)

      el.innerHTML = `
        <div class="wpr-view">
          <div class="wpr-page-header">
            <div>
              <h2 class="wpr-page-title">My Timesheet</h2>
              <p class="wpr-page-sub">${range.label}</p>
            </div>
            <button class="wpr-btn wpr-btn--primary wpr-btn--sm" id="wpr-mts-export">↓ Export CSV</button>
          </div>

          <div class="wpr-ts-stats">
            <div class="wpr-ts-stat">
              <div class="wpr-ts-stat__lbl">Hours Worked</div>
              <div class="wpr-ts-stat__val">${workedH.toFixed(2)}<span style="font-size:.85rem;font-weight:500;color:var(--wpr-muted)">h</span></div>
            </div>
            ${leaveDays > 0 ? `<div class="wpr-ts-stat wpr-ts-stat--amber">
              <div class="wpr-ts-stat__lbl">Leave</div>
              <div class="wpr-ts-stat__val">${leaveDays}<span style="font-size:.85rem;font-weight:500;color:var(--wpr-muted)"> ${leaveDays === 1 ? 'day' : 'days'}</span></div>
            </div>` : ''}
            ${hasPay && taxEnabled ? `
            <div class="wpr-ts-stat wpr-ts-stat--green">
              <div class="wpr-ts-stat__lbl">Gross Earnings</div>
              <div class="wpr-ts-stat__val">$${grossEarnings.toFixed(2)}</div>
            </div>
            <div class="wpr-ts-stat wpr-ts-stat--red">
              <div class="wpr-ts-stat__lbl">Tax (${taxPct}%)</div>
              <div class="wpr-ts-stat__val">−$${taxAmount.toFixed(2)}</div>
            </div>
            <div class="wpr-ts-stat wpr-ts-stat--green">
              <div class="wpr-ts-stat__lbl">Net Earnings</div>
              <div class="wpr-ts-stat__val">$${netEarnings.toFixed(2)}</div>
            </div>` : ''}
            ${hasPay && !taxEnabled ? `<div class="wpr-ts-stat wpr-ts-stat--green">
              <div class="wpr-ts-stat__lbl">Worked Earnings</div>
              <div class="wpr-ts-stat__val">$${workedEarnings.toFixed(2)}</div>
            </div>` : ''}
            ${hasPay && !taxEnabled && leaveH > 0 ? `<div class="wpr-ts-stat wpr-ts-stat--green">
              <div class="wpr-ts-stat__lbl">Total Earnings</div>
              <div class="wpr-ts-stat__val">$${totalEarnings.toFixed(2)}</div>
            </div>` : ''}
            ${hasPay ? `<div class="wpr-ts-stat wpr-ts-stat--brand">
              <div class="wpr-ts-stat__lbl">Pay Rate</div>
              <div class="wpr-ts-stat__val">$${payRate.toFixed(2)}<span style="font-size:.85rem;font-weight:500;color:var(--wpr-muted)">/hr</span></div>
            </div>` : ''}
          </div>

          <div class="wpr-ts-toolbar">
            <div class="wpr-ts-toolbar-left">
              <div class="wpr-view-toggle">
                ${['week','month','year'].map(p => `
                  <button class="wpr-vt-btn ${p===periodType?'wpr-vt-btn--active':''}" data-period="${p}">
                    ${p.charAt(0).toUpperCase()+p.slice(1)}
                  </button>`).join('')}
              </div>
              <div class="wpr-ts-nav">
                <button class="wpr-ts-nav-btn" data-dir="-1">‹</button>
                <span class="wpr-ts-period-label">${range.label}</span>
                <button class="wpr-ts-nav-btn" data-dir="1">›</button>
              </div>
            </div>
          </div>

          <div class="wpr-ts-gran-row">
            <span class="wpr-ts-gran-label">Group by</span>
            <div class="wpr-view-toggle">
              ${['weekly','monthly','yearly'].map(g => `
                <button class="wpr-vt-btn ${g===gran?'wpr-vt-btn--active':''}" data-gran="${g}">
                  ${g.charAt(0).toUpperCase()+g.slice(1)}
                </button>`).join('')}
            </div>
          </div>

          ${groups.length ? `
          <div class="wpr-table-wrap wpr-ts-earnings-table">
            <table class="wpr-table">
              <thead><tr>
                <th>Period</th>
                <th>Hours Worked</th>
                ${hasPay && taxEnabled ? '<th>Gross</th><th>Net</th>' : ''}
                ${hasPay && !taxEnabled ? '<th>Earnings</th>' : ''}
              </tr></thead>
              <tbody>
                ${groups.map(g => {
                  const gross = hasPay ? totalEarningsForSessions(g.sessions) : null;
                  const net   = (hasPay && taxEnabled) ? gross * (1 - taxPct / 100) : null;
                  return `<tr>
                    <td>${g.label}</td>
                    <td>${g.total > 0 ? `<strong>${g.total.toFixed(2)}h</strong>` : '<span class="wpr-c-muted">—</span>'}</td>
                    ${hasPay && taxEnabled && gross !== null ? `<td>$${gross.toFixed(2)}</td><td><strong class="wpr-c-green">$${net.toFixed(2)}</strong></td>` : ''}
                    ${hasPay && !taxEnabled && gross !== null ? `<td><strong class="wpr-c-green">$${gross.toFixed(2)}</strong></td>` : ''}
                  </tr>`;
                }).join('')}
                ${groups.length > 1 ? `<tr class="wpr-ts-total-row">
                  <td>Worked Total</td>
                  <td>${workedH.toFixed(2)}h</td>
                  ${hasPay && taxEnabled ? `<td>$${workedEarnings.toFixed(2)}</td><td class="wpr-c-green">$${(workedEarnings * (1 - taxPct / 100)).toFixed(2)}</td>` : ''}
                  ${hasPay && !taxEnabled ? `<td class="wpr-c-green">$${workedEarnings.toFixed(2)}</td>` : ''}
                </tr>` : ''}
              </tbody>
            </table>
          </div>` : `
          <div class="wpr-ts-empty">
            <div class="wpr-ts-empty__icon">⊟</div>
            <p class="wpr-ts-empty__msg">No ${SET.allowSelfClock ? 'attendance records' : 'scheduled shifts'} found for this period.</p>
          </div>`}

          ${paidLeave.length ? `
          <div class="wpr-ts-section-hd">
            <span class="wpr-ts-section-hd__title">Leave</span>
          </div>
          <div class="wpr-table-wrap wpr-ts-earnings-table">
            <table class="wpr-table">
              <thead><tr>
                <th>Type</th><th>From</th><th>To</th><th>Days</th>
                ${hasPay && taxEnabled ? '<th>Gross</th><th>Net</th>' : ''}
                ${hasPay && !taxEnabled ? '<th>Leave Pay</th>' : ''}
              </tr></thead>
              <tbody>
                ${paidLeave.map(l => {
                  const lGross = hasPay ? l.hours * payRate : null;
                  const lNet   = (hasPay && taxEnabled) ? lGross * (1 - taxPct / 100) : null;
                  return `<tr>
                    <td>${LEAVE_TYPE[l.leave_type]||l.leave_type}</td>
                    <td>${fmtDate(l.start_date)}</td>
                    <td>${fmtDate(l.end_date)}</td>
                    <td>${l.days} ${l.days === 1 ? 'day' : 'days'}</td>
                    ${hasPay && taxEnabled ? `<td>$${lGross.toFixed(2)}</td><td><strong class="wpr-c-green">$${lNet.toFixed(2)}</strong></td>` : ''}
                    ${hasPay && !taxEnabled ? `<td><strong class="wpr-c-green">$${lGross.toFixed(2)}</strong></td>` : ''}
                  </tr>`;
                }).join('')}
                <tr class="wpr-ts-total-row">
                  <td colspan="3">Leave Total</td>
                  <td>${leaveDays} ${leaveDays === 1 ? 'day' : 'days'}</td>
                  ${hasPay && taxEnabled ? `<td>$${(leaveH * payRate).toFixed(2)}</td><td class="wpr-c-green">$${(leaveH * payRate * (1 - taxPct / 100)).toFixed(2)}</td>` : ''}
                  ${hasPay && !taxEnabled ? `<td class="wpr-c-green">$${(leaveH * payRate).toFixed(2)}</td>` : ''}
                </tr>
              </tbody>
            </table>
          </div>
          ${hasPay && !taxEnabled ? `
          <div class="wpr-ts-grand-total">
            <span class="wpr-ts-grand-total__lbl">Grand Total · Worked + Leave</span>
            <span class="wpr-ts-grand-total__val">$${totalEarnings.toFixed(2)}</span>
          </div>` : ''}` : ''}
          ${hasPay && taxEnabled ? `
          <div class="wpr-ts-grand-total wpr-ts-grand-total--tax">
            <div class="wpr-ts-grand-total__row">
              <span class="wpr-ts-grand-total__lbl">Gross Earnings${paidLeave.length ? ' · Worked + Leave' : ''}</span>
              <span class="wpr-ts-grand-total__sub-val">$${grossEarnings.toFixed(2)}</span>
            </div>
            <div class="wpr-ts-grand-total__row">
              <span class="wpr-ts-grand-total__lbl">Income Tax (${taxPct}%)</span>
              <span class="wpr-ts-grand-total__sub-val wpr-ts-grand-total__sub-val--tax">−$${taxAmount.toFixed(2)}</span>
            </div>
            <div class="wpr-ts-grand-total__row wpr-ts-grand-total__row--net">
              <span class="wpr-ts-grand-total__lbl">Net Earnings</span>
              <span class="wpr-ts-grand-total__val">$${netEarnings.toFixed(2)}</span>
            </div>
          </div>` : ''}
        </div>`;

      el.querySelector('#wpr-mts-export').addEventListener('click', () => exportMyCSV(sessions, paidLeave, range));
      el.querySelectorAll('[data-period]').forEach(b => b.addEventListener('click', () => {
        periodType = b.dataset.period;
        if (periodType === 'week')  gran = 'weekly';
        if (periodType === 'month') gran = 'weekly';
        if (periodType === 'year')  gran = 'monthly';
        load();
      }));
      el.querySelectorAll('.wpr-ts-nav-btn').forEach(b => b.addEventListener('click', () => navigate(parseInt(b.dataset.dir))));
      el.querySelectorAll('[data-gran]').forEach(b => b.addEventListener('click', () => {
        gran = b.dataset.gran; render(sessions, paidLeave, range);
      }));
    }

    await load();
  }

  async function viewTimesheetDetail(el, userId, userName, userRoles, payRate, range, onBack) {
    spin(el);
    const taxEnabled  = PAY_ON && SET.taxEnabled && payRate > 0;
    const taxPct      = taxEnabled ? (SET.taxRate || 0) : 0;
    const periodDays  = (new Date(range.end) - new Date(range.start)) / 86400000 + 1;
    let gran = periodDays > 60 ? 'monthly' : 'weekly';

    function fmtD(d) { return [d.getFullYear(), pad(d.getMonth()+1), pad(d.getDate())].join('-'); }

    // Returns the date range to fetch based on the current gran.
    // 'weekly' → original range; 'monthly' → expand to full months; 'yearly' → expand to full years.
    function getEffectiveRange() {
      const s = new Date(range.start + 'T00:00:00');
      const e = new Date(range.end   + 'T00:00:00');
      if (gran === 'monthly') {
        const ms = new Date(s.getFullYear(), s.getMonth(), 1);
        const me = new Date(e.getFullYear(), e.getMonth() + 1, 0);
        const sameMo = s.getFullYear() === e.getFullYear() && s.getMonth() === e.getMonth();
        const label  = sameMo
          ? s.toLocaleDateString('en-AU', {month:'long', year:'numeric'})
          : ms.toLocaleDateString('en-AU', {month:'short', year:'numeric'}) + ' – ' +
            me.toLocaleDateString('en-AU', {month:'short', year:'numeric'});
        return { start: fmtD(ms), end: fmtD(me), label };
      }
      if (gran === 'yearly') {
        const y1 = s.getFullYear(), y2 = e.getFullYear();
        return {
          start: `${y1}-01-01`, end: `${y2}-12-31`,
          label: y1 === y2 ? String(y1) : `${y1} – ${y2}`,
        };
      }
      return range;
    }

    function calcNet(s) {
      if (!s.clock_in || !s.clock_out) return null;
      const h = (new Date(s.clock_out.replace(' ','T')) - new Date(s.clock_in.replace(' ','T'))) / 3600000;
      return Math.max(0, h - (s.break_minutes||0)/60);
    }
    function penaltyMult(clockIn) {
      if (!PENALTY_ON || !clockIn) return 1;
      const dow = new Date(clockIn.replace(' ', 'T')).getDay();
      return dow === 6 ? 1 + SAT_RATE / 100 : (dow === 0 ? 1 + SUN_RATE / 100 : 1);
    }
    function sessionEarnings(s) {
      const net = calcNet(s);
      if (net === null) return null;
      return net * payRate * penaltyMult(s.clock_in);
    }
    function totalEarningsForSessions(arr) {
      return arr.reduce((sum, s) => { const e = sessionEarnings(s); return sum + (e !== null ? e : 0); }, 0);
    }

    function groupByGran(sessions) {
      sessions = sessions.filter(s => s.clock_in);
      if (gran === 'daily') {
        return sessions.map(s => {
          const d = s.clock_in ? new Date(s.clock_in.replace(' ','T')) : null;
          return {
            label: d ? d.toLocaleDateString('en-AU',{weekday:'short',day:'numeric',month:'short',year:'numeric'}) : '—',
            total: calcNet(s), sessions: [s],
          };
        });
      }
      const groups = {};
      sessions.forEach(s => {
        const d = s.clock_in ? new Date(s.clock_in.replace(' ','T')) : null;
        let key, label;
        if (!d) { key = 'unknown'; label = 'Unknown'; }
        else if (gran === 'weekly') {
          const mon = new Date(d); mon.setDate(d.getDate()-((d.getDay()-WEEK_START+7)%7));
          const sun = new Date(mon); sun.setDate(mon.getDate()+6);
          key   = fmtD(mon);
          label = mon.toLocaleDateString('en-AU',{day:'numeric',month:'short'}) + ' – ' +
                  sun.toLocaleDateString('en-AU',{day:'numeric',month:'short',year:'numeric'});
        } else if (gran === 'monthly') {
          key   = `${d.getFullYear()}-${pad(d.getMonth()+1)}`;
          label = d.toLocaleDateString('en-AU',{month:'long',year:'numeric'});
        } else {
          key = `${d.getFullYear()}`; label = key;
        }
        if (!groups[key]) groups[key] = { key, label, sessions:[], total:0 };
        groups[key].sessions.push(s);
        const net = calcNet(s); if (net !== null) groups[key].total += net;
      });
      return Object.values(groups).sort((a,b) => a.key > b.key ? 1 : -1);
    }

    function editRecordModal(record, onSave) {
      const ov = modal({
        title: 'Edit Attendance Record',
        size: 'md',
        body: `
          <div class="wpr-form-grid">
            <div class="wpr-field">
              <label class="wpr-label">Clock In</label>
              <input id="wpr-rec-ci" class="wpr-input" type="datetime-local" value="${record.clock_in ? record.clock_in.replace(' ','T').slice(0,16) : ''}">
            </div>
            <div class="wpr-field">
              <label class="wpr-label">Clock Out</label>
              <input id="wpr-rec-co" class="wpr-input" type="datetime-local" value="${record.clock_out ? record.clock_out.replace(' ','T').slice(0,16) : ''}">
            </div>
            <div class="wpr-field">
              <label class="wpr-label">Break (minutes)</label>
              <input id="wpr-rec-brk" class="wpr-input" type="number" min="0" value="${record.break_minutes||0}">
            </div>
            <div class="wpr-field">
              <label class="wpr-label">Notes</label>
              <input id="wpr-rec-notes" class="wpr-input" type="text" maxlength="256" value="${record.notes||''}">
            </div>
          </div>`,
        actions: [
          { id:'cancel', label:'Cancel', cls:'wpr-btn--ghost', fn: ov => ov.remove() },
          { id:'save', label:'Save Changes', cls:'wpr-btn--primary', fn: async (ov) => {
            const ci    = document.getElementById('wpr-rec-ci').value;
            const co    = document.getElementById('wpr-rec-co').value;
            const brk   = parseInt(document.getElementById('wpr-rec-brk').value)||0;
            const notes = document.getElementById('wpr-rec-notes').value;
            if (!ci) { toast('Clock-in time is required.','err'); return; }
            try {
              await http(`/attendance/${record.id}`, 'PUT', {
                id: record.id, clock_in: ci, clock_out: co || null,
                break_minutes: brk, notes,
              });
              toast('Record updated.','ok');
              ov.remove();
              onSave();
            } catch(e) { toast(e.message,'err'); }
          }},
        ],
      });
      return ov;
    }

    function exportUserCSV(sessions, paidLeave, effectiveRange) {
      try {
        const showLeave = timesheetShowLeave();
        const groups  = groupByGran(sessions);
        const hasPay  = CAN_SEE_PAY && payRate > 0;
        const leaveH  = showLeave ? paidLeave.reduce((s, l) => s + (l.hours || 0), 0) : 0;
        const leaveDays = showLeave ? paidLeave.reduce((s, l) => s + (l.days || 0), 0) : 0;
        const taxCols = hasPay && taxEnabled && taxPct > 0
          ? [`Tax Amount (${taxPct}%)`, 'Net Earnings'] : [];
        const rows    = [['Period', 'Hours Worked', ...(showLeave ? ['Leave (Days)'] : []),
          ...(hasPay ? ['Pay Rate ($/hr)', 'Gross Earnings', ...taxCols] : [])]];
        groups.forEach(g => {
          const gross = hasPay ? totalEarningsForSessions(g.sessions) : null;
          const tax   = (hasPay && taxEnabled && taxPct > 0) ? gross * (taxPct / 100) : null;
          rows.push([
            g.label, g.total.toFixed(2), ...(showLeave ? [''] : []),
            ...(hasPay ? [
              payRate.toFixed(2),
              gross.toFixed(2),
              ...(tax !== null ? [tax.toFixed(2), (gross - tax).toFixed(2)] : []),
            ] : []),
          ]);
        });
        if (showLeave && paidLeave.length) {
          paidLeave.forEach(l => {
            const label  = `${LEAVE_TYPE[l.leave_type]||l.leave_type}: ${fmtDate(l.start_date)} – ${fmtDate(l.end_date)}`;
            const lGross = hasPay ? l.hours * payRate : null;
            const lTax   = (hasPay && taxEnabled && taxPct > 0) ? lGross * (taxPct / 100) : null;
            rows.push([label, '', String(l.days),
              ...(hasPay ? [
                payRate.toFixed(2),
                lGross.toFixed(2),
                ...(lTax !== null ? [lTax.toFixed(2), (lGross - lTax).toFixed(2)] : []),
              ] : [])]);
          });
        }
        const totalH  = sessions.reduce((s,r) => { const n=calcNet(r); return s+(n||0); }, 0);
        const tGross  = hasPay ? (totalEarningsForSessions(sessions) + leaveH * payRate) : null;
        const tTax    = (hasPay && taxEnabled && taxPct > 0) ? tGross * (taxPct / 100) : null;
        rows.push(['Total', totalH.toFixed(2), ...(showLeave ? [String(leaveDays)] : []),
          ...(hasPay ? [
            payRate.toFixed(2),
            tGross.toFixed(2),
            ...(tTax !== null ? [tTax.toFixed(2), (tGross - tTax).toFixed(2)] : []),
          ] : [])]);
        const csv  = rows.map(r => r.map(v => `"${String(v).replace(/"/g,'""')}"`).join(',')).join('\n');
        const blob = new Blob([csv], {type:'text/csv'});
        const url  = URL.createObjectURL(blob);
        const a    = document.createElement('a');
        a.href = url; a.download = `earnings-${userName.replace(/\s+/g,'-')}-${effectiveRange.start}-to-${effectiveRange.end}.csv`; a.click();
        URL.revokeObjectURL(url);
        toast('Export ready.', 'ok');
      } catch(e) { toast(e.message, 'err'); }
    }

    async function load() {
      spin(el);
      try {
        const r        = getEffectiveRange();
        const data     = await http(`/timesheets/${userId}?user_id=${userId}&start=${r.start}&end=${r.end}`);
        const sessions = Array.isArray(data) ? data : (data.sessions || []);
        const paidLeave = Array.isArray(data) ? [] : (data.paid_leave || []);
        render(sessions, paidLeave, r);
      } catch(e) { el.innerHTML = errBox(e.message); }
    }

    function render(sessions, paidLeave, displayRange) {
      const groups        = groupByGran(sessions);
      const roleHtml      = staffRoleBadge(userRoles);
      const workedH       = groups.reduce((s, g) => s + (g.total || 0), 0);
      const leaveH        = paidLeave.reduce((s, l) => s + (l.hours || 0), 0);
      const leaveDays     = paidLeave.reduce((s, l) => s + (l.days  || 0), 0);
      const totalH        = workedH + leaveH;
      const hasPay         = CAN_SEE_PAY && payRate > 0;
      const workedEarnings = hasPay ? groups.reduce((s, g) => s + totalEarningsForSessions(g.sessions), 0) : null;
      const grossEarnings  = hasPay ? (workedEarnings + leaveH * payRate) : null;
      const taxAmount      = (hasPay && taxEnabled) ? grossEarnings * (taxPct / 100) : null;
      const netEarnings    = (hasPay && taxEnabled) ? grossEarnings - taxAmount : null;
      const totalEarnings  = grossEarnings; // gross (used in non-tax grand total)

      el.innerHTML = `
        <div class="wpr-view">
          <div class="wpr-page-header">
            <div style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap">
              <button class="wpr-btn wpr-btn--ghost wpr-btn--sm" id="wpr-ts-back">← Back</button>
              <div>
                <h2 class="wpr-page-title" style="margin:0">${userName}</h2>
                <p class="wpr-page-sub" style="margin:.15rem 0 0">${displayRange.label || displayRange.start + ' – ' + displayRange.end}</p>
              </div>
              ${roleHtml}
            </div>
            <button class="wpr-btn wpr-btn--primary wpr-btn--sm" id="wpr-ts-export-user">↓ Export CSV</button>
          </div>

          <div class="wpr-ts-stats">
            <div class="wpr-ts-stat">
              <div class="wpr-ts-stat__lbl">Hours Worked</div>
              <div class="wpr-ts-stat__val">${workedH.toFixed(2)}<span style="font-size:.85rem;font-weight:500;color:var(--wpr-muted)">h</span></div>
            </div>
            ${leaveDays > 0 ? `<div class="wpr-ts-stat wpr-ts-stat--amber">
              <div class="wpr-ts-stat__lbl">Leave</div>
              <div class="wpr-ts-stat__val">${leaveDays}<span style="font-size:.85rem;font-weight:500;color:var(--wpr-muted)"> ${leaveDays === 1 ? 'day' : 'days'}</span></div>
            </div>` : ''}
            ${hasPay && taxEnabled ? `
            <div class="wpr-ts-stat wpr-ts-stat--green">
              <div class="wpr-ts-stat__lbl">Gross Earnings</div>
              <div class="wpr-ts-stat__val">$${grossEarnings.toFixed(2)}</div>
            </div>
            <div class="wpr-ts-stat wpr-ts-stat--red">
              <div class="wpr-ts-stat__lbl">Tax (${taxPct}%)</div>
              <div class="wpr-ts-stat__val">−$${taxAmount.toFixed(2)}</div>
            </div>
            <div class="wpr-ts-stat wpr-ts-stat--green">
              <div class="wpr-ts-stat__lbl">Net Earnings</div>
              <div class="wpr-ts-stat__val">$${netEarnings.toFixed(2)}</div>
            </div>` : ''}
            ${hasPay && !taxEnabled ? `<div class="wpr-ts-stat wpr-ts-stat--green">
              <div class="wpr-ts-stat__lbl">Worked Earnings</div>
              <div class="wpr-ts-stat__val">$${workedEarnings.toFixed(2)}</div>
            </div>` : ''}
            ${hasPay && !taxEnabled && leaveH > 0 ? `<div class="wpr-ts-stat wpr-ts-stat--green">
              <div class="wpr-ts-stat__lbl">Total Earnings</div>
              <div class="wpr-ts-stat__val">$${totalEarnings.toFixed(2)}</div>
            </div>` : ''}
            ${hasPay ? `<div class="wpr-ts-stat wpr-ts-stat--brand">
              <div class="wpr-ts-stat__lbl">Pay Rate</div>
              <div class="wpr-ts-stat__val">$${payRate.toFixed(2)}<span style="font-size:.85rem;font-weight:500;color:var(--wpr-muted)">/hr</span></div>
            </div>` : ''}
          </div>

          <div class="wpr-ts-gran-row">
            <span class="wpr-ts-gran-label">Group by</span>
            <div class="wpr-view-toggle">
              ${['weekly','monthly','yearly'].map(g => `
                <button class="wpr-vt-btn ${g===gran?'wpr-vt-btn--active':''}" data-gran="${g}">
                  ${g.charAt(0).toUpperCase()+g.slice(1)}
                </button>`).join('')}
            </div>
          </div>

          ${groups.length ? `
          <div class="wpr-table-wrap wpr-ts-earnings-table">
            <table class="wpr-table">
              <thead><tr>
                <th>Period</th>
                <th>Hours Worked</th>
                ${hasPay && taxEnabled ? '<th>Gross</th><th>Net</th>' : ''}
                ${hasPay && !taxEnabled ? '<th>Earnings</th>' : ''}
              </tr></thead>
              <tbody>
                ${groups.map(g => {
                  const gross = hasPay ? totalEarningsForSessions(g.sessions) : null;
                  const net   = (hasPay && taxEnabled) ? gross * (1 - taxPct / 100) : null;
                  return `<tr>
                    <td>${g.label}</td>
                    <td>${g.total > 0 ? `<strong>${g.total.toFixed(2)}h</strong>` : '<span class="wpr-c-muted">—</span>'}</td>
                    ${hasPay && taxEnabled && gross !== null ? `<td>$${gross.toFixed(2)}</td><td><strong class="wpr-c-green">$${net.toFixed(2)}</strong></td>` : ''}
                    ${hasPay && !taxEnabled && gross !== null ? `<td><strong class="wpr-c-green">$${gross.toFixed(2)}</strong></td>` : ''}
                  </tr>`;
                }).join('')}
                ${groups.length > 1 ? `<tr class="wpr-ts-total-row">
                  <td>Worked Total</td>
                  <td>${workedH.toFixed(2)}h</td>
                  ${hasPay && taxEnabled ? `<td>$${workedEarnings.toFixed(2)}</td><td class="wpr-c-green">$${(workedEarnings * (1 - taxPct / 100)).toFixed(2)}</td>` : ''}
                  ${hasPay && !taxEnabled ? `<td class="wpr-c-green">$${workedEarnings.toFixed(2)}</td>` : ''}
                </tr>` : ''}
              </tbody>
            </table>
          </div>` : `
          <div class="wpr-ts-empty">
            <div class="wpr-ts-empty__icon">⊟</div>
            <p class="wpr-ts-empty__msg">No ${SET.allowSelfClock ? 'attendance records' : 'scheduled shifts'} found for this period.</p>
          </div>`}

          ${paidLeave.length ? `
          <div class="wpr-ts-section-hd">
            <span class="wpr-ts-section-hd__title">Leave</span>
          </div>
          <div class="wpr-table-wrap wpr-ts-earnings-table">
            <table class="wpr-table">
              <thead><tr>
                <th>Type</th><th>From</th><th>To</th><th>Days</th>
                ${hasPay && taxEnabled ? '<th>Gross</th><th>Net</th>' : ''}
                ${hasPay && !taxEnabled ? '<th>Leave Pay</th>' : ''}
              </tr></thead>
              <tbody>
                ${paidLeave.map(l => {
                  const lGross = hasPay ? l.hours * payRate : null;
                  const lNet   = (hasPay && taxEnabled) ? lGross * (1 - taxPct / 100) : null;
                  return `<tr>
                    <td>${LEAVE_TYPE[l.leave_type]||l.leave_type}</td>
                    <td>${fmtDate(l.start_date)}</td>
                    <td>${fmtDate(l.end_date)}</td>
                    <td>${l.days} ${l.days === 1 ? 'day' : 'days'}</td>
                    ${hasPay && taxEnabled ? `<td>$${lGross.toFixed(2)}</td><td><strong class="wpr-c-green">$${lNet.toFixed(2)}</strong></td>` : ''}
                    ${hasPay && !taxEnabled ? `<td><strong class="wpr-c-green">$${lGross.toFixed(2)}</strong></td>` : ''}
                  </tr>`;
                }).join('')}
                <tr class="wpr-ts-total-row">
                  <td colspan="3">Leave Total</td>
                  <td>${leaveDays} ${leaveDays === 1 ? 'day' : 'days'}</td>
                  ${hasPay && taxEnabled ? `<td>$${(leaveH * payRate).toFixed(2)}</td><td class="wpr-c-green">$${(leaveH * payRate * (1 - taxPct / 100)).toFixed(2)}</td>` : ''}
                  ${hasPay && !taxEnabled ? `<td class="wpr-c-green">$${(leaveH * payRate).toFixed(2)}</td>` : ''}
                </tr>
              </tbody>
            </table>
          </div>
          ${hasPay && !taxEnabled ? `
          <div class="wpr-ts-grand-total">
            <span class="wpr-ts-grand-total__lbl">Grand Total · Worked + Leave</span>
            <span class="wpr-ts-grand-total__val">$${totalEarnings.toFixed(2)}</span>
          </div>` : ''}` : ''}
          ${hasPay && taxEnabled ? `
          <div class="wpr-ts-grand-total wpr-ts-grand-total--tax">
            <div class="wpr-ts-grand-total__row">
              <span class="wpr-ts-grand-total__lbl">Gross Earnings${paidLeave.length ? ' · Worked + Leave' : ''}</span>
              <span class="wpr-ts-grand-total__sub-val">$${grossEarnings.toFixed(2)}</span>
            </div>
            <div class="wpr-ts-grand-total__row">
              <span class="wpr-ts-grand-total__lbl">Income Tax (${taxPct}%)</span>
              <span class="wpr-ts-grand-total__sub-val wpr-ts-grand-total__sub-val--tax">−$${taxAmount.toFixed(2)}</span>
            </div>
            <div class="wpr-ts-grand-total__row wpr-ts-grand-total__row--net">
              <span class="wpr-ts-grand-total__lbl">Net Earnings</span>
              <span class="wpr-ts-grand-total__val">$${netEarnings.toFixed(2)}</span>
            </div>
          </div>` : ''}
        </div>`;

      el.querySelector('#wpr-ts-back').addEventListener('click', onBack);
      el.querySelector('#wpr-ts-export-user').addEventListener('click', () => exportUserCSV(sessions, paidLeave, displayRange));
      el.querySelectorAll('[data-gran]').forEach(b => b.addEventListener('click', () => {
        gran = b.dataset.gran; load();
      }));
    }

    await load();
  }

  /* ═══════════════ NAVIGATION ═══════════════ */

  function updateNavBadge(n) {
    // In WP admin the plugin's own sidebar is hidden (display:none via admin.css).
    // Target the WP admin menu submenu link instead so the badge is actually visible.
    var adminLink  = document.querySelector('#adminmenu a[href*="employee-roster-leave"]');
    var customLink = document.querySelector('[data-nav="leave"]');
    var link = adminLink || customLink;

    var b = document.getElementById('wpr-req-badge');
    if (n > 0) {
      if (!b) {
        if (!link) return;
        b = document.createElement('span');
        b.id = 'wpr-req-badge';
        b.style.cssText = adminLink
          ? 'display:inline-block;min-width:18px;height:18px;padding:0 4px;border-radius:9px;background:#dc2626;color:#fff;font-size:11px;font-weight:700;line-height:18px;text-align:center;margin-left:6px;vertical-align:middle;box-sizing:border-box'
          : 'display:inline-flex;align-items:center;justify-content:center;min-width:1.2rem;height:1.2rem;padding:0 .28rem;border-radius:.6rem;background:#dc2626;color:#fff;font-size:.65rem;font-weight:700;line-height:1;margin-left:auto;flex-shrink:0';
        link.appendChild(b);
      } else {
        b.style.display = adminLink ? 'inline-block' : 'inline-flex';
      }
      b.textContent = n > 99 ? '99+' : String(n);
    } else if (b) {
      b.style.display = 'none';
    }
  }


  function nav(wrap, currentPage) {
    const items = navItems().filter(i => i.show);

    const isActive = (i) => i.page === currentPage || (currentPage === '' && i.page === '');

    const sidebar = document.createElement('aside');
    sidebar.className = 'wpr-sidebar';
    sidebar.innerHTML = `
      <div class="wpr-sidebar__brand">
        <span class="wpr-sidebar__logo">⬡</span>
        <span class="wpr-sidebar__name">${SET.companyName || 'WP Roster'}</span>
      </div>
      <nav class="wpr-sidebar__nav">
        ${items.map(i => `
          <a class="wpr-nav-item ${isActive(i)?'wpr-nav-item--active':''}" data-nav="${i.page}" href="#">
            <span class="wpr-nav-item__icon">${i.icon}</span>
            <span class="wpr-nav-item__label">${i.label}</span>
          </a>`).join('')}
      </nav>
      <div class="wpr-sidebar__foot">
        <span class="wpr-sidebar__user">${USER.name}</span>
      </div>`;

    wrap.prepend(sidebar);
    updateNavBadge(PENDING_COUNT);

    sidebar.querySelectorAll('[data-nav]').forEach(a => {
      a.addEventListener('click', e => {
        e.preventDefault();
        sidebar.querySelectorAll('.wpr-nav-item').forEach(x => x.classList.remove('wpr-nav-item--active'));
        a.classList.add('wpr-nav-item--active');
        const content = wrap.querySelector('.wpr-content');
        if (content) route(a.dataset.nav, content);
      });
    });
  }

  function route(page, content) {
    page = routeGuard(page);
    content.innerHTML = '';
    const views = viewMap();
    const fn     = views[page] || viewDashboard;
    const result = fn(content);
    // All view functions are async — catch any unhandled rejection so the
    // content area always shows a message rather than going blank.
    if (result && typeof result.catch === 'function') {
      result.catch(e => {
        if (!content.querySelector('.wpr-err-box')) {
          content.innerHTML = errBox(e.message);
        }
      });
    }
  }

  function errBox(msg) {
    return `<div class="wpr-err-box">
      <strong>Error:</strong> ${msg}
      <button class="wpr-btn wpr-btn--ghost wpr-btn--xs" style="margin-left:1rem" onclick="window.location.reload()">Reload page</button>
    </div>`;
  }


  /* ═══════════════ OPTIONAL SEAMS (default implementations) ═══════════════ */
  // Default definitions. An optional views fragment may redeclare each with a full
  // body (last top-level declaration wins); absent that fragment these stand
  // unchanged. This file carries no build-variant vocabulary — what varies is
  // entirely a matter of which fragment is concatenated after it.

  // Dashboard analytics section — optional-seam. Renders nothing by default.
  function dashboardAnalytics() {}

  // Sidebar menu items. Free ships the 5 menus EMPR_Admin::menus() registers.
  function navItems() {
    return [
      { id:'wp-roster',            page:'',           label:'Dashboard',  icon:'⊞', show:true },
      { id:'wp-roster-shifts',     page:'shifts',     label:'Shifts',     icon:'◫', show:true },
      { id:'wp-roster-timesheets', page:'timesheets', label:'Timesheets', icon:'⊟', show:true },
      { id:'wp-roster-staff',      page:'staff',      label:'Staff',      icon:'⊞', show:CAN_VIEW_ALL },
      { id:'wp-roster-profile',    page:'profile',    label:'Profile',    icon:'◉', show:true },
    ];
  }

  // Route table. Free ships only the routes its menus expose.
  function viewMap() {
    return {
      '':             viewDashboard,
      'dashboard':    viewDashboard,
      'shifts':       viewShifts,
      'profile':      viewProfile,
      'availability': viewProfile,
      'staff':        viewStaff,
      'timesheets':   viewTimesheets,
    };
  }

  // Route guard hook. Free has no tier-gated routes.
  function routeGuard(page) { return page; }

  // Settings-refresh hook. Free applies settings only; the optional fragment also recomputes its own flags
  // and re-routes when the available views changed.
  function onSettings(data) { applySettings(data); }

  // Pending-requests nav badge count. Leave/open-shift/swap are optional-seam features; the default is 0.
  function pendingBadgeCount() { return 0; }

  // Dashboard KPI tiles that are optional-seam.
  function dashboardClockKpi() { return ''; }
  function dashboardManagerKpis() { return ''; }

  // Roster data overlays fetched only when the optional fragment is present.
  function rosterLeaveFetch() { return Promise.resolve([]); }
  function rosterOpenShiftsFetch() { return Promise.resolve([]); }

  // Roster UI that is optional-seam.
  function rosterToolbarButtons() { return ''; }
  function rosterBudgetRow() { return ''; }
  function rosterDayBudget() { return ''; }
  function jobHoursLimit() { return null; }
  function shiftSwapBtn() { return ''; }
  function jobSheetClip() { return ''; }
  function rosterOpenRow() { return ''; }

  // Shift modal fields/handlers that are optional-seam.
  function shiftTypeOptions() { return ''; }
  function jobSheetField() { return ''; }
  async function jobSheetSave() { return true; }
  function jobSheetWireRemove() {}
  function jobSheetDetailRow() { return ''; }

  // Profile skills — optional-seam.
  function profileSkillsView() { return ''; }
  function profileSkillsEditor() { return ''; }
  function profileSkillsWire() {}

  // Staff module — skills are optional-seam.
  function staffExportBtn() { return ''; }
  function staffAddRoleField() { return ''; }
  function staffSkillsEditor() { return ''; }
  function staffSkillsWire() {}
  function staffCreatedFeedback() { toast('Employee created successfully.'); }
  function staffResetAccountAction() { return []; }
  function staffDetailSkillsRow() { return ''; }

  // Timesheet leave column / export — optional-seam.
  function timesheetExportBtns() { return ''; }
  function timesheetLeaveHead() { return ''; }
  function timesheetLeaveCell() { return ''; }
  function timesheetShowLeave() { return false; }

  /* ═══════════════ BOOTSTRAP ═══════════════ */

  function boot() {
    // Apply brand colour immediately from the server-side-rendered settings value
    // so there is no flash of the default blue before the settings fetch resolves.
    if (SET.primaryColor) applyBrandColor(SET.primaryColor);

    // Wire up the shared info-tooltip popover used by .wpr-info card triggers.
    initInfoTips();

    // Label table cells so they can card-stack on phones.
    initTableLabels();

    // Live preview on the Settings page colour picker — reflects changes in real time
    // before the form is saved.
    const brandInput = document.querySelector('#emr_primary_color');
    if (brandInput) brandInput.addEventListener('input', function () { applyBrandColor(this.value); });

    // Show a deferred toast if a password change just triggered a reload.
    if (sessionStorage.getItem('emr_pw_changed')) {
      sessionStorage.removeItem('emr_pw_changed');
      setTimeout(() => toast('Password updated successfully.'), 400);
    }

    document.querySelectorAll('#emr-app, .emr-app').forEach(app => {
      if (app.dataset.booted) return;
      app.dataset.booted = '1';

      const page = app.dataset.page || '';

      app.classList.add('wpr-shell');

      const content = document.createElement('main');
      content.className = 'wpr-content';
      app.appendChild(content);

      nav(app, page);
      route(page, content);

      // Update the nav badge from live data on every page load.
      // viewDashboard and viewLeave also call updateNavBadge, but those only
      // fire when those specific views are active. This fetch covers all other
      // pages (Shifts, Staff, Timesheets, etc.).
      (function() {
        var _viewUpdates = page === '' || page === 'employee-roster' || page === 'dashboard' || page === 'leave';
        if (_viewUpdates) return; // already covered by viewDashboard / viewLeave
        http('/dashboard').then(function(d) {
          if (!d) return;
          updateNavBadge(pendingBadgeCount(d));
        }).catch(function() {});
      }());

      // Fetch fresh settings from the server after the initial render, then let
      // onSettings() apply them (and re-render if it needs to).
      http('/settings').then(function(data) {
        if (!data) return;
        onSettings(data, function() { route(page, content); });
      }).catch(function() {});
    });
  }

  // boot() is invoked at the very end of the concatenated file (in the optional views fragment
  // or app-free-stubs.js), after every fragment's top-level declarations have run — so
  // the `let`s declared in the optional views fragment are initialised before boot reads
  // them. See the build notes under build/.
/* ── app-free-stubs.js ──────────────────────────────────────────────────────────────────────────────────────────────────────────
 * Default seam implementations for this build.
 * Concatenated after app-core.js to complete the IIFE.
 * Build: cat admin/js/app-core.js admin/js/app-free-stubs.js > admin/js/app.js
 */

  function skillTagInputHTML() { return ''; }
  function wireSkillTags() {}
  async function uploadJobSheet() {}
  function dayCost() { return 0; }
  function budgetChip() { return ''; }
  function renderAnalytics() {}
  function copyRosterModal() {}
  function leaveModal() {}
  function leaveEditModal() {}
  function leaveAdjustModal() {}
  async function swapModal() {}
  function swapEditModal() {}
  function exportStaffData() {}
  function staffPasswordModal() {}
  function staffAccountResetModal() {}
  function exportCSV() {}
  function printRoster() {}
  async function viewAttendance() {}
  async function viewLeave() {}
  async function viewLoginLogs() {}

  // main() — run after all top-level declarations. See the build notes under build/.
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
  else boot();

})(window.EMPR);
