• #Vue.js
  • #TypeScript
  • #リアクティビティ
  • #Composition API
  • #財務アプリ
開発tax-assistantメモ

Vue 3でキャッシュフロー精算表に仕訳チェックボックス機能を実装する方法

CF Viewer(キャッシュフロー精算表ビューア)に、仕訳のチェックボックス機能を追加した。仕訳を選択/解除すると、精算表の計算結果がリアルタイムで更新される。

この記事では、Vue 3のComposition APIを使ったチェックボックス機能の実装方法と、Set型のリアクティビティ問題への対処法を解説する。

機能概要

実装した機能は以下の通り。

  1. 仕訳の個別選択: 各仕訳にチェックボックスを配置し、選択/解除できる
  2. 一括操作: 「全て選択」「全て外す」ボタンで一括切り替え
  3. 精算表の連動計算: チェック済み仕訳のみを集計対象として精算表を再計算
  4. 視覚的フィードバック: 未選択の仕訳は薄く表示

実装の全体像

データ構造

各仕訳には一意のIDを持たせている。

// data.jsonの仕訳データ構造
{
  "entries": [
    {
      "id": "q3-2-001",
      "section": "●前提",
      "debit": { "account": "現金及び預金", "amount": 10000 },
      "credit": { "account": "資本金", "amount": 10000 }
    },
    // ...
  ]
}

状態管理

チェック状態はSetで管理する。IDベースで選択状態を保持する設計とした。

// チェック済み仕訳のIDセット
const checkedEntryIds = ref(new Set());

Vue 3でのSet型のリアクティビティ問題

問題の発生

Vue 3のリアクティビティシステムは、Setの内部変更(adddelete)を検知しない場合がある。

// これはリアクティビティが発火しない場合がある
const toggleEntry = (entryId) => {
  if (checkedEntryIds.value.has(entryId)) {
    checkedEntryIds.value.delete(entryId);  // 検知されないことがある
  } else {
    checkedEntryIds.value.add(entryId);     // 検知されないことがある
  }
};

解決策: refの再代入パターン

Setを直接変更するのではなく、新しいSetを作成してrefに再代入する。これにより、Vueのリアクティビティが確実に発火する。

const toggleEntry = (entryId) => {
  // 新しいSetを作成(コピー)
  const next = new Set(checkedEntryIds.value);

  if (next.has(entryId)) {
    next.delete(entryId);
  } else {
    next.add(entryId);
  }

  // refに再代入することでリアクティビティを発火
  checkedEntryIds.value = next;
};

この「再代入パターン」はVue 3でSetやMapを扱う際の定番テクニック。

実装コード

1. 状態とcomputed

// app.js
const { ref, computed, nextTick } = Vue;

// チェックボックス状態
const checkedEntryIds = ref(new Set());

// チェック済み仕訳をフィルタリング
const filteredEntries = computed(() =>
  currentSheetData.value?.entries.filter(e =>
    checkedEntryIds.value.has(e.id)
  ) || []
);

// 精算表データ(filteredEntriesを使用して計算)
const worksheetData = computed(() => {
  if (!currentSheetData.value) return defaultData;

  const allEntries = currentSheetData.value.entries;
  const checkedEntries = filteredEntries.value;  // チェック済みのみ

  // チェック済み仕訳から残高を計算
  const openingBalances = computeAccountBalances(
    checkedEntries.filter(isOpeningBalanceEntry)
  );
  const currentPeriodBalances = computeAccountBalances(
    checkedEntries.filter(e => !isOpeningBalanceEntry(e))
  );

  // 精算表を構築して返す
  return buildWorksheetData(openingBalances, currentPeriodBalances);
});

2. チェック状態の操作関数

// 単一仕訳のトグル
const toggleEntry = (entryId) => {
  const next = new Set(checkedEntryIds.value);
  const wasChecked = next.has(entryId);

  if (wasChecked) {
    next.delete(entryId);
  } else {
    next.add(entryId);
  }

  checkedEntryIds.value = next;
};

// 全て選択
const checkAllEntries = () => {
  if (!currentSheetData.value) return;
  checkedEntryIds.value = new Set(
    currentSheetData.value.entries.map(e => e.id)
  );
};

// 全て外す
const uncheckAllEntries = () => {
  checkedEntryIds.value = new Set();
};

// 特定の仕訳がチェック済みか判定
const isEntryChecked = (entryId) =>
  checkedEntryIds.value.has(entryId);

3. HTMLテンプレート

<!-- 一括操作ボタン -->
<div class="bulk-actions">
  <button @click="checkAllEntries" class="btn-check-all">
    全て選択
  </button>
  <button @click="uncheckAllEntries" class="btn-uncheck-all">
    全て外す
  </button>
</div>

<!-- 仕訳リスト -->
<div v-for="(group, section) in groupedEntries" :key="section">
  <div class="sub-section">{{ section || '仕訳' }}</div>

  <div v-for="entry in group"
       :key="entry.id"
       class="journal-entry"
       :class="{ unchecked: !isEntryChecked(entry.id) }">

    <!-- チェックボックス -->
    <div class="journal-checkbox">
      <input type="checkbox"
             :checked="isEntryChecked(entry.id)"
             @change="toggleEntry(entry.id)">
    </div>

    <!-- 仕訳内容 -->
    <div class="journal-content">
      <div class="journal-sides">
        <div class="journal-side">
          <div class="journal-account">{{ entry.debit.account }}</div>
          <div class="journal-amount">{{ entry.debit.amount }}</div>
        </div>
        <div class="journal-side">
          <div class="journal-account">{{ entry.credit.account }}</div>
          <div class="journal-amount">{{ entry.credit.amount }}</div>
        </div>
      </div>
    </div>
  </div>
</div>

4. CSSスタイル

/* チェックボックスのコンテナ */
.journal-checkbox {
  padding: 8px 4px 8px 8px;
  display: flex;
  align-items: flex-start;
}

/* チェックボックス本体 */
.journal-checkbox input[type="checkbox"] {
  width: 18px;
  height: 18px;
  cursor: pointer;
  margin-top: 2px;
}

/* チェックが外れた仕訳は薄く表示 */
.journal-entry.unchecked {
  opacity: 0.4;
}

/* 仕訳カード */
.journal-entry {
  display: flex;
  padding: 4px;
  margin-bottom: 8px;
  background: #fafafa;
  border: 1px solid #e5e7eb;
  border-radius: 4px;
}

/* 仕訳コンテンツ部分 */
.journal-content {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-width: 0;
}

精算表との連動

filteredEntriesの活用

精算表の計算は、全仕訳ではなくfilteredEntries(チェック済み仕訳)のみを対象とする。

const worksheetData = computed(() => {
  // チェック済み仕訳を取得
  const checkedEntries = filteredEntries.value;

  // チェック済みを期首残高と当期に分離
  const checkedOpeningEntries = checkedEntries.filter(isOpeningBalanceEntry);
  const checkedCurrentEntries = checkedEntries.filter(e =>
    !isOpeningBalanceEntry(e) && !isIgnoredEntry(e)
  );

  // 期首残高を計算
  const openingBalances = computeAccountBalances(checkedOpeningEntries);

  // 当期仕訳の残高を計算
  const currentPeriodBalances = computeAccountBalances(checkedCurrentEntries);

  // CF参照を計算
  const checkedCfRefs = computeCfRefs(checkedCurrentEntries);

  // 精算表を構築
  return buildBSAccountsList(
    allBSAccounts,
    openingBalances,
    currentPeriodBalances,
    checkedCfRefs,
    cfColumns
  );
});

リアクティビティの流れ

  1. ユーザーがチェックボックスをクリック
  2. toggleEntryが呼ばれ、checkedEntryIdsが再代入される
  3. filteredEntries(computed)が再計算される
  4. worksheetData(computed)が再計算される
  5. 精算表のUIが自動更新される
checkedEntryIds.value = next
    ↓
filteredEntries(computed)が再計算
    ↓
worksheetData(computed)が再計算
    ↓
精算表のUIが自動更新

ポイント

IDベースの管理

仕訳オブジェクト自体ではなく、IDで選択状態を管理する。オブジェクト参照で管理すると、データ更新時に不整合が起きやすい。

// IDベースで管理
const checkedEntryIds = ref(new Set());  // Set<string>

// オブジェクト参照だと問題が起きやすい
const checkedEntries = ref(new Set());   // Set<EntryObject> - 非推奨

初期化時に全選択

新しいシートを読み込んだとき、全仕訳を選択状態にする。

const initializeCheckedEntries = () => {
  if (!currentSheetData.value) return;
  checkedEntryIds.value = new Set(
    currentSheetData.value.entries.map(e => e.id)
  );
};

// シート切り替え時に呼び出し
watch(selectedItem, () => {
  initializeCheckedEntries();
});

まとめ

Vue 3でSetを使った選択状態管理を実装する際は、以下の点に注意する。

  1. refの再代入パターン: Setの内部変更ではなく、新しいSetを作成して再代入する
  2. IDベース管理: オブジェクト参照ではなくIDで選択状態を管理する
  3. computedの連鎖: filteredEntries → worksheetDataの順でcomputedを連鎖させ、リアクティビティを活用する

この実装により、仕訳の選択/解除が精算表にリアルタイムで反映されるインタラクティブなUIが実現できた。