diff --git a/src/保育・教育日数マスタ/main.css b/src/保育・教育日数マスタ/main.css new file mode 100644 index 0000000..b62ae36 --- /dev/null +++ b/src/保育・教育日数マスタ/main.css @@ -0,0 +1,77 @@ +#legend { + width: 100%; + top: -24px; + position: absolute; + text-align: right; + font-size: 13px; +} + +#legend .box { + display: inline-block; + width: 14px; + height: 14px; + background: #ffe3e6; + margin-right: 4px; + vertical-align: middle; +} + +/* カレンダーエリア */ +#calendar { + width: 100%; + height: 100%; + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(4, 1fr); + gap: 12px; + box-sizing: border-box; +} + +.month { + display: grid; + grid-template-columns: repeat(7, 1fr); + grid-template-rows: auto repeat(6, 1fr); + border: 1px solid #aaa; + border-radius: 4px; +} + +.month .title { + grid-column: 1/8; + text-align: center; + font-weight: bold; + padding: 4px 0; +} + +.month .cell { + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; +} + +#calendar:not(.readonly) .month .cell:not(.header):not(:empty) { + cursor: pointer; +} + +.month .cell:not(:empty) { + padding: 2px 0; +} + +.month .sun { + color: #e74c3c; +} + +.month .sat { + color: #2196f3; +} + +.month .cell.selected { + background: #ffe3e6; +} + +#calendar:not(.readonly) .month .cell:not(.header):not(.selected):not(:empty):hover { + background: #ffe3e677; +} + +#calendar:not(.readonly) .month .cell.selected:hover { + background: #ffd3d7; +} diff --git a/src/保育・教育日数マスタ/main.js b/src/保育・教育日数マスタ/main.js new file mode 100644 index 0000000..7eb1b61 --- /dev/null +++ b/src/保育・教育日数マスタ/main.js @@ -0,0 +1,312 @@ +(() => { + 'use strict' + let _CALENDAR + + // レコード詳細/新規作成/編集画面表示時にカレンダーを初期化 + kintone.events.on( + ['app.record.detail.show', 'app.record.create.show', 'app.record.edit.show'], + (event) => { + kintone.app.record.setFieldShown('休日の生データ', false); + initCalendarArea( + event.record, + getHolidayMap(event.record), + event.type === 'app.record.detail.show' + ) + return event + } + ) + + // 年度フィールド変更時に各月の休日をクリアしてカレンダーを再描画 + kintone.events.on( + [ + 'app.record.create.change.年度', + 'app.record.edit.change.年度', + 'app.record.index.edit.change.年度' + ], + (event) => { + forEachRawFields((field) => { + event.record[field].value = '' + }) + if (event.type !== 'app.record.index.edit.change.年度') { + initCalendarArea(event.record) + } + return event + } + ) + + // 一覧編集画面では各月の休日フィールドを編集不可にする + kintone.events.on( + ['app.record.index.edit.show'], + (event) => { + forEachRawFields((field) => { + event.record[field].disabled = true + }) + return event + } + ) + + // レコード保存時にカレンダーで選択した休日を各月フィールドへ反映 + kintone.events.on( + ['app.record.create.submit', 'app.record.edit.submit'], + (event) => { + const resultMap = _CALENDAR.getSelectedDatesMap() + forEachRawFields((field, i) => { + event.record[field].value = resultMap[i].join(',') + }) + return event + } + ) + + /* -------------------------------------------------- + * 初期化・データ準備 + * -------------------------------------------------- */ + // カレンダー表示エリアを初期化 + function initCalendarArea(record, map = {}, readonly = false) { + const calendarAreaEl = kintone.app.record.getSpaceElement('calendar-area') + calendarAreaEl.innerHTML = '' + const year = record.年度.value + if (isYearString(year)) { + _CALENDAR = createCalendar(Number(year), map, readonly) + calendarAreaEl.appendChild(_CALENDAR.fragment) + } + } + + // 既存レコードに保存されている休日を取得 + function getHolidayMap(record) { + const res = {} + forEachRawFields((field, i) => { + const str = record[field].value + if (!str) { + return + } + res[i] = new Set(str.split(',')) + }) + return res + } + + /* -------------------------------------------------- + * ユーティリティ + * -------------------------------------------------- */ + // 各月フィールドをループ + const forEachRawFields = (callback) => { + for (let i = 1; i <= 12; i++) { + callback(`休日_${i}月`, i) + } + } + + // ゼロパディング + const padZero = (n) => (n < 10 ? '0' + n : n) + + // Date → yyyy-MM-dd + const toYmd = (d) => `${d.getFullYear()}-${padZero(d.getMonth() + 1)}-${padZero(d.getDate())}` + + // 土日判定 + const isWeekend = (d) => d.getDay() === 0 || d.getDay() === 6 + + // 年度文字列の妥当性チェック + function isYearString(str) { + // 文字列かつ空でない + if (typeof str !== 'string' || str.length === 0) { + return false + } + + // 数字のみ + if (!/^\d+$/.test(str)) { + return false + } + + // 4桁 + if (str.length !== 4) { + return false + } + + // 1900-3000 の範囲 + const year = parseInt(str, 10) + return year >= 1900 && year <= 3000 + } + + /* -------------------------------------------------- + * カレンダー生成 + * -------------------------------------------------- */ + const createCalendar = (year, highlightMap, readonly) => { + const fragment = document.createDocumentFragment() + + /* ----- 図例 ----- */ + const legend = document.createElement('div') + legend.id = 'legend' + legend.innerHTML = '休日' + fragment.appendChild(legend) + + /* ----- カレンダーコンテナ ----- */ + const calendar = document.createElement('div') + calendar.id = 'calendar' + if (readonly) { + calendar.classList.add('readonly') + } + fragment.appendChild(calendar) + + /* ----- 内部状態 ----- */ + const selectedSet = new Set() + const months = buildMonths(year) + months.forEach((m) => { + calendar.appendChild(buildMonth(m)) + }) + + /* ---------- 月情報生成 ---------- */ + function buildMonths(year) { + return Array.from({ length: 12 }, (_, i) => { + const monthIndex = 3 + i // 0 基準月で 4 月始まり + const date = new Date(year, monthIndex, 1) + return { + displayMonth: date.getMonth() + 1, + year: date.getFullYear(), + month: date.getMonth(), + label: `${date.getFullYear()}年${date.getMonth() + 1}月` + } + }) + } + + /* ---------- 月単位カレンダー生成 ---------- */ + function buildMonth({ displayMonth, year, month, label }) { + const monthBox = document.createElement('div') + monthBox.className = 'month' + + buildTitle(monthBox, label) + buildHeader(monthBox) + + const first = new Date(year, month, 1) + buildPadBlocksStart(first, monthBox) + buildDateBlocks(year, month, displayMonth, monthBox) + buildPadBlocksEnd(monthBox) + + return monthBox + } + + function buildTitle(monthBox, label) { + const title = document.createElement('div') + title.className = 'title' + title.textContent = label + monthBox.appendChild(title) + } + + function buildHeader(monthBox) { + ['日', '月', '火', '水', '木', '金', '土'].forEach((day, i) => { + const cell = document.createElement('div') + cell.className = 'cell header' + addWeekendClass(cell, i) + cell.textContent = day + monthBox.appendChild(cell) + }) + } + + function addWeekendClass(cell, week) { + if (week === 0) { + cell.classList.add('sun') + } else if (week === 6) { + cell.classList.add('sat') + } + } + + function buildPadBlocksStart(first, monthBox) { + const startDayInWeek = first.getDay() + for (let i = 0; i < startDayInWeek; i++) { + const cell = document.createElement('div') + cell.className = 'cell' + monthBox.appendChild(cell) + } + } + + function buildDateBlocks(year, month, displayMonth, monthBox) { + const last = new Date(year, month + 1, 0) + const highlights = highlightMap[displayMonth] || null + + for (let day = 1; day <= last.getDate(); day++) { + const dateObj = new Date(year, month, day) + const ymd = toYmd(dateObj) + const cell = document.createElement('div') + cell.className = 'cell' + cell.textContent = day + cell.dataset.date = ymd + + addWeekendClass(cell, dateObj.getDay()) + + // 初期選択判定 + let shouldSelect = false + if (highlights === null) { + shouldSelect = isWeekend(dateObj) + } else if (highlights instanceof Set) { + shouldSelect = highlights.has(ymd) + } + + if (shouldSelect) { + cell.classList.add('selected') + selectedSet.add(ymd) + } + + // 編集可モードのみクリックイベントを追加 + if (!readonly) { + cell.addEventListener('click', () => { + cell.classList.toggle('selected') + if (cell.classList.contains('selected')) { + selectedSet.add(ymd) + } else { + selectedSet.delete(ymd) + } + }) + } + + monthBox.appendChild(cell) + } + } + + function buildPadBlocksEnd(monthBox) { + // 7×7 マスに揃えるための埋め草 + while (monthBox.children.length < 49) { + const cell = document.createElement('div') + cell.className = 'cell' + monthBox.appendChild(cell) + } + } + + /* ---------- 選択済み日付取得 ---------- */ + function getSelectedDatesMap() { + const res = {} + months.forEach(({ month }) => { + res[month + 1] = [] + }) + + // 選択済み日付を振り分け + selectedSet.forEach((ymd) => { + const d = new Date(ymd) + const displayMonth = d.getMonth() + 1 + res[displayMonth].push(ymd) + }) + + // 選択が無い月は全土日を自動追加 + Object.keys(res).forEach((monthKey) => { + if (res[monthKey].length === 0) { + const monthInfo = months.find((m) => m.displayMonth == monthKey) + if (monthInfo) { + const year = monthInfo.year + const month = monthInfo.month + const lastDay = new Date(year, month + 1, 0).getDate() + + for (let day = 1; day <= lastDay; day++) { + const dateObj = new Date(year, month, day) + if (isWeekend(dateObj)) { + res[monthKey].push(toYmd(dateObj)) + } + } + } + } + }) + + return res + } + + return { + fragment, + getSelectedDatesMap + } + } +})() \ No newline at end of file