EPOCHシステム改善 + 宿直記録自動入力
// ==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(); } })();