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

2026年10月6日火曜日

Google Apps Script

t f B! P L

未処理で登録したカレンダーを"処理済"へ変更する機能を追加する。

  1. 提案するUI・操作フロー

    画面レイアウト(上から順)

    1. 処理モード切り替え(トグルボタン): 未処理(利用登録) / 処理済(請求確定)
    2. 利用日(日付選択): 画面先頭に配置(初期値:本日)
    3. 未処理明細リスト(「処理済」モード時のみ表示)
      • 選択された日付に該当する「未処理」カレンダーのイベントを一覧表示
      • 明細カードをタップすると、ワンタップで「処理済」カレンダーへ移動
    4. 入力フォーム(カード種別・金額・使用店舗・メモ)
      • 「未処理」モード時、または「処理済」モードでリストに該当項目がなかった場合に使用
  2. 「処理済」モード時の動的挙動
    1. 日付を変更したとき
      1. GASを呼び出し、選択された日付の「未処理」カレンダーからイベント一覧を取得する。
    2. 一覧に該当項目がある場合
      1. リスト内のアイテムをタップするだけで、対象イベントが「処理済」カレンダーへ移動し、完了メッセージを表示する(フォーム入力は不要)。
    3. 一覧に該当項目がない(または未登録の明細だった)場合
      1. リスト下部のフォームに情報を入力して「処理済として登録」ボタンを押すことで、最初から「処理済」カレンダーへ新規作成する。

改修後のコード

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 getUnprocessedEventsByDate(dateStr) {
  const calUnprocessed = getCalendarByName(CALENDAR_NAME_UNPROCESSED);
  const targetDate = new Date(dateStr);
  const startOfDay = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate(), 0, 0, 0);
  const endOfDay   = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate(), 23, 59, 59);
  
  const events = calUnprocessed.getEvents(startOfDay, endOfDay);
  
  return events.map(event => {
    return {
      id: event.getId(),
      title: event.getTitle(),
      location: event.getLocation(),
      description: event.getDescription(),
      time: Utilities.formatDate(event.getStartTime(), Session.getScriptTimeZone(), "HH:mm")
    };
  });
}

/**
 * イベントIDを指定して「未処理」から「処理済」へ移動する関数
 */
function processEventById(eventId) {
  const calUnprocessed = getCalendarByName(CALENDAR_NAME_UNPROCESSED);
  const calProcessed   = getCalendarByName(CALENDAR_NAME_PROCESSED);
  
  const event = calUnprocessed.getEventById(eventId);
  if (!event) {
    throw new Error("対象の未処理イベントが見つかりませんでした。");
  }
  
  const title = event.getTitle();
  const startTime = event.getStartTime();
  const endTime = event.getEndTime();
  const loc = event.getLocation();
  let desc = event.getDescription();
  desc = desc.replace(`【ステータス】: ${STATUS_UNPROCESSED}`, `【ステータス】: ${STATUS_PROCESSED}`);
  
  // 処理済カレンダーに新規作成
  const newProcessedEvent = calProcessed.createEvent(title, startTime, endTime, {
    location: loc,
    description: desc
  });
  
  // 未処理側を削除
  event.deleteEvent();
  
  return { success: true, title: title };
}

/**
 * 店舗の使用頻度履歴を更新
 */
function updateStoreHistory(cardCode, storeName) {
  if (!cardCode || !storeName) return;
  const userProps = PropertiesService.getUserProperties();
  const key = `HISTORY_${cardCode.toUpperCase()}`;
  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) || '{}');
  const sortedStores = Object.keys(history).sort((a, b) => history[b] - history[a]);
  return sortedStores.slice(0, 5);
}

/**
 * フォームからの登録処理(未処理 / 処理済 に対応)
 */
function submitCardUsage(formData) {
  const isProcessed = formData.mode === 'processed';
  const targetCalName = isProcessed ? CALENDAR_NAME_PROCESSED : CALENDAR_NAME_UNPROCESSED;
  const statusStr     = isProcessed ? STATUS_PROCESSED : STATUS_UNPROCESSED;
  
  const cal = getCalendarByName(targetCalName);
  
  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 = `【ステータス】: ${statusStr}\n【メモ】: ${note}`;
  
  const event = cal.createEvent(title, startTime, endTime, {
    location: location,
    description: description
  });
  
  if (location) {
    updateStoreHistory(cardCode, location);
  }
  
  return {
    success: true,
    title: title,
    date: dateStr,
    time: timeStr,
    mode: formData.mode
  };
}

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: 15px; }
      
      /* トグルボタン装飾 */
      .mode-toggle {
        display: flex;
        background-color: #e0e0e0;
        border-radius: 8px;
        padding: 4px;
        margin-bottom: 20px;
      }
      .mode-btn {
        flex: 1;
        padding: 10px;
        text-align: center;
        font-weight: bold;
        border-radius: 6px;
        cursor: pointer;
        transition: 0.2s;
        border: none;
        background: transparent;
        color: #666;
        font-size: 15px;
      }
      .mode-btn.active {
        background-color: #1a73e8;
        color: white;
      }

      .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;
      }
      .submit-btn {
        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;
      }
      .submit-btn:disabled { background-color: #9aa0a6; }
      
      /* 未処理リスト表示エリア */
      #unprocessedSection {
        display: none;
        margin-bottom: 20px;
        background: #fff;
        border: 1px solid #e0e0e0;
        border-radius: 8px;
        padding: 12px;
      }
      .section-title {
        font-weight: bold;
        color: #333;
        margin-bottom: 10px;
        font-size: 14px;
      }
      .event-item {
        background: #f8f9fa;
        border: 1px solid #dadce0;
        border-radius: 6px;
        padding: 10px;
        margin-bottom: 8px;
        cursor: pointer;
        display: flex;
        justify-content: space-between;
        align-items: center;
      }
      .event-item:active { background: #e8f0fe; }
      .event-title { font-weight: bold; color: #1a73e8; font-size: 16px; }
      .event-sub { font-size: 12px; color: #666; margin-top: 2px; }
      .action-tag {
        background: #34a853;
        color: white;
        font-size: 12px;
        padding: 4px 8px;
        border-radius: 4px;
        white-space: nowrap;
      }

      #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;
      }
    </style>
  </head>
  <body>
    <h2>💳 カード明細管理</h2>
    
    <!-- モード切り替えトグル -->
    <div class="mode-toggle">
      <button id="btnUnprocessed" class="mode-btn active" onclick="switchMode('unprocessed')">未処理(利用登録)</button>
      <button id="btnProcessed" class="mode-btn" onclick="switchMode('processed')">処理済(請求確定)</button>
    </div>

    <!-- 1. 利用日(最上部に配置) -->
    <div class="form-group">
      <label>対象日</label>
      <input type="date" id="date" onchange="onDateChange()" required>
    </div>

    <!-- 未処理一覧エリア(処理済モード時に表示) -->
    <div id="unprocessedSection">
      <div class="section-title">📋 タップして「処理済」に変更:</div>
      <div id="eventList"></div>
    </div>

    <!-- 入力フォーム -->
    <form id="cardForm" onsubmit="handleFormSubmit(event)">
      <div id="formSectionTitle" class="section-title" style="display:none; margin-top:15px;">➕ 新規で登録する場合:</div>

      <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>利用時刻 (未指定時は 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" class="submit-btn">未処理として登録</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>
      let currentMode = 'unprocessed'; // 'unprocessed' or 'processed'

      document.getElementById('date').valueAsDate = new Date();

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

      // モード切り替え(未処理 / 処理済)
      function switchMode(mode) {
        currentMode = mode;
        const btnUnprocessed = document.getElementById('btnUnprocessed');
        const btnProcessed   = document.getElementById('btnProcessed');
        const submitBtn      = document.getElementById('submitBtn');
        const unprocSection  = document.getElementById('unprocessedSection');
        const formTitle      = document.getElementById('formSectionTitle');

        if (mode === 'unprocessed') {
          btnUnprocessed.classList.add('active');
          btnProcessed.classList.remove('active');
          submitBtn.innerText = '未処理として登録';
          submitBtn.style.backgroundColor = '#1a73e8';
          unprocSection.style.display = 'none';
          formTitle.style.display = 'none';
        } else {
          btnProcessed.classList.add('active');
          btnUnprocessed.classList.remove('active');
          submitBtn.innerText = '処理済として登録';
          submitBtn.style.backgroundColor = '#34a853';
          unprocSection.style.display = 'block';
          formTitle.style.display = 'block';
          
          // 処理済モードになったら対象日の未処理一覧を取得
          fetchUnprocessedEvents();
        }
      }

      // 日付変更時のトリガー
      function onDateChange() {
        if (currentMode === 'processed') {
          fetchUnprocessedEvents();
        }
      }

      // 対象日の未処理イベントを取得・描画
      function fetchUnprocessedEvents() {
        const dateStr = document.getElementById('date').value;
        const listDiv = document.getElementById('eventList');
        listDiv.innerHTML = '<div style="text-align:center; color:#888;">読み込み中...</div>';

        google.script.run
          .withSuccessHandler(renderUnprocessedEvents)
          .withFailureHandler(onFailure)
          .getUnprocessedEventsByDate(dateStr);
      }

      function renderUnprocessedEvents(events) {
        const listDiv = document.getElementById('eventList');
        listDiv.innerHTML = '';

        if (!events || events.length === 0) {
          listDiv.innerHTML = '<div style="color:#888; font-size:13px;">該当日に未処理の明細はありません。</div>';
          return;
        }

        events.forEach(item => {
          const div = document.createElement('div');
          div.className = 'event-item';
          div.onclick = function() { processEvent(item.id, item.title); };
          
          div.innerHTML = `
            <div>
              <div class="event-title">${item.title}円 (${item.time})</div>
              <div class="event-sub">${item.location || '場所指定なし'}</div>
            </div>
            <div class="action-tag">処理済にする</div>
          `;
          listDiv.appendChild(div);
        });
      }

      // ワンタップで処理済にする
      function processEvent(eventId, title) {
        if (!confirm(`「${title}」を処理済に変更しますか?`)) return;

        google.script.run
          .withSuccessHandler(function(res) {
            showMessage(`✅ 「${res.title}」を処理済に変更しました。`, 'success');
            fetchUnprocessedEvents(); // 一覧更新
          })
          .withFailureHandler(onFailure)
          .processEventById(eventId);
      }

      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');
        submitBtn.disabled = true;
        submitBtn.innerText = '送信中...';

        const formData = {
          mode: currentMode,
          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 modeText = response.mode === 'processed' ? '処理済' : '未処理';
        showMessage(`✅ ${modeText}で登録完了: ${response.title} (${response.date})`, 'success');

        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';

        if (currentMode === 'processed') {
          fetchUnprocessedEvents();
        }

        const submitBtn = document.getElementById('submitBtn');
        submitBtn.disabled = false;
        submitBtn.innerText = currentMode === 'processed' ? '処理済として登録' : '未処理として登録';
      }

      function showMessage(msg, type) {
        const messageDiv = document.getElementById('message');
        messageDiv.className = type;
        messageDiv.innerText = msg;
        messageDiv.style.display = 'block';
      }

      function onFailure(error) {
        showMessage(`❌ エラーが発生しました: ${error.message}`, 'error');
        const submitBtn = document.getElementById('submitBtn');
        submitBtn.disabled = false;
        submitBtn.innerText = currentMode === 'processed' ? '処理済として登録' : '未処理として登録';
      }
    </script>
  </body>
</html>

完成イメージ

いい感じ

公開:2026.10.06(火) 19:59 くもり 23℃

QooQ