[GAS入門]GASを使ったWEBアプリ

GASで作れる!? 簡易なWEBアプリの 作成方法 GAS

1. はじめに

この記事では、Google Apps Script(GAS)を利用して開発した顧客管理アプリの導入方法を説明します。

1.1 導入目的

  • 未対応の顧客を見逃さないようにする。
  • リピーターの対応忘れを減らす。
  • 前回の受注から10か月経過した顧客をリストアップし、メールで通知する。
  • GUIを用意し、直感的な操作を可能にする。
  • スプレッドシートの手動操作は顧客追加時のみとする。
  • 個人情報保護の観点から最小限の情報のみ記録する。

2. Googleアカウントの取得

このアプリを使用するには、Googleアカウントが必要です。

2.1 Googleアカウントの作成方法

  1. Google アカウント作成ページ にアクセス。
  2. 必要な情報(名前、メールアドレス、パスワードなど)を入力。
  3. 画面の指示に従い、アカウントを作成。

既にGoogleアカウントを持っている場合、この手順は不要です。


3. Google Apps Script(GAS)とは?

Google Apps Script(GAS)は、Googleの各種サービス(スプレッドシート、Gmail、カレンダーなど)を自動化するためのスクリプト環境です。

3.1 GASの特徴

  • JavaScriptに基づいたプログラム言語。
  • GoogleスプレッドシートやGmailと連携できる。
  • クラウド上で動作し、インストール不要。

このアプリでは、GASを使用して顧客情報を管理し、メール通知を自動化します。


4. スプレッドシートの準備

このアプリでは、Googleスプレッドシートをデータベースとして使用します。

4.1 スプレッドシートの作成方法

  1. Googleスプレッドシート にアクセス。
  2. 新規スプレッドシートを作成。
  3. シート名を「一覧表」に変更

4.2 必要なカラム(列)

以下のような形式でデータを入力してください。

顧客ID名前最終受注項目最終受注日対応状況
1テストタロウソケット2024-05-01(空欄)
2ヤマダタロウ足部2023-01-15notStarted

入力ルール

  • 最終受注日は必ず日付形式に設定。
  • 対応状況はスクリプトが自動で入力するため空欄にする。

5. GASスクリプトの設定

このアプリではGASを使用します。以下の手順でスクリプトを追加してください。

5.1 GASの追加方法

  1. スプレッドシートを開く
  2. [拡張機能] → [Apps Script] をクリック
  3. エディタが開いたら、以下のスクリプトをコピーして貼り付ける。

ファイル名 コード.gs 

function doGet(e) {
  Logger.log("Received parameters:", e.parameter);
  const baseUrl = ScriptApp.getService().getUrl();
  if (e.parameter.page === 'detail'){
    // URLからトークンを取得
    const token = e.parameter.token;
    const cache = CacheService.getScriptCache();
    // キャッシュから対応する row 番号を取得
    const row = cache.get(token);
    if (!row) {
      // トークンが無効または期限切れの場合のエラーハンドリング
      throw new Error("無効なトークンです。再度操作をお願いします。");
    }
    // 取得した row 番号を使って、詳細データを取得する
    const detailData = getDetailData(row);
    // detail テンプレートにデータを渡す
    const template = HtmlService.createTemplateFromFile('detail');
    template.outputHTML = createDetailHTML(detailData,row);
    template.deployURL = baseUrl;
    template.detailData = detailData;
    const htmlOutput = template.evaluate();
    htmlOutput.addMetaTag('viewport', 'width=device-width, initial-scale=1');
    return htmlOutput;
  } else {
    // 一覧画面(index)を表示する
    const template = HtmlService.createTemplateFromFile('index');
    template.outputHTML = createCustomerCardHtml();
    template.deployURL = baseUrl;
    const htmlOutput = template.evaluate();
    // メタタグを追加
    htmlOutput.addMetaTag('viewport', 'width=device-width, initial-scale=1');
    return htmlOutput;
  }
}
function doPost(e) {
  if (e.parameter.update){
    // ここで更新処理(例:シートのステータス更新)を行う
    const token = e.parameter.token;
    const cache = CacheService.getScriptCache();
    const row = cache.get(token);
    if (!row) {
      throw new Error("無効なトークンです。再度操作をお願いします。");
    }
    //シートの取得
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = ss.getSheetByName('一覧表');
    //セレクトのステータスを取得
    const status =e.parameter.status;
    const newOrderDate = e.parameter.newOrderDate;
    // ヘッダーの取得:1行目全体の値を取得し、一次元配列にする
    const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
    const idx = headers.indexOf("対応状況") + 1;
    const dateidx = headers.indexOf("最終受注日") + 1;
    sheet.getRange(Number(row), idx).setValue(status);
    sheet.getRange(Number(row), dateidx).setValue(newOrderDate);
    // 更新が完了したら確認メッセージを返す
    const template = HtmlService.createTemplateFromFile('complete');
    template.deployURL = ScriptApp.getService().getUrl();
    const htmlOutput = template.evaluate();
    return htmlOutput;
  }
  // 更新以外の処理があれば追加する
}
//cssの読み込み
function include(filename) {
  return HtmlService.createHtmlOutputFromFile(filename).getContent();
}
function createCustomerCardHtml(){  
  let html = `<ul id="customerList">`;
  const overdueOrders = checkOverdueOrders(); // 日付チェックを実行
  const baseUrl = ScriptApp.getService().getUrl();
  const cache = CacheService.getScriptCache(); // キャッシュサービスの取得
  // ステータスの値と日本語表記の対応マッピング
  const statusMap = {
    "notStarted": "未対応",
    "inProgress": "対応中",
    "onHold": "保留中",
    "Completed": "完了"
  };
  for (const orderArray of overdueOrders) {
    const orderName = orderArray.name;
    const orderDate = orderArray.date;
    const orderRow = orderArray.row;
    const orderStatus = orderArray.status;
    // マッピングオブジェクトを使って日本語表示用のステータスを取得。対応する値があれば、オブジェクトの中身を表示。論理or。値無しの場合、orderStatusが表示
    const displayStatus = statusMap[orderStatus] || orderStatus;
    // 各注文データに対して、一意のトークンを生成し、キャッシュに行番号を保存
    // 一意なトークンを生成
    const token = Utilities.getUuid();
    // トークンと row 番号の対応をキャッシュに保存(有効期限1500秒)
    cache.put(token, orderRow, 1500);
    html += `
      <li class="listItem">
        <div class="card">
          <div class="card-body">
            <h5 class="card-title">顧客名</h5>
            <p class="card-text">${orderName}</p>
            <p class="status"><span class="statusInfo ${orderStatus}">${displayStatus}</span></p>
            <h5 class="card-title">最終受注日</h5>
            <p class="card-text">${orderDate}</p>
            <a href="${baseUrl}?page=detail&token=${token}" class="btn btn-primary">詳細を見る</a>
          </div>
        </div>
      </li>
    `;
  }
  html += `</ul>`;
  return html;
}
function getDetailData(rowNumber) {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('一覧表');
  // ヘッダーの取得:1行目全体の値を取得し、一次元配列にする
  const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
  // 指定行のデータを取得(rowNumberはシートの実際の行番号)
  const rowData = sheet.getRange(Number(rowNumber), 1, 1, sheet.getLastColumn()).getValues()[0];
  // ヘッダーと対応する行データをオブジェクトにまとめる
  let detailData = {};
  headers.forEach((header, index) => {
    detailData[header] = rowData[index];
  });
  Logger.log(detailData);
  return detailData;
}
/**
 * ※ detail.html を表示するための処理を実装する場合に使用します。
 * 今回は doGet 内でテンプレートに detailData を渡しているため、
 * 特に追加処理が必要なければこの関数は空のままで問題ありません。
 */
function createDetailHTML(detailData,row){
  const cache = CacheService.getScriptCache(); // キャッシュサービスの取得
  // 一意なトークンを生成
  const token = Utilities.getUuid();
  // トークンと row 番号の対応をキャッシュに保存(有効期限1500秒)
  cache.put(token, row, 1500);
  let status = detailData["対応状況"];
// 各 <option> タグ内で、${status === "notStarted" ? "selected" : ""} のように三項演算子を使用して、
// もし status が "notStarted" ならば selected 属性を出力し、
// それ以外の場合は空文字(何も出力しない)となります。
// 同様に、他のステータスに対しても条件をチェックしています。
// こうすることで、detailData["対応状況"] の値に対応する <option> に自動的に selected 属性が付与され、フォーム表示時にその選択肢がデフォルトで選ばれるようになります。
  let html = `
    <p><strong>名前:</strong> ${detailData["名前"]}</p>
    <p><strong>最終受注日:</strong> ${formatDate(detailData["最終受注日"])}</p>
    <p><strong>受注項目:</strong> ${detailData["最終受注項目"]}</p>
    <label for="status" class="form-label fw-bold">現在のステータス</label>
    <select id="status" name="status" class="form-select mb-3">
      <option value="notStarted" ${status === "notStarted" ? "selected" : ""}>未対応</option>
      <option value="inProgress" ${status === "inProgress" ? "selected" : ""}>対応中</option>
      <option value="onHold" ${status === "onHold" ? "selected" : ""}>保留中</option>
      <option value="Completed" ${status === "Completed" ? "selected" : ""}>完了</option>
    </select>
    <input type="hidden" name="token" value="${token}">
    <div id="orderDateContainer">
        <label for="orderDate" class="form-label">受注日</label>
        <input type="date" class="form-control" name="newOrderDate" id="newOrderDate" value="${formatDate(detailData["最終受注日"])}">
    </div>
  `;
  return html;
}
/**
 * '最終受注日' の列をチェックし、10か月以上経過したデータを取得
 * @return {Array} 10か月以上経過した行の情報(行番号とデータ)の配列
 */
function checkOverdueOrders() {
  const ss = SpreadsheetApp.getActiveSpreadsheet(); // アクティブなスプレッドシートを取得
  const sheet = ss.getSheetByName('一覧表'); // 操作対象のシート
  const data = sheet.getDataRange().getValues(); // シート全体のデータを取得(2次元配列)
  const headers = data.shift(); // ヘッダー行(1行目)を分離して取得
  const targetHeader = "最終受注日"; // チェック対象のヘッダー名
  const columnIndex = headers.indexOf(targetHeader); // '最終受注日' 列のインデックスを取得
  const statusHeader = "対応状況";//
  const statusIndex = headers.indexOf(statusHeader);//
  if (columnIndex === -1) { // ヘッダーが見つからない場合
    throw new Error(`ヘッダー "${targetHeader}" が見つかりません`);
  }
  const now = new Date(); // 現在の日付を取得
  const overdueOrders = []; // 10か月以上経過したデータを記録する配列
  // データ部分を1行ずつ処理
  data.forEach((row, index) => {
    const orderDate = row[columnIndex]; // 該当列(最終受注日)の値を取得
    if (orderDate instanceof Date) { // 日付型であることを確認
      const tenMonthsLater = new Date(orderDate); // 元の日付を基に新しい日付を作成
      tenMonthsLater.setMonth(tenMonthsLater.getMonth() + 10); // 10か月後の日付を計算
      if (now > tenMonthsLater) { // 現在の日付が10か月後を過ぎている場合
        const name = row[headers.indexOf('名前')]; // 名前列
        const item = row[headers.indexOf('最終受注項目')]; // 最終受注項目列
        const status = row[headers.indexOf('対応状況')];//対応状況列
        // 対応状況の初期設定
        // シートの行番号(ヘッダー行を考慮して +2 )
        const sheetRowIdx = index + 2;
        const targetCell = sheet.getRange(sheetRowIdx, statusIndex + 1); //セルを指定
        const cellValue = targetCell.getValue(); // セルの値を取得
        //セルが空白かチェック 空白の場合、ステータスを未対応にする。完了の場合も未対応に変更する。
        if(!cellValue || cellValue === "" ||cellValue === "Completed"){
          targetCell.setValue("notStarted");
        }
        overdueOrders.push({
          row: index + 2, // 行番号(スプレッドシート基準)
          name: name,
          date: formatDate(orderDate),
          item: item,
          status : status
        });
      }
    }
  });
  return overdueOrders;
}
/**
 * 日付を 'YYYY-MM-DD' 形式にフォーマット
 * @param {Date} date 日付オブジェクト
 * @return {string} フォーマット済みの日付文字列
 */
function formatDate(date) {
  const year = date.getFullYear(); // 年を取得
  const month = String(date.getMonth() + 1).padStart(2, '0'); // 月を2桁にフォーマット(0始まりなので+1)
  const day = String(date.getDate()).padStart(2, '0'); // 日を2桁にフォーマット
  return `${year}-${month}-${day}`; // 'YYYY-MM-DD' 形式で返す
}
/**
 * 10か月以上経過したデータをメールで送信
 * @param {Array} overdueOrders 10か月以上経過した行の情報
 */
function sendOverdueOrdersEmail(overdueOrders) {
  if (overdueOrders.length > 0) { // 該当データが存在する場合のみ送信
    let emailAddress = Session.getActiveUser().getEmail(); // 自分自身のメールアドレスを取得
    const subject = "10か月以上経過した最終受注日の通知";
    const body = `以下の受注日が10か月以上経過しています:\n\n` +
      overdueOrders.map(o => `行 ${o.row}: ${o.name} - ${o.date} - ${o.item}`).join('\n') +
      `\n\nアプリで対応状況を確認できます。\n\n以下のリンクからアプリにアクセス。\n\n`
      + ScriptApp.getService().getUrl();
    GmailApp.sendEmail(emailAddress, subject, body);
    Logger.log("メールを送信しました。");
  } else {
    Logger.log("10か月以上経過した受注日はありません。");
  }
}
/**
 * チェックとメール送信を統合的に実行
 */
function notifyOverdueOrders() {
  const overdueOrders = checkOverdueOrders();
  sendOverdueOrdersEmail(overdueOrders);
}

6. HTMLテンプレートの追加

以下の手順でHTMLテンプレートを追加します。

6.1 ファイルを追加する

  1. Apps Scriptエディタで「+」をクリック
  2. 「HTML」ファイルを作成
  3. 以下のファイルを追加し、それぞれに内容を貼り付ける。

index.html

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
  <?!= include('style') ?><!-- GASのテンプレートでCSSを読み込み -->
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  </head>
  <body>
    <div class="container" style="max-width: 600px;">
      <h2 class="text-center m-4">対応状況一覧表</h2>
        <? output._ = outputHTML ?>
    </div>
  </body>
</html>

detail.html

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <!-- Bootstrap のCSSを利用している場合はリンクを貼ると見た目が整います -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
  <?!= include('style') ?>  <!-- GASのテンプレートでCSSを読み込み -->
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  </head>
  <body>
    <div class="container mt-4">
      <div class="card">
        <div class="card-body">
          <form class="mb-5" method="POST" action="<?= deployURL ?>">
          <h2>顧客情報詳細</h2>
          <? output._ = outputHTML ?>
          <p><button id="updateButton" type="submit"class="btn btn-outline-primary" name="update" value="true">更新する</button></p>
          <p><a href="<?= deployURL ?>" class="btn btn-secondary">戻る</a></p>
          </form>
        </div>
      </div>
    </div>
  </body>
    <script>
    const selectElement = document.getElementById("status");
    const targetContainer = document.getElementById("orderDateContainer");
    // 初回の状態をチェックして適用
    if(selectElement.value === "Completed") {
      targetContainer.classList.add("ondisplay");
    }
    // ステータス変更時に表示・非表示を切り替え
    selectElement.addEventListener("change", function(){
      if(this.value === "Completed"){
        targetContainer.classList.add("ondisplay");
      } else {
        targetContainer.classList.remove("ondisplay");
      }
    });
  </script>
</html>

complete.html

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
  </head>
  <body>
    <div class="container" style="max-width: 600px;">
      <h2 class="text-center m-4">更新完了しました。</h2>
      <p class="text-center mt-3"><a href="<?= deployURL ?>">一覧に戻る</a></p>
    </div>
  </body>
</html>

style.html

<style>
#customerList {
  padding-left: 0;
}
#customerList .listItem {
  list-style: none;
  margin-bottom: 10px;
}
#customerList .listItem::last-child {
  margin-bottom: 0;
}
#orderDateContainer {
  margin-bottom: 10px;
  display: none;
  transition: all 0.3s ease;
}
#orderDateContainer.ondisplay {
  display: block;
}
.status {
  box-sizing: border-box;
  margin: 15px 0;
}
.statusInfo {
  font-weight: bold;
  font-size: 14px;
  color: #FFF;
  background-color: #adb5bd;
  border: 1px solid transparent;
  padding: 0.375rem 0.75rem;
  border-radius: 1rem;
}
.statusInfo.inProgress {
  background-color: #20c997;
}
.statusInfo.onHold {
  background-color: #ffc107;
}
.statusInfo.Completed {
  background-color: #dc3545;
}/*# sourceMappingURL=style.css.map */
</style>

7.トリガーの設定

このアプリは、一定の時間ごとに自動でメール通知を送信します。

7.1 トリガーの追加方法

  1. Apps Scriptエディタを開く
  2. 右側の「時計」アイコン(トリガーの設定)をクリック
  3. 「トリガーを追加」をクリック
  4. 以下を設定
    • 実行する関数 → notifyOverdueOrders
    • イベントのソース → 時間主導型
    • タイマーの種類 → 週ベースのタイマー
    • 曜日 → 月曜日
    • 時間 → 午前7時~8時
  5. 「保存」をクリック

8. デプロイの方法とアプリへのアクセス方法

作成したアプリにアクセスするには、一旦デプロイする必要があります。

8.1 デプロイの方法

  1. Apps Script エディタを開く。
  2. 上部メニューの「デプロイ」→「新しいデプロイ」をクリック。
  3. 「種類を選択」 で「ウェブアプリ」を選択。
  4. 「説明」 に任意のコメントを入力(例:「顧客管理アプリ 初回デプロイ」)。
  5. 「ウェブアプリの設定」
    • 実行するユーザー: 自分
    • アクセスできるユーザー: 自分
  6. 「デプロイ」をクリックし、承認要求を許可。
  7. 発行されたアプリのURLをクリックすればアプリにアクセスできる。

8.2 デプロイ後に再度アプリを表示する方法

  1. トリガーなしで関数を実行
    • Apps Scriptエディタで notifyOverdueOrders() を手動実行。
    • 10か月以上経過した顧客のリストがメールで送信される。
    • このメールには、アプリのURLが記載され、アクセスが可能。

8.3 アプリの使い方

  1. アプリをブラウザで開く
  2. 一覧画面で対応が必要な顧客を確認
  3. 「詳細を見る」をクリックし、対応状況を変更
  4. 「更新する」ボタンをクリックして保存
  5. 完了後、「一覧に戻る」をクリック
  6. アプリのURLを取得し、ブラウザで開く
  7. 一覧画面で対応が必要な顧客を確認
  8. 「詳細を見る」をクリックし、対応状況を変更
  9. 「更新する」ボタンをクリックして保存
  10. 完了後、「一覧に戻る」をクリック

9. エラー対処法

9.1 初回起動時の警告について

Google Apps Script では、初回実行時に「このアプリは Google によって確認されていません」という警告が表示されることがあります。

対処方法:

  1. 警告画面で「詳細」をクリック。
  2. 「(安全ではないページに移動)」をクリック。
  3. Google アカウントのアクセス許可を求められるので「許可」を選択。
  4. これでスクリプトが正常に動作するようになります。

より詳細な解説は以下の記事を参考にしてください: Google Apps Script の承認手続きガイド Google Apps Script では、初回実行時に「このアプリは Google によって確認されていません」という警告が表示されることがあります。

対処方法:

  1. 警告画面で「詳細」をクリック。
  2. 「(安全ではないページに移動)」をクリック。
  3. Google アカウントのアクセス許可を求められるので「許可」を選択。
  4. これでスクリプトが正常に動作するようになります。

詳細な解説は以下の記事を参照してください: 【GAS入門】最終受注日が10か月以上経過したデータを管理する方法

9.2 一般的な注意事項

  • スプレッドシートのカラム名を変更しないようにしてください。
  • スクリプトの変更を行った場合は必ず保存して再実行してください。
  • トリガーの設定が適切に行われているか確認してください。
  • Googleアカウントの権限設定を確認し、スクリプトがスプレッドシートやGmailにアクセスできるようにしてください。
  • エラー発生時には、Apps Scriptエディタの「表示」→「ログ」を確認してください。

9.1 一般的な注意事項

  • スプレッドシートのカラム名を変更しないようにしてください。
  • スクリプトの変更を行った場合は必ず保存して再実行してください。
  • トリガーの設定が適切に行われているか確認してください。
  • Googleアカウントの権限設定を確認し、スクリプトがスプレッドシートやGmailにアクセスできるようにしてください。
  • エラー発生時には、Apps Scriptエディタの「表示」→「ログ」を確認してください。

10. まとめ

このアプリは、顧客管理を効率化し、対応忘れを防ぐためのツールです。設定を正しく行い、定期的に動作確認を行ってください。

これで導入は完了です!

この記事についてのご意見や間違いのご指摘は、ぜひX(旧Twitter)でお知らせください!

皆さまのフィードバックをお待ちしています。以下のアカウントまでお気軽にメッセージをお寄せください。

この記事には筆者の個人的な解釈も一部含まれています。一つの参考としてお読みいただきつつ、最終的にはご自身や担当の方としっかり相談の上で判断いただけますと幸いです。

皆様の声で情報をアップデートしていきます。よろしくお願いします。

タイトルとURLをコピーしました