Webアプリ「出退勤管理アプリ」を作ってみた

【Webアプリ】出勤管理アプリ完成系

基本的なコードは(12)までで完成。

あとは配置や操作性、装飾を微調整し、完成系が下記となります。

login.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>ログイン画面</title>
    <script src="https://www.gstatic.com/firebasejs/10.11.0/firebase-app-compat.js"></script>
    <script src="https://www.gstatic.com/firebasejs/10.11.0/firebase-auth-compat.js"></script>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <h1>勤怠管理アプリ</h1>

    <input type="email" id="email" placeholder="メールアドレス">
    <input type="password" id="password" placeholder="パスワード">
    <button onclick="login()">ログイン</button>

    <p id="message"></p>

    <script>
        // Firebase 設定
        const firebaseConfig = {
            apiKey: "あなたのAPIキー",
            authDomain: "あなたのプロジェクトID.firebaseapp.com",
            projectId: "あなたのプロジェクトID",
            storageBucket: "あなたのプロジェクトID.appspot.com",
            messagingSenderId: "送信者ID",
            appId: "アプリID"
        };

        // Firebase初期化
        firebase.initializeApp(firebaseConfig);
        const auth = firebase.auth();

        // ログイン関数
        function login() {
            const email = document.getElementById('email').value;
            const password = document.getElementById('password').value;

            auth.signInWithEmailAndPassword(email, password)
                .then(() => {
                    document.getElementById('message').innerText = "ログイン成功!";
                    // ログイン後にindex.htmlに移動する
                    window.location.href = "index.html";
                })
                .catch((error) => {
                    document.getElementById('message').innerText = "ログイン失敗:" + error.message;
                });
        }
    </script>
</body>
</html>

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>勤怠管理アプリ</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <script src="https://www.gstatic.com/firebasejs/10.11.0/firebase-app-compat.js"></script>
  <script src="https://www.gstatic.com/firebasejs/10.11.0/firebase-auth-compat.js"></script>
  <script src="https://www.gstatic.com/firebasejs/10.11.0/firebase-firestore-compat.js"></script>
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
  <link rel="stylesheet" href="style.css">
  <style>
    #currentTime {
      font-size: 1.4em;
      font-weight: bold;
      color: #3333cc;
      background: linear-gradient(to right, #e0f7ff, #f0f4ff);
      padding: 12px 20px;
      border-radius: 12px;
      margin-top: 15px;
      text-align: center;
      box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
      display: inline-block;
    }
  
    #currentTime .time-part {
      font-size: 2em;
      color: #0055cc;
      font-family: 'Orbitron', sans-serif;
    }
  </style>  
</head>
<body>
  <h1>勤怠管理アプリ</h1>

  <nav>
    <button onclick="showTab('tab-clock')"><i class="fa-solid fa-clock icon"></i> 打刻</button>
    <button onclick="showTab('tab-application')"><i class="fa-solid fa-file-signature icon"></i> 申請</button>
    <button onclick="showTab('tab-settings')"><i class="fa-solid fa-gear icon"></i> 設定</button>
  </nav>

  <div id="tab-clock" class="tab active">
    <h2>打刻</h2>
    <button onclick="recordTime('出勤')">出勤</button>
    <button onclick="recordTime('退勤')">退勤</button>
    <br><br>
    <p id="currentTime"></p>
    <p id="result"></p>
  </div>

  <div id="tab-application" class="tab">
    <h2>申請</h2>

    <!-- 休日申請 -->
    <button onclick="toggleSection('holidaySection')">休日申請</button>
    <div id="holidaySection" class="section-content">
      <label>日付: <input type="date" id="holidayDate"></label><br>
      <label><input type="radio" name="holidayType" value="終日" checked>終日</label>
      <label><input type="radio" name="holidayType" value="午前休">午前休</label>
      <label><input type="radio" name="holidayType" value="午後休">午後休</label><br>
      <label>申請理由:<br>
        <textarea id="holidayReason" rows="2" cols="30" placeholder="有給休暇以外の申請はここに申請内容を明記する"></textarea>
      </label><br>
      <button onclick="submitHoliday()">申請</button>
      <p id="holidayResult" class="result-message"></p>
    </div>

    <!-- 遅刻申請 -->
    <button onclick="toggleSection('lateSection')">遅刻申請</button>
    <div id="lateSection" class="section-content">
      <label>日付: <input type="date" id="lateDate"></label><br>
      <label>就業開始時間: <input type="time" id="scheduledStart"></label><br>
      <label>遅刻時間: <input type="time" id="lateTime"></label><br>
      <button onclick="submitLate()">申請</button>
      <p id="lateResult" class="result-message"></p>
    </div>

    <!-- 残業申請 -->
    <button onclick="toggleSection('overtimeSection')">残業申請</button>
    <div id="overtimeSection" class="section-content">
      <label>日付: <input type="date" id="overtimeDate"></label><br>
      <label>開始時刻: <input type="time" id="overtimeStart"></label>
      <label>終了時刻: <input type="time" id="overtimeEnd"></label><br>
      <p>理由:</p>
      <label><input type="checkbox" name="overtimeReason" value="時間内困難">時間内困難</label>
      <label><input type="checkbox" name="overtimeReason" value="突発対応">突発対応</label>
      <label><input type="checkbox" name="overtimeReason" value="報告書">報告書</label><br>
      <label>その他の理由:<br>
        <textarea id="overtimeOtherReason" rows="2" cols="30" placeholder="その他の理由を入力"></textarea>
      </label><br>
      <button onclick="submitOvertime()">申請</button>
      <p id="overtimeResult" class="result-message"></p>
    </div>

    <!-- 休日出勤申請 -->
    <button onclick="toggleSection('workOnHolidaySection')">休日出勤申請</button>
    <div id="workOnHolidaySection" class="section-content">
      <label>出勤日: <input type="date" id="workOnHolidayDate"></label><br>
      <label>開始時間: <input type="time" id="workOnHolidayStart"></label>
      <label>終了時間: <input type="time" id="workOnHolidayEnd"></label><br>
      <label>業務内容:<br>
        <textarea id="workOnHolidayTask" rows="2" cols="30" placeholder="例: 定期システムメンテナンスなど"></textarea>
      </label><br>
      <button onclick="submitWorkOnHoliday()">申請</button>
      <p id="workOnHolidayResult" class="result-message"></p>
    </div>
  </div>

  <div id="tab-settings" class="tab">
    <h2>設定</h2>
    <button onclick="auth.signOut().then(() => alert('ログアウトしました'))">ログアウト</button>
  </div>

<script>
  const firebaseConfig = {
            apiKey: "あなたのAPIキー",
            authDomain: "あなたのプロジェクトID.firebaseapp.com",
            projectId: "あなたのプロジェクトID",
            storageBucket: "あなたのプロジェクトID.appspot.com",
            messagingSenderId: "送信者ID",
            appId: "アプリID"
  };

  firebase.initializeApp(firebaseConfig);
  const auth = firebase.auth();
  const db = firebase.firestore();

  function showTab(tabId) {
    document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
    document.getElementById(tabId).classList.add('active');
  }

  function toggleSection(id) {
    const section = document.getElementById(id);
    section.style.display = section.style.display === 'block' ? 'none' : 'block';
  }

  function updateTime() {
  const now = new Date();
  const dateStr = now.toLocaleDateString(); // 例: 2025/6/8
  const timeStr = now.toLocaleTimeString(); // 例: 14:23:11

  // 時間部分だけを太く大きく強調
  document.getElementById('currentTime').innerHTML =
    `現在時刻: <span class="time-part">${timeStr}</span> (${dateStr})`;
}
  setInterval(updateTime, 1000);

  function recordTime(type) {
    const user = auth.currentUser;
    if (!user) {
      document.getElementById("result").innerText = "ログインしていません。";
      return;
    }
    const now = new Date();
    db.collection("attendance").add({
      uid: user.uid,
      type: type,
      timestamp: firebase.firestore.Timestamp.fromDate(now)
    }).then(() => {
      document.getElementById("result").innerText = `${type}打刻しました (${now.toLocaleString()})`;
    }).catch((error) => {
      document.getElementById("result").innerText = `エラー: ${error.message}`;
    });
  }

  function submitHoliday() {
    const user = auth.currentUser;
    const date = document.getElementById("holidayDate").value;
    const type = document.querySelector('input[name="holidayType"]:checked').value;
    const now = new Date();
    const resultEl = document.getElementById("holidayResult");
    if (!user || !date || !type) {
      resultEl.innerText = "休日申請の入力に不足があります。";
      resultEl.style.color = "red";
      return;
    }
    db.collection("applications").add({
      uid: user.uid,
      type: "休日申請",
      date: date,
      holidayType: type,
      requestedAt: firebase.firestore.Timestamp.fromDate(now),
      status: "申請中"
    }).then(() => {
      resultEl.innerText = `✅ 休日申請完了(${date} - ${type})`;
      resultEl.style.color = "green";
    }).catch((error) => {
      resultEl.innerText = `❌ 申請失敗: ${error.message}`;
      resultEl.style.color = "red";
    });
  }

  function submitLate() {
    const user = auth.currentUser;
    const date = document.getElementById("lateDate").value;
    const scheduledStart = document.getElementById("scheduledStart").value;
    const lateTime = document.getElementById("lateTime").value;
    const now = new Date();
    const resultEl = document.getElementById("lateResult");
    if (!user || !date || !scheduledStart || !lateTime) {
      resultEl.innerText = "遅刻申請の入力に不足があります。";
      resultEl.style.color = "red";
      return;
    }
    db.collection("applications").add({
      uid: user.uid,
      type: "遅刻申請",
      date: date,
      scheduledStart: scheduledStart,
      lateTime: lateTime,
      requestedAt: firebase.firestore.Timestamp.fromDate(now),
      status: "申請中"
    }).then(() => {
      resultEl.innerText = `✅ 遅刻申請完了(${date} ${scheduledStart}→${lateTime})`;
      resultEl.style.color = "green";
    }).catch((error) => {
      resultEl.innerText = `❌ 申請失敗: ${error.message}`;
      resultEl.style.color = "red";
    });
  }

  function submitOvertime() {
    const user = auth.currentUser;
    const date = document.getElementById("overtimeDate").value;
    const start = document.getElementById("overtimeStart").value;
    const end = document.getElementById("overtimeEnd").value;
    const reasons = Array.from(document.querySelectorAll('input[name="overtimeReason"]:checked')).map(el => el.value);
    const otherReason = document.getElementById("overtimeOtherReason").value;
    const now = new Date();
    const resultEl = document.getElementById("overtimeResult");
    if (!user || !date || !start || !end) {
      resultEl.innerText = "残業申請の入力に不足があります。";
      resultEl.style.color = "red";
      return;
    }
    db.collection("applications").add({
      uid: user.uid,
      type: "残業申請",
      date: date,
      startTime: start,
      endTime: end,
      reasons: reasons,
      otherReason: otherReason,
      requestedAt: firebase.firestore.Timestamp.fromDate(now),
      status: "申請中"
    }).then(() => {
      resultEl.innerText = `✅ 残業申請完了(${date} ${start}~${end})`;
      resultEl.style.color = "green";
    }).catch((error) => {
      resultEl.innerText = `❌ 申請失敗: ${error.message}`;
      resultEl.style.color = "red";
    });
  }

  function submitWorkOnHoliday() {
    const user = auth.currentUser;
    const date = document.getElementById("workOnHolidayDate").value;
    const start = document.getElementById("workOnHolidayStart").value;
    const end = document.getElementById("workOnHolidayEnd").value;
    const task = document.getElementById("workOnHolidayTask").value;
    const now = new Date();
    const resultEl = document.getElementById("workOnHolidayResult");
    if (!user || !date || !start || !end || !task) {
      resultEl.innerText = "休日出勤申請の入力に不足があります。";
      resultEl.style.color = "red";
      return;
    }
    db.collection("applications").add({
      uid: user.uid,
      type: "休日出勤申請",
      date: date,
      startTime: start,
      endTime: end,
      task: task,
      requestedAt: firebase.firestore.Timestamp.fromDate(now),
      status: "申請中"
    }).then(() => {
      resultEl.innerText = `✅ 休日出勤申請完了(${date} ${start}~${end})`;
      resultEl.style.color = "green";
    }).catch((error) => {
      resultEl.innerText = `❌ 申請失敗: ${error.message}`;
      resultEl.style.color = "red";
    });
  }
  // ▼ 自動ログアウト処理 ▼

// 何分で自動ログアウトするか(例: 15分 = 15 * 60 * 1000 ミリ秒)
const AUTO_LOGOUT_TIME = 15 * 60 * 1000;

let logoutTimer;

// ログアウト処理
function autoLogout() {
  auth.signOut().then(() => {
    alert("15分間操作がなかったため、自動ログアウトしました。");
    window.location.href = "login.html"; // ログインページへリダイレクト
  });
}

// タイマーリセット処理
function resetLogoutTimer() {
  if (logoutTimer) clearTimeout(logoutTimer);
  logoutTimer = setTimeout(autoLogout, AUTO_LOGOUT_TIME);
}

// ユーザー操作を検知してタイマーをリセット
["click", "mousemove", "keydown", "scroll", "touchstart"].forEach(event => {
  document.addEventListener(event, resetLogoutTimer);
});

// サインイン状態確認後、タイマー起動
auth.onAuthStateChanged(user => {
  if (user) {
    resetLogoutTimer(); // 初回起動
  }
});
</script>
</body>
</html>

admin_login.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>管理者ログイン</title>
    <script src="https://www.gstatic.com/firebasejs/10.11.0/firebase-app-compat.js"></script>
    <script src="https://www.gstatic.com/firebasejs/10.11.0/firebase-auth-compat.js"></script>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <h1>管理者ログインページ</h1>

    <input type="email" id="adminEmail" placeholder="メールアドレス"><br>
    <input type="password" id="adminPassword" placeholder="パスワード"><br>
    <button onclick="adminLogin()">ログイン</button>

    <p id="adminResult"></p>

    <script>
        // Firebase設定
        const firebaseConfig = {
            apiKey: "あなたのAPIキー",
            authDomain: "あなたのプロジェクトID.firebaseapp.com",
            projectId: "あなたのプロジェクトID",
            storageBucket: "あなたのプロジェクトID.appspot.com",
            messagingSenderId: "送信者ID",
            appId: "アプリID"
        };

        firebase.initializeApp(firebaseConfig);
        const auth = firebase.auth();

        // 管理者ログイン関数
        function adminLogin() {
            const email = document.getElementById('adminEmail').value;
            const password = document.getElementById('adminPassword').value;

            auth.signInWithEmailAndPassword(email, password)
                .then((userCredential) => {
                    // 管理者かどうかチェック(メールアドレスで判断)
                    if (email === "⚪︎⚪︎⚪︎⚪︎⚪︎@⚪︎⚪︎⚪︎.com") {  // ここを書き換える!
                        window.location.href = "admin_attendance.html"; // 管理者ページに移動
                    } else {
                        document.getElementById('adminResult').innerText = "管理者アカウントではありません。";
                        auth.signOut(); // すぐログアウト
                    }
                })
                .catch((error) => {
                    document.getElementById('adminResult').innerText = "ログインエラー:" + error.message;
                });
        }
    </script>
</body>
</html>

admin_attendance.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>出勤状況一覧</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <h1>出勤状況一覧</h1>
  <a href="admin_requests.html">▶ 申請管理ページへ</a>

  <table border="1">
    <thead>
      <tr>
        <th>名前</th>
        <th>メールアドレス</th>
        <th>出勤状況</th>
        <th>詳細</th>
      </tr>
    </thead>
    <tbody id="userList"></tbody>
  </table>

  <script type="module">
    import { initializeApp } from "https://www.gstatic.com/firebasejs/10.11.0/firebase-app.js";
    import {
      getFirestore, collection, getDocs, query, where, orderBy, limit
    } from "https://www.gstatic.com/firebasejs/10.11.0/firebase-firestore.js";

    const firebaseConfig = {
            apiKey: "あなたのAPIキー",
            authDomain: "あなたのプロジェクトID.firebaseapp.com",
            projectId: "あなたのプロジェクトID",
            storageBucket: "あなたのプロジェクトID.appspot.com",
            messagingSenderId: "送信者ID",
            appId: "アプリID"
    };

    const app = initializeApp(firebaseConfig);
    const db = getFirestore(app);

    async function loadUsers() {
      const userList = document.getElementById("userList");
      userList.innerHTML = "";
      const usersSnapshot = await getDocs(collection(db, "users"));

      for (const docSnap of usersSnapshot.docs) {
        const user = docSnap.data();
        const uid = docSnap.id;
        let status = "不明";

        const attendanceQuery = query(
          collection(db, "attendance"),
          where("uid", "==", uid),
          orderBy("timestamp", "desc"),
          limit(1)
        );
        const attendanceSnapshot = await getDocs(attendanceQuery);

        if (!attendanceSnapshot.empty) {
          const last = attendanceSnapshot.docs[0].data();
          const lastTimestamp = last.timestamp.toDate();
          const now = new Date();
          const isSameDay =
            lastTimestamp.getFullYear() === now.getFullYear() &&
            lastTimestamp.getMonth() === now.getMonth() &&
            lastTimestamp.getDate() === now.getDate();
          status = (last.type === "出勤" && isSameDay) ? "出勤中" : "退勤済み";
        }

        const detailLink = `<a href="user_detail.html?uid=${uid}&name=${encodeURIComponent(user.name)}" target="_blank">詳細</a>`;

        const row = `
          <tr>
            <td>${user.name}</td>
            <td>${user.email}</td>
            <td>${status}</td>
            <td>${detailLink}</td>
          </tr>
        `;
        userList.innerHTML += row;
      }
    }

    window.onload = () => {
      loadUsers();
    };
  </script>
</body>
</html>

admin_requests.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>申請管理(申請中のみ表示)</title>
  <style>
    body {
      font-family: "Helvetica Neue", sans-serif;
      background-color: #f2f4f8;
      padding: 20px;
    }

    h1 {
      text-align: center;
      color: #333;
    }

    .application-card {
      border: 1px solid #ccc;
      border-left: 6px solid #007BFF;
      border-radius: 8px;
      background-color: #fff;
      margin: 20px auto;
      padding: 20px;
      max-width: 600px;
      box-shadow: 0 2px 5px rgba(0,0,0,0.05);
    }

    .application-header {
      font-size: 1.3em;
      font-weight: bold;
      margin-bottom: 10px;
      color: #2c3e50;
    }

    .application-content {
      font-size: 0.95em;
      margin-bottom: 16px;
      color: #444;
    }

    .application-buttons button {
      margin-right: 10px;
      padding: 6px 12px;
      border: none;
      border-radius: 4px;
      font-weight: bold;
      cursor: pointer;
    }

    .approve-button {
      background-color: #28a745;
      color: white;
    }

    .reject-button {
      background-color: #dc3545;
      color: white;
    }

    .application-footer {
      font-size: 0.85em;
      color: #888;
    }
  </style>
</head>
<body>
  <h1>申請管理ページ(申請中)</h1>
  <a href="admin_attendance.html">▶ 出勤状況一覧ページへ</a>
  <div id="applicationList"></div>

  <!-- Firebase SDK -->
  <script src="https://www.gstatic.com/firebasejs/9.22.2/firebase-app-compat.js"></script>
  <script src="https://www.gstatic.com/firebasejs/9.22.2/firebase-firestore-compat.js"></script>

  <script>
    const firebaseConfig = {
            apiKey: "あなたのAPIキー",
            authDomain: "あなたのプロジェクトID.firebaseapp.com",
            projectId: "あなたのプロジェクトID",
            storageBucket: "あなたのプロジェクトID.appspot.com",
            messagingSenderId: "送信者ID",
            appId: "アプリID"
    };

    firebase.initializeApp(firebaseConfig);
    const db = firebase.firestore();

    async function loadApplications() {
      const usersSnapshot = await db.collection("users").get();
      const userMap = {};
      usersSnapshot.forEach(doc => {
        userMap[doc.id] = doc.data().name || doc.data().displayName || "不明";
      });

      const querySnapshot = await db.collection("applications")
        .where("status", "==", "申請中")
        .orderBy("requestedAt", "desc")
        .get();

      const container = document.getElementById("applicationList");
      container.innerHTML = "";

      querySnapshot.forEach(doc => {
        const data = doc.data();
        const userName = userMap[data.uid] || "不明";
        renderApplication(doc, userName);
      });
    }

    function renderApplication(doc, userName) {
      const data = doc.data();
      const card = document.createElement("div");
      card.className = "application-card";

      const header = document.createElement("div");
      header.className = "application-header";
      header.textContent = `${userName}(${data.type})`;

      const content = document.createElement("div");
      content.className = "application-content";
      content.innerHTML = `
        <strong>日付:</strong> ${data.date || "未入力"}<br>
        <strong>詳細:</strong> ${formatDetails(data)}<br>
        <strong>ステータス:</strong> ${data.status}
      `;

      const buttons = document.createElement("div");
      buttons.className = "application-buttons";

      const approveBtn = document.createElement("button");
      approveBtn.className = "approve-button";
      approveBtn.textContent = "許可";
      approveBtn.onclick = () => updateApplicationStatus(doc.id, "許可");

      const rejectBtn = document.createElement("button");
      rejectBtn.className = "reject-button";
      rejectBtn.textContent = "却下";
      rejectBtn.onclick = () => updateApplicationStatus(doc.id, "却下");

      buttons.appendChild(approveBtn);
      buttons.appendChild(rejectBtn);

      const footer = document.createElement("div");
      footer.className = "application-footer";
      footer.textContent = `申請日時: ${data.requestedAt?.toDate().toLocaleString() || "不明"}`;

      card.appendChild(header);
      card.appendChild(content);
      card.appendChild(buttons);
      card.appendChild(footer);

      document.getElementById("applicationList").appendChild(card);
    }

    function formatDetails(data) {
      switch (data.type) {
        case "休日申請":
          return `種類: ${data.holidayType || ""}`;
        case "遅刻申請":
          return `予定: ${data.scheduledStart} → 実際: ${data.lateTime}`;
        case "残業申請":
          return `時間: ${data.startTime} ~ ${data.endTime}<br>理由: ${(data.reasons || []).join(", ")} ${data.otherReason || ""}`;
        case "休日出勤申請":
          return `時間: ${data.startTime} ~ ${data.endTime}<br>内容: ${data.task}`;
        default:
          return "詳細情報なし";
      }
    }

    async function updateApplicationStatus(docId, newStatus) {
      try {
        await db.collection("applications").doc(docId).update({
          status: newStatus
        });
        alert(`ステータスを「${newStatus}」に更新しました`);
        loadApplications();
      } catch (error) {
        console.error("ステータス更新エラー:", error);
        alert("更新に失敗しました");
      }
    }

    loadApplications();
  </script>
</body>
</html>

user_detail.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>ユーザー詳細 </title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <script src="https://www.gstatic.com/firebasejs/10.11.0/firebase-app-compat.js"></script>
  <script src="https://www.gstatic.com/firebasejs/10.11.0/firebase-auth-compat.js"></script>
  <script src="https://www.gstatic.com/firebasejs/10.11.0/firebase-firestore-compat.js"></script>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <h1>ユーザー詳細ページ</h1>
  <button onclick="location.href = 'admin_attendance.html'">戻る</button>

  <section>
    <h2>ユーザー情報</h2>
    <p><strong>名前:</strong> <span id="userName">-</span></p>
    <p><strong>メール:</strong> <span id="userEmail">-</span></p>
    <p><strong>UID:</strong> <span id="userId">-</span></p>
  </section>

  <section>
    <h2>出退勤履歴</h2>
    <ul id="attendanceList"></ul>
  </section>

  <section>
    <h2>申請履歴</h2>
    <ul id="applicationList"></ul>
  </section>

  <script>
    const firebaseConfig = {
            apiKey: "あなたのAPIキー",
            authDomain: "あなたのプロジェクトID.firebaseapp.com",
            projectId: "あなたのプロジェクトID",
            storageBucket: "あなたのプロジェクトID.appspot.com",
            messagingSenderId: "送信者ID",
            appId: "アプリID"
    };

    firebase.initializeApp(firebaseConfig);
    const auth = firebase.auth();
    const db = firebase.firestore();

    auth.onAuthStateChanged(currentUser => {
      if (!currentUser) {
        alert("ログインしていません。ログインページに戻ります。");
        location.href = 'admin-login.html';
        return;
      }

      const params = new URLSearchParams(location.search);
      const uid = params.get("uid");
      const name = decodeURIComponent(params.get("name") || "");

      if (!uid) {
        alert("UIDが指定されていません。");
        return;
      }

      document.getElementById("userName").innerText = name || "(不明)";
      document.getElementById("userId").innerText = uid;

      // Firestoreからメールアドレス取得
      db.collection("users").doc(uid).get().then(doc => {
        if (doc.exists) {
          document.getElementById("userEmail").innerText = doc.data().email || "(不明)";
        } else {
          document.getElementById("userEmail").innerText = "(データなし)";
        }
      });

      // 出退勤履歴取得
      db.collection("attendance")
        .where("uid", "==", uid)
        .orderBy("timestamp", "desc")
        .limit(30)
        .get()
        .then(snapshot => {
          const list = document.getElementById("attendanceList");
          list.innerHTML = "";
          snapshot.forEach(doc => {
            const data = doc.data();
            const date = data.timestamp?.toDate().toLocaleString() || "(不明)";
            const li = document.createElement("li");
            li.textContent = `${data.type}:${date}`;
            list.appendChild(li);
          });
        });

      // 申請履歴取得
      db.collection("applications")
        .where("uid", "==", uid)
        .orderBy("requestedAt", "desc")
        .limit(30)
        .get()
        .then(snapshot => {
          const list = document.getElementById("applicationList");
          list.innerHTML = "";
          snapshot.forEach(doc => {
            const data = doc.data();
            const date = data.date || "-";
            const type = data.type || "-";
            const status = data.status || "-";
            const li = document.createElement("li");
            li.textContent = `${type}(${date}) - ステータス: ${status}`;
            list.appendChild(li);
          });
        });
    });
  </script>
</body>
</html>

qr-attendance.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>QRコード出退勤打刻</title>
    <script src="https://www.gstatic.com/firebasejs/10.11.0/firebase-app-compat.js"></script>
    <script src="https://www.gstatic.com/firebasejs/10.11.0/firebase-firestore-compat.js"></script>
    <script src="https://unpkg.com/@zxing/library@latest"></script>
    <style>
        body {
            font-family: sans-serif;
            text-align: center;
            padding: 20px;
        }
        video {
            border: 1px solid #ccc;
            width: 300px;
            height: 200px;
        }
        button {
            margin: 10px;
            padding: 10px 20px;
            font-size: 16px;
        }
        #result {
            margin-top: 20px;
            font-size: 16px;
            color: green;
        }
        #clock {
            font-size: 18px;
            margin-bottom: 10px;
            color: #555;
        }
    </style>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <h1>すまログ(仮) QRコード打刻</h1>

    <div id="clock">--:--:--</div>

    <video id="preview" autoplay muted playsinline></video>

    <div>
        <button onclick="setType('出勤')">出勤モード</button>
        <button onclick="setType('退勤')">退勤モード</button>
    </div>

    <p id="result">モードを選択してください</p>

    <script>
        // Firebase 設定
        const firebaseConfig = {
            apiKey: "あなたのAPIキー",
            authDomain: "あなたのプロジェクトID.firebaseapp.com",
            projectId: "あなたのプロジェクトID",
            storageBucket: "あなたのプロジェクトID.appspot.com",
            messagingSenderId: "送信者ID",
            appId: "アプリID"
        };

        firebase.initializeApp(firebaseConfig);
        const db = firebase.firestore();

        let currentType = null;
        let codeReader = null;
        let cameraTimeout = null; // タイマー用変数

        // 時計表示
        function updateClock() {
            const now = new Date();
            const timeStr = now.toLocaleString('ja-JP', { hour12: false });
            document.getElementById("clock").textContent = timeStr;
        }
        setInterval(updateClock, 1000);
        updateClock();

        function setType(type) {
            // 前回のカメラを停止
            if (codeReader) {
                codeReader.reset();
            }
            if (cameraTimeout) {
                clearTimeout(cameraTimeout);
            }

            currentType = type;
            document.getElementById("result").innerText = `${type}モードです。QRコードをかざしてください。`;

            startCamera();
        }

        function startCamera() {
            const previewElement = document.getElementById('preview');
            codeReader = new ZXing.BrowserQRCodeReader();

            // 1分後に自動停止
            cameraTimeout = setTimeout(() => {
                codeReader.reset();
                document.getElementById("result").innerText = "カメラは1分後に自動停止しました。再度モードを選択してください。";
            }, 60000);

            codeReader.decodeOnceFromVideoDevice(undefined, previewElement)
                .then(async (result) => {
                    clearTimeout(cameraTimeout);
                    const uid = result.text;
                    const nowDate = new Date();

                    let latitude = null;
                    let longitude = null;
                    let address = "住所不明";

                    if (navigator.geolocation) {
                        navigator.geolocation.getCurrentPosition(async (pos) => {
                            latitude = pos.coords.latitude;
                            longitude = pos.coords.longitude;

                            try {
                                const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&accept-language=ja`);
                                const data = await res.json();
                                address = data.display_name || address;
                            } catch (e) {
                                console.warn("住所取得に失敗:", e);
                            }

                            saveAttendance(uid, nowDate, latitude, longitude, address);
                        }, (err) => {
                            console.warn("位置情報取得失敗:", err);
                            saveAttendance(uid, nowDate, latitude, longitude, address);
                        });
                    } else {
                        saveAttendance(uid, nowDate, latitude, longitude, address);
                    }

                    document.getElementById("result").innerText = `${uid} の ${currentType} を記録中...`;
                    codeReader.reset();
                })
                .catch((err) => {
                    clearTimeout(cameraTimeout);
                    console.error("QRコードの読み取りに失敗:", err);
                    document.getElementById("result").innerText = "QRコードの読み取りに失敗しました。もう一度試してください。";
                });
        }

        function saveAttendance(uid, now, latitude, longitude, address) {
            db.collection("attendance").add({
                uid: uid,
                type: currentType,
                timestamp: firebase.firestore.Timestamp.fromDate(now),
                location: {
                    latitude,
                    longitude,
                    address
                }
            })
            .then(() => {
                document.getElementById("result").innerText = `${uid} の ${currentType} を記録しました(${now.toLocaleString()})`;
            })
            .catch((error) => {
                document.getElementById("result").innerText = `記録に失敗: ${error.message}`;
            });
        }
    </script>
</body>
</html>

style.css

/* style.css */
* {
    box-sizing: border-box;
    font-family: 'Segoe UI', 'Helvetica Neue', sans-serif;
    scroll-behavior: smooth;
  }
  
  body {
    background: linear-gradient(to right, #f0f4ff, #e8f1ff);
    color: #2c3e50;
    margin: 0;
    padding: 0;
    line-height: 1.6;
  }
  
  h1 {
    text-align: center;
    background: linear-gradient(to right, #007bff, #0056b3);
    color: #fff;
    margin: 0;
    padding: 1.5rem;
    font-size: 2rem;
    box-shadow: 0 2px 8px rgba(0, 123, 255, 0.4);
  }
  
  nav {
    display: flex;
    overflow-x: auto;
    background-color: rgba(255, 255, 255, 0.8);
    backdrop-filter: blur(8px);
    padding: 10px;
    gap: 12px;
    justify-content: center;
    border-bottom: 1px solid #ccc;
  }
  
  nav button {
    background: linear-gradient(to bottom right, #007bff, #3399ff);
    color: #fff;
    border: none;
    padding: 10px 18px;
    font-size: 1rem;
    border-radius: 12px;
    cursor: pointer;
    display: flex;
    align-items: center;
    gap: 8px;
    box-shadow: 0 4px 8px rgba(0, 123, 255, 0.2);
    transition: background 0.3s, transform 0.2s;
    flex-shrink: 0;
  }
  
  nav button:hover {
    background: #0056b3;
    transform: translateY(-2px);
  }
  
  .tab {
    display: none;
    padding: 1.5rem;
    animation: fadeIn 0.3s ease-in-out;
  }
  .tab.active {
    display: block;
  }
  
  @keyframes fadeIn {
    from { opacity: 0; transform: translateY(10px); }
    to { opacity: 1; transform: translateY(0); }
  }
  
  .section-content {
    margin: 20px auto;
    padding: 20px;
    background: white;
    border-radius: 12px;
    box-shadow: 0 4px 16px rgba(0,0,0,0.05);
    max-width: 800px;
  }
  
  input, select, textarea {
    margin-top: 6px;
    margin-bottom: 14px;
    padding: 10px;
    border-radius: 6px;
    border: 1px solid #ccc;
    width: 100%;
    font-size: 1rem;
    transition: border-color 0.3s;
  }
  input:focus, select:focus, textarea:focus {
    border-color: #007bff;
    outline: none;
  }
  
  button {
    background: linear-gradient(to bottom right, #007bff, #3399ff);
    color: #fff;
    border: none;
    padding: 10px 20px;
    border-radius: 8px;
    cursor: pointer;
    font-size: 1rem;
    box-shadow: 0 3px 6px rgba(0,123,255,0.2);
    transition: background 0.3s, transform 0.2s;
  }
  
  button:hover {
    background: #0056b3;
    transform: scale(1.02);
  }
  
  #requestResult, #result {
    margin-top: 1rem;
    font-weight: bold;
    color: #007bff;
  }
  
  .icon {
    font-size: 1.3rem;
  }
  
  label {
    display: block;
    margin-bottom: 6px;
    font-weight: bold;
    color: #333;
  }
  
  @media screen and (max-width: 600px) {
    h1 {
      font-size: 1.5rem;
      padding: 1rem;
    }
  
    nav {
      justify-content: flex-start;
      padding: 8px;
    }
  
    nav button {
      font-size: 0.9rem;
      padding: 8px 14px;
    }
  
    .section-content {
      padding: 16px;
      margin: 10px;
    }
  
    input, select, textarea, button {
      font-size: 1rem;
    }
  }