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

0 件のコメント:
コメントを投稿