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

2026年10月6日火曜日

Google Apps Script

t f B! P L

処理済みの登録画面に「カード支払い日」を追加する。

  1. 「カード支払い日」入力欄の追加(「処理済」モード時のみ表示)

    初期値は「本日」とし、一度設定した日付は「処理済として登録」やワンタップ変更を行ってもリセットされずに保持する。

  2. メモへの自動追記

    イベントのメモ(Description)に 【カード支払い日】: YYYY-MM-DD を自動追記する。

  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 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, paymentDate) {
  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}`);
  if (paymentDate) {
    desc += `\n【カード支払い日】: ${paymentDate}`;
  }
  
  // 処理済カレンダーに新規作成
  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 paymentDate = formData.paymentDate || '';
  
  const title = `${cardCode}${amount}`;
  const startTime = new Date(`${dateStr}T${timeStr}:00`);
  const endTime = new Date(startTime.getTime() + 30 * 60 * 1000);
  
  let description = `【ステータス】: ${statusStr}\n【メモ】: ${note}`;
  if (isProcessed && paymentDate) {
    description += `\n【カード支払い日】: ${paymentDate}`;
  }
  
  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 class="form-group" id="paymentDateGroup" style="display:none;">
      <label style="color:#d93025;">📅 カード支払い日</label>
      <input type="date" id="paymentDate">
    </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';

      // 初期値セット
      document.getElementById('date').valueAsDate = new Date();
      document.getElementById('paymentDate').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');
        const paymentGroup    = document.getElementById('paymentDateGroup');

        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';
          paymentGroup.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';
          paymentGroup.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) {
        const paymentDate = document.getElementById('paymentDate').value;
        let confirmMsg = `「${title}」を処理済に変更しますか?`;
        if (paymentDate) {
          confirmMsg += `\n(カード支払い日: ${paymentDate})`;
        }

        if (!confirm(confirmMsg)) return;

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

      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,
          paymentDate: document.getElementById('paymentDate').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');

        if (currentMode === 'unprocessed') {
          // 未処理モード時は金額や店舗等の入力をクリア
          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';
        } else {
          // 処理済モード時は連続入力のため入力値(金額、店舗、メモ等)を保持し、金額欄だけ空にする
          document.getElementById('amount').value = '';
          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(火) 20:36 くもり 23℃

QooQ