開発tax-assistantメモ
帳票設定UIの大幅改善
tax-assistantプロジェクトの帳票設定画面(VoucherSettingsView)を大幅に改善した。勘定科目に対する帳票出力ルールを設定する画面で、使いやすさとデザインの一貫性を両立させた。
背景と課題
帳票設定画面には以下の課題があった。
- 中カテゴリ(direction)列が不要 - 入金/出金の方向を示す列があったが、実運用では使われていなかった
- ナビゲーションがない - 勘定科目間の移動が面倒で、一覧に戻る必要があった
- 勘定科目の検索ができない - 科目数が多く、目的の科目を探すのに時間がかかった
- アイコン選択UIがない - アイコンを選ぶ手段がなかった
- 編集中の離脱で変更が失われる - 未保存のまま別の科目に移動すると変更が消えた
- 入出金タイプが混在 - 入金時と出金時のルールが同じセクションに表示されていた
- デザインが統一されていない - 他の画面と色やスタイルが異なっていた
- 幅が固定されていない - コンテナの幅がバラバラで見た目が悪かった
実装内容
1. 中カテゴリ(direction)列の削除
不要な中カテゴリ列を削除し、UIをシンプルにした。
Before: 3列構成
大カテゴリ | 中カテゴリ(方向) | 勘定科目
After: 2列構成
大カテゴリ | 勘定科目
// columns.ts - 削除前
export const columns = [
{ key: 'category', label: 'カテゴリ' },
{ key: 'direction', label: '方向' },
{ key: 'account', label: '勘定科目' }
]
// columns.ts - 削除後
export const columns = [
{ key: 'category', label: 'カテゴリ' },
{ key: 'account', label: '勘定科目' }
]
これにより、左側のカラム一覧がすっきりし、勘定科目の選択が直感的になった。
2. NavigationBar(前へ/次へ)のグローバルナビゲーション実装
勘定科目間をスムーズに移動できるナビゲーションバーを追加した。
<!-- NavigationBar.vue -->
<template>
<div class="navigation-bar">
<button
class="nav-button"
:disabled="!canGoPrevious"
@click="$emit('previous')"
>
<ChevronLeftIcon class="icon" />
前へ
</button>
<span class="current-position">
{{ currentIndex + 1 }} / {{ totalCount }}
</span>
<button
class="nav-button"
:disabled="!canGoNext"
@click="$emit('next')"
>
次へ
<ChevronRightIcon class="icon" />
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/vue/24/outline'
const props = defineProps<{
currentIndex: number
totalCount: number
}>()
defineEmits<{
previous: []
next: []
}>()
const canGoPrevious = computed(() => props.currentIndex > 0)
const canGoNext = computed(() => props.currentIndex < props.totalCount - 1)
</script>
<style scoped>
.navigation-bar {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.nav-button {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 16px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: white;
cursor: pointer;
transition: all 0.15s ease;
}
.nav-button:hover:not(:disabled) {
background: #f1f5f9;
border-color: #94a3b8;
}
.nav-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.icon {
width: 16px;
height: 16px;
}
.current-position {
font-size: 14px;
color: #64748b;
}
</style>
親コンポーネントでの使用例:
<template>
<NavigationBar
:current-index="currentAccountIndex"
:total-count="filteredAccounts.length"
@previous="goToPreviousAccount"
@next="goToNextAccount"
/>
</template>
<script setup lang="ts">
const goToPreviousAccount = () => {
if (currentAccountIndex.value > 0) {
// 未保存変更があれば確認
if (hasUnsavedChanges.value) {
showUnsavedDialog.value = true
pendingNavigation.value = 'previous'
return
}
selectAccount(filteredAccounts.value[currentAccountIndex.value - 1])
}
}
const goToNextAccount = () => {
if (currentAccountIndex.value < filteredAccounts.value.length - 1) {
if (hasUnsavedChanges.value) {
showUnsavedDialog.value = true
pendingNavigation.value = 'next'
return
}
selectAccount(filteredAccounts.value[currentAccountIndex.value + 1])
}
}
</script>
3. SearchableSelectコンポーネントの作成(勘定科目検索機能)
勘定科目を検索できるドロップダウンコンポーネントを実装した。
<!-- SearchableSelect.vue -->
<template>
<div class="searchable-select" ref="containerRef">
<div class="select-trigger" @click="toggleDropdown">
<span v-if="selectedItem">{{ selectedItem.label }}</span>
<span v-else class="placeholder">{{ placeholder }}</span>
<ChevronDownIcon class="chevron" :class="{ open: isOpen }" />
</div>
<div v-if="isOpen" class="dropdown">
<div class="search-wrapper">
<MagnifyingGlassIcon class="search-icon" />
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
class="search-input"
:placeholder="searchPlaceholder"
@keydown.down.prevent="highlightNext"
@keydown.up.prevent="highlightPrevious"
@keydown.enter.prevent="selectHighlighted"
@keydown.escape="closeDropdown"
/>
</div>
<ul class="options-list" ref="optionsListRef">
<li
v-for="(item, index) in filteredItems"
:key="item.value"
class="option"
:class="{
selected: item.value === modelValue,
highlighted: index === highlightedIndex
}"
@click="selectItem(item)"
@mouseenter="highlightedIndex = index"
>
{{ item.label }}
</li>
<li v-if="filteredItems.length === 0" class="no-results">
該当する項目がありません
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { ChevronDownIcon, MagnifyingGlassIcon } from '@heroicons/vue/24/outline'
interface SelectItem {
value: string
label: string
}
const props = withDefaults(defineProps<{
modelValue: string | null
items: SelectItem[]
placeholder?: string
searchPlaceholder?: string
}>(), {
placeholder: '選択してください',
searchPlaceholder: '検索...'
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const containerRef = ref<HTMLElement>()
const searchInputRef = ref<HTMLInputElement>()
const optionsListRef = ref<HTMLElement>()
const isOpen = ref(false)
const searchQuery = ref('')
const highlightedIndex = ref(0)
const selectedItem = computed(() =>
props.items.find(item => item.value === props.modelValue)
)
const filteredItems = computed(() => {
if (!searchQuery.value) return props.items
const query = searchQuery.value.toLowerCase()
return props.items.filter(item =>
item.label.toLowerCase().includes(query) ||
item.value.toLowerCase().includes(query)
)
})
const toggleDropdown = () => {
isOpen.value = !isOpen.value
if (isOpen.value) {
searchQuery.value = ''
highlightedIndex.value = 0
nextTick(() => searchInputRef.value?.focus())
}
}
const closeDropdown = () => {
isOpen.value = false
}
const selectItem = (item: SelectItem) => {
emit('update:modelValue', item.value)
closeDropdown()
}
const highlightNext = () => {
if (highlightedIndex.value < filteredItems.value.length - 1) {
highlightedIndex.value++
scrollToHighlighted()
}
}
const highlightPrevious = () => {
if (highlightedIndex.value > 0) {
highlightedIndex.value--
scrollToHighlighted()
}
}
const selectHighlighted = () => {
const item = filteredItems.value[highlightedIndex.value]
if (item) selectItem(item)
}
const scrollToHighlighted = () => {
nextTick(() => {
const list = optionsListRef.value
const highlighted = list?.querySelector('.highlighted') as HTMLElement
if (highlighted && list) {
const listRect = list.getBoundingClientRect()
const itemRect = highlighted.getBoundingClientRect()
if (itemRect.bottom > listRect.bottom) {
highlighted.scrollIntoView({ block: 'nearest' })
} else if (itemRect.top < listRect.top) {
highlighted.scrollIntoView({ block: 'nearest' })
}
}
})
}
// 外側クリックで閉じる
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
closeDropdown()
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
// 検索クエリ変更時にハイライトをリセット
watch(searchQuery, () => {
highlightedIndex.value = 0
})
</script>
<style scoped>
.searchable-select {
position: relative;
width: 100%;
}
.select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: white;
cursor: pointer;
transition: border-color 0.15s ease;
}
.select-trigger:hover {
border-color: #94a3b8;
}
.placeholder {
color: #94a3b8;
}
.chevron {
width: 16px;
height: 16px;
color: #64748b;
transition: transform 0.15s ease;
}
.chevron.open {
transform: rotate(180deg);
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 6px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
z-index: 50;
overflow: hidden;
}
.search-wrapper {
display: flex;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid #e2e8f0;
}
.search-icon {
width: 16px;
height: 16px;
color: #94a3b8;
margin-right: 8px;
}
.search-input {
flex: 1;
border: none;
outline: none;
font-size: 14px;
}
.options-list {
max-height: 240px;
overflow-y: auto;
margin: 0;
padding: 4px 0;
list-style: none;
}
.option {
padding: 10px 12px;
cursor: pointer;
transition: background 0.1s ease;
}
.option:hover,
.option.highlighted {
background: #f1f5f9;
}
.option.selected {
background: #eff6ff;
color: #2563eb;
font-weight: 500;
}
.no-results {
padding: 16px 12px;
text-align: center;
color: #94a3b8;
}
</style>
4. IconPickerの追加
帳票に表示するアイコンを選択できるピッカーを実装した。
<!-- IconPicker.vue -->
<template>
<div class="icon-picker">
<label class="label">アイコン</label>
<div class="picker-trigger" @click="togglePicker">
<component
v-if="selectedIcon"
:is="selectedIcon.component"
class="preview-icon"
/>
<span v-else class="no-icon">未選択</span>
</div>
<div v-if="isOpen" class="picker-dropdown">
<div class="icon-grid">
<button
v-for="icon in availableIcons"
:key="icon.name"
class="icon-option"
:class="{ selected: icon.name === modelValue }"
@click="selectIcon(icon.name)"
:title="icon.label"
>
<component :is="icon.component" class="icon" />
</button>
</div>
<button class="clear-button" @click="clearIcon">
アイコンをクリア
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import {
CurrencyYenIcon,
BanknotesIcon,
CreditCardIcon,
BuildingOfficeIcon,
DocumentTextIcon,
ReceiptPercentIcon,
ShoppingCartIcon,
TruckIcon,
WrenchIcon,
ComputerDesktopIcon,
PhoneIcon,
EnvelopeIcon,
HomeIcon,
UserIcon,
UsersIcon,
BriefcaseIcon,
ChartBarIcon,
CalculatorIcon,
ClipboardDocumentIcon,
FolderIcon
} from '@heroicons/vue/24/outline'
const props = defineProps<{
modelValue: string | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
const isOpen = ref(false)
const availableIcons = [
{ name: 'currency-yen', label: '円マーク', component: CurrencyYenIcon },
{ name: 'banknotes', label: '紙幣', component: BanknotesIcon },
{ name: 'credit-card', label: 'クレジットカード', component: CreditCardIcon },
{ name: 'building-office', label: 'ビル', component: BuildingOfficeIcon },
{ name: 'document-text', label: 'ドキュメント', component: DocumentTextIcon },
{ name: 'receipt-percent', label: 'レシート', component: ReceiptPercentIcon },
{ name: 'shopping-cart', label: 'カート', component: ShoppingCartIcon },
{ name: 'truck', label: 'トラック', component: TruckIcon },
{ name: 'wrench', label: 'レンチ', component: WrenchIcon },
{ name: 'computer-desktop', label: 'パソコン', component: ComputerDesktopIcon },
{ name: 'phone', label: '電話', component: PhoneIcon },
{ name: 'envelope', label: '封筒', component: EnvelopeIcon },
{ name: 'home', label: '家', component: HomeIcon },
{ name: 'user', label: 'ユーザー', component: UserIcon },
{ name: 'users', label: 'グループ', component: UsersIcon },
{ name: 'briefcase', label: 'ブリーフケース', component: BriefcaseIcon },
{ name: 'chart-bar', label: 'グラフ', component: ChartBarIcon },
{ name: 'calculator', label: '電卓', component: CalculatorIcon },
{ name: 'clipboard-document', label: 'クリップボード', component: ClipboardDocumentIcon },
{ name: 'folder', label: 'フォルダ', component: FolderIcon }
]
const selectedIcon = computed(() =>
availableIcons.find(icon => icon.name === props.modelValue)
)
const togglePicker = () => {
isOpen.value = !isOpen.value
}
const selectIcon = (name: string) => {
emit('update:modelValue', name)
isOpen.value = false
}
const clearIcon = () => {
emit('update:modelValue', null)
isOpen.value = false
}
</script>
<style scoped>
.icon-picker {
position: relative;
}
.label {
display: block;
font-size: 14px;
font-weight: 500;
color: #374151;
margin-bottom: 6px;
}
.picker-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: white;
cursor: pointer;
transition: border-color 0.15s ease;
}
.picker-trigger:hover {
border-color: #94a3b8;
}
.preview-icon {
width: 24px;
height: 24px;
color: #374151;
}
.no-icon {
font-size: 12px;
color: #94a3b8;
}
.picker-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 8px;
padding: 12px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
z-index: 50;
}
.icon-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.icon-option {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: white;
cursor: pointer;
transition: all 0.15s ease;
}
.icon-option:hover {
background: #f1f5f9;
border-color: #94a3b8;
}
.icon-option.selected {
background: #eff6ff;
border-color: #2563eb;
}
.icon-option .icon {
width: 20px;
height: 20px;
color: #374151;
}
.icon-option.selected .icon {
color: #2563eb;
}
.clear-button {
width: 100%;
padding: 8px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
font-size: 13px;
color: #64748b;
cursor: pointer;
transition: background 0.15s ease;
}
.clear-button:hover {
background: #f1f5f9;
}
</style>
5. 編集モード/閲覧モードの切り替え実装
誤操作を防ぐため、閲覧モードと編集モードを明確に分離した。
<!-- VoucherSettingsView.vue -->
<template>
<div class="voucher-settings">
<div class="header">
<h1>帳票設定</h1>
<div class="mode-toggle">
<span class="mode-label">{{ isEditMode ? '編集中' : '閲覧中' }}</span>
<button
v-if="!isEditMode"
class="edit-button"
@click="enterEditMode"
>
<PencilIcon class="icon" />
編集
</button>
<template v-else>
<button
class="cancel-button"
@click="cancelEdit"
>
キャンセル
</button>
<button
class="save-button"
@click="saveChanges"
>
<CheckIcon class="icon" />
保存
</button>
</template>
</div>
</div>
<div class="settings-form" :class="{ 'view-mode': !isEditMode }">
<!-- フォームフィールド -->
<div class="field">
<label>勘定科目</label>
<input
v-model="form.accountName"
:disabled="!isEditMode"
type="text"
/>
</div>
<!-- 以下、他のフィールド... -->
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { PencilIcon, CheckIcon } from '@heroicons/vue/24/outline'
const isEditMode = ref(false)
const originalForm = ref<FormData | null>(null)
interface FormData {
accountName: string
icon: string | null
depositRules: Rule[]
withdrawalRules: Rule[]
}
const form = reactive<FormData>({
accountName: '',
icon: null,
depositRules: [],
withdrawalRules: []
})
const hasUnsavedChanges = computed(() => {
if (!originalForm.value) return false
return JSON.stringify(form) !== JSON.stringify(originalForm.value)
})
const enterEditMode = () => {
// 編集開始時に現在の状態を保存
originalForm.value = JSON.parse(JSON.stringify(form))
isEditMode.value = true
}
const cancelEdit = () => {
// 元の状態に戻す
if (originalForm.value) {
Object.assign(form, JSON.parse(JSON.stringify(originalForm.value)))
}
isEditMode.value = false
}
const saveChanges = async () => {
try {
await saveToServer(form)
originalForm.value = JSON.parse(JSON.stringify(form))
isEditMode.value = false
} catch (error) {
console.error('保存に失敗しました:', error)
}
}
</script>
<style scoped>
.mode-toggle {
display: flex;
align-items: center;
gap: 12px;
}
.mode-label {
font-size: 14px;
color: #64748b;
padding: 4px 8px;
background: #f1f5f9;
border-radius: 4px;
}
.edit-button {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: 1px solid #2563eb;
border-radius: 6px;
background: white;
color: #2563eb;
cursor: pointer;
transition: all 0.15s ease;
}
.edit-button:hover {
background: #eff6ff;
}
.cancel-button {
padding: 8px 16px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: white;
color: #64748b;
cursor: pointer;
}
.save-button {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 6px;
background: #2563eb;
color: white;
cursor: pointer;
transition: background 0.15s ease;
}
.save-button:hover {
background: #1d4ed8;
}
.settings-form.view-mode {
pointer-events: none;
opacity: 0.8;
}
.settings-form.view-mode input,
.settings-form.view-mode select,
.settings-form.view-mode button {
cursor: not-allowed;
}
</style>
6. 未保存変更の確認ダイアログ
編集中に別の科目に移動しようとした際、未保存の変更があれば確認ダイアログを表示する。
<!-- UnsavedChangesDialog.vue -->
<template>
<Teleport to="body">
<div v-if="modelValue" class="dialog-overlay" @click.self="$emit('update:modelValue', false)">
<div class="dialog">
<div class="dialog-header">
<ExclamationTriangleIcon class="warning-icon" />
<h2>未保存の変更があります</h2>
</div>
<p class="dialog-message">
変更を保存せずに移動すると、編集内容が失われます。
</p>
<div class="dialog-actions">
<button class="discard-button" @click="$emit('discard')">
保存せず移動
</button>
<button class="cancel-button" @click="$emit('update:modelValue', false)">
編集を続ける
</button>
<button class="save-button" @click="$emit('save')">
保存して移動
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ExclamationTriangleIcon } from '@heroicons/vue/24/outline'
defineProps<{
modelValue: boolean
}>()
defineEmits<{
'update:modelValue': [value: boolean]
discard: []
save: []
}>()
</script>
<style scoped>
.dialog-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
}
.dialog {
width: 100%;
max-width: 420px;
padding: 24px;
background: white;
border-radius: 12px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
.dialog-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.warning-icon {
width: 24px;
height: 24px;
color: #f59e0b;
}
.dialog-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1f2937;
}
.dialog-message {
margin: 0 0 24px;
font-size: 14px;
color: #6b7280;
line-height: 1.6;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.discard-button {
padding: 8px 16px;
border: none;
border-radius: 6px;
background: #fef2f2;
color: #dc2626;
cursor: pointer;
}
.cancel-button {
padding: 8px 16px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: white;
color: #64748b;
cursor: pointer;
}
.save-button {
padding: 8px 16px;
border: none;
border-radius: 6px;
background: #2563eb;
color: white;
cursor: pointer;
}
</style>
親コンポーネントでの使用:
<template>
<UnsavedChangesDialog
v-model="showUnsavedDialog"
@discard="handleDiscard"
@save="handleSaveAndNavigate"
/>
</template>
<script setup lang="ts">
const showUnsavedDialog = ref(false)
const pendingNavigation = ref<'previous' | 'next' | null>(null)
const handleDiscard = () => {
showUnsavedDialog.value = false
if (pendingNavigation.value === 'previous') {
selectAccount(filteredAccounts.value[currentAccountIndex.value - 1])
} else if (pendingNavigation.value === 'next') {
selectAccount(filteredAccounts.value[currentAccountIndex.value + 1])
}
pendingNavigation.value = null
}
const handleSaveAndNavigate = async () => {
await saveChanges()
showUnsavedDialog.value = false
if (pendingNavigation.value === 'previous') {
selectAccount(filteredAccounts.value[currentAccountIndex.value - 1])
} else if (pendingNavigation.value === 'next') {
selectAccount(filteredAccounts.value[currentAccountIndex.value + 1])
}
pendingNavigation.value = null
}
</script>
7. 入出金タイプの「入金時」「出金時」セクション分離
入金時と出金時のルールを明確に分離し、視認性を向上させた。
<!-- VoucherRulesSection.vue -->
<template>
<div class="rules-sections">
<!-- 入金時セクション -->
<section class="rules-section deposit">
<div class="section-header">
<ArrowDownCircleIcon class="section-icon deposit-icon" />
<h3>入金時</h3>
<span class="rule-count">{{ depositRules.length }}件</span>
</div>
<div class="rules-list">
<VoucherRuleItem
v-for="(rule, index) in depositRules"
:key="rule.id"
v-model="depositRules[index]"
:disabled="!isEditMode"
@remove="removeDepositRule(index)"
/>
<button
v-if="isEditMode"
class="add-rule-button"
@click="addDepositRule"
>
<PlusIcon class="icon" />
ルールを追加
</button>
</div>
</section>
<!-- 出金時セクション -->
<section class="rules-section withdrawal">
<div class="section-header">
<ArrowUpCircleIcon class="section-icon withdrawal-icon" />
<h3>出金時</h3>
<span class="rule-count">{{ withdrawalRules.length }}件</span>
</div>
<div class="rules-list">
<VoucherRuleItem
v-for="(rule, index) in withdrawalRules"
:key="rule.id"
v-model="withdrawalRules[index]"
:disabled="!isEditMode"
@remove="removeWithdrawalRule(index)"
/>
<button
v-if="isEditMode"
class="add-rule-button"
@click="addWithdrawalRule"
>
<PlusIcon class="icon" />
ルールを追加
</button>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import {
ArrowDownCircleIcon,
ArrowUpCircleIcon,
PlusIcon
} from '@heroicons/vue/24/outline'
interface Rule {
id: string
condition: string
voucherType: string
template: string
}
const depositRules = defineModel<Rule[]>('depositRules', { required: true })
const withdrawalRules = defineModel<Rule[]>('withdrawalRules', { required: true })
defineProps<{
isEditMode: boolean
}>()
const addDepositRule = () => {
depositRules.value.push({
id: crypto.randomUUID(),
condition: '',
voucherType: '',
template: ''
})
}
const addWithdrawalRule = () => {
withdrawalRules.value.push({
id: crypto.randomUUID(),
condition: '',
voucherType: '',
template: ''
})
}
const removeDepositRule = (index: number) => {
depositRules.value.splice(index, 1)
}
const removeWithdrawalRule = (index: number) => {
withdrawalRules.value.splice(index, 1)
}
</script>
<style scoped>
.rules-sections {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
.rules-section {
padding: 20px;
border-radius: 12px;
background: #f8fafc;
}
.rules-section.deposit {
border-left: 4px solid #10b981;
}
.rules-section.withdrawal {
border-left: 4px solid #f59e0b;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.section-icon {
width: 24px;
height: 24px;
}
.deposit-icon {
color: #10b981;
}
.withdrawal-icon {
color: #f59e0b;
}
.section-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
.rule-count {
margin-left: auto;
padding: 2px 8px;
font-size: 12px;
background: white;
border-radius: 10px;
color: #64748b;
}
.rules-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.add-rule-button {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px;
border: 2px dashed #cbd5e1;
border-radius: 8px;
background: transparent;
color: #64748b;
cursor: pointer;
transition: all 0.15s ease;
}
.add-rule-button:hover {
border-color: #94a3b8;
color: #475569;
}
.add-rule-button .icon {
width: 16px;
height: 16px;
}
</style>
8. デザインシステムとの統一
他の画面と一貫したデザインを適用した。
青いヘッダー
<template>
<header class="page-header">
<h1>帳票設定</h1>
<p class="subtitle">勘定科目ごとの帳票出力ルールを設定します</p>
</header>
</template>
<style scoped>
.page-header {
padding: 24px 32px;
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: white;
}
.page-header h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.subtitle {
margin: 8px 0 0;
font-size: 14px;
opacity: 0.9;
}
</style>
オレンジの「明細から取得」バッジ
明細データから自動取得されるフィールドを示すバッジを追加した。
<template>
<div class="field">
<label>
摘要
<span class="auto-badge">明細から取得</span>
</label>
<input :value="description" disabled />
</div>
</template>
<style scoped>
.auto-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
margin-left: 8px;
font-size: 11px;
font-weight: 500;
color: #ea580c;
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 4px;
}
</style>
9. 幅の統一問題の解決(width: 100%)
コンテナの幅がバラバラだった問題を解決した。
Before: 固定幅で不揃い
.container {
width: 800px; /* 画面サイズで見切れる */
}
.sidebar {
width: 250px;
}
.main {
width: 550px; /* 残りスペースを使い切れない */
}
After: フレックスボックスで統一
.layout {
display: flex;
width: 100%;
max-width: 1400px;
margin: 0 auto;
}
.sidebar {
flex: 0 0 280px; /* 固定幅 */
}
.main {
flex: 1; /* 残りを全て使う */
min-width: 0; /* オーバーフロー防止 */
}
.form-field input,
.form-field select {
width: 100%; /* 親要素に合わせる */
}
全体のレイアウト統一:
<template>
<div class="voucher-settings-page">
<header class="page-header">
<!-- ヘッダー -->
</header>
<div class="page-body">
<aside class="sidebar">
<!-- カテゴリ・科目一覧 -->
</aside>
<main class="main-content">
<NavigationBar
:current-index="currentAccountIndex"
:total-count="filteredAccounts.length"
@previous="goToPreviousAccount"
@next="goToNextAccount"
/>
<div class="settings-form">
<!-- 設定フォーム -->
</div>
</main>
</div>
</div>
</template>
<style scoped>
.voucher-settings-page {
display: flex;
flex-direction: column;
min-height: 100vh;
background: #f8fafc;
}
.page-header {
flex-shrink: 0;
}
.page-body {
display: flex;
flex: 1;
width: 100%;
max-width: 1400px;
margin: 0 auto;
padding: 24px;
gap: 24px;
}
.sidebar {
flex: 0 0 320px;
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.main-content {
flex: 1;
min-width: 0;
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.settings-form {
padding: 24px;
}
</style>
まとめ
今回の改善で、帳票設定画面の使い勝手とデザインの一貫性が向上した。
改善点:
- 中カテゴリ列の削除でUIがシンプルに
- NavigationBarで科目間の移動がスムーズに
- SearchableSelectで目的の科目をすぐに見つけられる
- IconPickerでアイコン選択が可能に
- 編集/閲覧モードの分離で誤操作を防止
- 未保存変更の確認ダイアログで変更の消失を防止
- 入金/出金セクションの分離で視認性が向上
- デザインシステムとの統一で一貫した見た目に
- 幅の統一でレイアウトが整った
作成したコンポーネント:
NavigationBar.vue- 前へ/次へナビゲーションSearchableSelect.vue- 検索可能なセレクトボックスIconPicker.vue- アイコン選択ピッカーUnsavedChangesDialog.vue- 未保存確認ダイアログVoucherRulesSection.vue- 入出金ルールセクション
設計方針:
- コンポーネントの単一責任
- v-modelによる双方向バインディング
- Heroiconsの統一使用
- Tailwind風のユーティリティスタイル