Skip to content

Admin Panel Full Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Extend the admin panel with dashboard, user management, and reference data CRUD.

Architecture: Three new feature slices (admin-dashboard, admin-users, admin-references) under FSD features layer. Each has composables for API + state and UI components for modals/actions. Pages consume features via auto-imported composables. All admin pages are SPA (ssr: false) with admin middleware.

Tech Stack: Nuxt 4, Vue 3.5, Nuxt UI v3, Pinia, VueUse, Zod, Vitest

Design doc: docs/plans/2026-02-08-admin-panel-full-design.md


Phase 1: Dashboard

Task 1: Update sidebar navigation

Files:

  • Modify: app/layouts/admin.vue
  • Modify: i18n/locales/ru.json

Step 1: Add i18n keys for new nav items

In i18n/locales/ru.json, add to the "admin" block:

json
"dashboard": "Дашборд",
"users": "Пользователи",
"references": "Справочники"

Step 2: Update navItems in admin layout

In app/layouts/admin.vue, replace the navItems computed:

ts
const navItems = computed(() => [
  { label: t('admin.dashboard'), to: '/admin', icon: 'i-heroicons-chart-bar-square' },
  { label: t('admin.products'), to: '/admin/products', icon: 'i-lucide-package' },
  { label: t('admin.users'), to: '/admin/users', icon: 'i-heroicons-users' },
  { label: t('admin.references'), to: '/admin/references', icon: 'i-heroicons-book-open' },
])

Step 3: Remove the redirect route rule

In nuxt.config.ts, remove the line:

ts
'/admin': { redirect: '/admin/products' },

The /admin route will now render the dashboard page instead of redirecting.

Step 4: Verify sidebar renders correctly

Run: npm run dev and navigate to /admin. Confirm all 4 nav items appear in sidebar. Active item should highlight.

Step 5: Commit

bash
git add app/layouts/admin.vue i18n/locales/ru.json nuxt.config.ts
git commit -m "feat(admin): add dashboard, users, references to sidebar nav"

Task 2: Create useAdminStats composable with tests

Files:

  • Create: app/features/admin-dashboard/composables/useAdminStats.ts
  • Create: app/features/admin-dashboard/composables/useAdminStats.test.ts

Step 1: Write the failing test

Create app/features/admin-dashboard/composables/useAdminStats.test.ts:

ts
import { mockNuxtImport } from '@nuxt/test-utils/runtime'
import { describe, it, expect, vi, beforeEach } from 'vitest'

const mockGet = vi.fn()

mockNuxtImport('useApi', () => () => ({
  get: mockGet,
}))

const { useAdminStats } = await import('./useAdminStats')

describe('useAdminStats', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('returns expected composable shape', () => {
    mockGet.mockResolvedValue({ data: {} })
    const result = useAdminStats()
    expect(result).toHaveProperty('generalStats')
    expect(result).toHaveProperty('userStats')
    expect(result).toHaveProperty('isLoading')
    expect(result).toHaveProperty('error')
    expect(result).toHaveProperty('refresh')
  })

  it('fetches general stats from /admin/stats', async () => {
    const statsData = {
      total_users: 21,
      total_products: 10,
      products_by_status: [
        { status: 'draft', count: 6 },
        { status: 'pending', count: 4 },
      ],
      new_users_today: 5,
      new_products_today: 9,
    }
    mockGet.mockResolvedValue({ data: statsData })

    const { generalStats, fetchStats } = useAdminStats()
    await fetchStats()

    expect(mockGet).toHaveBeenCalledWith('/admin/stats')
    expect(generalStats.value).toEqual(statsData)
  })

  it('fetches user stats from /admin/stats/users', async () => {
    const userData = {
      total: 21,
      active: 21,
      verified_email: 3,
      by_account_type: [{ account_type: 'personal', count: 21 }],
      new_this_week: 21,
      new_this_month: 21,
    }
    mockGet.mockResolvedValue({ data: userData })

    const { userStats, fetchUserStats } = useAdminStats()
    await fetchUserStats()

    expect(mockGet).toHaveBeenCalledWith('/admin/stats/users')
    expect(userStats.value).toEqual(userData)
  })

  it('sets error on fetch failure', async () => {
    mockGet.mockRejectedValue(new Error('Network error'))

    const { error, fetchStats } = useAdminStats()
    await fetchStats()

    expect(error.value).toBeTruthy()
  })
})

Step 2: Run test to verify it fails

Run: npx vitest run app/features/admin-dashboard/composables/useAdminStats.test.ts

Expected: FAIL — module not found.

Step 3: Write minimal implementation

Create app/features/admin-dashboard/composables/useAdminStats.ts:

ts
import { ref } from 'vue'

interface ProductsByStatus {
  status: string
  count: number
}

interface AccountTypeCount {
  account_type: string
  count: number
}

export interface GeneralStats {
  total_users: number
  total_products: number
  products_by_status: ProductsByStatus[]
  new_users_today: number
  new_products_today: number
}

export interface UserStats {
  total: number
  active: number
  verified_email: number
  by_account_type: AccountTypeCount[]
  new_this_week: number
  new_this_month: number
}

export function useAdminStats() {
  const api = useApi()
  const generalStats = ref<GeneralStats | null>(null)
  const userStats = ref<UserStats | null>(null)
  const isLoading = ref(false)
  const error = ref<Error | null>(null)

  async function fetchStats() {
    try {
      const response = await api.get<{ data: GeneralStats }>('/admin/stats')
      generalStats.value = response.data
    }
    catch (e: unknown) {
      error.value = e as Error
    }
  }

  async function fetchUserStats() {
    try {
      const response = await api.get<{ data: UserStats }>('/admin/stats/users')
      userStats.value = response.data
    }
    catch (e: unknown) {
      error.value = e as Error
    }
  }

  async function refresh() {
    isLoading.value = true
    error.value = null
    await Promise.all([fetchStats(), fetchUserStats()])
    isLoading.value = false
  }

  // Auto-fetch on init
  refresh()

  return {
    generalStats,
    userStats,
    isLoading,
    error,
    refresh,
    fetchStats,
    fetchUserStats,
  }
}

Step 4: Run test to verify it passes

Run: npx vitest run app/features/admin-dashboard/composables/useAdminStats.test.ts

Expected: PASS

Step 5: Commit

bash
git add app/features/admin-dashboard/
git commit -m "feat(admin): add useAdminStats composable with tests"

Task 3: Create StatCard component

Files:

  • Create: app/features/admin-dashboard/ui/StatCard.vue

Step 1: Create StatCard component

Create app/features/admin-dashboard/ui/StatCard.vue:

html
<script setup lang="ts">
defineProps<{
  title: string
  value: number | string
  subtitle?: string
  icon?: string
  to?: string
}>()
</script>

<template>
  <component
    :is="to ? resolveComponent('NuxtLink') : 'div'"
    :to="to"
    class="border border-[var(--ui-border)] rounded-lg p-4 bg-[var(--ui-bg)] hover:bg-[var(--ui-bg-elevated)] transition-colors"
    :class="{ 'cursor-pointer': to }"
  >
    <div class="flex items-center gap-3 mb-2">
      <UIcon v-if="icon" :name="icon" class="text-xl text-[var(--ui-text-muted)]" />
      <span class="text-sm text-[var(--ui-text-muted)]">&#123;&#123; title }}</span>
    </div>
    <div class="text-3xl font-bold text-[var(--ui-text)]">&#123;&#123; value }}</div>
    <div v-if="subtitle" class="text-sm text-[var(--ui-text-dimmed)] mt-1">&#123;&#123; subtitle }}</div>
  </component>
</template>

Step 2: Verify it renders

This is a presentational component. It will be tested through the dashboard page integration. Check visually in the next task.

Step 3: Commit

bash
git add app/features/admin-dashboard/ui/StatCard.vue
git commit -m "feat(admin): add StatCard component for dashboard"

Task 4: Create dashboard page

Files:

  • Modify: app/pages/admin/index.vue
  • Modify: i18n/locales/ru.json

Step 1: Add i18n keys

In i18n/locales/ru.json, add to "admin" block:

json
"totalUsers": "Пользователи",
"totalProducts": "Объявления",
"pendingModeration": "На модерации",
"drafts": "Черновики",
"newToday": "новых сегодня: {count}",
"usersSection": "Пользователи",
"productsSection": "Товары по статусам",
"activeUsers": "Активных",
"verifiedEmail": "Подтвердили email",
"accountType": "По типу",
"newThisWeek": "Новых за неделю",
"newThisMonth": "Новых за месяц",
"personal": "Физ. лицо",
"business": "Бизнес"

Step 2: Replace dashboard page

Replace app/pages/admin/index.vue contents:

html
<script setup lang="ts">
definePageMeta({ layout: 'admin', middleware: 'admin' })
useSeoMeta({ title: 'Дашборд', robots: 'noindex, nofollow' })

const { t } = useI18n()
const { generalStats, userStats, isLoading } = useAdminStats()

const statusLabels: Record<string, string> = {
  draft: t('admin.draft'),
  pending: t('admin.pending'),
  active: t('admin.active'),
  rejected: t('admin.rejected'),
  archived: t('admin.archived'),
  sold: t('admin.sold'),
}

function getStatusCount(status: string): number {
  return generalStats.value?.products_by_status.find(s => s.status === status)?.count ?? 0
}

function getAccountTypeCount(type: string): number {
  return userStats.value?.by_account_type.find(a => a.account_type === type)?.count ?? 0
}
</script>

<template>
  <div>
    <h1 class="text-2xl font-bold mb-6">&#123;&#123; t('admin.dashboard') }}</h1>

    <!-- Top row: 4 stat cards -->
    <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
      <template v-if="isLoading">
        <USkeleton v-for="i in 4" :key="i" class="h-28 rounded-lg" />
      </template>
      <template v-else>
        <StatCard
          :title="t('admin.totalUsers')"
          :value="generalStats?.total_users ?? 0"
          :subtitle="t('admin.newToday', { count: generalStats?.new_users_today ?? 0 })"
          icon="i-heroicons-users"
        />
        <StatCard
          :title="t('admin.totalProducts')"
          :value="generalStats?.total_products ?? 0"
          :subtitle="t('admin.newToday', { count: generalStats?.new_products_today ?? 0 })"
          icon="i-lucide-package"
        />
        <StatCard
          :title="t('admin.pendingModeration')"
          :value="getStatusCount('pending')"
          icon="i-lucide-clock"
          to="/admin/products"
        />
        <StatCard
          :title="t('admin.drafts')"
          :value="getStatusCount('draft')"
          icon="i-lucide-file-edit"
        />
      </template>
    </div>

    <!-- Bottom row: 2 info blocks -->
    <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
      <!-- Users block -->
      <div class="border border-[var(--ui-border)] rounded-lg p-5">
        <h2 class="text-lg font-semibold mb-4">&#123;&#123; t('admin.usersSection') }}</h2>
        <template v-if="isLoading">
          <USkeleton v-for="i in 4" :key="i" class="h-6 mb-2" />
        </template>
        <dl v-else class="space-y-3">
          <div class="flex justify-between">
            <dt class="text-[var(--ui-text-muted)]">&#123;&#123; t('admin.activeUsers') }}</dt>
            <dd class="font-medium">&#123;&#123; userStats?.active ?? 0 }}</dd>
          </div>
          <div class="flex justify-between">
            <dt class="text-[var(--ui-text-muted)]">&#123;&#123; t('admin.verifiedEmail') }}</dt>
            <dd class="font-medium">&#123;&#123; userStats?.verified_email ?? 0 }} &#123;&#123; t('common.of', { total: userStats?.total ?? 0 }) }}</dd>
          </div>
          <div class="flex justify-between">
            <dt class="text-[var(--ui-text-muted)]">&#123;&#123; t('admin.personal') }}</dt>
            <dd class="font-medium">&#123;&#123; getAccountTypeCount('personal') }}</dd>
          </div>
          <div class="flex justify-between">
            <dt class="text-[var(--ui-text-muted)]">&#123;&#123; t('admin.business') }}</dt>
            <dd class="font-medium">&#123;&#123; getAccountTypeCount('business') }}</dd>
          </div>
          <USeparator />
          <div class="flex justify-between">
            <dt class="text-[var(--ui-text-muted)]">&#123;&#123; t('admin.newThisWeek') }}</dt>
            <dd class="font-medium">&#123;&#123; userStats?.new_this_week ?? 0 }}</dd>
          </div>
          <div class="flex justify-between">
            <dt class="text-[var(--ui-text-muted)]">&#123;&#123; t('admin.newThisMonth') }}</dt>
            <dd class="font-medium">&#123;&#123; userStats?.new_this_month ?? 0 }}</dd>
          </div>
        </dl>
      </div>

      <!-- Products by status block -->
      <div class="border border-[var(--ui-border)] rounded-lg p-5">
        <h2 class="text-lg font-semibold mb-4">&#123;&#123; t('admin.productsSection') }}</h2>
        <template v-if="isLoading">
          <USkeleton v-for="i in 4" :key="i" class="h-6 mb-2" />
        </template>
        <div v-else class="flex flex-wrap gap-2">
          <NuxtLink
            v-for="status in ['pending', 'active', 'draft', 'rejected', 'archived', 'sold']"
            :key="status"
            :to="`/admin/products?status=${status}`"
          >
            <UBadge
              :color="status === 'pending' && getStatusCount('pending') > 0 ? 'warning' : 'neutral'"
              variant="subtle"
              size="lg"
            >
              &#123;&#123; statusLabels[status] }}: &#123;&#123; getStatusCount(status) }}
            </UBadge>
          </NuxtLink>
        </div>
      </div>
    </div>
  </div>
</template>

Step 3: Add missing i18n key "of"

In i18n/locales/ru.json, add to "common":

json
"of": "из {total}"

Step 4: Verify dashboard renders

Run: npm run dev, navigate to /admin. Confirm:

  • 4 stat cards show with data from API
  • Users block shows breakdown
  • Products by status shows clickable badges
  • Skeleton loading states appear while fetching
  • Clicking "На модерации" card navigates to /admin/products

Step 5: Commit

bash
git add app/pages/admin/index.vue i18n/locales/ru.json
git commit -m "feat(admin): add dashboard page with stats cards"

Phase 2: User Management

Task 5: Create useAdminUsers composable with tests

Files:

  • Create: app/features/admin-users/composables/useAdminUsers.ts
  • Create: app/features/admin-users/composables/useAdminUsers.test.ts

Step 1: Write the failing test

Create app/features/admin-users/composables/useAdminUsers.test.ts:

ts
import { ref } from 'vue'

import { mockNuxtImport } from '@nuxt/test-utils/runtime'
import { describe, it, expect, vi, beforeEach } from 'vitest'

const mockGet = vi.fn()
const mockPut = vi.fn()

mockNuxtImport('useApi', () => () => ({
  get: mockGet,
  put: mockPut,
}))

const mockRefresh = vi.fn()

mockNuxtImport('useCursorPagination', () => (fetcher: (...args: unknown[]) => unknown) => {
  const items = ref([])
  const hasMore = ref(false)
  const isLoading = ref(false)
  const error = ref(null)
  return {
    items,
    hasMore,
    isLoading,
    error,
    refresh: mockRefresh.mockImplementation(() => fetcher({ limit: 20 })),
    loadMore: vi.fn(),
  }
})

const { useAdminUsers } = await import('./useAdminUsers')

describe('useAdminUsers', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    mockGet.mockResolvedValue({ data: [], meta: { has_more: false, next_cursor: null } })
  })

  it('returns expected composable shape', () => {
    const result = useAdminUsers()
    expect(result).toHaveProperty('items')
    expect(result).toHaveProperty('hasMore')
    expect(result).toHaveProperty('isLoading')
    expect(result).toHaveProperty('error')
    expect(result).toHaveProperty('searchQuery')
    expect(result).toHaveProperty('accountTypeFilter')
    expect(result).toHaveProperty('statusFilter')
    expect(result).toHaveProperty('refresh')
    expect(result).toHaveProperty('loadMore')
    expect(result).toHaveProperty('fetchUser')
    expect(result).toHaveProperty('toggleUserActive')
  })

  it('fetches a single user by id', async () => {
    const mockUser = { id: 5, display_name: 'Test User' }
    mockGet.mockResolvedValue({ data: mockUser })
    const { fetchUser } = useAdminUsers()
    const result = await fetchUser(5)
    expect(mockGet).toHaveBeenCalledWith('/admin/users/5')
    expect(result).toEqual(mockUser)
  })

  it('toggles user active status', async () => {
    mockPut.mockResolvedValue({ data: { id: 5, is_active: false } })
    const { toggleUserActive } = useAdminUsers()
    await toggleUserActive(5, false)
    expect(mockPut).toHaveBeenCalledWith('/admin/users/5', { is_active: false })
  })

  it('calls refresh after toggling user status', async () => {
    mockPut.mockResolvedValue({ data: { id: 5, is_active: false } })
    const { toggleUserActive } = useAdminUsers()
    await toggleUserActive(5, false)
    expect(mockRefresh).toHaveBeenCalled()
  })
})

Step 2: Run test to verify it fails

Run: npx vitest run app/features/admin-users/composables/useAdminUsers.test.ts

Expected: FAIL — module not found.

Step 3: Write minimal implementation

Create app/features/admin-users/composables/useAdminUsers.ts:

ts
import { ref, watch } from 'vue'

import { useDebounceFn } from '@vueuse/core'

import type { User } from '~/entities/user/model/user.schema'
import type { ApiItemResponse, ApiListResponse } from '~/shared/api/types'

export function useAdminUsers() {
  const api = useApi()

  const searchQuery = ref('')
  const accountTypeFilter = ref<string | null>(null)
  const statusFilter = ref<string | null>(null)

  const { items, hasMore, isLoading, error, refresh, loadMore } = useCursorPagination<User>(
    (params: Record<string, unknown>) => {
      const query: Record<string, unknown> = { ...params }
      if (searchQuery.value) query.search = searchQuery.value
      if (accountTypeFilter.value) query.account_type = accountTypeFilter.value
      if (statusFilter.value === 'active') query.is_active = true
      if (statusFilter.value === 'blocked') query.is_active = false
      return api.get<ApiListResponse<User>>('/admin/users', query)
    },
    {},
    { limit: 20 },
  )

  const debouncedRefresh = useDebounceFn(() => refresh(), 300)

  watch(searchQuery, () => debouncedRefresh())
  watch([accountTypeFilter, statusFilter], () => refresh())

  // Initial fetch
  refresh()

  async function fetchUser(id: number) {
    const response = await api.get<ApiItemResponse<User>>(`/admin/users/${id}`)
    return response.data
  }

  async function toggleUserActive(id: number, isActive: boolean) {
    await api.put(`/admin/users/${id}`, { is_active: isActive })
    refresh()
  }

  return {
    items,
    hasMore,
    isLoading,
    error,
    searchQuery,
    accountTypeFilter,
    statusFilter,
    refresh,
    loadMore,
    fetchUser,
    toggleUserActive,
  }
}

Step 4: Run test to verify it passes

Run: npx vitest run app/features/admin-users/composables/useAdminUsers.test.ts

Expected: PASS

Step 5: Commit

bash
git add app/features/admin-users/
git commit -m "feat(admin): add useAdminUsers composable with tests"

Task 6: Create UserBlockAction component

Files:

  • Create: app/features/admin-users/ui/UserBlockAction.vue

Step 1: Add i18n keys

In i18n/locales/ru.json, add to "admin":

json
"blockUser": "Заблокировать",
"unblockUser": "Разблокировать",
"blockConfirm": "Заблокировать пользователя {name}?",
"unblockConfirm": "Разблокировать пользователя {name}?",
"blockSuccess": "Пользователь заблокирован",
"unblockSuccess": "Пользователь разблокирован",
"userStatus": "Статус",
"userActive": "Активен",
"userBlocked": "Заблокирован"

Step 2: Create component

Create app/features/admin-users/ui/UserBlockAction.vue:

html
<script setup lang="ts">
const props = defineProps<{
  userId: number
  userName: string
  isActive: boolean
  loading?: boolean
}>()

const emit = defineEmits<{
  toggle: [id: number, isActive: boolean]
}>()

const { t } = useI18n()
const confirmOpen = ref(false)
const toggling = ref(false)

function openConfirm() {
  confirmOpen.value = true
}

function handleToggle() {
  toggling.value = true
  emit('toggle', props.userId, !props.isActive)
  confirmOpen.value = false
}
</script>

<template>
  <div>
    <UButton
      v-if="isActive"
      color="error"
      variant="outline"
      icon="i-lucide-ban"
      :label="t('admin.blockUser')"
      :loading="toggling"
      :disabled="loading"
      @click="openConfirm"
    />
    <UButton
      v-else
      color="primary"
      variant="outline"
      icon="i-lucide-check-circle"
      :label="t('admin.unblockUser')"
      :loading="toggling"
      :disabled="loading"
      @click="openConfirm"
    />

    <UModal v-model:open="confirmOpen">
      <template #header>
        <h3 class="text-lg font-semibold">
          &#123;&#123; isActive ? t('admin.blockConfirm', { name: userName }) : t('admin.unblockConfirm', { name: userName }) }}
        </h3>
      </template>
      <template #footer>
        <div class="flex justify-end gap-3">
          <UButton
            :label="t('common.cancel')"
            variant="ghost"
            @click="confirmOpen = false"
          />
          <UButton
            :color="isActive ? 'error' : 'primary'"
            :label="isActive ? t('admin.blockUser') : t('admin.unblockUser')"
            :loading="toggling"
            @click="handleToggle"
          />
        </div>
      </template>
    </UModal>
  </div>
</template>

Step 3: Commit

bash
git add app/features/admin-users/ui/UserBlockAction.vue i18n/locales/ru.json
git commit -m "feat(admin): add UserBlockAction component with confirm modal"

Task 7: Create users list page

Files:

  • Create: app/pages/admin/users/index.vue

Step 1: Add i18n keys

In i18n/locales/ru.json, add to "admin":

json
"noUsers": "Нет пользователей",
"allStatuses": "Все статусы",
"allTypes": "Все типы",
"searchUsers": "Поиск по email или имени...",
"email": "Email",
"type": "Тип",
"emailVerified": "Email подтверждён",
"registrationDate": "Регистрация"

Step 2: Create users list page

Create app/pages/admin/users/index.vue:

html
<script setup lang="ts">
definePageMeta({ layout: 'admin', middleware: 'admin' })
useSeoMeta({ title: 'Пользователи', robots: 'noindex, nofollow' })

const { t } = useI18n()
const router = useRouter()

const {
  items,
  hasMore,
  isLoading,
  searchQuery,
  accountTypeFilter,
  statusFilter,
  loadMore,
} = useAdminUsers()

const accountTypeOptions = [
  { label: t('admin.allTypes'), value: null },
  { label: t('admin.personal'), value: 'personal' },
  { label: t('admin.business'), value: 'business' },
]

const statusOptions = [
  { label: t('admin.allStatuses'), value: null },
  { label: t('admin.userActive'), value: 'active' },
  { label: t('admin.userBlocked'), value: 'blocked' },
]

const formattedDate = (date: string) =>
  new Date(date).toLocaleDateString('ru-RU', {
    day: 'numeric',
    month: 'short',
    year: 'numeric',
  })

function goToUser(id: number) {
  router.push(`/admin/users/${id}`)
}
</script>

<template>
  <div>
    <h1 class="text-2xl font-bold mb-6">&#123;&#123; t('admin.users') }}</h1>

    <!-- Filters -->
    <div class="flex flex-col sm:flex-row gap-3 mb-6">
      <UInput
        v-model="searchQuery"
        :placeholder="t('admin.searchUsers')"
        icon="i-lucide-search"
        class="flex-1"
      />
      <USelectMenu
        v-model="accountTypeFilter"
        :items="accountTypeOptions"
        value-key="value"
        class="w-full sm:w-44"
      />
      <USelectMenu
        v-model="statusFilter"
        :items="statusOptions"
        value-key="value"
        class="w-full sm:w-44"
      />
    </div>

    <!-- Empty state -->
    <div v-if="!isLoading && items.length === 0" class="text-center py-12">
      <p class="text-[var(--ui-text-muted)]">&#123;&#123; t('admin.noUsers') }}</p>
    </div>

    <!-- Desktop table -->
    <div class="hidden md:block">
      <div class="border border-[var(--ui-border)] rounded-lg overflow-hidden">
        <table class="w-full">
          <thead class="bg-[var(--ui-bg-elevated)]">
            <tr>
              <th class="px-4 py-3 text-left text-sm font-medium text-[var(--ui-text-muted)]">&#123;&#123; t('auth.displayName') }}</th>
              <th class="px-4 py-3 text-left text-sm font-medium text-[var(--ui-text-muted)]">&#123;&#123; t('admin.email') }}</th>
              <th class="px-4 py-3 text-left text-sm font-medium text-[var(--ui-text-muted)]">&#123;&#123; t('admin.type') }}</th>
              <th class="px-4 py-3 text-center text-sm font-medium text-[var(--ui-text-muted)]">&#123;&#123; t('admin.emailVerified') }}</th>
              <th class="px-4 py-3 text-left text-sm font-medium text-[var(--ui-text-muted)]">&#123;&#123; t('admin.userStatus') }}</th>
              <th class="px-4 py-3 text-left text-sm font-medium text-[var(--ui-text-muted)]">&#123;&#123; t('admin.registrationDate') }}</th>
            </tr>
          </thead>
          <tbody class="divide-y divide-[var(--ui-border)]">
            <tr
              v-for="user in items"
              :key="user.id"
              class="hover:bg-[var(--ui-bg-elevated)] cursor-pointer"
              @click="goToUser(user.id)"
            >
              <td class="px-4 py-3">
                <div class="flex items-center gap-3">
                  <UAvatar :src="user.avatar_url ?? undefined" :alt="user.display_name" size="sm" />
                  <span class="text-sm font-medium">&#123;&#123; user.display_name }}</span>
                </div>
              </td>
              <td class="px-4 py-3 text-sm">&#123;&#123; user.email }}</td>
              <td class="px-4 py-3">
                <UBadge :color="user.account_type === 'business' ? 'info' : 'neutral'" variant="subtle" size="sm">
                  &#123;&#123; user.account_type === 'business' ? t('admin.business') : t('admin.personal') }}
                </UBadge>
              </td>
              <td class="px-4 py-3 text-center">
                <UIcon
                  :name="user.email_verified ? 'i-lucide-check-circle' : 'i-lucide-x-circle'"
                  :class="user.email_verified ? 'text-green-500' : 'text-[var(--ui-text-dimmed)]'"
                />
              </td>
              <td class="px-4 py-3">
                <UBadge :color="user.is_active ? 'success' : 'error'" variant="subtle" size="sm">
                  &#123;&#123; user.is_active ? t('admin.userActive') : t('admin.userBlocked') }}
                </UBadge>
              </td>
              <td class="px-4 py-3 text-sm text-[var(--ui-text-muted)]">&#123;&#123; formattedDate(user.created_at) }}</td>
            </tr>
          </tbody>
        </table>
      </div>
    </div>

    <!-- Mobile cards -->
    <div class="md:hidden grid grid-cols-1 gap-3">
      <div
        v-for="user in items"
        :key="user.id"
        class="border border-[var(--ui-border)] rounded-lg p-3 cursor-pointer"
        @click="goToUser(user.id)"
      >
        <div class="flex items-center gap-3">
          <UAvatar :src="user.avatar_url ?? undefined" :alt="user.display_name" size="sm" />
          <div class="min-w-0 flex-1">
            <div class="flex items-center justify-between gap-2">
              <span class="text-sm font-medium truncate">&#123;&#123; user.display_name }}</span>
              <UBadge :color="user.is_active ? 'success' : 'error'" variant="subtle" size="xs">
                &#123;&#123; user.is_active ? t('admin.userActive') : t('admin.userBlocked') }}
              </UBadge>
            </div>
            <div class="text-xs text-[var(--ui-text-muted)] mt-0.5">&#123;&#123; user.email }}</div>
            <div class="text-xs text-[var(--ui-text-dimmed)] mt-0.5">&#123;&#123; formattedDate(user.created_at) }}</div>
          </div>
        </div>
      </div>
    </div>

    <!-- Load more -->
    <div v-if="hasMore" class="text-center mt-6">
      <UButton variant="outline" :loading="isLoading" @click="loadMore">
        &#123;&#123; t('common.loadMore') }}
      </UButton>
    </div>
  </div>
</template>

Step 3: Verify page renders

Run: npm run dev, navigate to /admin/users. Confirm table renders with users. Test filters and search.

Step 4: Commit

bash
git add app/pages/admin/users/index.vue i18n/locales/ru.json
git commit -m "feat(admin): add users list page with filters"

Task 8: Create user profile page

Files:

  • Create: app/pages/admin/users/[id].vue

Step 1: Add i18n keys

In i18n/locales/ru.json, add to "admin":

json
"userProfile": "Профиль пользователя",
"backToUsers": "К пользователям",
"contactInfo": "Контактная информация",
"accountInfo": "Данные аккаунта",
"statistics": "Статистика",
"businessProfile": "Бизнес-профиль",
"companyName": "Название компании",
"inn": "ИНН",
"companyAddress": "Адрес компании",
"website": "Вебсайт",
"workingHours": "Часы работы",
"businessVerified": "Бизнес верифицирован",
"rating": "Рейтинг",
"reviewsCount": "Отзывов",
"notSpecified": "Не указано"

Step 2: Create user profile page

Create app/pages/admin/users/[id].vue:

html
<script setup lang="ts">
import type { User } from '~/entities/user/model/user.schema'

definePageMeta({ layout: 'admin', middleware: 'admin' })
useSeoMeta({ title: 'Профиль пользователя', robots: 'noindex, nofollow' })

const { t } = useI18n()
const route = useRoute()
const toast = useToast()

const { fetchUser, toggleUserActive } = useAdminUsers()

const userId = Number(route.params.id)
const user = ref<User | null>(null)
const loading = ref(true)
const actionLoading = ref(false)

async function load() {
  loading.value = true
  try {
    user.value = await fetchUser(userId)
  }
  catch {
    user.value = null
  }
  finally {
    loading.value = false
  }
}

async function handleToggle(_id: number, isActive: boolean) {
  actionLoading.value = true
  try {
    await toggleUserActive(userId, isActive)
    toast.add({
      title: isActive ? t('admin.unblockSuccess') : t('admin.blockSuccess'),
      color: 'success',
    })
    await load()
  }
  catch {
    toast.add({ title: 'Error', color: 'error' })
  }
  finally {
    actionLoading.value = false
  }
}

const formattedDate = (date: string) =>
  new Date(date).toLocaleDateString('ru-RU', {
    day: 'numeric',
    month: 'long',
    year: 'numeric',
  })

load()
</script>

<template>
  <div>
    <UButton
      :label="t('admin.backToUsers')"
      variant="ghost"
      icon="i-lucide-arrow-left"
      to="/admin/users"
      class="mb-4"
    />

    <div v-if="loading" class="space-y-4">
      <USkeleton class="h-8 w-64" />
      <USkeleton class="h-48" />
    </div>

    <template v-else-if="user">
      <!-- Header -->
      <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
        <div class="flex items-center gap-4">
          <UAvatar :src="user.avatar_url ?? undefined" :alt="user.display_name" size="lg" />
          <div>
            <h1 class="text-2xl font-bold">&#123;&#123; user.display_name }}</h1>
            <div class="flex items-center gap-2 mt-1">
              <UBadge :color="user.is_active ? 'success' : 'error'" variant="subtle" size="sm">
                &#123;&#123; user.is_active ? t('admin.userActive') : t('admin.userBlocked') }}
              </UBadge>
              <UBadge :color="user.account_type === 'business' ? 'info' : 'neutral'" variant="subtle" size="sm">
                &#123;&#123; user.account_type === 'business' ? t('admin.business') : t('admin.personal') }}
              </UBadge>
            </div>
          </div>
        </div>
        <UserBlockAction
          :user-id="user.id"
          :user-name="user.display_name"
          :is-active="user.is_active"
          :loading="actionLoading"
          @toggle="handleToggle"
        />
      </div>

      <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
        <!-- Contact info -->
        <div class="border border-[var(--ui-border)] rounded-lg p-5">
          <h2 class="text-lg font-semibold mb-4">&#123;&#123; t('admin.contactInfo') }}</h2>
          <dl class="space-y-3">
            <div class="flex justify-between">
              <dt class="text-[var(--ui-text-muted)]">&#123;&#123; t('admin.email') }}</dt>
              <dd class="font-medium flex items-center gap-2">
                &#123;&#123; user.email }}
                <UIcon
                  :name="user.email_verified ? 'i-lucide-check-circle' : 'i-lucide-x-circle'"
                  :class="user.email_verified ? 'text-green-500' : 'text-[var(--ui-text-dimmed)]'"
                  class="text-lg"
                />
              </dd>
            </div>
            <div class="flex justify-between">
              <dt class="text-[var(--ui-text-muted)]">&#123;&#123; t('auth.phone') }}</dt>
              <dd class="font-medium">&#123;&#123; user.phone ?? t('admin.notSpecified') }}</dd>
            </div>
          </dl>
        </div>

        <!-- Account info -->
        <div class="border border-[var(--ui-border)] rounded-lg p-5">
          <h2 class="text-lg font-semibold mb-4">&#123;&#123; t('admin.accountInfo') }}</h2>
          <dl class="space-y-3">
            <div class="flex justify-between">
              <dt class="text-[var(--ui-text-muted)]">ID</dt>
              <dd class="font-medium">&#123;&#123; user.id }}</dd>
            </div>
            <div class="flex justify-between">
              <dt class="text-[var(--ui-text-muted)]">&#123;&#123; t('admin.registrationDate') }}</dt>
              <dd class="font-medium">&#123;&#123; formattedDate(user.created_at) }}</dd>
            </div>
            <div class="flex justify-between">
              <dt class="text-[var(--ui-text-muted)]">&#123;&#123; t('admin.type') }}</dt>
              <dd class="font-medium">&#123;&#123; user.account_type === 'business' ? t('admin.business') : t('admin.personal') }}</dd>
            </div>
          </dl>
        </div>

        <!-- Statistics -->
        <div class="border border-[var(--ui-border)] rounded-lg p-5">
          <h2 class="text-lg font-semibold mb-4">&#123;&#123; t('admin.statistics') }}</h2>
          <dl class="space-y-3">
            <div class="flex justify-between">
              <dt class="text-[var(--ui-text-muted)]">&#123;&#123; t('admin.products') }}</dt>
              <dd class="font-medium">&#123;&#123; user.products_count }}</dd>
            </div>
            <div class="flex justify-between">
              <dt class="text-[var(--ui-text-muted)]">&#123;&#123; t('admin.rating') }}</dt>
              <dd class="font-medium">&#123;&#123; user.rating }}</dd>
            </div>
            <div class="flex justify-between">
              <dt class="text-[var(--ui-text-muted)]">&#123;&#123; t('admin.reviewsCount') }}</dt>
              <dd class="font-medium">&#123;&#123; user.reviews_count }}</dd>
            </div>
          </dl>
        </div>
      </div>
    </template>
  </div>
</template>

Step 3: Verify page renders

Run: npm run dev, navigate to /admin/users, click on a user. Confirm profile displays and block/unblock action works.

Step 4: Commit

bash
git add app/pages/admin/users/ i18n/locales/ru.json
git commit -m "feat(admin): add user profile page with block/unblock"

Phase 3: Reference Data CRUD

Task 9: Create useAdminCars composable with tests

Files:

  • Create: app/features/admin-references/composables/useAdminCars.ts
  • Create: app/features/admin-references/composables/useAdminCars.test.ts

Step 1: Write the failing test

Create app/features/admin-references/composables/useAdminCars.test.ts:

ts
import { mockNuxtImport } from '@nuxt/test-utils/runtime'
import { describe, it, expect, vi, beforeEach } from 'vitest'

const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()

mockNuxtImport('useApi', () => () => ({
  get: mockGet,
  post: mockPost,
  put: mockPut,
  delete: mockDelete,
}))

const { useAdminCars } = await import('./useAdminCars')

describe('useAdminCars', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    mockGet.mockResolvedValue({ data: [] })
  })

  it('returns expected composable shape', () => {
    const result = useAdminCars()
    expect(result).toHaveProperty('makes')
    expect(result).toHaveProperty('models')
    expect(result).toHaveProperty('generations')
    expect(result).toHaveProperty('selectedMakeId')
    expect(result).toHaveProperty('selectedModelId')
    expect(result).toHaveProperty('isLoading')
    expect(result).toHaveProperty('fetchMakes')
    expect(result).toHaveProperty('createMake')
    expect(result).toHaveProperty('updateMake')
    expect(result).toHaveProperty('deleteMake')
    expect(result).toHaveProperty('createModel')
    expect(result).toHaveProperty('updateModel')
    expect(result).toHaveProperty('deleteModel')
    expect(result).toHaveProperty('createGeneration')
    expect(result).toHaveProperty('updateGeneration')
    expect(result).toHaveProperty('deleteGeneration')
  })

  it('fetches makes from /store/cars/makes', async () => {
    const makesData = [{ id: 1, name: 'BMW', slug: 'bmw', logo_url: null, is_popular: true }]
    mockGet.mockResolvedValue({ data: makesData })
    const { fetchMakes, makes } = useAdminCars()
    await fetchMakes()
    expect(mockGet).toHaveBeenCalledWith('/store/cars/makes')
    expect(makes.value).toEqual(makesData)
  })

  it('creates a make via POST /admin/cars/makes', async () => {
    const newMake = { name: 'Audi', slug: 'audi', is_popular: false }
    mockPost.mockResolvedValue({ data: { id: 2, ...newMake } })
    mockGet.mockResolvedValue({ data: [] })
    const { createMake } = useAdminCars()
    await createMake(newMake)
    expect(mockPost).toHaveBeenCalledWith('/admin/cars/makes', newMake)
  })

  it('updates a make via PUT /admin/cars/makes/:id', async () => {
    mockPut.mockResolvedValue({ data: { id: 1, name: 'BMW Updated' } })
    mockGet.mockResolvedValue({ data: [] })
    const { updateMake } = useAdminCars()
    await updateMake(1, { name: 'BMW Updated' })
    expect(mockPut).toHaveBeenCalledWith('/admin/cars/makes/1', { name: 'BMW Updated' })
  })

  it('deletes a make via DELETE /admin/cars/makes/:id', async () => {
    mockDelete.mockResolvedValue({})
    mockGet.mockResolvedValue({ data: [] })
    const { deleteMake } = useAdminCars()
    await deleteMake(1)
    expect(mockDelete).toHaveBeenCalledWith('/admin/cars/makes/1')
  })
})

Step 2: Run test to verify it fails

Run: npx vitest run app/features/admin-references/composables/useAdminCars.test.ts

Expected: FAIL

Step 3: Write minimal implementation

Create app/features/admin-references/composables/useAdminCars.ts:

ts
import { ref, watch } from 'vue'

import type { CarMake, CarModel, CarGeneration } from '~/entities/car/model/car.schema'

export function useAdminCars() {
  const api = useApi()

  const makes = ref<CarMake[]>([])
  const models = ref<CarModel[]>([])
  const generations = ref<CarGeneration[]>([])
  const selectedMakeId = ref<number | null>(null)
  const selectedModelId = ref<number | null>(null)
  const isLoading = ref(false)

  async function fetchMakes() {
    const response = await api.get<{ data: CarMake[] }>('/store/cars/makes')
    makes.value = response.data
  }

  async function fetchModels(makeId: number) {
    const response = await api.get<{ data: CarModel[] }>(`/store/cars/makes/${makeId}/models`)
    models.value = response.data
  }

  async function fetchGenerations(modelId: number) {
    const response = await api.get<{ data: CarGeneration[] }>(`/store/cars/models/${modelId}/generations`)
    generations.value = response.data
  }

  // Cascade: when make changes, load models
  watch(selectedMakeId, async (makeId) => {
    models.value = []
    generations.value = []
    selectedModelId.value = null
    if (makeId) await fetchModels(makeId)
  })

  // Cascade: when model changes, load generations
  watch(selectedModelId, async (modelId) => {
    generations.value = []
    if (modelId) await fetchGenerations(modelId)
  })

  // Makes CRUD
  async function createMake(data: Partial<CarMake>) {
    await api.post('/admin/cars/makes', data as Record<string, unknown>)
    await fetchMakes()
  }

  async function updateMake(id: number, data: Partial<CarMake>) {
    await api.put(`/admin/cars/makes/${id}`, data as Record<string, unknown>)
    await fetchMakes()
  }

  async function deleteMake(id: number) {
    await api.delete(`/admin/cars/makes/${id}`)
    await fetchMakes()
  }

  // Models CRUD
  async function createModel(data: Partial<CarModel>) {
    await api.post('/admin/cars/models', data as Record<string, unknown>)
    if (selectedMakeId.value) await fetchModels(selectedMakeId.value)
  }

  async function updateModel(id: number, data: Partial<CarModel>) {
    await api.put(`/admin/cars/models/${id}`, data as Record<string, unknown>)
    if (selectedMakeId.value) await fetchModels(selectedMakeId.value)
  }

  async function deleteModel(id: number) {
    await api.delete(`/admin/cars/models/${id}`)
    if (selectedMakeId.value) await fetchModels(selectedMakeId.value)
  }

  // Generations CRUD
  async function createGeneration(data: Partial<CarGeneration>) {
    await api.post('/admin/cars/generations', data as Record<string, unknown>)
    if (selectedModelId.value) await fetchGenerations(selectedModelId.value)
  }

  async function updateGeneration(id: number, data: Partial<CarGeneration>) {
    await api.put(`/admin/cars/generations/${id}`, data as Record<string, unknown>)
    if (selectedModelId.value) await fetchGenerations(selectedModelId.value)
  }

  async function deleteGeneration(id: number) {
    await api.delete(`/admin/cars/generations/${id}`)
    if (selectedModelId.value) await fetchGenerations(selectedModelId.value)
  }

  // Initial fetch
  fetchMakes()

  return {
    makes,
    models,
    generations,
    selectedMakeId,
    selectedModelId,
    isLoading,
    fetchMakes,
    createMake,
    updateMake,
    deleteMake,
    fetchModels,
    createModel,
    updateModel,
    deleteModel,
    fetchGenerations,
    createGeneration,
    updateGeneration,
    deleteGeneration,
  }
}

Step 4: Run test to verify it passes

Run: npx vitest run app/features/admin-references/composables/useAdminCars.test.ts

Expected: PASS

Step 5: Commit

bash
git add app/features/admin-references/
git commit -m "feat(admin): add useAdminCars composable with tests"

Task 10: Create useAdminCategories composable with tests

Files:

  • Create: app/features/admin-references/composables/useAdminCategories.ts
  • Create: app/features/admin-references/composables/useAdminCategories.test.ts

Step 1: Write the failing test

Create app/features/admin-references/composables/useAdminCategories.test.ts:

ts
import { mockNuxtImport } from '@nuxt/test-utils/runtime'
import { describe, it, expect, vi, beforeEach } from 'vitest'

const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()

mockNuxtImport('useApi', () => () => ({
  get: mockGet,
  post: mockPost,
  put: mockPut,
  delete: mockDelete,
}))

const { useAdminCategories } = await import('./useAdminCategories')

describe('useAdminCategories', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    mockGet.mockResolvedValue({ data: [] })
  })

  it('returns expected composable shape', () => {
    const result = useAdminCategories()
    expect(result).toHaveProperty('categories')
    expect(result).toHaveProperty('typeFilter')
    expect(result).toHaveProperty('fetchCategories')
    expect(result).toHaveProperty('createCategory')
    expect(result).toHaveProperty('updateCategory')
    expect(result).toHaveProperty('deleteCategory')
  })

  it('fetches categories from /store/categories', async () => {
    const data = [{ id: 1, name: 'Запчасти', slug: 'parts', category_type: 'part' }]
    mockGet.mockResolvedValue({ data })
    const { fetchCategories, categories } = useAdminCategories()
    await fetchCategories()
    expect(mockGet).toHaveBeenCalledWith('/store/categories', {})
    expect(categories.value).toEqual(data)
  })

  it('creates a category via POST', async () => {
    mockPost.mockResolvedValue({ data: { id: 2 } })
    mockGet.mockResolvedValue({ data: [] })
    const { createCategory } = useAdminCategories()
    await createCategory({ name: 'New', slug: 'new', category_type: 'part' })
    expect(mockPost).toHaveBeenCalledWith('/admin/categories', { name: 'New', slug: 'new', category_type: 'part' })
  })

  it('deletes a category via DELETE', async () => {
    mockDelete.mockResolvedValue({})
    mockGet.mockResolvedValue({ data: [] })
    const { deleteCategory } = useAdminCategories()
    await deleteCategory(1)
    expect(mockDelete).toHaveBeenCalledWith('/admin/categories/1')
  })
})

Step 2: Run test to verify it fails

Run: npx vitest run app/features/admin-references/composables/useAdminCategories.test.ts

Expected: FAIL

Step 3: Write minimal implementation

Create app/features/admin-references/composables/useAdminCategories.ts:

ts
import { ref, watch } from 'vue'

import type { Category, CategoryType } from '~/entities/category/model/category.schema'

export function useAdminCategories() {
  const api = useApi()

  const categories = ref<Category[]>([])
  const typeFilter = ref<CategoryType | null>(null)
  const isLoading = ref(false)

  async function fetchCategories() {
    isLoading.value = true
    try {
      const params: Record<string, unknown> = {}
      if (typeFilter.value) params.type = typeFilter.value
      const response = await api.get<{ data: Category[] }>('/store/categories', params)
      categories.value = response.data
    }
    finally {
      isLoading.value = false
    }
  }

  watch(typeFilter, () => fetchCategories())

  async function createCategory(data: Partial<Category>) {
    await api.post('/admin/categories', data as Record<string, unknown>)
    await fetchCategories()
  }

  async function updateCategory(id: number, data: Partial<Category>) {
    await api.put(`/admin/categories/${id}`, data as Record<string, unknown>)
    await fetchCategories()
  }

  async function deleteCategory(id: number) {
    await api.delete(`/admin/categories/${id}`)
    await fetchCategories()
  }

  fetchCategories()

  return {
    categories,
    typeFilter,
    isLoading,
    fetchCategories,
    createCategory,
    updateCategory,
    deleteCategory,
  }
}

Step 4: Run test to verify it passes

Run: npx vitest run app/features/admin-references/composables/useAdminCategories.test.ts

Expected: PASS

Step 5: Commit

bash
git add app/features/admin-references/composables/useAdminCategories.*
git commit -m "feat(admin): add useAdminCategories composable with tests"

Task 11: Create useAdminGeo composable with tests

Files:

  • Create: app/features/admin-references/composables/useAdminGeo.ts
  • Create: app/features/admin-references/composables/useAdminGeo.test.ts

Step 1: Write the failing test

Create app/features/admin-references/composables/useAdminGeo.test.ts:

ts
import { mockNuxtImport } from '@nuxt/test-utils/runtime'
import { describe, it, expect, vi, beforeEach } from 'vitest'

const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPut = vi.fn()
const mockDelete = vi.fn()

mockNuxtImport('useApi', () => () => ({
  get: mockGet,
  post: mockPost,
  put: mockPut,
  delete: mockDelete,
}))

const { useAdminGeo } = await import('./useAdminGeo')

describe('useAdminGeo', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    mockGet.mockResolvedValue({ data: [] })
  })

  it('returns expected composable shape', () => {
    const result = useAdminGeo()
    expect(result).toHaveProperty('regions')
    expect(result).toHaveProperty('cities')
    expect(result).toHaveProperty('districts')
    expect(result).toHaveProperty('metroStations')
    expect(result).toHaveProperty('selectedRegionId')
    expect(result).toHaveProperty('selectedCityId')
    expect(result).toHaveProperty('fetchRegions')
    expect(result).toHaveProperty('createRegion')
    expect(result).toHaveProperty('updateRegion')
    expect(result).toHaveProperty('createCity')
    expect(result).toHaveProperty('createDistrict')
    expect(result).toHaveProperty('createMetroStation')
  })

  it('fetches regions', async () => {
    const data = [{ id: 1, name: 'СПб', slug: 'spb' }]
    mockGet.mockResolvedValue({ data })
    const { fetchRegions, regions } = useAdminGeo()
    await fetchRegions()
    expect(mockGet).toHaveBeenCalledWith('/store/geo/regions')
    expect(regions.value).toEqual(data)
  })

  it('creates a region via POST', async () => {
    mockPost.mockResolvedValue({ data: { id: 2 } })
    mockGet.mockResolvedValue({ data: [] })
    const { createRegion } = useAdminGeo()
    await createRegion({ name: 'Москва', slug: 'moscow' })
    expect(mockPost).toHaveBeenCalledWith('/admin/geo/regions', { name: 'Москва', slug: 'moscow' })
  })

  it('creates a metro station via POST', async () => {
    mockPost.mockResolvedValue({ data: { id: 1 } })
    mockGet.mockResolvedValue({ data: [] })
    const { createMetroStation } = useAdminGeo()
    await createMetroStation({ name: 'Невский', city_id: 1, line: 'Синяя', line_color: '#0000FF' })
    expect(mockPost).toHaveBeenCalledWith('/admin/geo/metro-stations', { name: 'Невский', city_id: 1, line: 'Синяя', line_color: '#0000FF' })
  })
})

Step 2: Run test to verify it fails

Run: npx vitest run app/features/admin-references/composables/useAdminGeo.test.ts

Expected: FAIL

Step 3: Write minimal implementation

Create app/features/admin-references/composables/useAdminGeo.ts:

ts
import { ref, watch } from 'vue'

import type { Region, City, District, MetroStation } from '~/entities/geo/model/geo.schema'

export function useAdminGeo() {
  const api = useApi()

  const regions = ref<Region[]>([])
  const cities = ref<City[]>([])
  const districts = ref<District[]>([])
  const metroStations = ref<MetroStation[]>([])
  const selectedRegionId = ref<number | null>(null)
  const selectedCityId = ref<number | null>(null)
  const isLoading = ref(false)

  async function fetchRegions() {
    const response = await api.get<{ data: Region[] }>('/store/geo/regions')
    regions.value = response.data
  }

  async function fetchCities(regionId: number) {
    const response = await api.get<{ data: City[] }>(`/store/geo/regions/${regionId}/cities`)
    cities.value = response.data
  }

  async function fetchDistricts(cityId: number) {
    const response = await api.get<{ data: District[] }>(`/store/geo/cities/${cityId}/districts`)
    districts.value = response.data
  }

  async function fetchMetroStations(cityId: number) {
    const response = await api.get<{ data: MetroStation[] }>(`/store/geo/cities/${cityId}/metro`)
    metroStations.value = response.data
  }

  // Cascade
  watch(selectedRegionId, async (regionId) => {
    cities.value = []
    districts.value = []
    metroStations.value = []
    selectedCityId.value = null
    if (regionId) await fetchCities(regionId)
  })

  watch(selectedCityId, async (cityId) => {
    districts.value = []
    metroStations.value = []
    if (cityId) {
      await Promise.all([fetchDistricts(cityId), fetchMetroStations(cityId)])
    }
  })

  // Regions CRUD
  async function createRegion(data: Partial<Region>) {
    await api.post('/admin/geo/regions', data as Record<string, unknown>)
    await fetchRegions()
  }

  async function updateRegion(id: number, data: Partial<Region>) {
    await api.put(`/admin/geo/regions/${id}`, data as Record<string, unknown>)
    await fetchRegions()
  }

  // Cities CRUD
  async function createCity(data: Partial<City>) {
    await api.post('/admin/geo/cities', data as Record<string, unknown>)
    if (selectedRegionId.value) await fetchCities(selectedRegionId.value)
  }

  async function updateCity(id: number, data: Partial<City>) {
    await api.put(`/admin/geo/cities/${id}`, data as Record<string, unknown>)
    if (selectedRegionId.value) await fetchCities(selectedRegionId.value)
  }

  // Districts CRUD
  async function createDistrict(data: Partial<District>) {
    await api.post('/admin/geo/districts', data as Record<string, unknown>)
    if (selectedCityId.value) await fetchDistricts(selectedCityId.value)
  }

  async function updateDistrict(id: number, data: Partial<District>) {
    await api.put(`/admin/geo/districts/${id}`, data as Record<string, unknown>)
    if (selectedCityId.value) await fetchDistricts(selectedCityId.value)
  }

  // Metro CRUD
  async function createMetroStation(data: Partial<MetroStation>) {
    await api.post('/admin/geo/metro-stations', data as Record<string, unknown>)
    if (selectedCityId.value) await fetchMetroStations(selectedCityId.value)
  }

  async function updateMetroStation(id: number, data: Partial<MetroStation>) {
    await api.put(`/admin/geo/metro-stations/${id}`, data as Record<string, unknown>)
    if (selectedCityId.value) await fetchMetroStations(selectedCityId.value)
  }

  fetchRegions()

  return {
    regions,
    cities,
    districts,
    metroStations,
    selectedRegionId,
    selectedCityId,
    isLoading,
    fetchRegions,
    createRegion,
    updateRegion,
    fetchCities,
    createCity,
    updateCity,
    fetchDistricts,
    createDistrict,
    updateDistrict,
    fetchMetroStations,
    createMetroStation,
    updateMetroStation,
  }
}

Step 4: Run test to verify it passes

Run: npx vitest run app/features/admin-references/composables/useAdminGeo.test.ts

Expected: PASS

Step 5: Commit

bash
git add app/features/admin-references/composables/useAdminGeo.*
git commit -m "feat(admin): add useAdminGeo composable with tests"

Task 12: Create ReferenceList and ReferenceFormModal components

Files:

  • Create: app/features/admin-references/ui/ReferenceList.vue
  • Create: app/features/admin-references/ui/ReferenceFormModal.vue

Step 1: Add i18n keys

In i18n/locales/ru.json, add to "admin":

json
"addItem": "Добавить",
"editItem": "Редактировать",
"deleteItem": "Удалить",
"deleteConfirm": "Удалить «{name}»?",
"deleteWarning": "Это действие нельзя отменить.",
"noItems": "Список пуст",
"name": "Название",
"slug": "Слаг",
"cars": "Автомобили",
"categories": "Категории",
"geoData": "Гео",
"makes": "Марки",
"models": "Модели",
"generations": "Поколения",
"regions": "Регионы",
"cities": "Города",
"districts": "Районы",
"metroStations": "Станции метро",
"popular": "Популярная",
"logoUrl": "URL логотипа",
"yearFrom": "Год начала",
"yearTo": "Год окончания",
"steeringType": "Руль",
"parentCategory": "Родительская категория",
"categoryType": "Тип категории",
"sortOrder": "Порядок",
"icon": "Иконка",
"lineName": "Линия",
"lineColor": "Цвет линии",
"selectItem": "Выберите элемент"

Step 2: Create ReferenceFormModal component

Create app/features/admin-references/ui/ReferenceFormModal.vue:

html
<script setup lang="ts">
const props = defineProps<{
  open: boolean
  title: string
  fields: { key: string; label: string; type?: 'text' | 'number' | 'boolean' | 'select'; options?: { label: string; value: unknown }[] }[]
  initialValues?: Record<string, unknown>
  loading?: boolean
}>()

const emit = defineEmits<{
  'update:open': [value: boolean]
  submit: [data: Record<string, unknown>]
}>()

const formData = ref<Record<string, unknown>>({})

watch(() => props.open, (isOpen) => {
  if (isOpen) {
    formData.value = { ...props.initialValues } ?? {}
  }
})

function handleSubmit() {
  emit('submit', { ...formData.value })
}
</script>

<template>
  <UModal :open="open" @update:open="emit('update:open', $event)">
    <template #header>
      <h3 class="text-lg font-semibold">&#123;&#123; title }}</h3>
    </template>
    <template #body>
      <div class="space-y-4">
        <div v-for="field in fields" :key="field.key">
          <template v-if="field.type === 'boolean'">
            <UCheckbox
              v-model="formData[field.key]"
              :label="field.label"
            />
          </template>
          <template v-else-if="field.type === 'select' && field.options">
            <label class="block text-sm font-medium mb-1">&#123;&#123; field.label }}</label>
            <USelectMenu
              v-model="formData[field.key]"
              :items="field.options"
              value-key="value"
            />
          </template>
          <template v-else>
            <label class="block text-sm font-medium mb-1">&#123;&#123; field.label }}</label>
            <UInput
              v-model="formData[field.key]"
              :type="field.type === 'number' ? 'number' : 'text'"
            />
          </template>
        </div>
      </div>
    </template>
    <template #footer>
      <div class="flex justify-end gap-3">
        <UButton
          :label="$t('common.cancel')"
          variant="ghost"
          @click="emit('update:open', false)"
        />
        <UButton
          :label="$t('common.save')"
          :loading="loading"
          @click="handleSubmit"
        />
      </div>
    </template>
  </UModal>
</template>

Step 3: Create ReferenceList component

Create app/features/admin-references/ui/ReferenceList.vue:

html
<script setup lang="ts">
const props = defineProps<{
  title: string
  items: { id: number; name: string; [key: string]: unknown }[]
  selectedId?: number | null
  selectable?: boolean
  loading?: boolean
}>()

const emit = defineEmits<{
  select: [id: number]
  add: []
  edit: [item: { id: number; name: string; [key: string]: unknown }]
  delete: [item: { id: number; name: string }]
}>()

const { t } = useI18n()

const deleteConfirmOpen = ref(false)
const itemToDelete = ref<{ id: number; name: string } | null>(null)

function confirmDelete(item: { id: number; name: string }) {
  itemToDelete.value = item
  deleteConfirmOpen.value = true
}

function handleDelete() {
  if (itemToDelete.value) {
    emit('delete', itemToDelete.value)
  }
  deleteConfirmOpen.value = false
  itemToDelete.value = null
}
</script>

<template>
  <div class="border border-[var(--ui-border)] rounded-lg">
    <div class="flex items-center justify-between p-3 border-b border-[var(--ui-border)]">
      <h3 class="font-semibold text-sm">&#123;&#123; title }}</h3>
      <UButton
        :label="t('admin.addItem')"
        icon="i-lucide-plus"
        size="xs"
        variant="soft"
        @click="emit('add')"
      />
    </div>
    <div v-if="items.length === 0" class="p-4 text-center text-sm text-[var(--ui-text-muted)]">
      &#123;&#123; t('admin.noItems') }}
    </div>
    <div v-else class="max-h-80 overflow-y-auto">
      <div
        v-for="item in items"
        :key="item.id"
        class="flex items-center justify-between px-3 py-2 text-sm hover:bg-[var(--ui-bg-elevated)] border-b border-[var(--ui-border)] last:border-0"
        :class="{ 'bg-[var(--ui-bg-elevated)]': selectable && selectedId === item.id, 'cursor-pointer': selectable }"
        @click="selectable && emit('select', item.id)"
      >
        <span class="truncate" :class="{ 'font-medium': selectable && selectedId === item.id }">&#123;&#123; item.name }}</span>
        <div class="flex items-center gap-1 shrink-0 ml-2">
          <UButton
            icon="i-lucide-pencil"
            size="xs"
            variant="ghost"
            @click.stop="emit('edit', item)"
          />
          <UButton
            icon="i-lucide-trash-2"
            size="xs"
            variant="ghost"
            color="error"
            @click.stop="confirmDelete(item)"
          />
        </div>
      </div>
    </div>

    <UModal v-model:open="deleteConfirmOpen">
      <template #header>
        <h3 class="text-lg font-semibold">&#123;&#123; t('admin.deleteConfirm', { name: itemToDelete?.name ?? '' }) }}</h3>
      </template>
      <template #body>
        <p class="text-[var(--ui-text-muted)]">&#123;&#123; t('admin.deleteWarning') }}</p>
      </template>
      <template #footer>
        <div class="flex justify-end gap-3">
          <UButton :label="t('common.cancel')" variant="ghost" @click="deleteConfirmOpen = false" />
          <UButton :label="t('common.delete')" color="error" @click="handleDelete" />
        </div>
      </template>
    </UModal>
  </div>
</template>

Step 4: Commit

bash
git add app/features/admin-references/ui/ i18n/locales/ru.json
git commit -m "feat(admin): add ReferenceList and ReferenceFormModal components"

Task 13: Create references page

Files:

  • Create: app/pages/admin/references.vue

Step 1: Create the references page

Create app/pages/admin/references.vue. This is a large page with 3 tabs. Each tab uses the composables and reusable components from tasks 9-12.

The page structure:

  • Tab "Автомобили": 3-column cascade (ReferenceList for makes, models, generations)
  • Tab "Категории": filtered list with type selector
  • Tab "Гео": cascade (regions → cities → districts + metro)

Each tab uses ReferenceFormModal for add/edit and ReferenceList for displaying items with delete confirmation.

html
<script setup lang="ts">
definePageMeta({ layout: 'admin', middleware: 'admin' })
useSeoMeta({ title: 'Справочники', robots: 'noindex, nofollow' })

const { t } = useI18n()
const toast = useToast()

const activeTab = ref('cars')
const tabs = [
  { label: t('admin.cars'), value: 'cars' },
  { label: t('admin.categories'), value: 'categories' },
  { label: t('admin.geoData'), value: 'geo' },
]

// Cars
const {
  makes, models, generations,
  selectedMakeId, selectedModelId,
  createMake, updateMake, deleteMake,
  createModel, updateModel, deleteModel,
  createGeneration, updateGeneration, deleteGeneration,
} = useAdminCars()

// Categories
const {
  categories, typeFilter,
  createCategory, updateCategory, deleteCategory,
} = useAdminCategories()

// Geo
const {
  regions, cities, districts, metroStations,
  selectedRegionId, selectedCityId,
  createRegion, updateRegion,
  createCity, updateCity,
  createDistrict, updateDistrict,
  createMetroStation, updateMetroStation,
} = useAdminGeo()

// Form modal state
const formOpen = ref(false)
const formTitle = ref('')
const formFields = ref<{ key: string; label: string; type?: string; options?: { label: string; value: unknown }[] }[]>([])
const formInitialValues = ref<Record<string, unknown>>({})
const formLoading = ref(false)
let formSubmitHandler: ((data: Record<string, unknown>) => Promise<void>) | null = null

function openForm(
  title: string,
  fields: typeof formFields.value,
  onSubmit: (data: Record<string, unknown>) => Promise<void>,
  initialValues: Record<string, unknown> = {},
) {
  formTitle.value = title
  formFields.value = fields
  formInitialValues.value = initialValues
  formSubmitHandler = onSubmit
  formOpen.value = true
}

async function handleFormSubmit(data: Record<string, unknown>) {
  if (!formSubmitHandler) return
  formLoading.value = true
  try {
    await formSubmitHandler(data)
    formOpen.value = false
    toast.add({ title: t('common.save'), color: 'success' })
  }
  catch {
    toast.add({ title: 'Error', color: 'error' })
  }
  finally {
    formLoading.value = false
  }
}

// --- Cars helpers ---
const makeFields = [
  { key: 'name', label: t('admin.name') },
  { key: 'slug', label: t('admin.slug') },
  { key: 'logo_url', label: t('admin.logoUrl') },
  { key: 'is_popular', label: t('admin.popular'), type: 'boolean' as const },
]
const modelFields = [
  { key: 'name', label: t('admin.name') },
  { key: 'slug', label: t('admin.slug') },
]
const generationFields = [
  { key: 'name', label: t('admin.name') },
  { key: 'code', label: 'Code' },
  { key: 'year_from', label: t('admin.yearFrom'), type: 'number' as const },
  { key: 'year_to', label: t('admin.yearTo'), type: 'number' as const },
  { key: 'steering', label: t('admin.steeringType'), type: 'select' as const, options: [
    { label: t('listing.steeringLeft'), value: 'left' },
    { label: t('listing.steeringRight'), value: 'right' },
    { label: 'Both', value: 'both' },
  ] },
]

// --- Category helpers ---
const categoryTypeOptions = [
  { label: 'Part', value: 'part' },
  { label: 'Condition', value: 'condition' },
  { label: 'Attribute', value: 'attribute' },
]
const categoryFields = computed(() => [
  { key: 'name', label: t('admin.name') },
  { key: 'slug', label: t('admin.slug') },
  { key: 'icon', label: t('admin.icon') },
  { key: 'category_type', label: t('admin.categoryType'), type: 'select' as const, options: categoryTypeOptions },
  { key: 'sort_order', label: t('admin.sortOrder'), type: 'number' as const },
  { key: 'parent_id', label: t('admin.parentCategory'), type: 'select' as const, options: [
    { label: '—', value: null },
    ...categories.value.map(c => ({ label: c.name, value: c.id })),
  ] },
])

// --- Geo helpers ---
const regionFields = [
  { key: 'name', label: t('admin.name') },
  { key: 'slug', label: t('admin.slug') },
]
const cityFields = [
  { key: 'name', label: t('admin.name') },
  { key: 'slug', label: t('admin.slug') },
]
const districtFields = [
  { key: 'name', label: t('admin.name') },
  { key: 'slug', label: t('admin.slug') },
]
const metroFields = [
  { key: 'name', label: t('admin.name') },
  { key: 'line', label: t('admin.lineName') },
  { key: 'line_color', label: t('admin.lineColor') },
]

// Delete handlers with toast
async function handleDelete(deleteFn: (id: number) => Promise<void>, item: { id: number; name: string }) {
  try {
    await deleteFn(item.id)
    toast.add({ title: t('common.delete'), color: 'success' })
  }
  catch {
    toast.add({ title: 'Error', color: 'error' })
  }
}

// Categories nested display
const sortedCategories = computed(() => {
  const cats = [...categories.value]
  const roots = cats.filter(c => !c.parent_id)
  const result: (typeof cats[number] & { depth: number })[] = []

  function addWithChildren(parent: typeof cats[number], depth: number) {
    result.push({ ...parent, depth })
    const children = cats.filter(c => c.parent_id === parent.id)
    for (const child of children) {
      addWithChildren(child, depth + 1)
    }
  }

  for (const root of roots) {
    addWithChildren(root, 0)
  }

  // Add orphans (parent_id set but parent not in list)
  const addedIds = new Set(result.map(r => r.id))
  for (const cat of cats) {
    if (!addedIds.has(cat.id)) result.push({ ...cat, depth: 0 })
  }

  return result
})
</script>

<template>
  <div>
    <h1 class="text-2xl font-bold mb-6">&#123;&#123; t('admin.references') }}</h1>

    <!-- Tabs -->
    <div class="flex gap-2 mb-6">
      <UButton
        v-for="tab in tabs"
        :key="tab.value"
        :variant="activeTab === tab.value ? 'solid' : 'ghost'"
        size="sm"
        @click="activeTab = tab.value"
      >
        &#123;&#123; tab.label }}
      </UButton>
    </div>

    <!-- Cars tab -->
    <div v-if="activeTab === 'cars'" class="grid grid-cols-1 md:grid-cols-3 gap-4">
      <ReferenceList
        :title="t('admin.makes')"
        :items="makes"
        :selected-id="selectedMakeId"
        selectable
        @select="selectedMakeId = $event"
        @add="openForm(t('admin.addItem'), makeFields, (d) => createMake(d))"
        @edit="(item) => openForm(t('admin.editItem'), makeFields, (d) => updateMake(item.id, d), item)"
        @delete="(item) => handleDelete(deleteMake, item)"
      />
      <ReferenceList
        :title="t('admin.models')"
        :items="models"
        :selected-id="selectedModelId"
        :selectable="!!selectedMakeId"
        @select="selectedModelId = $event"
        @add="openForm(t('admin.addItem'), modelFields, (d) => createModel({ ...d, make_id: selectedMakeId! }))"
        @edit="(item) => openForm(t('admin.editItem'), modelFields, (d) => updateModel(item.id, d), item)"
        @delete="(item) => handleDelete(deleteModel, item)"
      />
      <ReferenceList
        :title="t('admin.generations')"
        :items="generations"
        @add="openForm(t('admin.addItem'), generationFields, (d) => createGeneration({ ...d, model_id: selectedModelId! }))"
        @edit="(item) => openForm(t('admin.editItem'), generationFields, (d) => updateGeneration(item.id, d), item)"
        @delete="(item) => handleDelete(deleteGeneration, item)"
      />
    </div>

    <!-- Categories tab -->
    <div v-if="activeTab === 'categories'">
      <div class="flex gap-2 mb-4">
        <UButton
          :variant="!typeFilter ? 'solid' : 'ghost'"
          size="xs"
          @click="typeFilter = null"
        >
          &#123;&#123; t('admin.allProducts') }}
        </UButton>
        <UButton
          v-for="opt in categoryTypeOptions"
          :key="opt.value"
          :variant="typeFilter === opt.value ? 'solid' : 'ghost'"
          size="xs"
          @click="typeFilter = opt.value as 'part' | 'condition' | 'attribute'"
        >
          &#123;&#123; opt.label }}
        </UButton>
      </div>
      <div class="border border-[var(--ui-border)] rounded-lg">
        <div class="flex items-center justify-between p-3 border-b border-[var(--ui-border)]">
          <h3 class="font-semibold text-sm">&#123;&#123; t('admin.categories') }}</h3>
          <UButton
            :label="t('admin.addItem')"
            icon="i-lucide-plus"
            size="xs"
            variant="soft"
            @click="openForm(t('admin.addItem'), categoryFields, (d) => createCategory(d))"
          />
        </div>
        <div v-if="sortedCategories.length === 0" class="p-4 text-center text-sm text-[var(--ui-text-muted)]">
          &#123;&#123; t('admin.noItems') }}
        </div>
        <div v-else class="max-h-96 overflow-y-auto">
          <div
            v-for="cat in sortedCategories"
            :key="cat.id"
            class="flex items-center justify-between px-3 py-2 text-sm hover:bg-[var(--ui-bg-elevated)] border-b border-[var(--ui-border)] last:border-0"
            :style="{ paddingLeft: `${(cat.depth ?? 0) * 24 + 12}px` }"
          >
            <div class="flex items-center gap-2 truncate">
              <UIcon v-if="cat.icon" :name="cat.icon" />
              <span>&#123;&#123; cat.name }}</span>
              <UBadge variant="subtle" size="xs" color="neutral">&#123;&#123; cat.category_type }}</UBadge>
            </div>
            <div class="flex items-center gap-1 shrink-0 ml-2">
              <UButton
                icon="i-lucide-pencil"
                size="xs"
                variant="ghost"
                @click="openForm(t('admin.editItem'), categoryFields, (d) => updateCategory(cat.id, d), cat)"
              />
              <UButton
                icon="i-lucide-trash-2"
                size="xs"
                variant="ghost"
                color="error"
                @click="handleDelete(deleteCategory, cat)"
              />
            </div>
          </div>
        </div>
      </div>
    </div>

    <!-- Geo tab -->
    <div v-if="activeTab === 'geo'">
      <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
        <ReferenceList
          :title="t('admin.regions')"
          :items="regions"
          :selected-id="selectedRegionId"
          selectable
          @select="selectedRegionId = $event"
          @add="openForm(t('admin.addItem'), regionFields, (d) => createRegion(d))"
          @edit="(item) => openForm(t('admin.editItem'), regionFields, (d) => updateRegion(item.id, d), item)"
          @delete="(item) => handleDelete(async (id) => { await updateRegion(id, {}); await fetchRegions() }, item)"
        />
        <ReferenceList
          :title="t('admin.cities')"
          :items="cities"
          :selected-id="selectedCityId"
          :selectable="!!selectedRegionId"
          @select="selectedCityId = $event"
          @add="openForm(t('admin.addItem'), cityFields, (d) => createCity({ ...d, region_id: selectedRegionId! }))"
          @edit="(item) => openForm(t('admin.editItem'), cityFields, (d) => updateCity(item.id, d), item)"
          @delete="(item) => handleDelete(async () => {}, item)"
        />
        <div class="space-y-4">
          <ReferenceList
            :title="t('admin.districts')"
            :items="districts"
            @add="openForm(t('admin.addItem'), districtFields, (d) => createDistrict({ ...d, city_id: selectedCityId! }))"
            @edit="(item) => openForm(t('admin.editItem'), districtFields, (d) => updateDistrict(item.id, d), item)"
            @delete="(item) => handleDelete(async () => {}, item)"
          />
          <ReferenceList
            :title="t('admin.metroStations')"
            :items="metroStations"
            @add="openForm(t('admin.addItem'), metroFields, (d) => createMetroStation({ ...d, city_id: selectedCityId! }))"
            @edit="(item) => openForm(t('admin.editItem'), metroFields, (d) => updateMetroStation(item.id, d), item)"
            @delete="(item) => handleDelete(async () => {}, item)"
          />
        </div>
      </div>
    </div>

    <!-- Shared form modal -->
    <ReferenceFormModal
      v-model:open="formOpen"
      :title="formTitle"
      :fields="formFields"
      :initial-values="formInitialValues"
      :loading="formLoading"
      @submit="handleFormSubmit"
    />
  </div>
</template>

Step 2: Verify all tabs render

Run: npm run dev, navigate to /admin/references. Test each tab:

  • Cars: click makes, see models load, click model, see generations
  • Categories: see list with nesting, filter by type
  • Geo: click region, see cities load

Step 3: Commit

bash
git add app/pages/admin/references.vue i18n/locales/ru.json
git commit -m "feat(admin): add references page with cars, categories, geo tabs"

Task 14: Update CLAUDE.md and run final verification

Files:

  • Modify: CLAUDE.md

Step 1: Update CLAUDE.md

Add to the project structure the new features and pages. Update the progress section to mark all three admin features as done.

Step 2: Run lint

Run: npm run lint

Fix any issues.

Step 3: Run typecheck

Run: npm run typecheck

Fix any type errors.

Step 4: Run all tests

Run: npm run test:run

All tests must pass.

Step 5: Commit

bash
git add CLAUDE.md
git commit -m "docs: update CLAUDE.md with admin panel features"