0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【脱・スプレッドシート崩壊】GAS × Vue.js × Tailwind CSS で「最強の入力フォーム」を爆速で作る

Posted at

Adobe Express - 備品購入.gif

はじめに

スプレッドシートの「直入力」運用に、そろそろ限界を感じていませんか?

Googleスプレッドシートは手軽で便利なデータベースですが、複数人で「直入力」する運用をしていると、こんな事故が起きがちです。

  • 「大事な数式が入っているセルを、誰かがうっかり消してしまった」
  • 「行や列を追加したら、参照していたGASがエラーを吐き始めた」
  • 「『単価』の欄に『1,000円』と文字列で入力されて集計できない」

スプレッドシートは自由度が高すぎるがゆえに、不特定多数の入力インターフェースとしては脆弱な部分があります。

そこで今回は、スプレッドシートは 「見る専用(データベース)」に徹してもらい、入力は「GAS製のWebアプリ」 から行うという構成で作っていきます!

今回の構成:スプシDB化計画!

データの流れを一方通行にします。
ユーザーはスプレッドシートを直接触りません。

  • User: Webアプリで入力(バリデーション・自動計算付き)
  • GAS: データを受け取り、整形する
  • Sheet: ただ追記されるだけ(閲覧専用)

データベース(スプレッドシート)の設計

まず、データの保存先となるスプレッドシートを用意します。

今回は 「備品購入申請」 を想定して、1行目をヘッダーとして以下のように設定します。

項目名 データ例 備考
A 日時 2024/01/20 10:00:00 自動付与
B 申請者 山田 太郎 必須
C 購入品目 ノートPC (単価:¥100,000 x 1)
マウス (単価:¥2,000 x 2)
整形済みテキストで保存
D 合計金額 104000 自動計算
E 備考 急ぎです 任意

技術選定:なぜ Vue.js と Tailwind CSS なのか?

GAS標準のHTMLテンプレート機能だけでも作れますが、今回はあえてモダンなライブラリを採用してUX(ユーザー体験)を高めます。

Vue.js 3

  • 理由: 素のJavaScriptでフォーム制御を書くと、DOM操作でコードが複雑になりやすいためです。
  • メリット: データの状態を変えるだけで画面が更新されるため、必須チェックやボタンの活性/非活性の制御が劇的にラクになります。特に今回は「単価×個数のリアルタイム計算」で威力を発揮します。

Tailwind CSS

  • 理由: GASのエディタで開発する場合、<style>タグでCSSを書くと行数が増えて見通しが悪くなりがちです。また、クラス名の命名に悩みたくないため採用しました。
  • メリット: HTMLのclass属性に直接スタイルを書くため、コード全体が短くスッキリします。

実装手順

GASのエディタを開き、以下の3つのファイルを作成します。
保守性を高めるため、ロジックは別ファイルに切り出します。

  • Code.gs : サーバーサイド処理
  • index.html : 画面レイアウト
  • js.html : フロントエンドロジック

1. Code.gs(サーバーサイド)

GAS側の役割は「HTMLを返すこと」と「受け取ったデータをシートに見やすく書き込むこと」です。
排他制御(LockService) を入れることで、同時アクセスの衝突を防ぐようにします。

const SPREADSHEET_ID = 'ここに実際のスプレッドシートIDを記述してください';
const SHEET_NAME = 'シート1';

function doGet() {
  return HtmlService.createTemplateFromFile('index')
    .evaluate()
    .setTitle('備品購入申請アプリ')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

// HTMLファイル内で別のHTMLファイルを読み込むための関数
function include(filename) {
  return HtmlService.createHtmlOutputFromFile(filename).getContent();
}

/**
 * 申請データを保存する関数
 * フロントエンドから渡された payload オブジェクトを受け取ります
 */
function saveData(payload) {
  const lock = LockService.getScriptLock();
  try {
    // 10秒間のロックを取得(同時編集防止)
    lock.waitLock(10000);
    
    const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
    const sheet = ss.getSheetByName(SHEET_NAME);
    
    // C列に保存するデータを、JSONではなく読みやすい文字列に変換
    const itemsFormatted = payload.items.map(item => {
      const priceStr = item.price ? Number(item.price).toLocaleString() : '0';
      return `${item.name} (単価:¥${priceStr} x ${item.qty})`;
    }).join('\n');
    
    sheet.appendRow([
      new Date(),
      payload.applicant,
      itemsFormatted,
      payload.totalAmount,
      payload.memo
    ]);
    
    return { success: true, message: '申請が完了しました!' };
    
  } catch (e) {
    return { success: false, message: 'エラー: ' + e.toString() };
  } finally {
    lock.releaseLock();
  }
}

2. index.html (画面レイアウト)

Tailwind CSSを使ってUIを構築します。
Vueの v-model でデータを紐付け、バリデーションエラー時にはクラスを動的に切り替えて赤枠を表示するようにしています。

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
    <script src="https://cdn.tailwindcss.com"></script>
    <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
  </head>
  <body class="bg-gray-50 text-gray-800 min-h-screen p-4">
    
    <div id="app" class="max-w-2xl mx-auto">
      
      <header class="mb-6">
        <h1 class="text-2xl font-bold text-gray-900">備品購入申請フォーム</h1>
        <p class="text-sm text-gray-500">必要な備品を入力してください自動計算</p>
      </header>

      <main class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
        
        <div v-if="!isMounted" class="text-center py-10 text-gray-400">Loading...</div>

        <form v-else @submit.prevent="submitForm" class="space-y-6">
          
          <div>
            <label class="block text-sm font-bold text-gray-700 mb-1">申請者名</label>
            <input type="text" v-model="form.applicant" placeholder="山田 太郎"
              @blur="isTouched.applicant = true"
              class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none"
              :class="(isTouched.applicant || isSubmitting) && errors.applicant ? 'border-red-500 bg-red-50' : 'border-gray-300'">
            
            <p v-if="(isTouched.applicant || isSubmitting) && errors.applicant" class="text-xs text-red-500 mt-1">
              {{ errors.applicant }}
            </p>
          </div>

          <div>
            <label class="block text-sm font-bold text-gray-700 mb-2">購入品目リスト</label>
            
            <div class="flex gap-2 text-xs text-gray-500 mb-1 px-1 font-bold">
              <span class="w-64">品名</span>
              <span class="w-24">単価</span>
              <span class="w-16">個数</span>
              <span v-if="form.items.length > 1">削除</span>
            </div>
            
            <div class="space-y-3">
              <div v-for="(item, index) in form.items" :key="index" class="flex gap-2 items-start">
                
                <input type="text" v-model="item.name" placeholder="品名を入力" 
                  class="w-64 px-3 py-2 border border-gray-300 rounded-lg">
                
                <input type="number" v-model.number="item.price" placeholder="0" 
                  class="w-24 px-3 py-2 border border-gray-300 rounded-lg">
                
                <input type="number" v-model.number="item.qty" placeholder="1" 
                  class="w-16 px-3 py-2 border border-gray-300 rounded-lg">
                
                <button type="button" @click="removeItem(index)" v-if="form.items.length > 1" 
                  class="text-red-400 hover:text-red-600 p-2 mt-1">
                  <i class="fas fa-trash"></i>
                </button>
              </div>
            </div>

            <button type="button" @click="addItem" class="mt-3 text-sm text-blue-600 hover:text-blue-800 font-medium">
              <i class="fas fa-plus-circle mr-1"></i> 品目を追加す            </button>
          </div>

          <div class="bg-gray-100 p-4 rounded-lg flex justify-between items-center">
            <span class="font-bold text-gray-600">合計金額</span>
            <span class="text-2xl font-bold text-blue-600">¥ {{ totalAmount.toLocaleString() }}</span>
          </div>

          <div>
            <label class="block text-sm font-bold text-gray-700 mb-1">備考</label>
            <textarea v-model="form.memo" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-lg"></textarea>
          </div>

          <button type="submit" :disabled="isSubmitting || !isValid"
            class="w-full py-3 bg-blue-600 text-white font-bold rounded-lg hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed">
            <span v-if="isSubmitting"><i class="fas fa-spinner fa-spin mr-2"></i>送信中...</span>
            <span v-else>申請する</span>
          </button>

        </form>

        <div v-if="message" class="mt-4 p-4 rounded-lg text-center font-medium"
             :class="isSuccess ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'">
          {{ message }}
        </div>

      </div>
    </div>

    <?!= include('js'); ?>
  </body>
</html>

3. js.html (Vue.jsロジック)

Vue.jsのComposition APIを使用します。

<script>
  const { createApp, ref, computed, onMounted } = Vue;

  createApp({
    setup() {
      const isMounted = ref(false);
      const isSubmitting = ref(false);
      const message = ref('');
      const isSuccess = ref(false);

      // ユーザーが入力欄を触ったかどうかを管理するフラグ
      const isTouched = ref({
        applicant: false
      });

      // フォームデータ定義
      const form = ref({
        applicant: '',
        memo: '',
        items: [
          { name: '', price: null, qty: 1 } // 初期状態で1行用意
        ]
      });

      // 合計金額の自動計算
      // itemsの中身(単価や個数)が変わるたびに、自動で再計算されます
      const totalAmount = computed(() => {
        return form.value.items.reduce((sum, item) => {
          const price = item.price || 0;
          const qty = item.qty || 0;
          return sum + (price * qty);
        }, 0);
      });

      // バリデーションエラー定義
      const errors = computed(() => {
        const err = {};
        if (!form.value.applicant) err.applicant = '申請者名は必須です';
        return err;
      });

      // 送信可能フラグ
      const isValid = computed(() => {
        // 名前があり、かつ合計金額が0より大きいこと
        return !errors.value.applicant && totalAmount.value > 0;
      });

      // 行追加メソッド
      const addItem = () => {
        form.value.items.push({ name: '', price: null, qty: 1 });
      };

      // 行削除メソッド
      const removeItem = (index) => {
        form.value.items.splice(index, 1);
      };

      // GAS呼び出し用ラッパー
      const runGoogleScript = (funcName, args) => {
        return new Promise((resolve, reject) => {
          google.script.run
            .withSuccessHandler(resolve)
            .withFailureHandler(reject)
            [funcName](args);
        });
      };

      // 送信処理
      const submitForm = async () => {
        // 念の為送信時にもバリデーションチェック
        if (isSubmitting.value || !isValid.value) {
            // 入力漏れがある状態でボタンを押した場合、強制的にエラーを表示させる
            isTouched.value.applicant = true;
            return;
        }

        isSubmitting.value = true;
        message.value = '';

        try {
          // VueのReactiveデータを純粋なオブジェクトに変換して送信
          const payload = {
            ...JSON.parse(JSON.stringify(form.value)),
            totalAmount: totalAmount.value
          };

          // サーバーサイド(Code.gs)の関数を呼び出し
          const res = await runGoogleScript('saveData', payload);

          if (res.success) {
            isSuccess.value = true;
            message.value = res.message;
            // フォームリセット
            form.value = { 
              applicant: '', 
              memo: '', 
              items: [{ name: '', price: null, qty: 1 }] 
            };
            // 完了後はエラー表示フラグもリセット
            isTouched.value.applicant = false;
          } else {
            throw new Error(res.message);
          }
        } catch (e) {
          isSuccess.value = false;
          message.value = '送信エラー: ' + e.toString();
        } finally {
          isSubmitting.value = false;
        }
      };

      onMounted(() => {
        isMounted.value = true;
      });

      return {
        form, errors, isValid, totalAmount,
        isMounted, isSubmitting, message, isSuccess,
        isTouched, // テンプレート側でエラー制御に使うためreturn
        addItem, removeItem, submitForm
      };
    }
  }).mount('#app');
</script>

デプロイと実行

  1. GASエディタ右上の 「デプロイ」 > 「新しいデプロイ」 をクリック
  2. 「種類の選択」 から 「ウェブアプリ」 を選択
  3. アクセスできるユーザー
    • 「全員」
    • または「ドメイン内の全員」
      に設定してデプロイ
  4. 発行されたURLを開くと、アプリが起動します

まとめ

今回の構成で実装したことによるメリットを振り返ります!

  • 堅牢なデータ管理: スプレッドシートの数式に頼らず、Vue.js側で計算した「値」だけを保存するため、「誰かが数式を壊して計算が合わない」という事故が物理的に起きなくなりました。
  • 圧倒的なUX: v-for による動的な行追加や、computed によるリアルタイム計算は、素のJavaScriptで書くと大変ですが、Vue.jsなら数行で実装できます。
  • 開発効率: Tailwind CSSのおかげで、GASでよくある「<style>タグとHTMLを行ったり来たりするスクロールの手間」や、「クラス名の命名」に悩む時間がなくなりました。

「スプレッドシートでの管理に限界を感じているが、本格的なシステムを入れる予算はない」 そんな時、この GAS × Vue.js × Tailwind CSS の構成は最強のソリューションになると思います!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?