• #Vue.js
  • #リファクタリング
  • #UI設計
  • #状態管理
  • #TypeScript
開発メモ

税務アシスタントUIの大規模リファクタリング

tax-assistantプロジェクトで、領収書・レシートに加えて売上伝票(sales_slip)のOCR処理に対応するため、フロントエンドUIの構造を大きく見直した。

背景と課題

当初のUIは「レシート」のみを前提とした設計だった。売上伝票という新しい帳票タイプに対応するにあたり、以下の課題が浮上した。

  1. 帳票種別の切り替えUIがない - レシートと売上伝票を切り替える手段がなかった
  2. ラベルが「レシート」固定 - 「レシート内容」「レシート画像」など、汎用性のない命名
  3. 状態管理が複雑 - currentBatchFilterが空文字列/null/batch_idの3値を取り、挙動が予測しづらかった
  4. 帳票種別ごとの位置が記憶されない - 種別を切り替えると最初のアイテムに戻ってしまう

実装内容

1. 帳票種別パネルの追加(BatchDocumentTypeList.vue)

Miller Columnsの左端に「帳票種別」パネルを追加した。

Before: 3列構成

バッチ一覧 | ファイル | 内容

After: 4列構成

帳票種別 | バッチ一覧 | ファイル | 内容
<!-- BatchDocumentTypeList.vue -->
<template>
  <div class="document-type-list">
    <div class="header">帳票種別</div>
    <ul class="items">
      <li
        v-for="type in documentTypes"
        :key="type.value"
        :class="{ selected: type.value === selectedType }"
        @click="$emit('select', type.value)"
      >
        {{ type.label }}
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
defineProps<{
  documentTypes: { value: string; label: string }[]
  selectedType: string
}>()

defineEmits<{
  select: [value: string]
}>()
</script>

コンポーネント名はBatchDocumentTypeList.vueとした。NuxtのpathPrefix: false設定により、ファイル名がそのままコンポーネント名として解決される。

2. ラベルの汎用化

ハードコードされた「レシート」という文字列を汎用的な「帳票」に置き換えた。

Before:

<div class="panel-header">レシート内容</div>
<div class="panel-header">レシート画像</div>

After:

<div class="panel-header">帳票内容</div>
<div class="panel-header">帳票画像</div>

修正対象ファイル:

  • ReceiptForm.vue
  • ReceiptImage.vue
  • MillerColumnsView.vue

3. useReceipts.tsのリファクタリング

3値状態の排除

Before: 3値状態(複雑)

// currentBatchFilterが3つの状態を持つ
const currentBatchFilter = ref<string | null>(null)
// null: 全データ表示
// '': 空(データなし)
// 'batch_id': 特定バッチでフィルター

// 判定ロジックが煩雑
const items = computed(() => {
  if (currentBatchFilter.value === null) {
    return allItems.value
  }
  if (currentBatchFilter.value === '') {
    return []
  }
  return allItems.value.filter(
    item => item.batch_id === currentBatchFilter.value
  )
})

After: 2値状態(シンプル)

// batch_idは常に文字列、nullは「未選択」のみを意味
const currentBatchId = ref<string | null>(null)

// バッチ選択時にアイテムを自動選択
const doSelectBatch = (batchId: string | null) => {
  currentBatchId.value = batchId
  if (batchId) {
    const batchItems = getBatchItems(batchId)
    if (batchItems.length > 0) {
      currentIndex.value = allItems.value.indexOf(batchItems[0])
    }
  }
}

// itemsは派生stateとして計算
const filteredItems = computed(() => {
  if (!currentBatchId.value) return []
  return allItems.value.filter(
    item => item.batch_id === currentBatchId.value
  )
})

派生stateへの集約

年月フィルターの値も、現在選択中のアイテムから自動的に導出するようにした。

// 年月は現在のアイテムから派生
const selectedYear = computed(() => {
  const item = currentItem.value
  if (!item) return null
  return new Date(item.date).getFullYear()
})

const selectedMonth = computed(() => {
  const item = currentItem.value
  if (!item) return null
  return new Date(item.date).getMonth() + 1
})

// バッチ内の全アイテムを年月日順でソート
const batchItems = computed(() => {
  if (!currentBatchId.value) return []
  return allItems.value
    .filter(item => item.batch_id === currentBatchId.value)
    .sort((a, b) => {
      const dateA = new Date(a.date)
      const dateB = new Date(b.date)
      return dateA.getTime() - dateB.getTime()
    })
})

4. 複合キーによるlocalStorage保存

帳票種別ごとに最後に閲覧していた位置を記憶するため、複合キーでlocalStorageに保存する仕組みを実装した。

Before: 単一キー(種別を切り替えると位置がリセット)

// 保存
localStorage.setItem('selectedFileName', currentItem.value?.file_name)

// 復元
const fileName = localStorage.getItem('selectedFileName')
selectByFileName(fileName)

After: 複合キー(種別ごとに位置を記憶)

// 帳票種別ごとの位置を管理
interface DocumentTypePosition {
  batchId: string
  year: number
  month: number
  fileName: string
}

// 保存: 種別をキーにしたオブジェクト
const savePosition = (docType: string, pos: DocumentTypePosition) => {
  const key = `tax-assistant:position:${docType}`
  localStorage.setItem(key, JSON.stringify(pos))
}

// 復元: 種別切り替え時に呼び出し
const restorePosition = (docType: string) => {
  const key = `tax-assistant:position:${docType}`
  const saved = localStorage.getItem(key)
  if (saved) {
    const pos = JSON.parse(saved) as DocumentTypePosition
    // 年月フィルターとファイルを復元
    selectByKey(pos.batchId, pos.fileName)
  }
}

index.vueでの使用例:

// 帳票種別変更時
const handleSelectDocumentType = (docType: string) => {
  // 現在の位置を保存
  if (currentItem.value) {
    savePosition(currentDocumentType.value, {
      batchId: currentBatchId.value!,
      year: selectedYear.value!,
      month: selectedMonth.value!,
      fileName: currentItem.value.file_name
    })
  }

  // 種別を切り替え
  currentDocumentType.value = docType

  // 保存していた位置を復元
  restorePosition(docType)
}

// watchで自動保存(初期化中は除外)
watch(currentItem, (item) => {
  if (!isInitialized.value || !item) return
  savePosition(currentDocumentType.value, {
    batchId: currentBatchId.value!,
    year: selectedYear.value!,
    month: selectedMonth.value!,
    fileName: item.file_name
  })
})

5. document_typeの伝播修正

loadAllReceiptsでデータを取得する際、バッチのdocument_typeを各アイテムに設定するように修正した。これにより、アイテム選択時に帳票種別が自動的に同期される。

const loadAllReceipts = async () => {
  const response = await fetch('/api/receipts')
  const data = await response.json()

  // バッチ情報からdocument_typeを取得してアイテムに設定
  const batchMap = new Map(
    data.batches.map((b: Batch) => [b.batch_id, b.document_type])
  )

  allItems.value = data.items.map((item: Receipt) => ({
    ...item,
    document_type: batchMap.get(item.batch_id) ?? 'receipt'
  }))
}

動作確認

Chrome DevTools MCPを使用して以下の動作を確認した。

  1. 帳票種別パネル - 「レシート」「売上伝票」が左端に表示される
  2. 種別切り替え - クリックで帳票種別を切り替えられる
  3. データなし表示 - 売上伝票選択時、データがなければ「データがありません」と表示
  4. 位置記憶 - 種別を切り替えて戻っても、前回の位置が復元される
  5. ナビゲーション - 矢印キーでファイル間を移動しても、帳票種別が維持される

Codexレビュー結果

GPT-5.2によるレビューで以下の指摘を受け、対応した。

指摘事項対応
selectByFileNameが同名ファイル対応していない複合キー(batch_id + file_name)で特定するよう修正
localStorage保存で種別ごとの分離が不完全種別をキーに含める形式に変更
初期化中のwatch発火で位置がリセットされるisInitializedフラグで初期化完了まで保存を抑制

まとめ

今回のリファクタリングで、UIの構造が明確になり、新しい帳票タイプの追加も容易になった。

改善点:

  • Miller Columnsの4列構成により、帳票種別の切り替えが直感的に
  • 3値状態を排除し、状態管理がシンプルに
  • 複合キーによる位置記憶で、使い勝手が向上
  • Codexレビューによる品質向上

今後の展望:

  • 帳票タイプごとのテーブル分離(gemini_receipts / gemini_sales_slips
  • 年月フィルターに進捗表示を追加
  • 編集モードとロック状態の明確化