仕訳ルールUIの大幅改善
tax-assistantの仕訳ルール管理画面(ShiwakeRulesView / RuleListView)に対して、複数の改善を実施した。本記事では、DB設計の変更からフロントエンドのUI改善まで、一連の作業内容を記録する。
背景と課題
仕訳ルール管理画面には2つのビューがある。
| ビュー | 用途 |
|---|---|
| 勘定科目ビュー | 既存の勘定科目に対してルールを追加する簡便な方法 |
| ルール一覧ビュー | 新規の勘定科目も含めて詳細にルールを定義する方法 |
従来の問題点として、以下があった。
shiwake_rulesテーブルのdocument_typeは汎用カテゴリ(creditcard,receipt,bank)のみで、具体的な帳票タイプ(三井住友カード、アメックスなど)を区別できなかった- ルール一覧ビューのカラム構成が勘定科目ビューと異なり、見比べづらかった
- 書類タイプによっては収支区分が片方しかないにもかかわらず、両方選択可能になっていた
- 件数表示がなく、ルールの分布が把握しづらかった
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('接待交際費')
})
})
まとめ
今回の改善により、以下が実現できた。
- DBレベル:
document_type_idにより具体的な帳票タイプでルールを管理可能に - UI一貫性: 勘定科目ビューとルール一覧ビューでカラム構成を統一
- UX向上: 収支区分の自動選択、件数表示、ローカルストレージ永続化
- 操作性: ループナビゲーションによる快適なキーボード操作
- 品質担保: E2Eテストとユニットテストによる回帰防止
これらの改善により、仕訳ルールの管理作業が効率化された。