• #Vue.js
  • #UI設計
  • #UX改善
  • #コンポーネント設計
  • #TypeScript
  • #デザインシステム
開発tax-assistantメモ

帳票設定UIの大幅改善

tax-assistantプロジェクトの帳票設定画面(VoucherSettingsView)を大幅に改善した。勘定科目に対する帳票出力ルールを設定する画面で、使いやすさとデザインの一貫性を両立させた。

背景と課題

帳票設定画面には以下の課題があった。

  1. 中カテゴリ(direction)列が不要 - 入金/出金の方向を示す列があったが、実運用では使われていなかった
  2. ナビゲーションがない - 勘定科目間の移動が面倒で、一覧に戻る必要があった
  3. 勘定科目の検索ができない - 科目数が多く、目的の科目を探すのに時間がかかった
  4. アイコン選択UIがない - アイコンを選ぶ手段がなかった
  5. 編集中の離脱で変更が失われる - 未保存のまま別の科目に移動すると変更が消えた
  6. 入出金タイプが混在 - 入金時と出金時のルールが同じセクションに表示されていた
  7. デザインが統一されていない - 他の画面と色やスタイルが異なっていた
  8. 幅が固定されていない - コンテナの幅がバラバラで見た目が悪かった

実装内容

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風のユーティリティスタイル