開発tax-assistantメモ
Vue 3でキャッシュフロー精算表に仕訳チェックボックス機能を実装する方法
CF Viewer(キャッシュフロー精算表ビューア)に、仕訳のチェックボックス機能を追加した。仕訳を選択/解除すると、精算表の計算結果がリアルタイムで更新される。
この記事では、Vue 3のComposition APIを使ったチェックボックス機能の実装方法と、Set型のリアクティビティ問題への対処法を解説する。
機能概要
実装した機能は以下の通り。
- 仕訳の個別選択: 各仕訳にチェックボックスを配置し、選択/解除できる
- 一括操作: 「全て選択」「全て外す」ボタンで一括切り替え
- 精算表の連動計算: チェック済み仕訳のみを集計対象として精算表を再計算
- 視覚的フィードバック: 未選択の仕訳は薄く表示
実装の全体像
データ構造
各仕訳には一意の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の内部変更(add、delete)を検知しない場合がある。
// これはリアクティビティが発火しない場合がある
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
);
});
リアクティビティの流れ
- ユーザーがチェックボックスをクリック
toggleEntryが呼ばれ、checkedEntryIdsが再代入されるfilteredEntries(computed)が再計算されるworksheetData(computed)が再計算される- 精算表の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を使った選択状態管理を実装する際は、以下の点に注意する。
- refの再代入パターン: Setの内部変更ではなく、新しいSetを作成して再代入する
- IDベース管理: オブジェクト参照ではなくIDで選択状態を管理する
- computedの連鎖: filteredEntries → worksheetDataの順でcomputedを連鎖させ、リアクティビティを活用する
この実装により、仕訳の選択/解除が精算表にリアルタイムで反映されるインタラクティブなUIが実現できた。