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

【Webアプリ】出勤管理アプリ⑨Nominatim API を使って現在地の住所を取得

Nominatim API を使って現在地の住所を取得し、出勤/退勤時に Firestore に一緒に保存していきます。

✅出退勤管理ページ改善内容

  • navigator.geolocationで緯度・経度を取得
  • Nominatim APIで逆ジオコーディング(緯度経度 → 住所)を取得
  • Firestore attendance コレクションに保存:uid, type, timestamp, location: { latitude, longitude, address }

📝 出退勤管理ページ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>
    <script src="https://www.gstatic.com/firebasejs/10.11.0/firebase-firestore-compat.js"></script>
</head>
<body>
    <h1>出退勤管理アプリ</h1>

    <button onclick="recordTime('出勤')">出勤</button>
    <button onclick="recordTime('退勤')">退勤</button>

    <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初期化
        firebase.initializeApp(firebaseConfig);
        const auth = firebase.auth();
        const db = firebase.firestore();

        // 出勤・退勤を記録する関数
        function recordTime(type) {
            const resultEl = document.getElementById('result');
            const user = auth.currentUser;

            if (!user) {
                resultEl.innerText = "ユーザーがログインしていません。";
                return;
            }

            if (!navigator.geolocation) {
                resultEl.innerText = "この端末では位置情報を取得できません。";
                return;
            }

            navigator.geolocation.getCurrentPosition(async (position) => {
                const latitude = position.coords.latitude;
                const longitude = position.coords.longitude;

                let address = "住所不明";

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

                const now = new Date();
                const timeString = now.toLocaleString();

                db.collection("attendance").add({
                    uid: user.uid,
                    type: type,
                    timestamp: firebase.firestore.Timestamp.fromDate(now),
                    location: {
                        latitude,
                        longitude,
                        address
                    }
                })
                .then(() => {
                    resultEl.innerText = `${type} を記録しました:${timeString}(住所:${address})`;
                })
                .catch((error) => {
                    resultEl.innerText = `エラー: ${error.message}`;
                });

            }, (error) => {
                resultEl.innerText = "位置情報の取得に失敗しました:" + error.message;
            });
        }

        // (必要に応じて)自動ログイン処理や認証監視をここに追加可能
        // 例:
        // auth.onAuthStateChanged(user => {
        //     if (user) { console.log("ログイン済み:", user.uid); }
        // });
    </script>
</body>
</html>

✅ 備考

  • Nominatimは無料でAPIキーも不要ですが、1秒に1リクエストの制限があります。業務利用では独自サーバーや商用APIを検討してください。
  • 表示される住所は正確さにばらつきがあるため、緯度・経度も併存することをおすすめします。

ご希望があれば:

  • ログイン機能と連携した完成例
  • 管理者ページでの住所表示実装

も追加可能ですので、お気軽にお知らせください。

✅ 管理者ページ改善内容

  • users テーブルに「最新の住所」列を追加
  • attendance コレクションから最新の打刻データの location.address を取得して表示
  • 出退勤履歴表示にも住所欄(address)を追加

これにより、社員一覧でも出退勤履歴でも住所が確認できます。

Firestore に保存されている住所は、以下のような構造になっている必要があります:

{
  uid: "ユーザーのUID",
  timestamp: Timestamp,
  type: "出勤" または "退勤",
  location: {
    latitude: 35.x,
    longitude: 139.x,
    address: "東京都新宿区..." ← ここを表示
  }
}

📝 管理者ページHTMLコード

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>出退勤履歴一覧</title>
</head>
<body>
  <h1>管理者ページ</h1>

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

  <div id="historyContainer" style="margin-top: 40px; display:none;">
    <h2 id="historyTitle"></h2>

    <div>
      <label for="yearSelect">年:</label>
      <select id="yearSelect"></select>
      <label for="monthSelect">月:</label>
      <select id="monthSelect"></select>
    </div>

    <table border="1" id="historyTable" style="margin-top: 10px;">
      <thead>
        <tr>
          <th>日付</th>
          <th>時刻</th>
          <th>種類</th>
          <th>住所</th>
        </tr>
      </thead>
      <tbody></tbody>
    </table>
  </div>

  <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 = "";

      try {
        const usersSnapshot = await getDocs(collection(db, "users"));

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

          try {
            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();

              if (last.type === "出勤" && isSameDay) {
                status = "出勤中";
              } else {
                status = "退勤済み";
              }
            }
          } catch (e) {
            console.warn("出退勤データ取得エラー:", e);
          }

          const row = `
            <tr>
              <td><a href="#" onclick="loadHistory('${uid}', '${user.name}'); return false;">${user.name}</a></td>
              <td>${user.email}</td>
              <td>${status}</td>
            </tr>
          `;
          userList.innerHTML += row;
        }
      } catch (error) {
        console.error("ユーザーデータ取得エラー:", error);
      }
    }

    window.loadHistory = async function(uid, name) {
      const historyContainer = document.getElementById("historyContainer");
      const historyTitle = document.getElementById("historyTitle");
      historyTitle.innerText = `${name} さんの出退勤履歴`;
      historyContainer.style.display = "block";

      await populateYearMonthSelects(uid);
    };

    async function populateYearMonthSelects(uid) {
      const attendanceRef = collection(db, "attendance");
      const q = query(attendanceRef, where("uid", "==", uid), orderBy("timestamp", "desc"));
      const snapshot = await getDocs(q);

      const dates = snapshot.docs.map(doc => doc.data().timestamp.toDate());
      const years = [...new Set(dates.map(d => d.getFullYear()))];
      const yearSelect = document.getElementById("yearSelect");
      const monthSelect = document.getElementById("monthSelect");

      yearSelect.innerHTML = years.map(y => `<option value="${y}">${y}</option>`).join("");

      yearSelect.onchange = () => {
        const selectedYear = parseInt(yearSelect.value);
        const months = [...new Set(dates
          .filter(d => d.getFullYear() === selectedYear)
          .map(d => d.getMonth() + 1))];
        monthSelect.innerHTML = months.map(m => `<option value="${m}">${m}</option>`).join("");
        displayHistory(uid, selectedYear, parseInt(monthSelect.value));
      };

      monthSelect.onchange = () => {
        displayHistory(uid, parseInt(yearSelect.value), parseInt(monthSelect.value));
      };

      yearSelect.dispatchEvent(new Event('change'));
    }

    async function displayHistory(uid, year, month) {
      const attendanceRef = collection(db, "attendance");
      const q = query(attendanceRef, where("uid", "==", uid), orderBy("timestamp", "desc"));
      const snapshot = await getDocs(q);
      const tbody = document.querySelector("#historyTable tbody");
      tbody.innerHTML = "";

      snapshot.docs.forEach(doc => {
        const data = doc.data();
        const ts = data.timestamp.toDate();
        if (ts.getFullYear() === year && (ts.getMonth() + 1) === month) {
          const address = data.location?.address || "取得なし";
          const row = `
            <tr>
              <td>${ts.getFullYear()}-${ts.getMonth() + 1}-${ts.getDate()}</td>
              <td>${ts.getHours()}:${ts.getMinutes().toString().padStart(2, "0")}</td>
              <td>${data.type}</td>
              <td>${address}</td>
            </tr>
          `;
          tbody.innerHTML += row;
        }
      });
    }

    window.onload = loadUsers;
  </script>
</body>
</html>