EPOCH extension

EPOCHシステム改善 + 宿直記録自動入力

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         EPOCH extension
// @namespace    https://iweb.is.jp.panasonic.com/
// @version      1.7.0
// @author       auto-generated
// @match        https://iweb.is.jp.panasonic.com/cont/mhio/*
// @match        https://iweb.is.jp.panasonic.com/epoch/pa1npa/a2A/*
// @require      https://cdn.jsdelivr.net/npm/[email protected]/lodash.min.js
// @connect      cdn.jsdelivr.net
// @grant        none
// @run-at       document-idle
// @license      MIT
// @description  EPOCHシステム改善 + 宿直記録自動入力
// ==/UserScript==

/* global $ */
/* global _ */

(function () {
  'use strict';

  // ══════════════════════════════════════════════════════════════════════
  // グローバル状態
  // ══════════════════════════════════════════════════════════════════════
  let isApplyingShukucho = false;

  // ══════════════════════════════════════════════════════════════════════
  // 機能1: EPOCHシステムボタン自動クリック(/cont/mhio/)
  // ══════════════════════════════════════════════════════════════════════
  if (location.href.includes('/cont/mhio/')) {
    const EPOCH_URL    = 'https://iweb.is.jp.panasonic.com/epoch/pa1ncv/a2A/?_GLOC=PA1NCV.CV01';
    const EPOCH_TARGET = 'wCallEpoch';
    const clicked = $('a[href]').filter((i, a) =>
      a.href.includes('/epoch/pa1ncv/a2A/') || $(a).text().trim() === 'EPOCHシステム'
    ).first().each(function () { this.click(); }).length > 0;
    if (!clicked) window.open(EPOCH_URL, EPOCH_TARGET);
    return;
  }

  // ══════════════════════════════════════════════════════════════════════
  // 機能2: TDT_SKB_KB プルダウン → ラジオボタン変換(PA0105)
  // ══════════════════════════════════════════════════════════════════════
  if (location.href.includes('/epoch/pa1npa/a2A/PA0105')) {
    const RADIO_OPTIONS = [{ value: '01', label: '特別勤務' }, { value: '02', label: '自己研鑽' }];

    const replaceSelectWithRadio = (i, select) => {
      if (select.dataset.skbReplaced) return;
      select.dataset.skbReplaced = 'true';
      const $sel = $(select).hide();
      const groupName = select.name + '_radio';
      const $container = $('<span>', { style: 'display:inline-flex;gap:0;align-items:center;flex-wrap:wrap' });
      RADIO_OPTIONS.forEach(opt => {
        const $radio = $('<input>', {
          type: 'radio', name: groupName, value: opt.value,
          style: 'cursor:pointer;margin:0'
        })
          .prop('checked', select.value === opt.value)
          .on('change', function () {
            select.value = this.value;
            $sel[0].dispatchEvent(new Event('change', { bubbles: true }));
          });
        $('<label>', { style: 'cursor:pointer;display:inline-flex;align-items:center;gap:3px;white-space:nowrap' })
          .append($radio, document.createTextNode(opt.label))
          .appendTo($container);
      });
      $sel.after($container);
    };

    const processAllSelects = () =>
      $('select[name*="TDT_SKB_KB"],select#TDT_SKB_KB').each(replaceSelectWithRadio);

    processAllSelects();
    new MutationObserver(_.debounce(processAllSelects, 150))
      .observe(document.body, { childList: true, subtree: true });
    return;
  }

  // ══════════════════════════════════════════════════════════════════════
  // 機能3 + 機能4: PA0101 共通処理
  // ══════════════════════════════════════════════════════════════════════
  if (!location.href.includes('/epoch/pa1npa/a2A/PA0101')) return;

  // ── 共通スタイル注入 ─────────────────────────────────────────────────
  $('<style>').text(`
    .epoch-gate-times {
      display:inline-flex; align-items:center; gap:14px; margin-left:10px;
      font-size:13px; color:#fff; font-weight:normal; letter-spacing:.3px; vertical-align:middle;
    }
    .epoch-gate-times span { display:inline-flex; align-items:center; gap:4px }
    #shukucho-dialog { border:none; border-radius:6px; padding:0; min-width:560px; box-shadow:0 4px 24px rgba(0,0,0,.3); }
    #shukucho-dialog::backdrop { background:rgba(0,0,0,.4); }
    .sd-header { display:flex; justify-content:space-between; align-items:center;
                 padding:10px 14px; background:#1a56a0; color:#fff; border-radius:6px 6px 0 0; }
    .sd-header h2 { margin:0; font-size:15px; }
    .sd-body { padding:12px 14px; max-height:60vh; overflow-y:auto; }
    .sd-footer { display:flex; justify-content:flex-end; gap:8px;
                 padding:10px 14px; border-top:1px solid #ddd; }
    .sd-date-bar { display:flex; align-items:center; gap:8px; margin-bottom:10px; font-size:13px; }
    .sd-table { width:100%; border-collapse:collapse; font-size:13px; }
    .sd-table th, .sd-table td { border:1px solid #ccc; padding:4px 6px; }
    .sd-table th { background:#f0f4fa; text-align:center; }
    .sd-labor { font-size:13px; margin-top:8px; text-align:right; }
    #sd-icon-btn { cursor:pointer; }
    .sd-preview { margin-top:12px; padding:10px; background:#f9f9f9; border:1px dashed #bbb; border-radius:4px; font-size:12px; color:#333; }
    .sd-preview h3 { margin:0 0 6px 0; font-size:13px; color:#1a56a0; border-bottom:1px solid #eee; padding-bottom:3px; }
    .sd-preview ul { margin:0; padding-left:20px; }
    .sd-preview li { margin-bottom:3px; }
    .sd-row-reset-btn { background:none; border:none; padding:2px; cursor:pointer; color:#777; display:inline-flex; align-items:center; justify-content:center; border-radius:3px; }
    .sd-row-reset-btn:hover { background:#eee; color:#cc0000; }
    .epoch-toggle-wrap { display:inline-flex; gap:2px; align-items:center; flex-wrap:nowrap }
    .epoch-toggle-wrap label {
      display:inline-block; padding:3px 6px; border:1px solid #0041c0; border-radius:3px;
      cursor:pointer; font-size:12px; line-height:1.4; white-space:nowrap;
      user-select:none; transition:background-color .15s,color .15s;
      background:#fff; color:#0041c0;
    }
    .epoch-toggle-wrap label.active { background:#0041c0; color:#fff }
    .epoch-toggle-wrap input[type=radio] { position:absolute; opacity:0; width:0; height:0; pointer-events:none }
    .epoch-loading-mask {
      position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
      background: rgba(0, 0, 0, 0.4); z-index: 99999;
      display: flex; justify-content: center; align-items: center;
      backdrop-filter: blur(1px);
    }
    .epoch-loading-box {
      background: #ffffff; padding: 22px 45px; border-radius: 8px;
      box-shadow: 0 4px 20px rgba(0,0,0,0.25); text-align: center;
      border: 1px solid #ddd;
    }
    .epoch-loading-text {
      color: #222; font-size: 16px; font-weight: bold; margin: 0;
      font-family: sans-serif; letter-spacing: 0.5px;
    }
  `).appendTo('head');

  // iframe内に注入するトグルCSS
  const TOGGLE_CSS = `
    .epoch-toggle-wrap { display:inline-flex; gap:2px; align-items:center; flex-wrap:nowrap }
    .epoch-toggle-wrap label {
      display:inline-block; padding:3px 6px; border:1px solid #0041c0; border-radius:3px;
      cursor:pointer; font-size:12px; line-height:1.4; white-space:nowrap;
      user-select:none; transition:background-color .15s,color .15s;
      background:#fff; color:#0041c0;
    }
    .epoch-toggle-wrap label.active { background:#0041c0; color:#fff }
    .epoch-toggle-wrap input[type=radio] { position:absolute; opacity:0; width:0; height:0; pointer-events:none }
  `;

  // ── 【修正】SVG定数をテンプレートリテラルで定義 ──────────────────────
  const SVG_LAND = `<svg xmlns="http://www.w3.org/2000/svg" height="18" width="18" viewBox="0 -960 960 960" fill="currentColor" aria-hidden="true" focusable="false" style="vertical-align:middle;flex-shrink:0"><title>入門時間</title><path d="M120-120v-80h720v80H120Zm622-202L120-499v-291l96 27 48 139 138 39-35-343 115 34 128 369 172 49q25 8 41.5 29t16.5 48q0 35-28.5 61.5T742-322Z"/></svg>`;
  const SVG_TAKEOFF = `<svg xmlns="http://www.w3.org/2000/svg" height="18" width="18" viewBox="0 -960 960 960" fill="currentColor" aria-hidden="true" focusable="false" style="vertical-align:middle;flex-shrink:0"><title>退門時間</title><path d="M120-120v-80h720v80H120Zm70-200L40-570l96-26 112 94 140-37-207-276 116-31 299 251 170-46q32-9 60.5 7.5T864-585q9 32-7.5 60.5T808-487L190-320Z"/></svg>`;
  const SVG_BEDTIME = `<svg xmlns="http://www.w3.org/2000/svg" height="38" width="38" viewBox="0 -960 960 960" fill="currentColor" aria-hidden="true" focusable="false" class="icon" style="vertical-align:middle;flex-shrink:0;cursor:pointer;"><title>就寝時間</title><path d="M484-80q-84 0-157.5-32t-128-86.5Q144-253 112-326.5T80-484q0-146 93-257.5T410-880q-18 99 11 193.5T521-521q71 71 165.5 100T880-410q-26 144-138 237T484-80Zm0-80q88 0 163-44t118-121q-86-8-163-43.5T464-465q-61-61-97-138t-43-163q-77 43-120.5 118.5T160-484q0 135 94.5 229.5T484-160Zm-20-305Z"/></svg>`;
  const SVG_REFRESH = `<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16" viewBox="0 -960 960 960" fill="currentColor"><path d="M480-160q-134 0-227-93t-93-227q0-134 93-227t227-93q69 0 132 28.5T720-690v-110h80v280H520v-80h115q-41-44-97.5-67T480-720q-100 0-170 70t-70 170q0 100 70 170t170 70q77 0 139-44t87-116h84q-28 106-114 173t-196 67Z"/></svg>`;

  const formatTime = val => {
    if (!val || val === '----' || !val.trim()) return '--:--';
    const s = val.replace(/\D/g, '');
    return s.length === 4 ? s.slice(0, 2) + ':' + s.slice(2) : val;
  };

  // ══════════════════════════════════════════════════════════════════════
  // 機能3: .face-modal 強化
  // ══════════════════════════════════════════════════════════════════════

  // 3-A: ラジオ → トグルボタン変換
  const convertRadioToToggle = span => {
    if (span.dataset.toggleConverted) return;
    span.dataset.toggleConverted = 'true';
    span.className = 'epoch-toggle-wrap';
    $(span).find('label').each((i, label) => {
      const radio = label.querySelector('input[type="radio"]');
      if (!radio) return;
      label.removeAttribute('style');
      label.classList.toggle('active', radio.checked);
      const updateActive = () =>
        $(span).find('label').each((j, lbl) => {
          void lbl.classList.toggle('active', !!lbl.querySelector('input[type="radio"]')?.checked);
        });
      $(label).on('click', function () {
        radio.checked = true;
        radio.dispatchEvent(new Event('change', { bubbles: true }));
        setTimeout(updateActive, 0);
      });
      $(radio).on('change', updateActive);
    });
  };

  // 3-B: 列幅調整
  const adjustTableColumns = doc => {
    const $table = $(doc).find('table#PRM_DATA_LST');
    if (!$table.length || $table.data('colAdjusted')) return;
    $table.data('colAdjusted', true);
    const cols = $table.find('col');
    if (cols.length < 7) return;
    $.each([1, 2, 4, 5, 6], (i, ci) => $(cols[ci]).css('width', ['15%', '8%', '8%', '8%', '37%'][i]));
  };

  // 3-C: iframe 内処理(CSS注入 + ラジオ変換 + 列幅調整)
  const processIframeContent = doc => {
    if (!doc.head.querySelector('style[data-epoch-toggle]')) {
      $(doc.head).append($('<style>', { 'data-epoch-toggle': '' }).text(TOGGLE_CSS));
    }
    $(doc).find('input[name*="TDT_SKB_KB_radio"]').each((i, radio) => {
      const span = radio.closest('span');
      if (span) convertRadioToToggle(span);
    });
    adjustTableColumns(doc);
  };

  // 3-D: モーダル強化メイン
  const enhanceModal = modal => {
    if (modal.dataset.enhanced) return;
    modal.dataset.enhanced = 'true';

    const applyCenter = () => {
      const w = modal.getBoundingClientRect().width;
      if (w > 0) $(modal).css('left', Math.max(0, (window.innerWidth - w) / 2) + 'px');
      $(modal).css({ width: '75%', maxWidth: '95vw' });
    };
    _.defer(applyCenter);
    _.delay(applyCenter, 200);

    // 入門・退門時間をタイトルバーに表示
    const injectGateTimes = () => {
      let rowNo = window.face?.modal?.request?.ROW_NO ?? null;
      if (rowNo == null) {
        try {
          const doc = modal.querySelector('.modalFrame')?.contentDocument;
          rowNo = doc ? parseInt(doc.querySelector('input[name="ROW_NO"]')?.value, 10) : null;
        } catch (e) {}
      }
      if (rowNo == null) return;

      // ── 【修正】テンプレートリテラルを明示 ──
      const nmnVal = formatTime($(`input[name="DATA_LIST[${rowNo}].NMN_TM"]`).val());
      const tmnVal = formatTime($(`input[name="DATA_LIST[${rowNo}].TMN_TM"]`).val());

      const $panel = $(modal).find('.modalPanel').removeClass('noTitle');
      const $title = $panel.find('.modalTitle');
      if (!$title.length) return;

      let $info = $title.find('.epoch-gate-times');
      if (!$info.length) $info = $('<span class="epoch-gate-times">').appendTo($title);
      // ── 【修正】テンプレートリテラルを明示 ──
      $info.html(
        `<span>${SVG_LAND}<span>${nmnVal}</span></span>` +
        `<span>/</span>` +
        `<span>${SVG_TAKEOFF}<span>${tmnVal}</span></span>`
      );
    };
    _.defer(injectGateTimes);
    _.delay(injectGateTimes, 100);
    _.delay(injectGateTimes, 500);

    const iframe = modal.querySelector('.modalFrame');
    if (!iframe) return;

    const tryProcess = () => {
      try {
        const doc = iframe.contentDocument || iframe.contentWindow.document;
        if (!doc?.body || !doc.querySelector('table#PRM_DATA_LST')) return false;
        processIframeContent(doc);
        new iframe.contentWindow.MutationObserver(_.debounce(() => processIframeContent(doc), 150))
          .observe(doc.body, { childList: true, subtree: true });
        return true;
      } catch (e) { return false; }
    };

    if (!tryProcess()) {
      // ── 【修正】_.delay の _ を補完 ──
      $(iframe).on('load', () => {
        tryProcess();
        _.delay(tryProcess, 200);
        _.delay(tryProcess, 500);
      });
    } else {
      _.delay(tryProcess, 200);
      _.delay(tryProcess, 500);
    }
  };

  // MutationObserver でモーダル出現を監視
  new MutationObserver(mutations => {
    mutations.forEach(m => {
      m.addedNodes.forEach(node => {
        if (node.nodeType !== 1) return;
        if (node.classList?.contains('face-modal')) enhanceModal(node);
        $(node).find('.face-modal').each((i, el) => enhanceModal(el));
      });
      if (m.type === 'attributes' && m.attributeName === 'style') {
        const el = m.target;
        if (el.classList?.contains('face-modal') && getComputedStyle(el).display !== 'none') {
          delete el.dataset.enhanced;
          enhanceModal(el);
        }
      }
    });
  }).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });

  // ══════════════════════════════════════════════════════════════════════
  // 機能4: 宿直記録モーダル + 自動入力(PA0101.G01 のみ)
  // ══════════════════════════════════════════════════════════════════════
  if (!location.href.includes('PA0101.G01')) return;

  // ── 定数 ──────────────────────────────────────────────────────────────
  const STORE_KEY         = 'shukuchoRecords';
  const DEFAULT_ROWS      = 10;
  const DAY_NAMES         = ['日', '月', '火', '水', '木', '金', '土'];
  const TKS_SHUKUCHO_VAL  = '11';
  const JIYUU_TOCHOKU_VAL = 'B2276';
  const DAY_MIN           = 24 * 60;

  const SECTOR_SHUKUCHO_START = 22 * 60;
  const SECTOR_MIDNIGHT_END   = DAY_MIN + 5 * 60;
  const SECTOR_SHUKUCHO_END   = DAY_MIN + 8 * 60 + 30;

  // ── ユーティリティ ────────────────────────────────────────────────────
  const sleep     = ms => new Promise(r => setTimeout(r, ms));
  const toMinutes = s  => { const [h, m] = (s || '').split(':').map(Number); return (isNaN(h) || isNaN(m)) ? 0 : h * 60 + m; };
  // ── 【修正】テンプレートリテラルを明示 ──
  const toHHMM    = m  => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;

  function getCurrentYearMonth() {
    const text = ($('.subtitle').text() || document.title || '');
    const m = text.match(/(\d{4})年(\d{1,2})月/);
    return m ? { year: +m[1], month: +m[2] } : { year: new Date().getFullYear(), month: new Date().getMonth() + 1 };
  }

  function buildDateOptions(year, month) {
    const days = new Date(year, month, 0).getDate();
    return _.range(1, days + 1).map(d => {
      const dow = DAY_NAMES[new Date(year, month - 1, d).getDay()];
      return {
        // ── 【修正】テンプレートリテラルを明示 ──
        value: `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`,
        label: `${month}月${d}日 ${dow}曜日`
      };
    });
  }

  function waitForIframe(selector) {
    return new Promise(resolve => {
      const check = () => {
        const f = document.querySelector(selector);
        if (f?.contentDocument?.readyState === 'complete' &&
            f.contentDocument.querySelector('select, button')) { resolve(f); return; }
        setTimeout(check, 100);
      };
      check();
    });
  }

  // ── 【修正1】テーブルを table#DATA_LIST で直接取得(rows数依存を排除)──
  const getJissekiTable = () => document.querySelector('table#DATA_LIST') || null;

  const getRow = day => getJissekiTable()?.rows[day - 1] || null;

  // ── 【修正2】列インデックスの代わりに name 属性で入力欄を取得 ──────────
  function getEditableInput(row, fieldName) {
    if (!row) return null;
    // 行インデックスを hidden input の name 属性から特定
    const anyHidden = row.querySelector('input[name^="DATA_LIST["]');
    const idxMatch  = anyHidden?.name.match(/DATA_LIST\[(\d+)\]/);
    const rowIdx    = idxMatch ? parseInt(idxMatch[1], 10) : row.rowIndex;
    return row.querySelector(
      `input[name="DATA_LIST[${rowIdx}].${fieldName}"][type="text"]:not([disabled])`
    ) || null;
  }

  function setVal(input, v) {
    if (!input) return;
    $(input).val(v).trigger('input').trigger('change').trigger('blur');
  }

  // ── 【修正3】ボタン取得を ID 指定で安定化 ─────────────────────────────
  function getJiyuuBtn(row, fieldName) {
    if (!row) return null;
    if (fieldName === 'DTY_STH') {
      // 始業欄の事由入力ボタン:onclick が設定されている NMN_JY_INPUT
      return row.querySelector('button#NMN_JY_INPUT[onclick]') || null;
    }
    if (fieldName === 'DTY_ENH') {
      // 終業欄の事由入力ボタン:onclick が設定されている TMN_JY_INPUT
      return row.querySelector('button#TMN_JY_INPUT[onclick]') || null;
    }
    return null;
  }

  // ── 【修正4】特殊作業ボタンを ID 指定で取得(.last()依存を排除)────────
  function getTokusaBtn(row) {
    if (!row) return null;
    return row.querySelector('button#TKS_SGY_BTN[onclick^="clickTksSkky"]')
      || $(row).find('button[onclick^="clickTksSkky"]').last().get(0)  // フォールバック
      || null;
  }

  // ── 【修正5】TM_CHG1 を「時間変更」テキストで確実に取得 ─────────────────
  function getTmChg1Btn(day) {
    const row = getRow(day);
    if (!row) return null;
    return Array.from(row.querySelectorAll('button#TM_CHG1'))
      .find(b => b.textContent.trim() === '時間変更')
      || row.querySelector('button#TM_CHG1')
      || null;
  }

  // ── 累積タイムラインベースの変換ロジック ─────────────────────────────
  function convertRecordsToEntries(records) {
    const entries = [];
    let currentDayOffsetMin = 0;
    let lastEndAbsMin = 0;

    records.forEach(r => {
      if (!r.task || !r.start || !r.end) return;
      const toMin = t => { const [h, m] = t.split(':').map(Number); return h * 60 + m; };

      let startMin = toMin(r.start) + currentDayOffsetMin;
      if (startMin < lastEndAbsMin) {
        currentDayOffsetMin += DAY_MIN;
        startMin += DAY_MIN;
      }
      let endMin = toMin(r.end) + currentDayOffsetMin;
      if (endMin <= startMin) endMin += DAY_MIN;
      lastEndAbsMin = endMin;

      const boundaries = [SECTOR_SHUKUCHO_START, DAY_MIN, SECTOR_MIDNIGHT_END, SECTOR_SHUKUCHO_END]
        .filter(b => b > startMin && b < endMin);
      const breakpoints = [startMin, ...boundaries, endMin];

      for (let i = 0; i < breakpoints.length - 1; i++) {
        const segStart = breakpoints[i];
        const segEnd   = breakpoints[i + 1];

        let label = r.task;
        if (segStart >= SECTOR_SHUKUCHO_START && segStart < SECTOR_MIDNIGHT_END) {
          label = `当直(深夜)${r.task}`;
        } else if (segStart >= SECTOR_MIDNIGHT_END && segStart < SECTOR_SHUKUCHO_END) {
          label = `当直${r.task}`;
        }

        const dateOffset = segStart >= DAY_MIN ? 1 : 0;
        const startStr   = segStart % DAY_MIN === 0 && segStart > 0
          ? '00:00'
          : toHHMM(segStart % DAY_MIN);
        const endStr     = segEnd % DAY_MIN === 0
          ? (dateOffset === 0 ? '24:00' : '00:00')
          : toHHMM(segEnd % DAY_MIN);

        entries.push({ label, startStr, endStr, dateOffset });
      }
    });
    return entries;
  }

  // ── PA0105へのエントリ入力 ────────────────────────────────────────────
  async function fillPA0105(iframeEl, entries) {
    const doc   = iframeEl.contentDocument;
    const table = doc.querySelector('table#PRM_DATA_LST');
    if (!table) return;

    const currentRows = table.rows.length - 1;
    const needRows    = entries.length;
    const templateRow = table.rows[1];
    if (!templateRow) return;

    // 行が足りない場合は複製して追加
    for (let i = currentRows; i < needRows; i++) {
      const newRow = templateRow.cloneNode(true);
      newRow.querySelectorAll('[name]').forEach(el => {
        el.name = el.name.replace(/\[\d+\]/, `[${i}]`);
      });
      newRow.querySelectorAll('input[type="text"]').forEach(el => { el.value = ''; });
      newRow.querySelectorAll('select').forEach(el => { el.selectedIndex = 0; });
      newRow.querySelectorAll('input[type="radio"]').forEach(el => { el.checked = false; });
      table.appendChild(newRow);
    }

    const listSizeInput = doc.querySelector('input[name="LIST_SIZE"]');
    if (listSizeInput) listSizeInput.value = String(needRows);

    // ── 【修正】テンプレートリテラル・乗算演算子を明示 ──
    entries.forEach((entry, idx) => {
      const p = `PRM_DATA_LST[${idx}].`;

      const sel = doc.querySelector(`select[name="${p}TDT_SKB_KB"]`);
      if (sel) { sel.value = '01'; sel.dispatchEvent(new Event('change', { bubbles: true })); }

      const startInput = doc.querySelector(`input[name="${p}TDT_H_STTM"]`);
      if (startInput) { startInput.value = entry.startStr; startInput.dispatchEvent(new Event('change', { bubbles: true })); }

      const endInput = doc.querySelector(`input[name="${p}TDT_H_ETM"]`);
      if (endInput) { endInput.value = entry.endStr; endInput.dispatchEvent(new Event('change', { bubbles: true })); }

      const [sh, sm] = entry.startStr.split(':').map(Number);
      let   [eh, em] = entry.endStr.split(':').map(Number);
      if (eh === 0 && entry.endStr === '00:00') eh = 24;
      const diffMin = (eh * 60 + em) - (sh * 60 + sm);  // ── 【修正】乗算を明示

      const hInput = doc.querySelector(`input[name="${p}TDT_H"]`);
      if (hInput) hInput.value = toHHMM(Math.max(0, diffMin));

      const ryInput = doc.querySelector(`input[name="${p}TDT_GOS_RY"]`);
      if (ryInput) ryInput.value = entry.label;

      const noInput = doc.querySelector(`input[name="${p}NO"]`);
      if (noInput) noInput.value = String(idx + 1);
    });

    // 余剰行をクリア
    for (let i = needRows; i < Math.max(currentRows, needRows); i++) {
      const p = `PRM_DATA_LST[${i}].`;
      ['TDT_SKB_KB', 'TDT_H_STTM', 'TDT_H_ETM', 'TDT_H', 'TDT_GOS_RY'].forEach(field => {
        const el = doc.querySelector(`[name="${p}${field}"]`);
        if (el) el.value = '';
      });
    }

    if (listSizeInput) listSizeInput.value = String(needRows);

    const confirmBtn = doc.querySelector('button#confirmBtn');
    if (confirmBtn) confirmBtn.click();
  }

  // ── 自動入力メイン制御 ─────────────────────────────────────────────────
  async function applyShukucho({ date }) {
    const [year, month, day] = date.split('-').map(Number);
    const daysInMonth = new Date(year, month, 0).getDate();
    const hasNextDay  = day < daysInMonth;

    const row1 = getRow(day);
    const row2 = hasNextDay ? getRow(day + 1) : null;

    // ── 【修正】列インデックスの代わりにフィールド名を渡す ──
    setVal(getEditableInput(row1, 'DTY_ENH'), '24:00');
    if (row2) setVal(getEditableInput(row2, 'DTY_STH'), '00:00');

    const tksBtn = getTokusaBtn(row1);
    if (tksBtn) {
      tksBtn.click();
      const fr  = await waitForIframe('iframe[src*="PA0104"]');
      const sel = fr.contentDocument.querySelector('select[name="TKS_SGY_KB_A"]');
      if (sel) { sel.value = TKS_SHUKUCHO_VAL; sel.dispatchEvent(new Event('change')); }
      const okBtn = Array.from(fr.contentDocument.querySelectorAll('button'))
                    .find(b => b.textContent.trim() === '確定');
      if (okBtn) okBtn.click();
      await sleep(400);
    }

    await sleep(200);

    const enhJiyuuBtn = getJiyuuBtn(row1, 'DTY_ENH');
    if (enhJiyuuBtn) {
      enhJiyuuBtn.click();
      const fr  = await waitForIframe('iframe[src*="PA0102"]');
      const sel = fr.contentDocument.querySelector('select[name="TMSJY_KB"]');
      if (sel) { sel.value = JIYUU_TOCHOKU_VAL; sel.dispatchEvent(new Event('change')); }
      const regBtn = Array.from(fr.contentDocument.querySelectorAll('button'))
                     .find(b => b.textContent.trim() === '登録');
      if (regBtn) regBtn.click();
      await sleep(400);
    }

    await sleep(200);

    if (row2) {
      const sthJiyuuBtn = getJiyuuBtn(row2, 'DTY_STH');
      if (sthJiyuuBtn) {
        sthJiyuuBtn.click();
        const fr  = await waitForIframe('iframe[src*="PA0102"]');
        const sel = fr.contentDocument.querySelector('select[name="NMSJY_KB"]');
        if (sel) { sel.value = JIYUU_TOCHOKU_VAL; sel.dispatchEvent(new Event('change')); }
        const regBtn = Array.from(fr.contentDocument.querySelectorAll('button'))
                       .find(b => b.textContent.trim() === '登録');
        if (regBtn) regBtn.click();
        await sleep(400);
      }
    }

    await sleep(200);

    const saved   = JSON.parse(localStorage.getItem(STORE_KEY) || '{}');
    const records = saved.records || [];
    if (records.length === 0) return;

    const allEntries   = convertRecordsToEntries(records);
    const entries_day0 = allEntries.filter(e => e.dateOffset === 0);
    const entries_day1 = allEntries.filter(e => e.dateOffset === 1);

    if (entries_day0.length > 0) {
      const tmChg1_day0 = getTmChg1Btn(day);
      if (tmChg1_day0) {
        tmChg1_day0.click();
        const fr0 = await waitForIframe('iframe[src*="PA0105"]');
        await sleep(300);
        await fillPA0105(fr0, entries_day0);
        await sleep(400);
      }
    }

    if (entries_day1.length > 0 && hasNextDay) {
      const tmChg1_day1 = getTmChg1Btn(day + 1);
      if (tmChg1_day1) {
        tmChg1_day1.click();
        const fr1 = await waitForIframe('iframe[src*="PA0105"]');
        await sleep(300);
        await fillPA0105(fr1, entries_day1);
        await sleep(400);
      }
    }
  }

  // ── 宿直記録ダイアログ構築 ───────────────────────────────────────────
  function buildDialog() {
    const saved = JSON.parse(localStorage.getItem(STORE_KEY) || '{}');
    const { year, month } = getCurrentYearMonth();
    const opts = buildDateOptions(year, month);

    // ── 【修正】_.times の _ を明示 ──
    const rows = saved.records?.length
      ? saved.records
      : _.times(DEFAULT_ROWS, () => ({ task: '', start: '', end: '' }));

    const $dateSelect = $('<select>', { class: 'face-text', style: 'width:160px' });
    $('<option>', { value: '', text: '-- 日付を選択 --' }).appendTo($dateSelect);
    opts.forEach(o => $('<option>', { value: o.value, text: o.label }).appendTo($dateSelect));
    if (saved.date) $dateSelect.val(saved.date);

    const TASK_OPTIONS = [
      { value: '救急外来', label: '救急外来' },
      { value: '病棟業務', label: '病棟業務' },
    ];

    const updatePreviewArea = () => {
      const $ul = $('#sd-preview-list').empty();
      const selectedDateVal = $dateSelect.val();

      let day0Label = '宿直日';
      let day1Label = '翌日';
      if (selectedDateVal) {
        const [y, m, d] = selectedDateVal.split('-').map(Number);
        const d0 = new Date(y, m - 1, d);
        // ── 【修正】テンプレートリテラルを明示 ──
        day0Label = `${d0.getMonth() + 1}月${d0.getDate()}日(${DAY_NAMES[d0.getDay()]})`;
        const d1 = new Date(y, m - 1, d + 1);
        day1Label = `${d1.getMonth() + 1}月${d1.getDate()}日(${DAY_NAMES[d1.getDay()]})`;
      }

      const currentRecords = $tbody.find('tr').toArray().map(tr => {
        const tds = $(tr).find('td');
        return {
          task:  tds.eq(1).find('input[type="radio"]:checked').val() || '',
          start: tds.eq(2).find('input').val(),
          end:   tds.eq(3).find('input').val()
        };
      });

      const entries = convertRecordsToEntries(currentRecords);
      if (entries.length === 0) {
        $('<li>', {
          text: '有効な時間入力がありません(プレビューはここに表示されます)',
          style: 'color:#999; list-style:none;'
        }).appendTo($ul);
        return;
      }

      entries.forEach(e => {
        const dateString = e.dateOffset === 0 ? day0Label : day1Label;
        // ── 【修正】テンプレートリテラルを明示 ──
        $('<li>').html(
          `<strong>${dateString}</strong> ${e.startStr} ~ ${e.endStr} : <span style="color:#0041c0;">${e.label}</span>`
        ).appendTo($ul);
      });
    };

    function makeRow(r, idx) {
      const $tr = $('<tr>');
      $('<td>', { class: 'center font-small', text: idx + 1 }).appendTo($tr);

      const groupName  = 'sd-task-' + idx;
      const $taskWrap  = $('<span>', { class: 'epoch-toggle-wrap' });

      TASK_OPTIONS.forEach(opt => {
        const $radio = $('<input>', { type: 'radio', name: groupName, value: opt.value });
        if (r.task === opt.value) $radio.prop('checked', true);

        const $lbl = $('<label>', {
          style: 'cursor:pointer;display:inline-flex;align-items:center;gap:3px;white-space:nowrap'
        }).append($radio, document.createTextNode(opt.label));

        if (r.task === opt.value) $lbl.addClass('active');

        $radio.on('change', function () {
          $taskWrap.find('label').each((j, l) => {
            $(l).toggleClass('active', !!$(l).find('input[type="radio"]').prop('checked'));
          });
          updatePreviewArea();
        });

        $taskWrap.append($lbl);
      });

      const $start = $('<input>', { type: 'text', class: 'face-text', style: 'width:50px', value: r.start || '', placeholder: 'HH:MM' });
      const $end   = $('<input>', { type: 'text', class: 'face-text', style: 'width:50px', value: r.end   || '', placeholder: 'HH:MM' });
      const $labor = $('<td>', { class: 'center font-small' });

      const $resetBtnCell = $('<td>', { class: 'center' });
      const $rowResetBtn  = $('<button>', {
        type: 'button', class: 'sd-row-reset-btn', title: 'この行の入力をクリア'
      }).html(SVG_REFRESH).on('click', function () {
        $taskWrap.find('input[type="radio"]').prop('checked', false);
        $taskWrap.find('label').removeClass('active');
        $start.val('');
        $end.val('');
        $labor.text('');
        updateTotal();
        updatePreviewArea();
      });
      $resetBtnCell.append($rowResetBtn);

      const handleTimeInput = function () {
        let cursor   = this.selectionStart;
        let val      = $(this).val();
        const cleanVal = val.replace(/[^0-9:]/g, '');
        const digits   = cleanVal.replace(/:/g, '');
        let newVal = '';
        if (digits.length > 0) {
          if (digits.length <= 2) newVal = digits;
          else newVal = digits.slice(0, 2) + ':' + digits.slice(2, 4);
        }
        if (val !== newVal) {
          $(this).val(newVal);
          if (digits.length === 3 && cleanVal.indexOf(':') === -1 && cursor === 3) cursor = 4;
          else if (newVal.length < val.length && cursor > newVal.length) cursor = newVal.length;
          this.setSelectionRange(cursor, cursor);
        }
      };

      $start.on('input', handleTimeInput);
      $end.on('input', handleTimeInput);

      const calcLabor = () => {
        const sStr = $start.val(), eStr = $end.val();
        if (!sStr.trim() || !eStr.trim()) { $labor.text(''); return; }
        let s = toMinutes(sStr), e = toMinutes(eStr);
        if (e <= s) e += DAY_MIN;
        $labor.text(toHHMM(e - s));
      };

      $start.on('change blur input', () => { calcLabor(); updatePreviewArea(); });
      $end.on('change blur input',   () => { calcLabor(); updatePreviewArea(); });

      $('<td>', { class: 'center font-small' }).append($taskWrap).appendTo($tr);
      $('<td>', { class: 'center font-small' }).append($start).appendTo($tr);
      $('<td>', { class: 'center font-small' }).append($end).appendTo($tr);
      $labor.appendTo($tr);
      $resetBtnCell.appendTo($tr);

      calcLabor();
      return $tr;
    }

    const $tbody = $('<tbody>');
    rows.forEach((r, i) => makeRow(r, i).appendTo($tbody));

    const $table = $('<table>', { class: 'sd-table' }).append(
      $('<thead>').append(
        $('<tr>').append(
          ['#', '業務内容', '開始', '終了', '労働時間', 'クリア'].map(h =>
            $('<th>', { class: 'head-cell-style center font-small', text: h }))
        )
      ),
      $tbody
    );

    const $totalLabel = $('<span>');
    const updateTotal = _.debounce(() => {
      const total = $tbody.find('tr').toArray().reduce((sum, tr) => {
        const tds  = $(tr).find('td');
        const sStr = tds.eq(2).find('input').val();
        const eStr = tds.eq(3).find('input').val();
        if (!sStr.trim() || !eStr.trim()) return sum;
        let s = toMinutes(sStr), e = toMinutes(eStr);
        if (e <= s) e += DAY_MIN;
        return sum + (e - s);
      }, 0);
      $totalLabel.text('合計労働時間: ' + toHHMM(total));
    }, 300);

    $tbody.on('change blur', 'input', updateTotal);
    updateTotal();

    const $previewArea = $('<div>', { class: 'sd-preview' }).append(
      $('<h3>', { text: 'EPOCHシステム変換プレビュー' }),
      $('<ul>',  { id: 'sd-preview-list' })
    );

    $dateSelect.on('change', updatePreviewArea);

    const dlg = $('<dialog>', { id: 'shukucho-dialog' }).append(
      $('<div>', { class: 'sd-header' }).append(
        $('<h2>', { text: '宿直記録' }),
        $('<button>', {
          id: 'sd-close', class: 'face-form-button btnStyle link',
          style: 'color: white; text-decoration: none;', type: 'button', text: '✕'
        })
      ),
      $('<div>', { class: 'sd-body' }).append(
        $('<div>', { class: 'sd-date-bar' }).append(
          $('<span>', { text: '宿直:' }),
          $dateSelect,
          $('<span>', { text: 'から開始' })
        ),
        $table,
        $('<div>', { class: 'sd-labor' }).append($totalLabel),
        $previewArea
      ),
      $('<div>', { class: 'sd-footer' }).append(
        $('<button>', {
          id: 'sd-all-reset', class: 'face-form-button btnStyle sub small', type: 'button',
          text: '入力リセット',
          style: 'margin-right:auto; width:auto; padding:0 0.5em; border-color:#cc0000; color:#cc0000;'
        }).on('click', () => {
          if (confirm('全ての入力内容をクリアしますか?')) {
            $dateSelect.val('');
            $tbody.find('tr').each((i, tr) => {
              const $tr = $(tr);
              $tr.find('input[type="radio"]').prop('checked', false);
              $tr.find('label').removeClass('active');
              $tr.find('input[type="text"]').val('');
              $tr.find('td').eq(4).text('');
            });
            updateTotal();
            updatePreviewArea();
          }
        }),
        $('<button>', {
          class: 'face-form-button btnStyle sub small',
          style: 'width:auto; padding:0 0.5em;', type: 'button', text: '+ 行を追加'
        }).on('click', () => {
          makeRow({ task: '', start: '', end: '' }, $tbody.children().length).appendTo($tbody);
          updatePreviewArea();
        }),
        $('<button>', {
          id: 'sd-save', class: 'face-form-button btnStyle main small',
          style: 'width:auto; padding:0 0.5em;', type: 'button', text: '保存して自動入力'
        })
      )
    ).appendTo('body');

    updatePreviewArea();

    const el = dlg.get(0);

    dlg.on('click', '#sd-close, #sd-cancel', () => el.close());

    dlg.on('click', '#sd-save', async function () {
      const selectedDate = $dateSelect.val();
      if (!selectedDate) { alert('宿直日を選択してください'); return; }

      const records = $tbody.find('tr').toArray().map(tr => {
        const tds = $(tr).find('td');
        return {
          task:  tds.eq(1).find('input[type="radio"]:checked').val() || '',
          start: tds.eq(2).find('input').val(),
          end:   tds.eq(3).find('input').val()
        };
      });

      const dateObj = opts.find(o => o.value === selectedDate);
      localStorage.setItem(STORE_KEY, JSON.stringify({
        date:      selectedDate,
        dateLabel: dateObj?.label || '',
        records
      }));

      el.close();
      isApplyingShukucho = true;

      const $mask = $(
        '<div class="epoch-loading-mask"><div class="epoch-loading-box"><p class="epoch-loading-text">自動登録中...</p></div></div>'
      ).appendTo('body');

      try {
        await applyShukucho({ date: selectedDate });
        alert('登録完了しました');
      } catch (err) {
        alert('自動入力中にエラーが発生しました。ページをリフレッシュしてやり直してください。');
        console.error(err);
      } finally {
        $mask.remove();
        isApplyingShukucho = false;
      }
    });

    return el;
  }

  // ── 【修正6】アイコンボタン挿入位置を data-face-icon-label 属性で安定取得 ──
  function addIcon() {
    if (document.querySelector('#sd-icon-btn')) return;

    let dlg = null;
    const openDlg = () => {
      if (isApplyingShukucho) {
        alert('現在、宿直記録の自動入力プロセスが実行中です。完了するまでお待ちください。');
        return;
      }
      if (dlg) dlg.remove();
      dlg = buildDialog();
      dlg.showModal();
    };

    // ── 【修正】nth-child(3) の代わりに「本人」ラベルを持つ div を検索 ──
    // SCRIPT タグ等の数が月ごとに変動しても正しく動作する
    const $honninDiv = $('#homebarForm').find('div.appMenu').filter(function () {
      return !!$(this).find('button[data-face-icon-label="本人"]').length;
    }).first();

    if (!$honninDiv.length) return;

    $('<div>', { class: 'appMenu' }).append(
      $('<button>', {
        id: 'sd-icon-btn', class: 'face-icon', type: 'button',
        'data-face-icon-label': '宿直'
      }).html(SVG_BEDTIME).on('click', openDlg)
    ).insertAfter($honninDiv);
  }

  // ── 初期化 ────────────────────────────────────────────────────────────
  function init() {
    if (!document.querySelector('#homebarForm')) return;
    addIcon();
  }

  new MutationObserver(_.debounce(() => {
    if (document.querySelector('#homebarForm') && !document.querySelector('#sd-icon-btn')) {
      addIcon();
    }
  }, 300)).observe(document.body, { childList: true, subtree: true });

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }

})();