1. 全体アーキテクチャ
👤 ユーザー
アンケート回答
→
アンケート回答
⚛️ React App
Vercel
→
Vercel
🔥 Firestore
データ保存
→
データ保存
⚡ Cloud Functions
トリガー実行
→
トリガー実行
📊 スプレッドシート
データ蓄積
データ蓄積
データフロー詳細
- ユーザーがアンケートに回答し「診断結果を見る」をクリック
- React AppがFirestoreに回答データを書き込み
- Firestoreの
onCreateトリガーでCloud Functionsが起動 - Cloud FunctionsがGoogle Sheets APIでスプレッドシートに行追加
- ユーザーには従来通り診断結果を表示(UX変更なし)
2. 実装方式の比較
方式A: Cloud Functions 推奨
- リアルタイム性: ◎ 即時転記
- 信頼性: ◎ 自動リトライ
- コスト: 従量課金(月100件なら無料枠内)
- 難易度: 中(TypeScript必要)
- メリット: 回答と同時に転記、エラー通知可能
- デメリット: Firebase有料プラン必要(Blaze)
方式B: Google Apps Script
- リアルタイム性: △ 5分〜1時間遅延
- 信頼性: ○ 定期実行
- コスト: 完全無料
- 難易度: 低(JavaScript)
- メリット: 無料、スプレッドシート側で完結
- デメリット: リアルタイムではない
💡 推奨理由
回答直後にデータが転記されることで、管理者がリアルタイムで状況を把握できます。また、エラー発生時の通知や自動リトライが可能なため、データ欠損リスクが低くなります。
3. Firestore データ構造
コレクション設計
surveys/ # コレクション
└── {autoGeneratedId}/ # ドキュメント(自動生成ID)
├── employeeId: "EMP12345"
├── age: 35
├── gender: "male"
├── employmentType: "fulltime"
├── yearsOfService: 5
├── monthsOfService: 3
├── jobDescription: "営業"
├── healthIssues: {
│ selectedIssues: ["back_pain", "shoulder"]
│ primaryIssue: "back_pain"
│ symptomDays: 10
│ absenceDays: 2
│ workQuantity: 6
│ workQuality: 7
│ selfCare: "ストレッチ"
│ wantsTraining: "yes"
│ }
├── workEngagement: {
│ vigor: 4
│ dedication: 5
│ absorption: 3
│ }
├── result: {
│ engagementScore: 4.0
│ absenteeismCostPerMonth: 27000
│ absenteeismCostPerYear: 324000
│ absenteeismRate: 9.09
│ presenteeismScore: 65
│ riskLevel: "moderate"
│ }
├── submittedAt: Timestamp
└── syncedToSheet: false # スプレッドシート転記フラグ
セキュリティルール
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// surveys コレクション
match /surveys/{surveyId} {
// 誰でも書き込み可能(匿名回答のため)
allow create: if true;
// 読み取り・更新・削除は認証済み管理者のみ
allow read, update, delete: if request.auth != null
&& request.auth.token.admin == true;
}
}
}
⚠️ セキュリティ注意
匿名アンケートのため書き込みは認証なしで許可しますが、読み取りは管理者のみに制限します。これにより他者の回答が見られることを防ぎます。
4. スプレッドシート設計
列構成
| 列 | 項目名 | データ型 | 例 |
|---|---|---|---|
| A | 回答日時 | 日時 | 2026/02/06 14:30:00 |
| B | 社員番号 | 文字列 | EMP12345 |
| C | 年齢 | 数値 | 35 |
| D | 性別 | 文字列 | 男性 |
| E | 雇用形態 | 文字列 | 正社員 |
| F | 勤続年数 | 文字列 | 5年3ヶ月 |
| G | 業務内容 | 文字列 | 営業 |
| H | 健康問題(複数) | 文字列 | 腰痛, 肩こり |
| I | 主要健康問題 | 文字列 | 腰痛 |
| J | 症状日数 | 数値 | 10 |
| K | 欠勤日数 | 数値 | 2 |
| L | 仕事量スコア | 数値 | 6 |
| M | 仕事の質スコア | 数値 | 7 |
| N | 活力 | 数値 | 4 |
| O | 熱意 | 数値 | 5 |
| P | 没頭 | 数値 | 3 |
| Q | WEスコア | 数値 | 4.00 |
| R | 月間損失額 | 通貨 | ¥27,000 |
| S | 年間損失額 | 通貨 | ¥324,000 |
| T | 損失率 | % | 9.09% |
| U | プレゼンティーズム | 数値 | 65 |
| V | リスクレベル | 文字列 | 中リスク |
| W | 自己対策 | 文字列 | ストレッチ |
| X | 講習希望 | 文字列 | はい |
自動集計シート(別シートで作成推奨)
- 回答数の推移グラフ
- 健康問題の分布(円グラフ)
- リスクレベル別人数
- 部署別・年代別クロス集計
- 平均損失額サマリー
5. Cloud Functions 実装
functions/src/index.ts
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import { google } from "googleapis";
admin.initializeApp();
const SPREADSHEET_ID = "YOUR_SPREADSHEET_ID";
const SHEET_NAME = "回答データ";
// サービスアカウント認証
const auth = new google.auth.GoogleAuth({
scopes: ["https://www.googleapis.com/auth/spreadsheets"],
});
const sheets = google.sheets({ version: "v4", auth });
// 健康問題の日本語変換
const HEALTH_ISSUE_LABELS: Record<string, string> = {
none: "問題なし",
allergy: "アレルギー",
skin: "皮膚疾患",
infection: "感染症",
stomach: "胃腸不調",
joint: "関節痛",
back_pain: "腰痛",
shoulder: "肩こり",
headache: "頭痛",
tooth: "歯の不調",
mental: "精神不調",
sleep: "睡眠不調",
fatigue: "倦怠感",
eye: "眼の不調",
other: "その他",
};
// Firestore onCreate トリガー
export const syncToSpreadsheet = functions.firestore
.document("surveys/{surveyId}")
.onCreate(async (snapshot, context) => {
const data = snapshot.data();
const surveyId = context.params.surveyId;
try {
// スプレッドシートに追加する行データ
const row = [
new Date(data.submittedAt?.toDate() || Date.now()).toLocaleString("ja-JP"),
data.employeeId || "",
data.age || "",
data.gender === "male" ? "男性" : "女性",
data.employmentType === "fulltime" ? "正社員" :
data.employmentType === "parttime" ? "パート" : "その他",
`${data.yearsOfService || 0}年${data.monthsOfService || 0}ヶ月`,
data.jobDescription || "",
(data.healthIssues?.selectedIssues || [])
.map((i: string) => HEALTH_ISSUE_LABELS[i] || i).join(", "),
HEALTH_ISSUE_LABELS[data.healthIssues?.primaryIssue] || "",
data.healthIssues?.symptomDays || 0,
data.healthIssues?.absenceDays || 0,
data.healthIssues?.workQuantity || 0,
data.healthIssues?.workQuality || 0,
data.workEngagement?.vigor || 0,
data.workEngagement?.dedication || 0,
data.workEngagement?.absorption || 0,
data.result?.engagementScore?.toFixed(2) || "0.00",
data.result?.absenteeismCostPerMonth || 0,
data.result?.absenteeismCostPerYear || 0,
((data.result?.absenteeismRate || 0)).toFixed(2) + "%",
data.result?.presenteeismScore || 0,
data.result?.riskLevel === "low" ? "低リスク" :
data.result?.riskLevel === "moderate" ? "中リスク" :
data.result?.riskLevel === "high" ? "高リスク" : "最高リスク",
data.healthIssues?.selfCare || "",
data.healthIssues?.wantsTraining === "yes" ? "はい" :
data.healthIssues?.wantsTraining === "no" ? "いいえ" : "検討中",
];
// スプレッドシートに行追加
await sheets.spreadsheets.values.append({
spreadsheetId: SPREADSHEET_ID,
range: `${SHEET_NAME}!A:X`,
valueInputOption: "USER_ENTERED",
requestBody: {
values: [row],
},
});
// 転記完了フラグを更新
await snapshot.ref.update({ syncedToSheet: true });
console.log(`Survey ${surveyId} synced to spreadsheet`);
return { success: true };
} catch (error) {
console.error(`Error syncing survey ${surveyId}:`, error);
throw error; // リトライのためにエラーを再スロー
}
});
デプロイコマンド
cd functions
npm install
firebase deploy --only functions
6. React App 変更点
Firebase SDK インストール
npm install firebase
src/lib/firebase.ts
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
};
const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);
ConfirmPage.tsx の変更
import { collection, addDoc, serverTimestamp } from "firebase/firestore";
import { db } from "../lib/firebase";
// 「診断結果を見る」ボタンのハンドラ
const handleSubmit = async () => {
try {
// 計算結果を取得
const result = calculateProductivityResult(surveyData);
// Firestoreに保存
await addDoc(collection(db, "surveys"), {
...surveyData.basicInfo,
healthIssues: surveyData.healthIssues,
workEngagement: surveyData.workEngagement,
result: {
engagementScore: result.engagementScore.totalScore,
absenteeismCostPerMonth: result.absenteeismCostPerMonth,
absenteeismCostPerYear: result.absenteeismCostPerYear,
absenteeismRate: result.absenteeismRate,
presenteeismScore: result.presenteeismScore,
riskLevel: result.riskLevel,
},
submittedAt: serverTimestamp(),
syncedToSheet: false,
});
// 結果画面へ遷移
navigate("/result");
} catch (error) {
console.error("Error saving survey:", error);
// エラーでも結果画面は表示(UX優先)
navigate("/result");
}
};
✅ ユーザー体験への影響
Firebase保存は非同期で行われ、エラーが発生しても診断結果は表示されます。ユーザーには従来通りのスムーズな体験を提供します。
7. セットアップ手順
-
Firebaseプロジェクト作成
- Firebase Console にアクセス
- 「プロジェクトを追加」→ プロジェクト名を入力
- Google アナリティクスは任意
-
Firestoreデータベース作成
- 「Firestore Database」→「データベースを作成」
- 本番モードで開始
- ロケーション: asia-northeast1(東京)
-
Blazeプラン(従量課金)へアップグレード
- Cloud Functions使用には必須
- 無料枠内なら課金なし
-
Webアプリ登録
- 「プロジェクトの設定」→「アプリを追加」→ Web
- Firebase SDK設定をコピー
-
サービスアカウント作成
- 「プロジェクトの設定」→「サービスアカウント」
- 「新しい秘密鍵を生成」→ JSONファイル保存
-
Googleスプレッドシート準備
- 新規スプレッドシート作成
- 1行目にヘッダー行を入力(列構成参照)
- サービスアカウントのメールアドレスに編集権限を付与
-
Cloud Functionsデプロイ
- Firebase CLI インストール:
npm install -g firebase-tools - ログイン:
firebase login - 初期化:
firebase init functions - コード作成・デプロイ
- Firebase CLI インストール:
-
React App 環境変数設定
.envファイルにFirebase設定を追加- Vercelの環境変数にも設定
8. コスト見積もり
月間100件回答の場合
| サービス | 無料枠 | 予想使用量 | 料金 |
|---|---|---|---|
| Firestore 書き込み | 20,000回/日 | 100回/月 | $0 |
| Firestore ストレージ | 1GB | 〜1MB | $0 |
| Cloud Functions 呼び出し | 200万回/月 | 100回/月 | $0 |
| Cloud Functions 時間 | 40万GB秒/月 | 〜100秒 | $0 |
| Google Sheets API | 無制限 | 100回/月 | $0 |
| 合計(月間100件の場合) | $0 無料 | ||
月間1,000件回答の場合
| サービス | 予想使用量 | 料金 |
|---|---|---|
| Firestore | 1,000回/月 | $0 |
| Cloud Functions | 1,000回/月 | $0 |
| 合計 | $0 無料 | |
💰 コストまとめ
月間数千件程度の回答であれば、Firebase無料枠内で運用可能です。大規模(月間10万件以上)になった場合のみ従量課金が発生します。
9. 代替案: Google Apps Script(完全無料)
Cloud Functionsを使わない場合、Google Apps Scriptで定期的にFirestoreからデータを取得する方法もあります。
メリット
- 完全無料(Blazeプラン不要)
- スプレッドシート側で完結
- 設定が比較的簡単
デメリット
- リアルタイムではない(5分〜1時間の遅延)
- Firestore REST API の認証設定が必要
- 大量データ時の実行時間制限(6分)
実装概要
// Google Apps Script
function syncFromFirestore() {
const FIRESTORE_URL = "https://firestore.googleapis.com/v1/projects/YOUR_PROJECT/databases/(default)/documents/surveys";
// Firestore REST API でデータ取得
const response = UrlFetchApp.fetch(FIRESTORE_URL, {
headers: {
"Authorization": "Bearer " + ScriptApp.getOAuthToken()
}
});
const data = JSON.parse(response.getContentText());
// スプレッドシートに追記
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("回答データ");
data.documents.forEach(doc => {
if (!doc.fields.syncedToSheet?.booleanValue) {
// 行を追加
sheet.appendRow([...]);
}
});
}
// トリガー設定: 5分ごとに実行
10. 次のステップ
✅ 実装を開始する場合
以下のコマンドで実装を依頼してください:
Cloud Functions方式で実装して
または
Google Apps Script方式で実装して
実装に必要な情報
- Firebaseプロジェクト名(新規作成 or 既存使用)
- スプレッドシートのURL または ID
- 追加したい列項目があれば