• #tax-assistant
  • #vue
  • #ui-ux
  • #sqlite
  • #typescript
開発tax-assistantメモ

仕訳ルールUIの大幅改善

tax-assistantの仕訳ルール管理画面(ShiwakeRulesView / RuleListView)に対して、複数の改善を実施した。本記事では、DB設計の変更からフロントエンドのUI改善まで、一連の作業内容を記録する。

背景と課題

仕訳ルール管理画面には2つのビューがある。

ビュー用途
勘定科目ビュー既存の勘定科目に対してルールを追加する簡便な方法
ルール一覧ビュー新規の勘定科目も含めて詳細にルールを定義する方法

従来の問題点として、以下があった。

  1. shiwake_rulesテーブルのdocument_typeは汎用カテゴリ(creditcard, receipt, bank)のみで、具体的な帳票タイプ(三井住友カード、アメックスなど)を区別できなかった
  2. ルール一覧ビューのカラム構成が勘定科目ビューと異なり、見比べづらかった
  3. 書類タイプによっては収支区分が片方しかないにもかかわらず、両方選択可能になっていた
  4. 件数表示がなく、ルールの分布が把握しづらかった

Phase 1: DBスキーマの変更

shiwake_rulesテーブルへのdocument_type_id追加

shiwake_rulesテーブルにdocument_type_idカラムを追加し、document_typesテーブルと外部キーで関連付けた。

-- マイグレーション
ALTER TABLE shiwake_rules ADD COLUMN document_type_id INTEGER;

-- 既存データの移行(document_type → document_type_id)
UPDATE shiwake_rules
SET document_type_id = (
  SELECT id FROM document_types
  WHERE document_types.document_type = shiwake_rules.document_type
  LIMIT 1
);

-- インデックス追加
CREATE INDEX idx_shiwake_rules_document_type_id ON shiwake_rules(document_type_id);

API更新

バックエンドのAPI(shiwake_rules.py)も更新し、document_type_idを返すように変更した。

def get_shiwake_rules():
    query = """
        SELECT
            sr.*,
            dt.display_name as document_type_display_name
        FROM shiwake_rules sr
        LEFT JOIN document_types dt ON sr.document_type_id = dt.id
        WHERE sr.is_active = 1
        ORDER BY sr.id
    """
    # ...

Phase 2: フロントエンドUI改善

書類タイプ別の帳票タイプ表示

APIから取得したdocument_typesを使って、書類タイプ列に具体的な帳票名(三井住友カード、アメックス等)を表示するように変更した。

// RuleListView.vue
const documentTypes = ref<DocumentType[]>([])

// loadData()内でdocument_typesを取得
const dtRes = await $fetch<{ data: DocumentType[] }>('/api/document-types')
documentTypes.value = dtRes.data

RuleListViewのカラム構成変更

従来の「フィールド」「マッチタイプ」カラムを削除し、「勘定科目」「税区分」カラムを追加した。

変更前:

書類タイプ | 収支区分 | フィールド | マッチタイプ | パターン

変更後:

書類タイプ | 収支区分 | 勘定科目 | 税区分 | パターン

これにより、勘定科目ビューとルール一覧ビューで同じ情報が表示され、見比べやすくなった。

収支区分の自動選択とdisabled表示

書類タイプによって収支区分が片方しかない場合(例: クレジットカードは支出のみ)、自動で選択してdisabled表示にする機能を追加した。

// 有効な収支区分を計算
const validDirections = computed(() => {
  const docTypeId = selectedDocTypeId.value
  if (!docTypeId) return ['expense', 'income']

  const rules = allRules.value.filter(r => r.document_type_id === docTypeId)
  const hasExpense = rules.some(r => r.direction === 'expense')
  const hasIncome = rules.some(r => r.direction === 'income')

  return {
    expense: hasExpense,
    income: hasIncome
  }
})

// 書類タイプ選択時に有効な収支区分を自動選択
function selectDocType(docTypeId: number) {
  selectedDocTypeId.value = docTypeId

  const valid = validDirections.value
  if (valid.expense && !valid.income) {
    selectedDirection.value = 'expense'
  } else if (!valid.expense && valid.income) {
    selectedDirection.value = 'income'
  }
}

テンプレート側では、無効な収支区分にdisabledクラスを適用した。

<li
  v-for="dir in directions"
  :key="dir.value"
  :class="{
    selected: selectedDirection === dir.value,
    disabled: !validDirections[dir.value]
  }"
  @click="validDirections[dir.value] && selectDirection(dir.value)"
>
  {{ dir.label }} ({{ getCountForDirection(dir.value) }})
</li>
.disabled {
  opacity: 0.5;
  cursor: not-allowed;
  color: #999;
}

カラム幅の統一

勘定科目ビューとルール一覧ビューでカラム幅を統一し、視覚的な一貫性を確保した。

/* 両ビュー共通 */
.doc-type-column,
.direction-column,
.account-column {
  width: 150px;
  flex-shrink: 0;
}

.item-name {
  font-size: 12px;
  color: #333;
  word-break: break-word;
}

件数表示の追加

書類タイプ、収支区分、勘定科目の各列にルール件数を表示するようにした。

<!-- 書類タイプ -->
<li v-for="dt in docTypeList" :key="dt.id">
  {{ dt.display_name }} ({{ dt.ruleCount }})
</li>

<!-- 勘定科目 -->
<li v-for="acc in accountList" :key="acc.name">
  {{ acc.name }} ({{ acc.count }})
</li>

勘定科目は件数の降順でソートするようにした。

const accountList = computed(() => {
  const accounts = new Map<string, number>()

  filteredRules.value.forEach(rule => {
    const count = accounts.get(rule.account_name) || 0
    accounts.set(rule.account_name, count + 1)
  })

  return Array.from(accounts.entries())
    .map(([name, count]) => ({ name, count }))
    .sort((a, b) => b.count - a.count) // 件数降順
})

Phase 3: キーボードナビゲーションの修正

インデックスのソート順修正

カラム構成変更に伴い、矢印キー(←→)で移動するためのallFilteredRulesのソート順も更新した。

const allFilteredRules = computed(() => {
  return filteredRules.value.slice().sort((a, b) => {
    // 勘定科目件数の降順
    const aCount = accountList.value.find(acc => acc.name === a.account_name)?.count || 0
    const bCount = accountList.value.find(acc => acc.name === b.account_name)?.count || 0
    if (aCount !== bCount) return bCount - aCount

    // 税区分件数の降順
    const aTaxCount = taxTypeList.value.find(t => t.name === a.tax_type)?.count || 0
    const bTaxCount = taxTypeList.value.find(t => t.name === b.tax_type)?.count || 0
    return bTaxCount - aTaxCount
  })
})

ループナビゲーションの実装

最初のルールで←を押すと最後に、最後のルールで→を押すと最初に移動するループナビゲーションを実装した。

function goToPrevRule() {
  if (currentRuleIndex.value <= 0) {
    currentRuleIndex.value = allFilteredRules.value.length - 1 // 最後に移動
  } else {
    currentRuleIndex.value--
  }
  selectRuleByIndex(currentRuleIndex.value)
}

function goToNextRule() {
  if (currentRuleIndex.value >= allFilteredRules.value.length - 1) {
    currentRuleIndex.value = 0 // 最初に移動
  } else {
    currentRuleIndex.value++
  }
  selectRuleByIndex(currentRuleIndex.value)
}

デザインシステムにもループナビゲーションの原則を追加した。

Phase 4: ローカルストレージ永続化

useShiwakeRulesStorage composable

ビューモードや選択状態をローカルストレージに保存するcomposableを作成した。

// composables/useShiwakeRulesStorage.ts
const STORAGE_KEYS = {
  VIEW_MODE: 'shiwake-rules-view-mode',
  ACCOUNT_VIEW_STATE: 'shiwake-rules-account-view-state',
  LIST_VIEW_STATE: 'shiwake-rules-list-view-state'
}

export function useShiwakeRulesStorage() {
  // ビューモード
  function saveViewMode(mode: 'account' | 'list') {
    localStorage.setItem(STORAGE_KEYS.VIEW_MODE, mode)
  }

  function loadViewMode(): 'account' | 'list' {
    return (localStorage.getItem(STORAGE_KEYS.VIEW_MODE) as 'account' | 'list') || 'account'
  }

  // 勘定科目ビューの状態
  function saveAccountViewState(state: AccountViewState) {
    localStorage.setItem(STORAGE_KEYS.ACCOUNT_VIEW_STATE, JSON.stringify(state))
  }

  function loadAccountViewState(): AccountViewState | null {
    const saved = localStorage.getItem(STORAGE_KEYS.ACCOUNT_VIEW_STATE)
    return saved ? JSON.parse(saved) : null
  }

  // ルール一覧ビューの状態
  function saveListViewState(state: ListViewState) {
    localStorage.setItem(STORAGE_KEYS.LIST_VIEW_STATE, JSON.stringify(state))
  }

  function loadListViewState(): ListViewState | null {
    const saved = localStorage.getItem(STORAGE_KEYS.LIST_VIEW_STATE)
    return saved ? JSON.parse(saved) : null
  }

  return {
    saveViewMode,
    loadViewMode,
    saveAccountViewState,
    loadAccountViewState,
    saveListViewState,
    loadListViewState
  }
}

タブ切り替え時の復元

v-showでタブを切り替えているため、currentTabをwatchしてビューモードを復元する必要があった。

// ShiwakeRulesView.vue
watch(() => (currentTab as Ref<string>).value, (newTab) => {
  if (newTab === 'shiwake-rules') {
    const savedMode = storage.loadViewMode()
    viewMode.value = savedMode

    if (savedMode === 'list') {
      switchToRuleListView()
    } else {
      switchToAccountView()
    }
  }
})

テストコードの追加

E2Eテストでローカルストレージ永続化を検証するテストを追加した。

// shiwake-rules-url-sync.spec.ts
test.describe('localStorage永続化テスト', () => {
  test('ビューモードがlocalStorageに保存される', async ({ page }) => {
    await page.goto('/shiwake-rules')

    // ルール一覧ビューに切り替え
    await page.click('button:has-text("ルール一覧ビュー")')

    // localStorageを確認
    const viewMode = await page.evaluate(() =>
      localStorage.getItem('shiwake-rules-view-mode')
    )
    expect(viewMode).toBe('list')
  })

  test('タブ切り替え後にビューモードが復元される', async ({ page }) => {
    await page.goto('/shiwake-rules')
    await page.click('button:has-text("ルール一覧ビュー")')

    // 別のタブに移動
    await page.click('[data-tab="document-types"]')

    // 仕訳ルールタブに戻る
    await page.click('[data-tab="shiwake-rules"]')

    // ルール一覧ビューが表示されていることを確認
    await expect(page.locator('.rule-list-view')).toBeVisible()
  })

  test('ルール一覧ビューで勘定科目選択が保持される', async ({ page }) => {
    await page.goto('/shiwake-rules')
    await page.click('button:has-text("ルール一覧ビュー")')

    // 支払手数料を選択
    await page.click('.rule-list-view .account-column li:has-text("支払手数料")')

    // 別のビューに切り替えて戻る
    await page.click('button:has-text("勘定科目ビュー")')
    await page.click('button:has-text("ルール一覧ビュー")')

    // 支払手数料が選択されていることを確認
    await expect(
      page.locator('.rule-list-view .account-column li.selected')
    ).toContainText('支払手数料')
  })
})

また、ソート順の整合性を検証するユニットテストも追加した。

// RuleListView.spec.ts
describe('RuleListView ソート順テスト', () => {
  test('allFilteredRulesとUI表示順が一致する', () => {
    const rules = [
      { account_name: '消耗品費', tax_type: '課税仕入10%' },
      { account_name: '接待交際費', tax_type: '課税仕入10%' },
      { account_name: '消耗品費', tax_type: '非課税' }
    ]

    // accountListは件数降順
    const accountList = getAccountList(rules)
    expect(accountList[0].name).toBe('消耗品費') // 2件
    expect(accountList[1].name).toBe('接待交際費') // 1件

    // allFilteredRulesも同じ順序
    const sorted = getSortedRules(rules, accountList)
    expect(sorted[0].account_name).toBe('消耗品費')
    expect(sorted[1].account_name).toBe('消耗品費')
    expect(sorted[2].account_name).toBe('接待交際費')
  })
})

まとめ

今回の改善により、以下が実現できた。

  1. DBレベル: document_type_idにより具体的な帳票タイプでルールを管理可能に
  2. UI一貫性: 勘定科目ビューとルール一覧ビューでカラム構成を統一
  3. UX向上: 収支区分の自動選択、件数表示、ローカルストレージ永続化
  4. 操作性: ループナビゲーションによる快適なキーボード操作
  5. 品質担保: E2Eテストとユニットテストによる回帰防止

これらの改善により、仕訳ルールの管理作業が効率化された。