Appearance
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)]">{{ title }}</span>
</div>
<div class="text-3xl font-bold text-[var(--ui-text)]">{{ value }}</div>
<div v-if="subtitle" class="text-sm text-[var(--ui-text-dimmed)] mt-1">{{ 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">{{ 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">{{ 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)]">{{ t('admin.activeUsers') }}</dt>
<dd class="font-medium">{{ userStats?.active ?? 0 }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--ui-text-muted)]">{{ t('admin.verifiedEmail') }}</dt>
<dd class="font-medium">{{ userStats?.verified_email ?? 0 }} {{ t('common.of', { total: userStats?.total ?? 0 }) }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--ui-text-muted)]">{{ t('admin.personal') }}</dt>
<dd class="font-medium">{{ getAccountTypeCount('personal') }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--ui-text-muted)]">{{ t('admin.business') }}</dt>
<dd class="font-medium">{{ getAccountTypeCount('business') }}</dd>
</div>
<USeparator />
<div class="flex justify-between">
<dt class="text-[var(--ui-text-muted)]">{{ t('admin.newThisWeek') }}</dt>
<dd class="font-medium">{{ userStats?.new_this_week ?? 0 }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--ui-text-muted)]">{{ t('admin.newThisMonth') }}</dt>
<dd class="font-medium">{{ 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">{{ 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"
>
{{ statusLabels[status] }}: {{ 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">
{{ 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">{{ 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)]">{{ 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)]">{{ t('auth.displayName') }}</th>
<th class="px-4 py-3 text-left text-sm font-medium text-[var(--ui-text-muted)]">{{ t('admin.email') }}</th>
<th class="px-4 py-3 text-left text-sm font-medium text-[var(--ui-text-muted)]">{{ t('admin.type') }}</th>
<th class="px-4 py-3 text-center text-sm font-medium text-[var(--ui-text-muted)]">{{ t('admin.emailVerified') }}</th>
<th class="px-4 py-3 text-left text-sm font-medium text-[var(--ui-text-muted)]">{{ t('admin.userStatus') }}</th>
<th class="px-4 py-3 text-left text-sm font-medium text-[var(--ui-text-muted)]">{{ 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">{{ user.display_name }}</span>
</div>
</td>
<td class="px-4 py-3 text-sm">{{ user.email }}</td>
<td class="px-4 py-3">
<UBadge :color="user.account_type === 'business' ? 'info' : 'neutral'" variant="subtle" size="sm">
{{ 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">
{{ user.is_active ? t('admin.userActive') : t('admin.userBlocked') }}
</UBadge>
</td>
<td class="px-4 py-3 text-sm text-[var(--ui-text-muted)]">{{ 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">{{ user.display_name }}</span>
<UBadge :color="user.is_active ? 'success' : 'error'" variant="subtle" size="xs">
{{ user.is_active ? t('admin.userActive') : t('admin.userBlocked') }}
</UBadge>
</div>
<div class="text-xs text-[var(--ui-text-muted)] mt-0.5">{{ user.email }}</div>
<div class="text-xs text-[var(--ui-text-dimmed)] mt-0.5">{{ 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">
{{ 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">{{ user.display_name }}</h1>
<div class="flex items-center gap-2 mt-1">
<UBadge :color="user.is_active ? 'success' : 'error'" variant="subtle" size="sm">
{{ user.is_active ? t('admin.userActive') : t('admin.userBlocked') }}
</UBadge>
<UBadge :color="user.account_type === 'business' ? 'info' : 'neutral'" variant="subtle" size="sm">
{{ 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">{{ t('admin.contactInfo') }}</h2>
<dl class="space-y-3">
<div class="flex justify-between">
<dt class="text-[var(--ui-text-muted)]">{{ t('admin.email') }}</dt>
<dd class="font-medium flex items-center gap-2">
{{ 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)]">{{ t('auth.phone') }}</dt>
<dd class="font-medium">{{ 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">{{ 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">{{ user.id }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--ui-text-muted)]">{{ t('admin.registrationDate') }}</dt>
<dd class="font-medium">{{ formattedDate(user.created_at) }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--ui-text-muted)]">{{ t('admin.type') }}</dt>
<dd class="font-medium">{{ 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">{{ t('admin.statistics') }}</h2>
<dl class="space-y-3">
<div class="flex justify-between">
<dt class="text-[var(--ui-text-muted)]">{{ t('admin.products') }}</dt>
<dd class="font-medium">{{ user.products_count }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--ui-text-muted)]">{{ t('admin.rating') }}</dt>
<dd class="font-medium">{{ user.rating }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-[var(--ui-text-muted)]">{{ t('admin.reviewsCount') }}</dt>
<dd class="font-medium">{{ 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">{{ 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">{{ 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">{{ 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">{{ 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)]">
{{ 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 }">{{ 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">{{ t('admin.deleteConfirm', { name: itemToDelete?.name ?? '' }) }}</h3>
</template>
<template #body>
<p class="text-[var(--ui-text-muted)]">{{ 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">{{ 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"
>
{{ 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"
>
{{ 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'"
>
{{ 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">{{ 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)]">
{{ 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>{{ cat.name }}</span>
<UBadge variant="subtle" size="xs" color="neutral">{{ 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"