税務アシスタントUIの大規模リファクタリング
tax-assistantプロジェクトで、領収書・レシートに加えて売上伝票(sales_slip)のOCR処理に対応するため、フロントエンドUIの構造を大きく見直した。
背景と課題
当初のUIは「レシート」のみを前提とした設計だった。売上伝票という新しい帳票タイプに対応するにあたり、以下の課題が浮上した。
- 帳票種別の切り替えUIがない - レシートと売上伝票を切り替える手段がなかった
- ラベルが「レシート」固定 - 「レシート内容」「レシート画像」など、汎用性のない命名
- 状態管理が複雑 -
currentBatchFilterが空文字列/null/batch_idの3値を取り、挙動が予測しづらかった - 帳票種別ごとの位置が記憶されない - 種別を切り替えると最初のアイテムに戻ってしまう
実装内容
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.vueReceiptImage.vueMillerColumnsView.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を使用して以下の動作を確認した。
- 帳票種別パネル - 「レシート」「売上伝票」が左端に表示される
- 種別切り替え - クリックで帳票種別を切り替えられる
- データなし表示 - 売上伝票選択時、データがなければ「データがありません」と表示
- 位置記憶 - 種別を切り替えて戻っても、前回の位置が復元される
- ナビゲーション - 矢印キーでファイル間を移動しても、帳票種別が維持される
Codexレビュー結果
GPT-5.2によるレビューで以下の指摘を受け、対応した。
| 指摘事項 | 対応 |
|---|---|
selectByFileNameが同名ファイル対応していない | 複合キー(batch_id + file_name)で特定するよう修正 |
| localStorage保存で種別ごとの分離が不完全 | 種別をキーに含める形式に変更 |
| 初期化中のwatch発火で位置がリセットされる | isInitializedフラグで初期化完了まで保存を抑制 |
まとめ
今回のリファクタリングで、UIの構造が明確になり、新しい帳票タイプの追加も容易になった。
改善点:
- Miller Columnsの4列構成により、帳票種別の切り替えが直感的に
- 3値状態を排除し、状態管理がシンプルに
- 複合キーによる位置記憶で、使い勝手が向上
- Codexレビューによる品質向上
今後の展望:
- 帳票タイプごとのテーブル分離(
gemini_receipts/gemini_sales_slips) - 年月フィルターに進捗表示を追加
- 編集モードとロック状態の明確化