消費税経理処理パターンの問題集を強化した - Miller Columns 4カラム化・全文検索・条件付きハイライト

開発mdx-playgroundメモ

消費税経理処理パターンの問題集を強化した

消費税の実務処理を学ぶための問題集アプリに、丸1日かけて複数の機能を追加した。Miller Columnsの4カラム化、全文検索機能、仕訳テーブルの条件付きハイライト、200事例キュレーション、クイズの解答選択UIなど、朝8時から夕方までほぼ途切れず作業した記録。


全体の流れ

今日の作業は大きく分けて6つのまとまりがある。

  1. タブUIの統一(08:19)
  2. Miller Columns 4カラム化(08:30)
  3. 全文検索機能の実装(08:31 - 最大セッション)
  4. 仕訳テーブルの条件付きハイライトと JournalPatternCard の抽出(09:31)
  5. 項目別ビューへのタグ追加(10:25)
  6. 200事例キュレーションとクイズ解答UI(gitコミット単位)

以下、時系列で書く。


1. タブUIの統一(08:19)

件数表示の統合

勘定科目別ビューと項目別ビューで、事例件数の表示スタイルがバラバラだった。.item-count.example-count という別々のクラスが存在していて、フォントサイズや色が微妙に異なっていた。

これを .column-item-count という単一のクラスに統合した。小さな修正だが、こういう不統一は放置すると後から探すのが面倒になる。

タブコンテナのスタイル変更

タブコンテナ全体にグレー背景(background: #f3f4f6)、paddingborder-radius が付いていたのを削除し、個別のタブにボーダーを付ける形に変えた。情報密度の高いアプリでは、余計な背景色やpaddingがあると画面が狭く感じる。

論点別問題集タブには、勘定科目別・項目別のグループとの視覚的な区切りとして margin-left: 0.75rem を追加した。

検索機能の要件を議論

このセッションで、検索機能の基本方針も決めた。

  • ヘッダー(breadcrumb nav内)に検索窓を配置
  • 2文字以上のキーワードで検索開始
  • 検索対象は問題文、注記、勘定科目名、摘要
  • Miller Columnsの上に検索結果オーバーレイを重ねる
  • 結果をクリックすると勘定科目別ビューの該当位置にジャンプ

2. Miller Columns 4カラム化(08:30)

中カラムの問題

もともと3カラム構成で、中カラムにサブカテゴリ(経理処理例 / 簡易課税制度の事業区分 / 個別対応方式の用途区分)をタブ形式で表示していた。ところが、タブの中に問題リストも入っていて、カラムの役割が曖昧だった。

Chrome DevTools MCPで実際の表示を確認しながら、サブカテゴリーの問題リストを独立した3カラム目に分離した。

4カラム構成

最終的に、大(勘定科目カテゴリー) / 中(サブセクション) / 小(ページ番号リスト) / 詳細の4カラム構成に落ち着いた。

+----------------+----------------+----------+------------------------+
| 勘定科目       | 区分           | ページ   | 詳細                   |
| (360px)        | (240px)        | (80px)   | (1fr)                  |
+----------------+----------------+----------+------------------------+
| [1] 売上高     | ・経理処理例   | p.183    | 問題文...              |
| [2] 売上値引   | ・簡易課税     | p.184    |                        |
| ...            | ・個別対応     | p.185    | [No.001]               |
|                |                |          | 借方 | 貸方 | 摘要     |
+----------------+----------------+----------+------------------------+

CSSグリッドの定義は以下の通り。

.miller-columns {
  display: grid;
  grid-template-columns: 360px 240px 80px 1fr;
  flex: 1;
  overflow: hidden;
}

Miller Columnsの良さは、どの階層にいるかが常に見えること。タブ切り替えだと「今どこを見ているか」がわからなくなりがちだが、カラム形式なら左から右へ文脈が並ぶ。macOSのFinderのカラム表示と同じ発想。


3. 全文検索機能の実装(08:31セッション)

今日最も時間をかけたセッション。計画段階で決めた要件をそのまま実装に落とし込んだ。

検索ロジック

searchQuery が2文字以上になると searchResults computedが動く。検索対象は4箇所。

const searchResults = computed((): SearchResult[] => {
  const query = searchQuery.value.toLowerCase().trim();
  if (query.length < 2) return [];

  return allExamples.value
    .filter(ex => {
      // 問題文
      if (ex.problem.toLowerCase().includes(query)) return true;
      // 注記
      if (ex.notes?.some(note => note.toLowerCase().includes(query))) return true;
      // 仕訳の勘定科目・摘要
      return ex.patterns.some(p =>
        p.entries.some(entry =>
          entry.debit.accountTitle.toLowerCase().includes(query) ||
          entry.credit.accountTitle.toLowerCase().includes(query) ||
          (entry.description && entry.description.toLowerCase().includes(query))
        )
      );
    })
    .map(ex => ({ example: ex, categoryName: ex.categoryName }));
});

全件をフィルタするだけの単純な実装だが、931件程度ならパフォーマンス上の問題はない。入力のたびにcomputedが再計算されるものの、文字列マッチングだけなのでミリ秒単位で終わる。

検索結果オーバーレイのレイアウト

Miller Columnsの上にオーバーレイを重ねる設計にした。position: absolute で Miller Columns のラッパー全体を覆い、中身は左右2分割ペイン。

+---検索結果オーバーレイ(白背景)---+
| 左ペイン(1/3)  | 右ペイン(2/3)   |
| 結果リスト      | 選択した事例     |
|                 | の詳細表示       |
| No.001 売上高   | [問題文]         |
| No.023 広告宣伝 | [仕訳テーブル]   |
| No.145 交際費   | [注記]           |
|                 | [ジャンプリンク] |
+-----------------+-----------------+

左ペインはスクロール可能な結果リスト。右ペインには選択中の事例の詳細を表示する。結果が0件なら「該当する事例はありません」のメッセージを出す。

キーボードハンドラー

検索オーバーレイが表示されているときのキーボード操作を3つ実装した。

function handleKeydown(e: KeyboardEvent) {
  // Escで検索クリア
  if (e.key === 'Escape' && showSearchOverlay.value) {
    e.preventDefault();
    clearSearch();
    return;
  }

  // 検索中: 左右矢印で結果を移動
  if (showSearchOverlay.value) {
    if (e.key === 'ArrowLeft') {
      e.preventDefault();
      goSearchPrev();
      return;
    } else if (e.key === 'ArrowRight') {
      e.preventDefault();
      goSearchNext();
      return;
    }
  }

  // Alt+矢印はブラウザバック/フォワードなのでスキップ
  if (e.altKey) return;

  // 通常のナビゲーション(検索オーバーレイ非表示時)
  // ...
}

ポイントは、検索入力欄にフォーカスがある状態でも左右矢印キーが結果のナビゲーションとして機能すること。通常のテキスト入力ではカーソル移動に使いたいところだが、検索結果の事例間を移動するほうが頻度が高いと判断した。

選択変更時の左ペイン自動スクロール

左ペインの検索結果リストで、キーボードで選択を切り替えたときに、選択中の項目が画面外にあれば自動的にスクロールする処理を入れた。scrollIntoView({ block: 'nearest' }) で最小限のスクロールにしている。

タグ表示とジャンプリンク

検索結果の右ペインには、勘定科目名(青タグ)とサブセクション種別のタグを表示する。どのカテゴリの事例かが一目でわかる。

さらに「勘定科目別で表示」ボタンを追加。クリックすると検索オーバーレイを閉じて、勘定科目別ビューの該当位置にジャンプする。これで検索から通常の閲覧へスムーズに移れる。


4. 仕訳テーブルの条件付きハイライト(09:31)

JournalPatternCard コンポーネントの抽出

index.vuecurated.vuequiz.vue の3ファイルに、仕訳テーブルを描画するほぼ同じコードが重複していた。勘定科目・補助科目・税区分・金額のカラム構成、colgroup による列幅固定、借方と貸方の境界線など、全部コピペ状態。

これを JournalPatternCard.vue(257行)として切り出した。propsは3つだけ。

const props = defineProps<{
  pattern: JournalPattern;
  exampleId: string;
  highlightSide: 'debit' | 'credit';
}>();

ハイライトロジックの設計

消費税の仕訳で「どこが論点なのか」を視覚的に示すためのハイライト機能を追加した。

最初は「対象外」かどうかでハイライトを判定しようとしたが、これだと的外れになるケースが多かった。正しい判定基準は、費用/収益のどちらかで決まる。

  • 費用項目(仕入高、広告宣伝費、交際費など): 借方の税区分が論点 → highlightSide: 'debit'
  • 収益項目(売上高、受取利息、雑収入など): 貸方の税区分が論点 → highlightSide: 'credit'
const REVENUE_CATEGORIES = [
  '売上高', '売上値引及び戻り高', '受取利息', '受取配当金', '雑収入'
];

function getHighlightSide(categoryName: string): 'debit' | 'credit' {
  return REVENUE_CATEGORIES.includes(categoryName) ? 'credit' : 'debit';
}

売上高の仕訳なら貸方に「課税売上 10%」と入る。その税区分が正しいかどうかがクイズの論点になる。逆に、広告宣伝費なら借方に「課税仕入 10%」が入る。学習者が注目すべき箇所を太字+マゼンタ(#c026d3)で目立たせた。

控除率のハイライト

インボイス未保存の場合に適用される経過措置(80%控除、50%控除)がある事例では、税区分そのものではなく控除率のほうをハイライトする。

<!-- 税区分本体: 控除率がなければハイライト -->
<span class="tax-line"
  :class="{ 'tax-highlight': highlightSide === 'debit' && !entry.debit.deductionRate }">
  {{ getTaxBaseLine(entry.debit) }}
</span>

<!-- 控除率: あればこちらをハイライト -->
<span v-if="entry.debit.deductionRate" class="tax-line"
  :class="{ 'tax-highlight': highlightSide === 'debit' }">
  {{ entry.debit.deductionRate }}
</span>

インボイス制度の経過措置は仕入税額控除の割合が変わるだけで、税区分自体は「課税仕入」のまま。だから控除率こそが判断の分かれ目であり、そこをハイライトするのが学習上正しい。

少額特例パターンの判定

条件文に「中小事業者」を含むパターン(少額特例)では、金額セルもハイライトする。少額特例は1万円未満の取引でインボイス不要になる制度で、金額自体が要件になるため。

const isSmallAmountException = computed(() =>
  !!props.pattern.condition?.includes('中小事業者')
);

5. 項目別ビューへのタグ追加(10:25)

検索結果の右ペインでは事例ごとに categoryName(勘定科目名)と subSectionType(区分経理処理 / 簡易課税制度の事業区分 / インボイス対応方式の用途区分)のタグを表示していた。ところが、項目別ビューの事例詳細にはこのタグがなかった。

項目別ビューは「税込経理」「本則課税」といった税務上の論点から事例を引くためのもの。しかし事例を開いたとき、その事例がどの勘定科目に属するのか、経理処理例なのか簡易課税の事業区分なのかがわからないと文脈がつかめない。

検索結果と同じ形式のタグ表示を項目別ビューにも追加して、どちらの経路から事例にたどり着いても同じ情報が見えるようにした。


6. 200事例キュレーションとクイズ解答UI

200事例キュレーション(gitコミット: 26e4074)

なぜ絞り込んだか

OCRで抽出した931件の事例を全部順に解くのは現実的でない。同じ勘定科目で似たような仕訳が何十件も続く箇所がある。たとえば「売上高」カテゴリだけで100件以上あるが、税区分のバリエーションは限られている。

学習の初期段階では、各カテゴリの代表的なパターンを押さえるほうが効率がいい。931件の中から200件を選定するキュレーションスクリプトを作った。

build-curated-200.ts スクリプト(589行)

処理の流れ。

  1. 931件の全データから税区分キー(taxCategoryBase + taxRate + usageCategory + deductionRate)を抽出
  2. 勘定科目カテゴリ別にグルーピング
  3. 各カテゴリから、税区分のバリエーションが最大になるよう代表事例を選定
  4. トピック別のサブカテゴリに分類(「課税売上の判定」「非課税取引」「免税取引」「経過措置」など)
  5. curated-200.ts(11,363行)としてTypeScriptファイルを生成

出力データには各事例に topicsubtopic、難易度の情報を付与した。CuratedExample 型は元の AccountingExample を拡張して、分類メタデータを持つ。

curated.vue(905行)

キュレーションされた200事例を閲覧する専用ページ。index.vue の4カラムMiller Columnsとは別の構成で、大(論点カテゴリー) / 中(サブトピック) / 小(勘定科目名) / 詳細の階層でナビゲーションする。

// 大カテゴリー → 中カテゴリー → 小カテゴリー → 詳細
const selectedCuratedTopicId = ref<string | null>(null);
const selectedCuratedSubtopicId = ref<string | null>(null);
const selectedCuratedAccountName = ref<string | null>(null);

ナビゲーションポイントは勘定科目単位で生成し、矢印キーで前後に移動できる。

interface CuratedNavPoint {
  topicId: string;
  subtopicId: string;
  accountName: string;
}

const allCuratedNavPoints = computed(() => {
  const points: CuratedNavPoint[] = [];
  for (const topic of curatedTopics) {
    for (const sub of topic.subcategories) {
      const entries = curatedExamples.filter(
        e => e.topic === topic.id && e.subtopic === sub.id
      );
      const seen = new Set<string>();
      for (const e of entries) {
        if (!seen.has(e.categoryName)) {
          seen.add(e.categoryName);
          points.push({
            topicId: topic.id,
            subtopicId: sub.id,
            accountName: e.categoryName,
          });
        }
      }
    }
  }
  return points;
});

クイズ機能の強化(gitコミット: 860d431)

quiz.vue を拡張して、解答選択と正誤トラッキングの機能を追加した。

  • 各問題に対して複数の税区分選択肢を提示
  • ユーザーがクリックで解答を選択
  • 選択後に正解/不正解をハイライト表示
  • セッション中の正答率を記録

難易度ソートも実装した。選択したサブカテゴリ内で、基礎的な課税取引から始めて免税・非課税の判定が必要な応用問題へと進む構成。最初に簡単な問題で「課税仕入」「課税売上」のパターンに慣れてから、「非課税売上」「不課税」「輸出免税」の判断を問う問題に進む流れになる。

キーボードナビゲーション改善(gitコミット: c4df7f4)

curated.vueindex.vuequiz.vue の3ページに、Alt+矢印キーによるブラウザバック/フォワード動作をスキップする処理を追加した。

問題集アプリでは左右矢印で問題間を移動する。Altキーを誤って押しながら矢印を押すと、ブラウザが前のページに戻ってしまう。keydown イベントで event.altKey を検出し、矢印キーの場合は preventDefault() でデフォルト動作を止めた。


プラン進捗の更新(09:18)

2026-02-04-item-index-implementation.md の実装プランの進捗を更新した。項目別ビューの実装は前日に基本部分が完了しており、今日のタグ追加と検索機能で当初の要件をおおむねカバーした。


技術的な判断のまとめ

Miller Columns vs タブ vs ツリー

階層データの表示方法はいくつか候補があった。

  • タブ: カテゴリをタブで切り替える方法。画面幅を有効に使えるが、今どの階層にいるかが見えない
  • ツリー: 左サイドバーにツリービュー。折り畳みの開閉が面倒で、深い階層だとインデントで見づらくなる
  • Miller Columns: macOS Finderのカラム表示。全階層が横に並ぶので文脈がわかりやすい。横幅を食うのが欠点

今回はデスクトップ前提の学習アプリなので、横幅の問題は許容した。モバイル対応は後回し。

検索のクライアントサイド実装

Elasticsearchやサーバーサイド検索を使わず、ブラウザ上の Array.filter だけで実装した。理由は単純で、931件なら全件走査しても速いから。SSG(Static Site Generation)で配信するアプリなので、サーバー側の検索基盤を持つとデプロイ構成が複雑になる。

将来的にデータが1万件を超えるようなら、Fuse.jsなどのクライアントサイド全文検索ライブラリへの移行を検討する。

ハイライトの「費用/収益」判定

最初は仕訳行ごとに taxCategoryBase === '対象外' かどうかで非ハイライト側を判定しようとした。しかし、借方にも貸方にも「対象外」が入るケースがあり、判定が曖昧になった。

結局、勘定科目カテゴリが収益(売上高、受取利息など)なら貸方をハイライト、費用なら借方をハイライトという単純なルールに落ち着いた。消費税の仕訳で学習者が判断する箇所は、「この取引の税区分は何か」という1点に集約される。費用なら借方、収益なら貸方。この対応関係は消費税法の構造に由来するもので、例外がない。


今日の作業量

  • JournalPatternCard.vue: 257行(新規)
  • curated.vue: 905行(新規)
  • build-curated-200.ts: 589行(新規)
  • curated-200.ts: 11,363行(自動生成)
  • index.vue: 検索機能・タグ・Miller Columns変更で数百行の追加
  • quiz.vue: 解答選択UI・難易度ソートの追加

コード追加量は多いが、JournalPatternCard の抽出によって3ファイルの重複コードは減った。新規作成したファイルのうち、curated-200.ts はスクリプトによる自動生成なので手で書いたわけではない。


残っている課題

  • モバイル対応(Miller Columnsは横幅が狭いと破綻する)
  • 検索のあいまい一致(現状は完全一致のみ)
  • クイズの正答率をlocalStorageに永続化
  • curated.vue と index.vue のナビゲーションロジックの共通化