第二弾、交番ローテーション(ブラウザ版)です。
HTML・CSS・JSのコードで作成しました。この交番ローテイションの特徴は年間を通して作業内容をプールする仕様です。つまり平日64交番(64種類の各作業内容) 土曜58交番(58種類の各作業内容) 日祭日50交番(50種類の各作業内容)の各連番循環方式を採用しています。連番循環方式は、例えば平日64交番を1交番からスタートして、最後の64交番に達したら1交番に戻り、月や年をまたいでも交番が永続していく仕組みです。
当月の実際のカレンダーに準じて日曜、土曜、祭日の各交番内容が自動で配置されます。また、当日の交番内容が重複していないか、年間を通して公休が平均化されているかのチェックも設けています。※年間公休日数は平均です。不足過剰分は加算年次支給や対象者間で公休調整を図ってください。
下記のプログラム(コード)はデモ用として公開しています。稼働人数や各交番内容など変更して開発者にそった仕様変更(UIを含む)しても構わないです。ただし、仕様変更、不具合など発生した場合は自己責任で対処してください。
コードの動作確認は行っていますが、バグがあるかもしれません。その際はコピーされた開発者様で修正するなど調整してださい。あくまで参考コードとして理解してください。
UIで特にボタンや表関係のCSSは標準仕様のデザインになっていますのでご自由に変更してください。
また、個別の本プログラムの依頼調整や変更は行っておりませんのでご了承ください。


| 仕様 |
| 1.スタート年月日 ※カレンダーに準じた当日以前の設定は不可 2.氏名の入力 全削除機能 ※入力数制限なし 3.入力した氏名の一覧・削除 4.当日の重複交番のチェック 5.氏名別の月カレンダー表示 ※年月の設定 6.個人別の月カレンダー・年間の一覧印刷 |
| HTML(CSS含む) |
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>交番ローテーション(氏名入力+検索版)</title>
<link rel="stylesheet" href="style.css">
<script src="japanese-holidays-browser.js"></script>
<script src="script.js" defer></script>
<style>
@media print {
body * {
visibility: hidden;
}
#weekHeader, #weekHeader *,
#calendar, #calendar *,
#memberName {
visibility: visible;
}
.day-header {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
color: inherit !important;
}
.day-header.sunday {
color: red !important;
}
.day-header.saturday {
color: blue !important;
}
#memberName {
display: block !important;
position: absolute;
top: 0;
left: 0;
font-size: 20px;
font-weight: bold;
}
#weekHeader {
position: absolute;
top: 40px;
left: 0;
width: 100%;
}
#calendar {
position: absolute;
top: 80px;
left: 0;
transform-origin: top left;
}
.day {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
.day-header.sunday {
color: red !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.day-header.saturday {
color: blue !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
/* ★ 年間ローテーション専用印刷モード */
@media print {
body.print-year * {
visibility: hidden;
}
body.print-year #yearRotationResult,
body.print-year #yearRotationResult * {
visibility: visible;
}
.print-year-btn {
display: none !important;
}
body.print-year #yearRotationResult {
position: absolute;
top: 0;
left: 0;
}
}
</style>
</head>
<body>
<h1>交番ローテーション</h1>
<!-- 年月選択 -->
<div id="controls">
<label>スタート年月日:</label>
<input type="number" id="yearInput" value="2026" min="2026" max="2100">
<label>月:</label>
<select id="monthSelect">
<option value="0">1月</option>
<option value="1">2月</option>
<option value="2">3月</option>
<option value="3">4月</option>
<option value="4">5月</option>
<option value="5">6月</option>
<option value="6">7月</option>
<option value="7">8月</option>
<option value="8">9月</option>
<option value="9">10月</option>
<option value="10">11月</option>
<option value="11">12月</option>
</select>
<label>日:</label>
<select id="daySelect"></select>
</div>
<hr>
<!-- ① 職員氏名入力欄(136名分) -->
<h2>平日64交番 土曜58交番 日祭日50交番の各連番循環方式</h2>
<h2>職員氏名登録</h2>
<!-- 上部操作バー(横並び) -->
<div id="topBar">
<!-- 氏名入力欄+登録ボタン -->
<div class="topBlock">
<textarea id="memberNamesInput" rows="3" cols="20"
placeholder="佐藤太郎
田中三郎
ミウラダイスケ"></textarea>
<button id="memberNamesSaveBtn">氏名登録</button>
<button id="deleteAllMembersBtn">全職員削除</button>
</div>
</div>
<div id="messageArea"></div>
<hr>
<!-- 選択中の職員表示(常時表示) -->
<div id="selectedNameDisplay"
style="font-size:20px; font-weight:bold; margin:10px 0;"></div>
<!-- 職員一覧アコーディオン -->
<div class="accordion">
<div class="accordion-header" id="memberAccordionHeader">
▼ 職員一覧を選択するとローテーション表示
</div>
<div class="accordion-body" id="memberAccordionBody">
<div id="memberList" class="member-list-row"></div>
</div>
</div>
<div id="restInfo" style="display:none;"></div>
<button id="checkKobanBtn">交番チェック確認</button>
<div id="checkResult" style="display:none;"></div>
<label>検索する年:</label>
<input type="number" id="searchYear" value="2026" min="2026" max="2100">
<label>検索する月:</label>
<select id="searchMonth">
<option value="0">1月</option>
<option value="1">2月</option>
<option value="2">3月</option>
<option value="3">4月</option>
<option value="4">5月</option>
<option value="5">6月</option>
<option value="6">7月</option>
<option value="7">8月</option>
<option value="8">9月</option>
<option value="9">10月</option>
<option value="10">11月</option>
<option value="11">12月</option>
</select>
<button id="searchBtn">この年月で検索</button>
<button id="showYearRotationBtn">年間ローテーション表示</button>
<button id="printBtn">このカレンダーを印刷</button>
<button id="printYearRotationBtn" class="print-year-btn">
年間ローテーションを印刷
</button>
<div id="yearRotationResult">
<div id="yearRotationResult"></div>
</div>
<div id="weekHeader"></div>
<h2 id="memberName"></h2>
<div id="calendar"></div>
</body>
</html>
| JS |
// =========================
// 年・月関連
// =========================
function getYear() {
const y = parseInt(document.getElementById("yearInput").value);
return Math.max(y, 2026);
}
function isLeapYear(year) {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
function getDaysInMonth(year) {
return [
31,
isLeapYear(year) ? 29 : 28,
31, 30, 31, 30,
31, 31, 30, 31, 30, 31
];
}
// =========================
// holiday-jp を使った祝日判定
// =========================
function isNationalHoliday(date) {
return (typeof JapaneseHolidays !== "undefined" &&
JapaneseHolidays.isHoliday(date) !== null);
}
function isSubstituteHoliday(date) {
const prev = new Date(date);
prev.setDate(date.getDate() - 1);
return isNationalHoliday(prev) && prev.getDay() === 0;
}
function isCitizenHoliday(date) {
const w = date.getDay();
if (w === 0 || w === 6) return false;
const prev = new Date(date);
prev.setDate(date.getDate() - 1);
const next = new Date(date);
next.setDate(date.getDate() + 1);
return isNationalHoliday(prev) && isNationalHoliday(next);
}
function isHoliday(date) {
if (isNationalHoliday(date)) return true;
if (isSubstituteHoliday(date)) return true;
if (isCitizenHoliday(date)) return true;
if (date.getDay() === 0) return true;
return false;
}
function getDayType(date) {
if (isHoliday(date)) return "holiday";
if (date.getDay() === 6) return "saturday";
return "weekday";
}
// =========================
// 永続 dayIndex(左回転用)
// =========================
const BASE_DATE = new Date(2026, 0, 1);
function getGlobalDayIndex(year, monthIndex, day) {
const target = new Date(year, monthIndex, day);
return Math.floor((target - BASE_DATE) / (1000 * 60 * 60 * 24));
}
// =========================
// 職員データ
// =========================
const members = [];
let selectedMemberId = 1;
// =========================
// 枠数(平日64・土曜58・日祭日50)
// =========================
function getFrameCount(date) {
const w = date.getDay();
const hol = isHoliday(date);
if (hol || w === 0) return 50;
if (w === 6) return 58;
return 64;
}
function getReserveIndex(frameCount) {
return frameCount; // 枠の次が予備
}
// =========================
// 個人ローテーション(泊り→明け→…)
// =========================
function seededRandom(seed) {
let x = Math.sin(seed) * 10000;
return x - Math.floor(x);
}
const fixedRotation = ["泊り", "明け"];
const randomPool = ["1日", "中", "早出", "公休", "公休"];
function getDailyRotation(globalDayIndex) {
const pool = [...randomPool];
// globalDayIndex を seed にして毎日同じ並びになるようにシャッフル
for (let i = pool.length - 1; i > 0; i--) {
const r = seededRandom(globalDayIndex * 100 + i); // seed を日付+位置で決める
const j = Math.floor(r * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
return [...fixedRotation, ...pool];
}
function getShiftForMember(globalDayIndex, memberId) {
const rotation = getDailyRotation(globalDayIndex);
const startOffset = (memberId - 1) % rotation.length;
return rotation[(globalDayIndex + startOffset) % rotation.length];
}
// =========================
// 並び順(左回転)
// =========================
function getDailyOrder(globalDayIndex) {
// members を globalDayIndex 回左回転する
const N = members.length;
const rotated = [];
for (let i = 0; i < N; i++) {
rotated.push(members[(i + globalDayIndex) % N]);
}
return rotated;
}
// =========================
// 予備担当者(毎日1名・均等ローテーション)
// =========================
function getReserveMember(globalDayIndex) {
if (members.length === 0) return null;
return members[globalDayIndex % members.length];
}
// =========================
// 当日の役割判定(交番・予備・公休・通常)
// =========================
function getDailyAssignment(date, globalDayIndex, myId) {
const reserveMember = getReserveMember(globalDayIndex);
// 予備担当者は最優先で role="reserve"
if (reserveMember && reserveMember.id === myId) {
return { role: "reserve", shift: "予備" };
}
// 公休ローテーション
const shift = getShiftForMember(globalDayIndex, myId);
if (shift === "公休") {
return { role: "off", shift };
}
// 交番ローテーション
const kobanNumber = getKobanNumberForMember(date, myId);
return {
role: "koban",
kobanNumber,
shift
};
}
// =========================
// カレンダー描画
// =========================
function renderCalendar(monthIndex, overrideYear = null) {
// 職員名を印刷用にセット
const selectedMember = members.find(m => m.id === selectedMemberId);
if (!selectedMember || !selectedMember.name) {
document.getElementById("memberName").innerText = "職員:未選択";
} else {
document.getElementById("memberName").innerText =
"職員:" + selectedMember.name;
}
const year = overrideYear !== null ? overrideYear : getYear();
const daysInMonth = getDaysInMonth(year)[monthIndex];
const calendar = document.getElementById("calendar");
calendar.innerHTML = "";
const firstDay = new Date(year, monthIndex, 1).getDay();
// 1日が始まる位置まで「空セル」を入れて曜日を合わせる
for (let i = 0; i < firstDay; i++) {
const empty = document.createElement("div");
empty.className = "day empty";
calendar.appendChild(empty);
}
for (let d = 1; d <= daysInMonth; d++) {
let startDay = parseInt(document.getElementById("daySelect").value);
if (isNaN(startDay)) startDay = 1;
// 検索中かどうか判定
const isSearching =
document.getElementById("searchYear").value !== "" &&
document.getElementById("searchMonth").value !== "";
// 検索時は当日判定を無効化
if (!isSearching && d < startDay) {
const div = document.createElement("div");
div.className = "day empty";
calendar.appendChild(div);
continue;
}
const date = new Date(year, monthIndex, d);
const globalDayIndex = getGlobalDayIndex(year, monthIndex, d);
const dailyAssignments = members.map(m => ({
member: m,
assignment: getDailyAssignment(date, globalDayIndex, m.id)
}));
const selected = dailyAssignments.find(x => x.member.id === selectedMemberId);
// selected が undefined の場合のガード
if (!selected) {
const div = document.createElement("div");
div.className = "day weekday";
div.innerHTML = `
<div>${year}年 ${monthIndex + 1}月 ${d}日</div>
<div>(選択中の職員なし)</div>
<div>-</div>
`;
calendar.appendChild(div);
continue;
}
const kobanLabel = getKobanLabel(date, selected.assignment);
const div = document.createElement("div");
// 曜日クラス付与
const w = date.getDay();
const hol = isHoliday(date);
// 予備判定を最優先で追加
if (selected.assignment.role === "reserve") {
div.className = "day yobi";
} else if (selected.assignment.role === "off") {
div.className = "day off"; //公休日専用色
} else if (hol || w === 0) {
div.className = "day holiday";
} else if (w === 6) {
div.className = "day saturday";
} else {
div.className = "day weekday";
}
div.innerHTML = `
<div>${year}年 ${monthIndex + 1}月 ${d}日</div>
<div>${kobanLabel}</div>
<div>${selected.assignment.shift}</div>
`;
calendar.appendChild(div);
}
}
function getKobanLabel(date, assignment) {
if (assignment.role !== "koban") return assignment.shift;
const num = assignment.kobanNumber;
if (!num || num === 0) return ""; // 0 は空白
const w = date.getDay();
const hol = isHoliday(date);
if (hol || w === 0) return `日祭日交番${num}`;
if (w === 6) return `土曜交番${num}`;
return `平日交番${num}`;
}
// =========================
// 職員一覧更新+選択+削除
// =========================
function updateMemberList() {
const area = document.getElementById("memberList");
area.innerHTML = "";
members.forEach(m => {
const wrapper = document.createElement("div");
wrapper.className = "memberItem";
const nameDiv = document.createElement("div");
nameDiv.textContent = m.name || "(氏名未設定)"; // 空文字ガード
if (m.id === selectedMemberId) nameDiv.classList.add("selected");
nameDiv.addEventListener("click", () => {
// ID をセット
selectedMemberId = m.id;
localStorage.setItem("selectedMemberId", selectedMemberId);
// ★ 氏名が空の場合のガード
if (!m.name) {
document.getElementById("selectedNameDisplay").textContent =
"選択中の職員:未選択";
} else {
document.getElementById("selectedNameDisplay").textContent =
`選択中の職員:${m.name}`;
}
const year = getYear();
const monthIndex = parseInt(document.getElementById("monthSelect").value);
// カレンダー再描画(内部で selectedMember のガード)
renderCalendar(monthIndex);
// 氏名が空なら公休日数計算はしない
if (!m.name) {
const restInfo = document.getElementById("restInfo");
restInfo.innerHTML = `
<div class="rest-box">
<div class="rest-title">職員未選択</div>
<div class="rest-line">月間公休:-</div>
<div class="rest-line">年間公休:-</div>
</div>
`;
restInfo.style.display = "block";
updateMemberList();
return;
}
// 公休日数の自動更新
const monthlyRest = countRestDaysForMonthForMember(year, monthIndex, m.name);
const yearlyRest = countRestDaysForYearForMember(year, m.name);
const restInfo = document.getElementById("restInfo");
restInfo.innerHTML = `
<div class="rest-box">
<div class="rest-title">${m.name} の公休日数</div>
<div class="rest-line">月間公休:<b>${monthlyRest}日</b></div>
<div class="rest-line">年間公休:<b>${yearlyRest}日</b></div>
</div>
`;
restInfo.style.display = "block";
updateMemberList();
});
const delBtn = document.createElement("button");
delBtn.textContent = "削除";
delBtn.className = "deleteBtn";
delBtn.addEventListener("click", () => {
const idx = members.findIndex(x => x.id === m.id);
if (idx !== -1) members.splice(idx, 1);
localStorage.setItem("members", JSON.stringify(members));
// 削除後の selectedMemberId の安全化
if (selectedMemberId === m.id) {
selectedMemberId = members[0] ? members[0].id : null;
localStorage.setItem("selectedMemberId", selectedMemberId);
}
// selectedMemberId が null の場合のガード
if (!selectedMemberId || !members.some(x => x.id === selectedMemberId)) {
document.getElementById("selectedNameDisplay").textContent =
"選択中の職員:未選択";
renderCalendar(parseInt(document.getElementById("monthSelect").value) || 0);
updateMemberList();
return;
}
updateMemberList();
renderCalendar(parseInt(document.getElementById("monthSelect").value) || 0);
});
wrapper.appendChild(nameDiv);
wrapper.appendChild(delBtn);
area.appendChild(wrapper);
});
}
// =========================
// 公休日数集計(完全版)
// =========================
function findMemberByName(name) {
return members.find(m => m.name === name) || null;
}
function countRestDaysForMonthForMember(year, month, memberName) {
const member = findMemberByName(memberName);
if (!member) return 0;
let count = 0;
const days = getDaysInMonth(year)[month];
for (let d = 1; d <= days; d++) {
const date = new Date(year, month, d);
const globalDayIndex = getGlobalDayIndex(year, month, d);
const assignment = getDailyAssignment(date, globalDayIndex, member.id);
if (assignment.role === "off") count++;
}
return count;
}
function countRestDaysForYearForMember(year, memberName) {
const member = findMemberByName(memberName);
if (!member) return 0;
let count = 0;
for (let month = 0; month < 12; month++) {
const days = getDaysInMonth(year)[month];
for (let d = 1; d <= days; d++) {
const date = new Date(year, month, d);
const globalDayIndex = getGlobalDayIndex(year, month, d);
const assignment = getDailyAssignment(date, globalDayIndex, member.id);
if (assignment.role === "off") count++;
}
}
return count;
}
document.getElementById("searchBtn").addEventListener("click", () => {
const year = parseInt(document.getElementById("searchYear").value);
const monthIndex = parseInt(document.getElementById("searchMonth").value);
// 曜日ヘッダー再描画
renderWeekHeader();
// カレンダー描画(内部で selectedMember のガード済)
renderCalendar(monthIndex, year);
const restInfo = document.getElementById("restInfo");
// members が空の場合のガード
if (members.length === 0) {
restInfo.innerHTML = `
<div class="rest-box">
<div class="rest-title">職員が登録されていません</div>
<div class="rest-line">月間公休:-</div>
<div class="rest-line">年間公休:-</div>
</div>
`;
restInfo.style.display = "block";
return;
}
// selectedMemberId が不正の場合のガード
const member = members.find(m => m.id === selectedMemberId);
if (!member || !member.name) {
restInfo.innerHTML = `
<div class="rest-box">
<div class="rest-title">職員が選択されていません</div>
<div class="rest-line">月間公休:-</div>
<div class="rest-line">年間公休:-</div>
</div>
`;
restInfo.style.display = "block";
return;
}
// 氏名が空文字の場合のガード(既存ロジック維持)
if (member.name.trim() === "") {
restInfo.innerHTML = `
<div class="rest-box">
<div class="rest-title">職員名が未設定です</div>
<div class="rest-line">月間公休:-</div>
<div class="rest-line">年間公休:-</div>
</div>
`;
restInfo.style.display = "block";
return;
}
// 公休日数計算(安全化済)
const monthlyRest = countRestDaysForMonthForMember(year, monthIndex, member.name);
const yearlyRest = countRestDaysForYearForMember(year, member.name);
restInfo.innerHTML = `
<div class="rest-box">
<div class="rest-title">${member.name} の公休日数</div>
<div class="rest-line">月間公休:<b>${monthlyRest}日</b></div>
<div class="rest-line">年間公休:<b>${yearlyRest}日</b></div>
</div>
`;
restInfo.style.display = "block";
});
// 曜日ヘッダー生成
window.addEventListener("load", () => {
const weekHeader = document.getElementById("weekHeader");
const labels = ["日","月","火","水","木","金","土"];
labels.forEach((label, i) => {
const div = document.createElement("div");
div.textContent = label;
if (i === 0) div.className = "week-sun";
else if (i === 6) div.className = "week-sat";
else div.className = "week-day";
weekHeader.appendChild(div);
});
});
// 月変更時にカレンダー再描画
document.getElementById("monthSelect").addEventListener("change", () => {
updateDaySelect();
const monthIndex = parseInt(document.getElementById("monthSelect").value) || 0;
renderCalendar(monthIndex);
});
// 交番表示確認(全員の交番表)
document.getElementById("checkKobanBtn").addEventListener("click", () => {
const resultArea = document.getElementById("checkResult");
//すでに表示されているなら閉じる
if (resultArea.style.display === "grid") {
resultArea.style.display = "none";
resultArea.innerHTML = "";
return;
}
const year = getYear();
const monthIndex = parseInt(document.getElementById("monthSelect").value) || 0;
const daysInMonth = getDaysInMonth(year)[monthIndex];
resultArea.innerHTML = "";
resultArea.className = "check-result";
for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(year, monthIndex, d);
const globalDayIndex = getGlobalDayIndex(year, monthIndex, d);
const fullTable = generateFullKobanTable(date, globalDayIndex);
const box = document.createElement("div");
box.className = "check-box check-ok";
const dateStr = `${year}年${monthIndex + 1}月${d}日`;
box.innerHTML = `
<div class="check-date">${dateStr}</div>
<div class="check-info">
交番1〜枠数まで重複・欠番なし(仕様どおり)
</div>
`;
resultArea.appendChild(box);
}
// grid に戻す
resultArea.style.display = "grid";
});
// 氏名登録
document.getElementById("memberNamesSaveBtn").addEventListener("click", () => {
const text = document.getElementById("memberNamesInput").value.trim();
const lines = text.split("\n").map(s => s.trim()).filter(s => s !== "");
// 空白チェック
if (lines.length === 0) {
const msg = document.getElementById("messageArea");
msg.textContent = "氏名を入力してください";
msg.style.display = "block";
//4秒後に自動消去
setTimeout(() => {
msg.style.display = "none";
msg.innerHTML = "";
}, 4000);
return;
}
lines.forEach(name => {
if (!members.some(m => m.name === name)) {
const nextId = members.length > 0
? Math.max(...members.map(m => m.id)) + 1
: 1;
members.push({ id: nextId, name });
}
});
if (members.length > 0) {
if (!selectedMemberId || !members.some(m => m.id === selectedMemberId)) {
selectedMemberId = members[0].id;
}
localStorage.setItem("selectedMemberId", selectedMemberId);
}
localStorage.setItem("members", JSON.stringify(members));
updateMemberList();
renderCalendar(parseInt(document.getElementById("monthSelect").value) || 0);
const msg = document.getElementById("messageArea");
msg.textContent = `職員 ${members.length} 名を登録しました。`;
msg.style.display = "block";
//4秒後に自動消去
setTimeout(() => {
msg.style.display = "none";
msg.innerHTML = "";
}, 4000);
document.getElementById("memberNamesInput").value = "";
});
// 初期ロード
window.addEventListener("load", () => {
// 今日の月を初期選択にする
const today = new Date();
document.getElementById("monthSelect").value = today.getMonth();
// members のロード(壊れていても安全)
const saved = localStorage.getItem("members");
if (saved) {
try {
const arr = JSON.parse(saved);
members.length = 0;
arr.forEach(m => members.push(m));
} catch (e) {
// JSON が壊れている場合
members.length = 0;
localStorage.removeItem("members");
}
}
// selectedMemberId のロード(不正でも安全)
const savedSelectedId = parseInt(localStorage.getItem("selectedMemberId"));
if (
savedSelectedId &&
members.some(m => m.id === savedSelectedId)
) {
selectedMemberId = savedSelectedId;
} else {
// 不正 or null の場合は先頭を選択
selectedMemberId = members[0] ? members[0].id : null;
localStorage.setItem("selectedMemberId", selectedMemberId);
}
//職員一覧更新(内部で name が空でも安全)
updateMemberList();
//selectedMember 表示(null / 空文字でも安全)
if (members.length > 0) {
const selectedMember = members.find(m => m.id === selectedMemberId);
if (selectedMember && selectedMember.name) {
document.getElementById("selectedNameDisplay").textContent =
`選択中の職員:${selectedMember.name}`;
} else {
document.getElementById("selectedNameDisplay").textContent =
"選択中の職員:未選択";
}
} else {
// 職員が 0 名の場合
document.getElementById("selectedNameDisplay").textContent =
"選択中の職員:未選択";
}
//カレンダー描画(内部で selectedMember のガード済)
const monthIndex = parseInt(document.getElementById("monthSelect").value) || 0;
renderCalendar(monthIndex);
//daySelect の安全更新
updateDaySelect();
});
// =========================
// 全員の交番表生成
// =========================
function generateFullKobanTable(date, globalDayIndex) {
const order = getDailyOrder(globalDayIndex);
const frame = getFrameCount(date);
const activeMembers = order.filter(m => {
const shift = getShiftForMember(globalDayIndex, m.id);
return shift !== "公休";
});
const N = activeMembers.length;
const kobanTable = [];
for (let k = 0; k < frame; k++) {
const startIndex = k % N;
const kobanMembers = [];
for (let i = 0; i < frame; i++) {
const idx = (startIndex + i) % N;
kobanMembers.push(activeMembers[idx].name);
}
kobanTable.push({
kobanNumber: k + 1,
members: kobanMembers
});
}
return kobanTable;
}
function getKobanNumberForMember(date, memberId) {
const startYear = parseInt(document.getElementById("yearInput").value);
const startMonth = parseInt(document.getElementById("monthSelect").value);
const startDay = parseInt(document.getElementById("daySelect").value);
const startDate = new Date(startYear, startMonth, startDay);
// 検索中かどうか判定
const isSearching = document.getElementById("searchYear").value !== "" &&
document.getElementById("searchMonth").value !== "";
// 検索時は当日判定を無効化
if (!isSearching && date < startDate) return 0;
const dayType = getDayType(date);
let frameCount;
if (dayType === "weekday") frameCount = 64;
else if (dayType === "saturday") frameCount = 58;
else frameCount = 50;
let count = 0;
const d = new Date(startDate);
while (d <= date) {
if (getDayType(d) === dayType) count++;
d.setDate(d.getDate() + 1);
}
return ((memberId - 1) + (count - 1)) % frameCount + 1;
}
function updateDaySelect() {
const year = getYear();
const month = parseInt(document.getElementById("monthSelect").value);
const daySelect = document.getElementById("daySelect");
const today = new Date();
today.setHours(0,0,0,0);
const daysInMonth = getDaysInMonth(year)[month];
daySelect.innerHTML = "";
for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(year, month, d);
date.setHours(0,0,0,0);
// 過去は選択不可(当日OK)
if (date < today) continue;
const opt = document.createElement("option");
opt.value = d;
opt.textContent = `${d}日`;
daySelect.appendChild(opt);
}
// 初期選択は「当日」
if (daySelect.options.length > 0) {
daySelect.value = today.getDate();
}
}
function needsForcedRest(date, memberName) {
const year = date.getFullYear();
const month = date.getMonth();
const currentRest = countRestDaysForMonthForMember(year, month, memberName);
// 公休が7日未満なら強制公休
if (currentRest < 7) {
return true;
}
return false;
}
document.getElementById("printBtn").addEventListener("click", () => {
window.print();
});
function renderWeekHeader() {
const weekHeader = document.getElementById("weekHeader");
weekHeader.innerHTML = `
<div class="week-sun">日</div>
<div class="week-day">月</div>
<div class="week-day">火</div>
<div class="week-day">水</div>
<div class="week-day">木</div>
<div class="week-day">金</div>
<div class="week-sat">土</div>
`;
}
document.getElementById("showYearRotationBtn").addEventListener("click", () => {
const box = document.getElementById("yearRotationResult");
// すでに表示されているなら閉じる
if (box.style.display === "block") {
box.style.display = "none";
box.innerHTML = "";
//通常カレンダーを再表示
document.getElementById("weekHeader").style.display = "grid";
document.getElementById("calendar").style.display = "grid";
return;
}
const msg = document.getElementById("messageArea");
const year = getYear();
const memberName = document.getElementById("selectedNameDisplay")
.textContent.replace("選択中の職員:", "").trim();
//氏名が空の場合のガード
if (!memberName) {
msg.innerHTML = `
<div style="padding:10px; background:#fee; border:1px solid #f88;">
<b>氏名が空の状態では年間ローテーションを表示できません。</b><br>
職員を選択するか、氏名を入力してください。
</div>
`;
msg.style.display = "block";
setTimeout(() => {
msg.style.display = "none";
msg.innerHTML = "";
}, 4000);
return;
}
//該当職員が存在しない場合のガード
const memberObj = members.find(m => m.name === memberName);
if (!memberObj) {
msg.innerHTML = `
<div style="padding:10px; background:#fee; border:1px solid #f88;">
<b>選択された職員が存在しません。</b><br>
職員一覧から選択してください。
</div>
`;
msg.style.display = "block";
setTimeout(() => {
msg.style.display = "none";
msg.innerHTML = "";
}, 4000);
return;
}
//安全に memberId を取得
const memberId = memberObj.id;
//年間ローテーションのHTML開始
let html = `
<div class="year-rotation-box">
<div class="year-rotation-title">${memberName} の年間ローテーション(${year}年)</div>
`;
//通常カレンダーを非表示
document.getElementById("weekHeader").style.display = "none";
document.getElementById("calendar").style.display = "none";
let globalDayIndex = 0;
for (let month = 1; month <= 12; month++) {
html += `<h3 class="month-title">${month}月</h3>`;
html += `<ul class="year-rotation-list">`;
const daysInMonth = new Date(year, month, 0).getDate();
for (let day = 1; day <= daysInMonth; day++) {
const dateObj = new Date(year, month - 1, day);
const dateStr = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const shift = getShiftForMember(globalDayIndex, memberId);
const dayOfWeek = dateObj.getDay();
const isSunday = dayOfWeek === 0;
const isSaturday = dayOfWeek === 6;
const isHoliday = JapaneseHolidays.isHoliday(dateObj);
let dayClass = "";
if (isSunday) dayClass = "sunday";
else if (isSaturday) dayClass = "saturday";
else if (isHoliday) dayClass = "holiday";
let shiftClass = "";
if (shift === "公休") shiftClass = "rest-day";
let pairClass = "";
if (shift === "泊り") pairClass = "night-shift";
if (shift === "明け") pairClass = "after-night";
//交番番号(通常カレンダーと完全一致)
const kobanNumber = getKobanNumberForMember(dateObj, memberId);
//交番の種類(平日・土曜・日祭日)
let kobanLabel = "";
if (shift === "公休" || shift === "予備") {
kobanLabel = ""; // 公休・予備は交番なし
} else if (!isSaturday && !isSunday && !isHoliday) {
kobanLabel = `平日交番${kobanNumber}`;
} else if (isSaturday) {
kobanLabel = `土曜交番${kobanNumber}`;
} else if (isSunday || isHoliday) {
kobanLabel = `日祭日交番${kobanNumber}`;
}
html += `
<li class="${dayClass} ${shiftClass} ${pairClass}">
<span class="date">${dateStr}</span>
<span class="rotation">${kobanLabel}</span>
<span class="shift">${shift}</span>
</li>
`;
globalDayIndex++;
}
html += `</ul>`;
}
box.innerHTML = html;
box.style.display = "block";
});
// 年間ローテーション印刷ボタン
document.addEventListener("click", (e) => {
if (e.target.id === "printYearRotationBtn") {
const box = document.getElementById("yearRotationResult");
const content = box.innerHTML; //表示内容をそのまま印刷
// 新しいウィンドウを開く
const printWindow = window.open("", "_blank");
//最小限のHTMLとCSSを流し込む
printWindow.document.write(`
<html>
<head>
<title>年間ローテーション印刷</title>
<style>
body {
font-family: sans-serif;
}
.year-rotation-box {
page-break-inside: avoid;
}
@media print {
.sunday .date {
color: #d60000;
font-weight: bold;
}
.saturday .date {
color: #0040ff;
font-weight: bold;
}
.holiday .date {
color: #d60000;
font-weight: bold;
}
.rest-day {
background: #e0e0e0;
}
.night-shift {
background: #f3e8ff;
}
.after-night {
background: #ffe8d1;
}
.rotation {
font-weight: bold;
color: #333;
}
body {
margin: 10mm;
}
}
</style>
</head>
<body>
${content}
</body>
</html>
`);
printWindow.document.close();
//ウィンドウの内容が読み込まれてから印刷
printWindow.focus();
printWindow.print();
printWindow.close();
}
});
// =========================
// 全職員削除ボタン(messageArea)
// =========================
document.getElementById("deleteAllMembersBtn").addEventListener("click", () => {
const msg = document.getElementById("messageArea");
//職員が 0 名 or 氏名空の職員が存在する場合のガード
if (
members.length === 0 ||
members.some(m => !m.name || m.name.trim() === "")
) {
msg.innerHTML = `
<div style="padding:10px; background:#fee; border:1px solid #f88;">
<b>氏名が空の状態では全削除できません。</b><br>
職員が登録されていないか、氏名が空の職員が存在します。<br>
氏名を入力するか、該当職員を削除してください。
</div>
`;
msg.style.display = "block";
//4秒後に自動消去
setTimeout(() => {
msg.style.display = "none";
msg.innerHTML = "";
}, 4000);
return;
}
// ここから先は「全員氏名あり・1人以上いる」場合だけ
msg.innerHTML = `
<div style="padding:10px; background:#fee; border:1px solid #f88;">
<b>全職員を削除しますか?</b><br>
<button id="confirmDeleteAll" style="margin-top:5px;">はい、削除します</button>
<button id="cancelDeleteAll" style="margin-left:10px;">キャンセル</button>
</div>
`;
msg.style.display = "block";
document.getElementById("confirmDeleteAll").addEventListener("click", () => {
members.length = 0;
selectedMemberId = null;
localStorage.removeItem("members");
localStorage.removeItem("selectedMemberId");
document.getElementById("selectedNameDisplay").textContent =
"選択中の職員:未選択";
const restInfo = document.getElementById("restInfo");
restInfo.innerHTML = `
<div class="rest-box">
<div class="rest-title">職員が登録されていません</div>
<div class="rest-line">月間公休:-</div>
<div class="rest-line">年間公休:-</div>
</div>
`;
restInfo.style.display = "block";
updateMemberList();
const monthIndex = parseInt(document.getElementById("monthSelect").value) || 0;
renderCalendar(monthIndex);
msg.innerHTML = `
<div style="padding:10px; background:#efe; border:1px solid #8c8;">
<b>全職員を削除しました。</b>
</div>
`;
//4秒後に自動消去
setTimeout(() => {
msg.style.display = "none";
msg.innerHTML = "";
}, 4000);
});
document.getElementById("cancelDeleteAll").addEventListener("click", () => {
msg.style.display = "none";
msg.innerHTML = "";
});
});
// =========================
// 職員一覧アコーディオン
// =========================
document.getElementById("memberAccordionHeader").addEventListener("click", () => {
const body = document.getElementById("memberAccordionBody");
const header = document.getElementById("memberAccordionHeader");
if (body.style.display === "none") {
body.style.display = "block";
header.textContent = "▲ 職員一覧(クリックで閉じる)";
} else {
body.style.display = "none";
header.textContent = "▼ 職員一覧を選択するとローテーション表示";
}
});| CSS |
body {
font-family: sans-serif;
padding-top: 5px;
padding-left: 20px;
padding-right: 20px;
padding-bottom: 20px;
}
#calendar {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-top: -7px;
}
.day {
border: 1px solid #ccc;
font-size: 12px;
}
.day-header.sunday {
color: red;
}
.day-header.saturday {
color: blue;
}
.holiday { background: #ffdddd; }
.saturday { background: #ddeeff; }
.weekday { background: #ffffff; }
.empty { background: #f0f0f0; }
.yobi { background: #d8f5d1; }
.off {background: #ffe8aa;}
.shift {
font-weight: bold;
margin-top: 4px;
}
#weekHeader {
display: grid;
grid-template-columns: repeat(7, 1fr);
text-align: center;
font-weight: bold;
padding: 6px 0;
column-gap: 4px;
}
.week-sun,.week-sat,.week-day{
border: 1px solid #ccc;
}
.week-sun {
color: red;
}
.week-sat {
color: blue;
}
.week-day {
color: black;
}
#topBar {
display: flex;
gap: 20px;
align-items: flex-start;
margin-bottom: 20px;
}
.topBlock {
display: flex;
flex-direction: column;
gap: 8px;
}
#messageArea {
margin: 10px 0;
padding: 8px 12px;
background: #e0ffe0;
border-left: 5px solid #4CAF50;
font-size: 16px;
font-weight: bold;
display: none;
}
.shift-yobi {
background-color: #d8f5d1;
padding: 2px 4px;
border-radius: 4px;
font-weight: bold;
}
.member-list-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 15px;
}
.member-list-row div {
padding: 2px 4px;
background: #f0f0f0;
border-radius: 4px;
cursor: pointer;
}
.member-list-row div.selected {
font-weight: bold;
color: blue;
background: #d0e4ff;
}
#topBar {
display: flex;
gap: 20px;
align-items: flex-start;
}
.topBlock {
display: flex;
flex-direction: row;
gap: 10px;
}
#memberNamesInput {
resize: vertical;
}
#memberNamesSaveBtn {
height: 30px;
}
.member-list-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.memberItem {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
background: #f0f0f0;
border-radius: 4px;
}
.memberItem .selected {
font-weight: bold;
color: blue;
}
.deleteBtn {
background: #ff6666;
color: white;
border: none;
padding: 3px 6px;
border-radius: 3px;
cursor: pointer;
}
.accordion {
margin: 10px 0;
border: 1px solid #ccc;
border-radius: 6px;
}
.accordion-header {
padding: 10px;
background: #e0e0e0;
cursor: pointer;
font-weight: bold;
}
.accordion-body {
padding: 10px;
display: none;
}
/* 職員一覧(横並び) */
.member-list-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.memberItem {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
background: #f0f0f0;
border-radius: 4px;
}
.memberItem .selected {
font-weight: bold;
color: blue;
}
.deleteBtn {
background: #eb6868;
color: rgb(240, 238, 238);
border: none;
padding: 3px 6px;
border-radius: 3px;
cursor: pointer;
}
.check-result {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 4px;
font-family: "Segoe UI", system-ui, sans-serif;
font-size: 12px;
}
.check-box {
border: 1px solid #ccc;
border-radius: 4px;
padding: 4px;
min-height: 40px;
}
.check-ok {
background: #e6f5e6;
border-color: #8ac28a;
}
.check-ng {
background: #fdeaea;
border-color: #e08a8a;
}
.check-date {
font-weight: 600;
margin-bottom: 2px;
}
.check-info {
font-size: 11px;
}
.check-info span.missing {
color: #c0392b;
font-weight: 600;
}
.rest-box {
margin-top: 8px;
padding: 6px;
border: 1px solid #ccc;
border-radius: 4px;
background: #f8f8f8;
font-size: 13px;
}
.rest-title {
font-weight: 600;
margin-bottom: 4px;
}
.rest-line {
margin: 2px 0;
}
#memberName {
display: none;
}
.year-rotation-box {
border: 1px solid #ccc;
padding: 10px;
background: #f9f9f9;
width: 350px;
max-height: 400px;
overflow-y: scroll;
}
.year-rotation-title {
font-weight: bold;
margin-bottom: 8px;
}
.year-rotation-list {
padding-left: 20px;
margin: 0;
font-size: 13px;
line-height: 1.4;
}
/* 土日祝 */
.sunday { color: red; }
.saturday { color: blue; }
.holiday { color: #d9534f; font-weight: bold; }
/* 公休の背景色 */
.rest-day {
background-color: #f0f0f0;
}
/* 泊り→明けのペア強調 */
.night-shift {
background-color: #ffe0e0;
font-weight: bold;
}
.after-night {
background-color: #fff0f0;
}
/* 月タイトル */
.month-title {
font-size: 18px;
font-weight: bold;
margin-top: 20px;
border-bottom: 2px solid #333;
}
@media print {
.month-title:not(:first-of-type) {
page-break-before: always;
}
body.print-year #yearRotationResult {
position: static !important;
}
}| japanese-holidays-browser.js |
// 日本の祝日判定(ブラウザ用)
// 対応範囲:2000〜2100年くらいを想定
(function () {
function ymd(date) {
return {
y: date.getFullYear(),
m: date.getMonth() + 1,
d: date.getDate(),
w: date.getDay()
};
}
// 第n月曜日(ハッピーマンデー系)
function nthMonday(year, month, n) {
const first = new Date(year, month - 1, 1);
const firstW = first.getDay();
const offset = (8 - firstW) % 7; // 最初の月曜までの日数
return 1 + offset + 7 * (n - 1);
}
// 春分の日(簡易近似:1980〜2099年)
function shunbun(year) {
if (year <= 1979 || year >= 2100) return 20;
if (year <= 2099) return Math.floor(20.8431 + 0.242194 * (year - 1980)) - Math.floor((year - 1980) / 4);
return 20;
}
// 秋分の日(簡易近似:1980〜2099年)
function shubun(year) {
if (year <= 1979 || year >= 2100) return 23;
if (year <= 2099) return Math.floor(23.2488 + 0.242194 * (year - 1980)) - Math.floor((year - 1980) / 4);
return 23;
}
// 固定祝日(年月日で判定)
function fixedHoliday(y, m, d) {
// 元日
if (m === 1 && d === 1) return "元日";
// 建国記念の日
if (m === 2 && d === 11) return "建国記念の日";
// 昭和の日
if (m === 4 && d === 29) return "昭和の日";
// 憲法記念日
if (m === 5 && d === 3) return "憲法記念日";
// みどりの日
if (m === 5 && d === 4) return "みどりの日";
// こどもの日
if (m === 5 && d === 5) return "こどもの日";
// 山の日
if (m === 8 && d === 11) return "山の日";
// 文化の日
if (m === 11 && d === 3) return "文化の日";
// 勤労感謝の日
if (m === 11 && d === 23) return "勤労感謝の日";
// 天皇誕生日(令和以降)
if (y >= 2020 && m === 2 && d === 23) return "天皇誕生日";
return null;
}
// ハッピーマンデー系
function happyMondayHoliday(y, m, d, w) {
if (w !== 1) return null; // 月曜のみ
// 成人の日:1月第2月曜(2000年以降)
if (m === 1 && y >= 2000 && d === nthMonday(y, 1, 2)) return "成人の日";
// 海の日:7月第3月曜(2003年以降)
if (m === 7 && y >= 2003 && d === nthMonday(y, 7, 3)) return "海の日";
// 敬老の日:9月第3月曜(2003年以降)
if (m === 9 && y >= 2003 && d === nthMonday(y, 9, 3)) return "敬老の日";
// スポーツの日(旧・体育の日):10月第2月曜(2000年以降)
if (m === 10 && y >= 2000 && d === nthMonday(y, 10, 2)) return "スポーツの日";
return null;
}
// 春分・秋分
function equinoxHoliday(y, m, d) {
const shun = shunbun(y);
const shu = shubun(y);
if (m === 3 && d === shun) return "春分の日";
if (m === 9 && d === shu) return "秋分の日";
return null;
}
// 国民の祝日(基本判定)
function baseNationalHoliday(date) {
const { y, m, d, w } = ymd(date);
// 固定祝日
const fixed = fixedHoliday(y, m, d);
if (fixed) return fixed;
// ハッピーマンデー
const happy = happyMondayHoliday(y, m, d, w);
if (happy) return happy;
// 春分・秋分
const eq = equinoxHoliday(y, m, d);
if (eq) return eq;
return null;
}
// 振替休日
function isSubstituteHoliday(date) {
const { y, m, d, w } = ymd(date);
if (w === 0) return null; // 日曜は対象外
const prev = new Date(y, m - 1, d - 1);
const prevName = baseNationalHoliday(prev);
const prevW = prev.getDay();
if (prevName && prevW === 0) {
return "振替休日";
}
return null;
}
// 国民の休日(祝日に挟まれた平日)
function isCitizenHoliday(date) {
const { y, m, d, w } = ymd(date);
if (w === 0 || w === 6) return null; // 平日限定
const prev = new Date(y, m - 1, d - 1);
const next = new Date(y, m - 1, d + 1);
const prevName = baseNationalHoliday(prev);
const nextName = baseNationalHoliday(next);
if (prevName && nextName) {
return "国民の休日";
}
return null;
}
// 公開API
window.JapaneseHolidays = {
// 祝日なら { name: "祝日名" }、そうでなければ null
isHoliday: function (date) {
const base = baseNationalHoliday(date);
if (base) return { name: base };
const sub = isSubstituteHoliday(date);
if (sub) return { name: sub };
const citizen = isCitizenHoliday(date);
if (citizen) return { name: citizen };
return null;
}
};
})();
以上です。
| 更新情報 |
| 2026 07 25 ・氏名空白の場合にエラーを表示する不具合の修正 ・氏名全削除ボタンの追加 ・年間の一覧印刷機能追加 本プログラムコードはこんな交番ローテーションがあったらいいなあと思い、思い付きで作成しました。 また、上記の追加変更は要望のご返事をいただき修正追加しましたが、本システムは当方でもWEB上では公開していません。従って参考コードとして公開していますので、実務的に開発される場合は、参考にされた開発者様で専用コードを作成してください。 (※尚、勝手ながら更新は今回のみとなります。参考にされた開発者様で調整・修正をお願いします) |
プログラムの丸ごとの使用はMOMO企画のクレジット表記が必要ですが、全てのコードをコピーして公開する場合に、少しでも変更や調整を行った場合の著作権は変更調整された開発者様に帰属されます。
必要であれば交番ローテイションの圧縮ファイルダウンロードできます(kouban.zip)
圧縮ファイルの内容 index.html script.js style.css japanese-holidays-browser.js
≪≪圧縮ファイルをダウンロード≫≫