◇ GAC #5: クレジットカード使用管理アプリの作成 4

2026年10月6日火曜日

Google Apps Script

t f B! P L

カード種別ごとの使用店舗履歴(頻度順)から簡単に選択できる仕組みを設ける。

実装のポイント

  1. GASのプロパティサービス(PropertiesService)の活用

    カレンダー登録成功時に、カード種別 + 店舗名 の組み合わせごとの利用回数を自動記録・更新

  2. カード種別変更時の動的履歴ロード

    Web画面で「カード種別」を入力(または変更)した際、GAS側からそのカードでよく使う店舗リスト(頻度順トップ5など)を取得してボタン/サジェスト表示

  3. ワンタップ入力

    履歴ボタンをタップすると、使用店舗の入力欄に店舗名を自動補完

code.gs

// ==========================================
// 設定項目
// ==========================================
const CALENDAR_NAME_UNPROCESSED = '未処理';
const CALENDAR_NAME_PROCESSED   = '処理済';
const STATUS_UNPROCESSED = '未処理';
const STATUS_PROCESSED   = '処理済';

const GOOGLE_MAPS_API_KEY = 'YOUR_GOOGLE_MAPS_API_KEY'; // ※ご自身のAPIキー

function doGet() {
  const template = HtmlService.createTemplateFromFile('index');
  template.apiKey = GOOGLE_MAPS_API_KEY;
  return template.evaluate()
    .setTitle('カード利用登録')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

function getCalendarByName(name) {
  const calendars = CalendarApp.getCalendarsByName(name);
  if (calendars.length === 0) {
    throw new Error(`カレンダー「${name}」が見つかりませんでした。`);
  }
  return calendars[0];
}

/**
 * 店舗の使用頻度履歴を更新(保存)するヘルパー関数
 */
function updateStoreHistory(cardCode, storeName) {
  if (!cardCode || !storeName) return;
  
  const userProps = PropertiesService.getUserProperties();
  const key = `HISTORY_${cardCode.toUpperCase()}`;
  
  // 現在の履歴オブジェクトを取得 { "店舗名A": 5, "店舗名B": 2 }
  let history = JSON.parse(userProps.getProperty(key) || '{}');
  
  // カウントアップ
  history[storeName] = (history[storeName] || 0) + 1;
  
  // 保存
  userProps.setProperty(key, JSON.stringify(history));
}

/**
 * カード種別ごとの店舗利用履歴を頻度順で取得する関数(フロント用)
 */
function getStoreHistoryByCard(cardCode) {
  if (!cardCode) return [];
  
  const userProps = PropertiesService.getUserProperties();
  const key = `HISTORY_${cardCode.toUpperCase()}`;
  const history = JSON.parse(userProps.getProperty(key) || '{}');
  
  // 使用頻度の高い順(降降順)にソートして上位5件を返す
  const sortedStores = Object.keys(history).sort((a, b) => history[b] - history[a]);
  return sortedStores.slice(0, 5);
}

/**
 * フォームからの送信処理(履歴保存処理を追加)
 */
function submitCardUsage(formData) {
  const calUnprocessed = getCalendarByName(CALENDAR_NAME_UNPROCESSED);
  
  const cardCode = formData.cardCode.toUpperCase();
  const amount   = formData.amount;
  const dateStr  = formData.date;
  const timeStr  = formData.time || '00:00';
  const location = formData.location || '';
  const note     = formData.note || '';
  
  const title = `${cardCode}${amount}`;
  const startTime = new Date(`${dateStr}T${timeStr}:00`);
  const endTime = new Date(startTime.getTime() + 30 * 60 * 1000);
  
  const description = `【ステータス】: ${STATUS_UNPROCESSED}\n【メモ】: ${note}`;
  
  const event = calUnprocessed.createEvent(title, startTime, endTime, {
    location: location,
    description: description
  });
  
  // 店舗が入力されている場合、カード種別ごとの使用履歴を更新
  if (location) {
    updateStoreHistory(cardCode, location);
  }
  
  return {
    success: true,
    title: title,
    date: dateStr,
    time: timeStr
  };
}

index.html

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <style>
      body {
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
        padding: 16px;
        max-width: 480px;
        margin: 0 auto;
        background-color: #f9f9f9;
      }
      h2 { color: #333; text-align: center; margin-bottom: 20px; }
      .form-group { margin-bottom: 15px; }
      label { display: block; margin-bottom: 6px; font-weight: bold; color: #555; }
      input, select, textarea {
        width: 100%;
        padding: 10px;
        box-sizing: border-box;
        border: 1px solid #ccc;
        border-radius: 6px;
        font-size: 16px;
      }
      button {
        width: 100%;
        padding: 14px;
        background-color: #1a73e8;
        color: white;
        border: none;
        border-radius: 6px;
        font-size: 18px;
        font-weight: bold;
        cursor: pointer;
        margin-top: 10px;
      }
      button:disabled { background-color: #9aa0a6; }
      #message {
        margin-top: 15px;
        padding: 12px;
        border-radius: 6px;
        display: none;
        text-align: center;
        font-weight: bold;
      }
      .success { background-color: #d4edda; color: #155724; }
      .error { background-color: #f8d7da; color: #721c24; }
      
      /* 店舗履歴タグ用スタイル */
      .history-container {
        margin-top: 6px;
        display: flex;
        flex-wrap: wrap;
        gap: 6px;
      }
      .history-chip {
        background-color: #e8f0fe;
        color: #1a73e8;
        border: 1px solid #aecbfa;
        padding: 4px 10px;
        border-radius: 16px;
        font-size: 13px;
        cursor: pointer;
        user-select: none;
      }
      .history-chip:active {
        background-color: #d2e3fc;
      }
    </style>
  </head>
  <body>
    <h2>💳 カード利用登録</h2>
    
    <form id="cardForm" onsubmit="handleFormSubmit(event)">
      <div class="form-group">
        <label>カード種別 (1~2文字)</label>
        <input type="text" id="cardCode" placeholder="例: M (マスタ), V (ビュー)" required maxlength="2" oninput="loadStoreHistory()">
      </div>

      <div class="form-group">
        <label>金額 (円)</label>
        <input type="number" id="amount" placeholder="例: 1200" required pattern="\d*">
      </div>

      <div class="form-group">
        <label>利用日</label>
        <input type="date" id="date" required>
      </div>

      <div class="form-group">
        <label>利用時刻 (未指定時は 00:00)</label>
        <input type="time" id="time" value="00:00">
      </div>

      <div class="form-group">
        <label>使用店舗 (Google Maps検索)</label>
        <input type="text" id="location" placeholder="店舗名・場所を入力">
        <!-- 店舗履歴ボタンが表示されるエリア -->
        <div id="historyContainer" class="history-container"></div>
      </div>

      <div class="form-group">
        <label>メモ / 明細 (任意)</label>
        <textarea id="note" rows="2" placeholder="ランチ代 など"></textarea>
      </div>

      <button type="submit" id="submitBtn">カレンダーに登録</button>
    </form>

    <div id="message"></div>

    <script src="https://maps.googleapis.com/maps/api/js?key=<?= apiKey ?>&libraries=places&callback=initAutocomplete" async defer></script>
    
    <script>
      document.getElementById('date').valueAsDate = new Date();

      function initAutocomplete() {
        const input = document.getElementById('location');
        const autocomplete = new google.maps.places.Autocomplete(input, {
          types: ['establishment', 'geocode']
        });
      }

      // カード種別が変更された際に履歴を呼び出す処理
      function loadStoreHistory() {
        const cardCode = document.getElementById('cardCode').value.trim();
        const container = document.getElementById('historyContainer');
        
        if (!cardCode) {
          container.innerHTML = '';
          return;
        }

        google.script.run
          .withSuccessHandler(renderHistoryChips)
          .getStoreHistoryByCard(cardCode);
      }

      // 取得した履歴をタグボタンとして描画する処理
      function renderHistoryChips(stores) {
        const container = document.getElementById('historyContainer');
        container.innerHTML = '';

        if (!stores || stores.length === 0) return;

        stores.forEach(store => {
          const chip = document.createElement('span');
          chip.className = 'history-chip';
          chip.innerText = store;
          chip.onclick = function() {
            document.getElementById('location').value = store;
          };
          container.appendChild(chip);
        });
      }

      function handleFormSubmit(e) {
        e.preventDefault();
        
        const submitBtn = document.getElementById('submitBtn');
        const messageDiv = document.getElementById('message');
        
        submitBtn.disabled = true;
        submitBtn.innerText = '送信中...';
        messageDiv.style.display = 'none';

        const formData = {
          cardCode: document.getElementById('cardCode').value.toUpperCase(),
          amount: document.getElementById('amount').value,
          date: document.getElementById('date').value,
          time: document.getElementById('time').value,
          location: document.getElementById('location').value,
          note: document.getElementById('note').value
        };

        google.script.run
          .withSuccessHandler(onSuccess)
          .withFailureHandler(onFailure)
          .submitCardUsage(formData);
      }

      function onSuccess(response) {
        const messageDiv = document.getElementById('message');
        messageDiv.className = 'success';
        messageDiv.innerText = `✅ 登録完了: ${response.title} (${response.date} ${response.time})`;
        messageDiv.style.display = 'block';

        const currentCardCode = document.getElementById('cardCode').value;
        document.getElementById('cardForm').reset();
        document.getElementById('cardCode').value = currentCardCode;
        document.getElementById('date').valueAsDate = new Date();
        document.getElementById('time').value = '00:00';

        // 登録後に履歴表示を最新化
        loadStoreHistory();

        const submitBtn = document.getElementById('submitBtn');
        submitBtn.disabled = false;
        submitBtn.innerText = 'カレンダーに登録';
      }

      function onFailure(error) {
        const messageDiv = document.getElementById('message');
        messageDiv.className = 'error';
        messageDiv.innerText = `❌ エラーが発生しました: ${error.message}`;
        messageDiv.style.display = 'block';

        const submitBtn = document.getElementById('submitBtn');
        submitBtn.disabled = false;
        submitBtn.innerText = 'カレンダーに登録';
      }
    </script>
  </body>
</html>

内容を登録すると、次回カード種別入力時に使用添付下に履歴を使用頻度の高い順に表示する。
クリックすれば店舗入力完了。

公開:2026.10.06(火) 16:43 くもり 26℃

QooQ