Merge branch 'tests'
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export type ChecklistResultDto = {
|
||||
passed: boolean
|
||||
comment: string | null
|
||||
checkedAt: string
|
||||
}
|
||||
|
||||
export type TestChecklistResponse = {
|
||||
results: Record<string, ChecklistResultDto>
|
||||
}
|
||||
|
||||
export type UpdateChecklistItemResponse = {
|
||||
itemKey: string
|
||||
passed: boolean
|
||||
comment: string | null
|
||||
checkedAt: string
|
||||
}
|
||||
|
||||
export async function fetchTestChecklistResults(): Promise<TestChecklistResponse> {
|
||||
const { data } = await apiClient.get<TestChecklistResponse>('admin/test-checklist')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateTestChecklistItem(
|
||||
itemKey: string,
|
||||
passed: boolean,
|
||||
comment?: string | null,
|
||||
): Promise<UpdateChecklistItemResponse> {
|
||||
const { data } = await apiClient.patch<{ result: UpdateChecklistItemResponse }>('admin/test-checklist', {
|
||||
itemKey,
|
||||
passed,
|
||||
comment: passed ? null : comment ?? null,
|
||||
})
|
||||
return data.result
|
||||
}
|
||||
|
||||
export async function resetTestChecklist(): Promise<void> {
|
||||
await apiClient.post('admin/test-checklist/reset')
|
||||
}
|
||||
@@ -15,7 +15,17 @@ import Typography from '@mui/material/Typography'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { Bell, Image, LayoutGrid, ListOrdered, MessageSquare, Settings, Store, Users } from 'lucide-react'
|
||||
import {
|
||||
Bell,
|
||||
ClipboardCheck,
|
||||
Image,
|
||||
LayoutGrid,
|
||||
ListOrdered,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
Store,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api'
|
||||
import { AdminCategoriesPage } from '@/pages/admin-categories'
|
||||
@@ -24,6 +34,7 @@ import { AdminOrdersPage } from '@/pages/admin-orders'
|
||||
import { AdminProductsPage } from '@/pages/admin-products'
|
||||
import { AdminReviewsPage } from '@/pages/admin-reviews'
|
||||
import { AdminSettingsPage } from '@/pages/admin-settings'
|
||||
import { AdminTestChecklistPage } from '@/pages/admin-test-checklist'
|
||||
import { AdminUsersPage } from '@/pages/admin-users'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate'
|
||||
@@ -63,6 +74,7 @@ export function AdminLayoutPage() {
|
||||
{ to: '/admin/users', label: 'Пользователи', icon: <Users /> },
|
||||
{ to: '/admin/notifications', label: 'Уведомления', icon: <Bell /> },
|
||||
{ to: '/admin/settings', label: 'Настройки', icon: <Settings /> },
|
||||
{ to: '/admin/test-checklist', label: 'Тест-чеклист', icon: <ClipboardCheck /> },
|
||||
],
|
||||
[],
|
||||
)
|
||||
@@ -193,6 +205,7 @@ export function AdminLayoutPage() {
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="notifications" element={<AdminNotificationsPage />} />
|
||||
<Route path="settings" element={<AdminSettingsPage />} />
|
||||
<Route path="test-checklist" element={<AdminTestChecklistPage />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { AdminTestChecklistPage } from './ui/AdminTestChecklistPage'
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Accordion from '@mui/material/Accordion'
|
||||
import AccordionDetails from '@mui/material/AccordionDetails'
|
||||
import AccordionSummary from '@mui/material/AccordionSummary'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Table from '@mui/material/Table'
|
||||
import TableBody from '@mui/material/TableBody'
|
||||
import TableCell from '@mui/material/TableCell'
|
||||
import TableContainer from '@mui/material/TableContainer'
|
||||
import TableHead from '@mui/material/TableHead'
|
||||
import TableRow from '@mui/material/TableRow'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
|
||||
import CancelIcon from '@mui/icons-material/Cancel'
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { TEST_CHECKLIST_ITEMS } from '@shared/constants/test-checklist-items'
|
||||
import {
|
||||
fetchTestChecklistResults,
|
||||
resetTestChecklist,
|
||||
updateTestChecklistItem,
|
||||
} from '@/entities/test-checklist/api/test-checklist-api'
|
||||
|
||||
type Status = 'passed' | 'failed' | 'unchecked'
|
||||
|
||||
function statusFromResult(r: { passed: boolean; comment: string | null } | undefined): Status {
|
||||
if (!r) return 'unchecked'
|
||||
return r.passed ? 'passed' : 'failed'
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function StatusIcon({ status, onClick }: { status: Status; onClick: () => void }) {
|
||||
const icons = {
|
||||
passed: <CheckCircleIcon sx={{ color: 'success.main' }} />,
|
||||
failed: <CancelIcon sx={{ color: 'error.main' }} />,
|
||||
unchecked: <Box sx={{ width: 24, height: 24, borderRadius: '50%', border: 2, borderColor: 'action.disabled' }} />,
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={status === 'passed' ? 'Пройдено' : status === 'failed' ? 'Не пройдено' : 'Не проверено'}>
|
||||
<IconButton size="small" onClick={onClick} sx={{ p: 0.5 }}>
|
||||
{icons[status]}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminTestChecklistPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [expanded, setExpanded] = useState<string | false>(false)
|
||||
const [errorDialogOpen, setErrorDialogOpen] = useState(false)
|
||||
const [errorItemKey, setErrorItemKey] = useState<string | null>(null)
|
||||
const [errorComment, setErrorComment] = useState('')
|
||||
const [errorCommentError, setErrorCommentError] = useState(false)
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['admin', 'test-checklist'],
|
||||
queryFn: fetchTestChecklistResults,
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ itemKey, passed, comment }: { itemKey: string; passed: boolean; comment?: string | null }) =>
|
||||
updateTestChecklistItem(itemKey, passed, comment),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'test-checklist'] })
|
||||
},
|
||||
})
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: resetTestChecklist,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'test-checklist'] })
|
||||
setConfirmOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const map = new Map<string, (typeof TEST_CHECKLIST_ITEMS)[number][]>()
|
||||
for (const item of TEST_CHECKLIST_ITEMS) {
|
||||
const existing = map.get(item.section) || []
|
||||
existing.push(item)
|
||||
map.set(item.section, existing)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
}, [])
|
||||
|
||||
const results = data?.results ?? {}
|
||||
|
||||
const total = TEST_CHECKLIST_ITEMS.length
|
||||
const passedCount = TEST_CHECKLIST_ITEMS.filter((i) => results[i.key]?.passed).length
|
||||
|
||||
const handleStatusClick = useCallback(
|
||||
(itemKey: string) => {
|
||||
const current = statusFromResult(results[itemKey])
|
||||
if (current === 'failed') {
|
||||
updateMutation.mutate({ itemKey, passed: false, comment: null })
|
||||
} else if (current === 'unchecked') {
|
||||
updateMutation.mutate({ itemKey, passed: true })
|
||||
} else {
|
||||
setErrorItemKey(itemKey)
|
||||
setErrorComment('')
|
||||
setErrorCommentError(false)
|
||||
setErrorDialogOpen(true)
|
||||
}
|
||||
},
|
||||
[results, updateMutation],
|
||||
)
|
||||
|
||||
const handleSaveError = () => {
|
||||
if (!errorItemKey) return
|
||||
if (!errorComment.trim()) {
|
||||
setErrorCommentError(true)
|
||||
return
|
||||
}
|
||||
updateMutation.mutate({ itemKey: errorItemKey, passed: false, comment: errorComment.trim() })
|
||||
setErrorDialogOpen(false)
|
||||
setErrorItemKey(null)
|
||||
setErrorComment('')
|
||||
}
|
||||
|
||||
const errorItem = errorItemKey ? TEST_CHECKLIST_ITEMS.find((i) => i.key === errorItemKey) : null
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" sx={{ mb: 3, justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
Тест-чеклист
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Пройдено: {passedCount} из {total}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
disabled={resetMutation.isPending}
|
||||
>
|
||||
Сбросить все
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : isError ? (
|
||||
<Alert severity="error">Не удалось загрузить чеклист.</Alert>
|
||||
) : (
|
||||
sections.map(([section, items]) => (
|
||||
<Accordion
|
||||
key={section}
|
||||
expanded={expanded === section}
|
||||
onChange={(_, isExpanded) => setExpanded(isExpanded ? section : false)}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography sx={{ fontWeight: 600 }}>{section}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 0 }}>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ width: 56 }} />
|
||||
<TableCell sx={{ fontWeight: 600 }}>Действие</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Ожидаемый результат</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Комментарий</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, whiteSpace: 'nowrap' }}>Дата проверки</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
const r = results[item.key]
|
||||
const status = statusFromResult(r)
|
||||
return (
|
||||
<TableRow key={item.key} hover>
|
||||
<TableCell>
|
||||
<StatusIcon status={status} onClick={() => handleStatusClick(item.key)} />
|
||||
</TableCell>
|
||||
<TableCell>{item.action}</TableCell>
|
||||
<TableCell>{item.expectedResult}</TableCell>
|
||||
<TableCell sx={{ maxWidth: 200 }}>
|
||||
{r?.comment ? (
|
||||
<Tooltip title={r.comment} arrow>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="error.main"
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{r.comment}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.disabled">
|
||||
—
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ whiteSpace: 'nowrap', color: r ? 'text.primary' : 'text.disabled' }}>
|
||||
{r ? formatDate(r.checkedAt) : '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))
|
||||
)}
|
||||
|
||||
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)}>
|
||||
<DialogTitle>Сбросить все проверки?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>Все отметки будут удалены. Это действие нельзя отменить.</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmOpen(false)}>Отмена</Button>
|
||||
<Button color="warning" onClick={() => resetMutation.mutate()}>
|
||||
Сбросить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={errorDialogOpen} onClose={() => setErrorDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Описание ошибки — {errorItem?.action}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
value={errorComment}
|
||||
onChange={(e) => {
|
||||
setErrorComment(e.target.value)
|
||||
if (errorCommentError) setErrorCommentError(false)
|
||||
}}
|
||||
placeholder="Опишите, что пошло не так..."
|
||||
error={errorCommentError}
|
||||
helperText={errorCommentError ? 'Обязательное поле' : ''}
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setErrorDialogOpen(false)}>Отмена</Button>
|
||||
<Button color="error" onClick={handleSaveError}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
# Admin Test Checklist Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add an admin page with a manual testing checklist where each check can be marked as passed/failed with auto-timestamp.
|
||||
|
||||
**Architecture:** Hardcoded checklist items in shared constants, Prisma model for results, Fastify API route (admin-only), React admin page with MUI Accordion grouped by section.
|
||||
|
||||
**Tech Stack:** Prisma (SQLite), Fastify, React + MUI, @tanstack/react-query, apiClient (axios)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Prisma Migration — ChecklistResult Model
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/prisma/schema.prisma` (add model at end)
|
||||
|
||||
- [ ] **Step 1: Add ChecklistResult model to schema**
|
||||
|
||||
Append to `server/prisma/schema.prisma` after the last model:
|
||||
|
||||
```prisma
|
||||
/// Результат ручной проверки тест-чеклиста
|
||||
model ChecklistResult {
|
||||
id String @id @default(cuid())
|
||||
itemKey String @unique
|
||||
passed Boolean
|
||||
checkedAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run migration**
|
||||
|
||||
```bash
|
||||
cd /mnt/d/my_projects/shop/server && npx prisma migrate dev --name add_checklist_result
|
||||
```
|
||||
|
||||
Expected: Migration created and applied successfully.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/prisma/schema.prisma server/prisma/migrations/
|
||||
git commit -m "feat: add ChecklistResult model for manual test checklist"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Shared Constants — Checklist Items
|
||||
|
||||
**Files:**
|
||||
- Create: `shared/constants/test-checklist-items.js`
|
||||
- Create: `shared/constants/test-checklist-items.d.ts`
|
||||
|
||||
- [ ] **Step 1: Create the JS file with all checklist items**
|
||||
|
||||
Create `shared/constants/test-checklist-items.js`:
|
||||
|
||||
```js
|
||||
export const TEST_CHECKLIST_ITEMS = Object.freeze([
|
||||
// Авторизация
|
||||
{ key: 'auth.register-email', section: 'Авторизация', action: 'Зарегистрироваться по email', expectedResult: 'Код приходит на почту, аккаунт создаётся' },
|
||||
{ key: 'auth.login-password', section: 'Авторизация', action: 'Войти по паролю', expectedResult: 'Корректный пароль пускает, неправильный — ошибка' },
|
||||
{ key: 'auth.oauth-vk', section: 'Авторизация', action: 'Войти через OAuth VK', expectedResult: 'Редирект на VK, callback, авторизация успешна' },
|
||||
{ key: 'auth.oauth-yandex', section: 'Авторизация', action: 'Войти через OAuth Yandex', expectedResult: 'Редирект на Yandex, callback, авторизация успешна' },
|
||||
{ key: 'auth.reset-password', section: 'Авторизация', action: 'Сбросить пароль', expectedResult: 'Письмо приходит, ссылка работает, пароль меняется' },
|
||||
{ key: 'auth.logout', section: 'Авторизация', action: 'Выйти из аккаунта', expectedResult: 'Сессия очищается, редирект на страницу входа' },
|
||||
|
||||
// Каталог и товары
|
||||
{ key: 'catalog.homepage', section: 'Каталог и товары', action: 'Открыть главную страницу', expectedResult: 'Слайдер грузится, товары отображаются' },
|
||||
{ key: 'catalog.filters', section: 'Каталог и товары', action: 'Применить фильтры', expectedResult: 'Фильтры по категории, цене, материалам работают' },
|
||||
{ key: 'catalog.product-page', section: 'Каталог и товары', action: 'Открыть страницу товара', expectedResult: 'Фото, описание, цена, кнопка "В корзину" отображаются' },
|
||||
{ key: 'catalog.seo', section: 'Каталог и товары', action: 'Проверить SEO-метаданные', expectedResult: 'Title, meta, slug корректные' },
|
||||
|
||||
// Корзина
|
||||
{ key: 'cart.add', section: 'Корзина', action: 'Добавить товар в корзину', expectedResult: 'Счётчик корзины обновляется' },
|
||||
{ key: 'cart.change-qty', section: 'Корзина', action: 'Изменить количество товара', expectedResult: 'Сумма пересчитывается' },
|
||||
{ key: 'cart.remove', section: 'Корзина', action: 'Удалить товар из корзины', expectedResult: 'Товар убирается, сумма пересчитывается' },
|
||||
|
||||
// Чекаут
|
||||
{ key: 'checkout.address', section: 'Чекаут', action: 'Выбрать адрес доставки', expectedResult: 'Можно выбрать из сохранённых или добавить новый' },
|
||||
{ key: 'checkout.delivery', section: 'Чекаут', action: 'Выбрать способ доставки', expectedResult: 'Почта, OZON, Яндекс, 5post — доступны' },
|
||||
{ key: 'checkout.payment', section: 'Чекаут', action: 'Выбрать способ оплаты', expectedResult: 'Онлайн / при получении — доступны' },
|
||||
{ key: 'checkout.comment', section: 'Чекаут', action: 'Добавить комментарий к заказу', expectedResult: 'Поле работает, текст сохраняется' },
|
||||
{ key: 'checkout.create', section: 'Чекаут', action: 'Создать заказ', expectedResult: 'Заказ создаётся, статус DRAFT' },
|
||||
|
||||
// Оплата
|
||||
{ key: 'payment.yookassa', section: 'Оплата', action: 'Оплатить через ЮKassa', expectedResult: 'Редирект на оплату, webhook обрабатывается' },
|
||||
{ key: 'payment.status', section: 'Оплата', action: 'Проверить статус платежа', expectedResult: 'Статус обновляется после webhook' },
|
||||
|
||||
// Профиль пользователя
|
||||
{ key: 'profile.avatar', section: 'Профиль пользователя', action: 'Управление аватаром', expectedResult: 'Загрузка, отображение, удаление работают' },
|
||||
{ key: 'profile.settings', section: 'Профиль пользователя', action: 'Изменить настройки профиля', expectedResult: 'Email, имя, способы входа обновляются' },
|
||||
{ key: 'profile.addresses', section: 'Профиль пользователя', action: 'Управление адресами', expectedResult: 'Добавление, редактирование, удаление, по умолчанию' },
|
||||
{ key: 'profile.orders', section: 'Профиль пользователя', action: 'Просмотр заказов', expectedResult: 'Список, детали, статусы отображаются' },
|
||||
{ key: 'profile.messages', section: 'Профиль пользователя', action: 'Сообщения по заказу', expectedResult: 'Отправка, получение, read state работают' },
|
||||
{ key: 'profile.notifications', section: 'Профиль пользователя', action: 'Настройки уведомлений', expectedResult: 'Вкл/выкл каналов работают' },
|
||||
{ key: 'profile.delete-account', section: 'Профиль пользователя', action: 'Удалить аккаунт', expectedResult: 'Данные удаляются' },
|
||||
|
||||
// Админ — Товары
|
||||
{ key: 'admin-products.list', section: 'Админ — Товары', action: 'Открыть список товаров', expectedResult: 'Пагинация, поиск работают' },
|
||||
{ key: 'admin-products.create', section: 'Админ — Товары', action: 'Создать товар', expectedResult: 'Все поля сохраняются, фото загружаются, публикация работает' },
|
||||
{ key: 'admin-products.edit', section: 'Админ — Товары', action: 'Редактировать товар', expectedResult: 'Изменения сохраняются' },
|
||||
{ key: 'admin-products.delete', section: 'Админ — Товары', action: 'Удалить товар', expectedResult: 'Товар удаляется' },
|
||||
{ key: 'admin-products.images', section: 'Админ — Товары', action: 'Управление изображениями товара', expectedResult: 'Добавление, сортировка, удаление работают' },
|
||||
|
||||
// Админ — Категории
|
||||
{ key: 'admin-categories.crud', section: 'Админ — Категории', action: 'CRUD категорий', expectedResult: 'Создание, редактирование, удаление, сортировка работают' },
|
||||
|
||||
// Админ — Заказы
|
||||
{ key: 'admin-orders.list', section: 'Админ — Заказы', action: 'Открыть список заказов', expectedResult: 'Фильтрация по статусу, внимание отображается' },
|
||||
{ key: 'admin-orders.details', section: 'Админ — Заказы', action: 'Открыть детали заказа', expectedResult: 'Состав, статус, смена статуса работают' },
|
||||
{ key: 'admin-orders.messages', section: 'Админ — Заказы', action: 'Ответить на сообщение заказа', expectedResult: 'Сообщение отправляется пользователю' },
|
||||
|
||||
// Админ — Отзывы
|
||||
{ key: 'admin-reviews.list', section: 'Админ — Отзывы', action: 'Открыть список отзывов', expectedResult: 'Фильтрация pending/approved/rejected работает' },
|
||||
{ key: 'admin-reviews.moderate', section: 'Админ — Отзывы', action: 'Модерировать отзыв', expectedResult: 'Approve/reject работают' },
|
||||
|
||||
// Админ — Пользователи
|
||||
{ key: 'admin-users.list', section: 'Админ — Пользователи', action: 'Открыть список пользователей', expectedResult: 'Email, дата регистрации отображаются' },
|
||||
{ key: 'admin-users.orders', section: 'Админ — Пользователи', action: 'Просмотр заказов пользователя', expectedResult: 'Заказы пользователя отображаются' },
|
||||
|
||||
// Админ — Галерея
|
||||
{ key: 'admin-gallery.upload', section: 'Админ — Галерея', action: 'Управление галереей', expectedResult: 'Загрузка, удаление, использование в слайдере работают' },
|
||||
|
||||
// Админ — Настройки
|
||||
{ key: 'admin-settings.notifications', section: 'Админ — Настройки', action: 'Настройки уведомлений админа', expectedResult: 'Email, telegram настраиваются' },
|
||||
|
||||
// Инфо-страницы
|
||||
{ key: 'info.pages', section: 'Инфо-страницы', action: 'Открыть инфо-страницы', expectedResult: 'Доставка, оплата, как заказать, статусы заказов отображаются' },
|
||||
{ key: 'info.legal', section: 'Инфо-страницы', action: 'Открыть юридические страницы', expectedResult: 'Политика конфиденциальности, условия использования отображаются' },
|
||||
|
||||
// SSE / Realtime
|
||||
{ key: 'sse.notifications', section: 'SSE / Realtime', action: 'Проверить SSE-уведомления', expectedResult: 'Уведомления приходят в реальном времени' },
|
||||
])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the TypeScript declaration file**
|
||||
|
||||
Create `shared/constants/test-checklist-items.d.ts`:
|
||||
|
||||
```ts
|
||||
export interface TestChecklistItem {
|
||||
key: string
|
||||
section: string
|
||||
action: string
|
||||
expectedResult: string
|
||||
}
|
||||
|
||||
export declare const TEST_CHECKLIST_ITEMS: readonly TestChecklistItem[]
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add shared/constants/test-checklist-items.js shared/constants/test-checklist-items.d.ts
|
||||
git commit -m "feat: add test checklist items shared constants"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Server API Route
|
||||
|
||||
**Files:**
|
||||
- Create: `server/src/routes/api/admin/test-checklist.js`
|
||||
- Modify: `server/src/routes/api.js` (register new route)
|
||||
|
||||
- [ ] **Step 1: Create the API route file**
|
||||
|
||||
Create `server/src/routes/api/admin/test-checklist.js`:
|
||||
|
||||
```js
|
||||
import { prisma } from '../../../lib/prisma.js'
|
||||
|
||||
export async function registerAdminTestChecklistRoutes(fastify) {
|
||||
fastify.get('/api/admin/test-checklist', { preHandler: [fastify.verifyAdmin] }, async () => {
|
||||
const results = await prisma.checklistResult.findMany()
|
||||
const resultMap = {}
|
||||
for (const r of results) {
|
||||
resultMap[r.itemKey] = { passed: r.passed, checkedAt: r.checkedAt.toISOString() }
|
||||
}
|
||||
return { results: resultMap }
|
||||
})
|
||||
|
||||
fastify.patch('/api/admin/test-checklist', { preHandler: [fastify.verifyAdmin] }, async (request) => {
|
||||
const { itemKey, passed } = request.body || {}
|
||||
if (!itemKey || typeof passed !== 'boolean') {
|
||||
return fastify.httpErrors.badRequest('itemKey and passed (boolean) are required')
|
||||
}
|
||||
|
||||
const result = await prisma.checklistResult.upsert({
|
||||
where: { itemKey },
|
||||
create: { itemKey, passed },
|
||||
update: { passed, checkedAt: new Date() },
|
||||
})
|
||||
|
||||
return { result: { itemKey: result.itemKey, passed: result.passed, checkedAt: result.checkedAt.toISOString() } }
|
||||
})
|
||||
|
||||
fastify.post('/api/admin/test-checklist/reset', { preHandler: [fastify.verifyAdmin] }, async () => {
|
||||
await prisma.checklistResult.deleteMany({})
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register the route in api.js**
|
||||
|
||||
Read `server/src/routes/api.js` to find where other admin routes are registered (look for imports and calls like `registerAdminNotificationRoutes`).
|
||||
|
||||
Add import and registration following the existing pattern. Example:
|
||||
|
||||
```js
|
||||
import { registerAdminTestChecklistRoutes } from './api/admin/test-checklist.js'
|
||||
|
||||
// ... inside the registerApiRoutes function:
|
||||
await registerAdminTestChecklistRoutes(fastify)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/src/routes/api/admin/test-checklist.js server/src/routes/api.js
|
||||
git commit -m "feat: add admin test-checklist API routes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Client — API Layer
|
||||
|
||||
**Files:**
|
||||
- Create: `client/src/entities/test-checklist/api/test-checklist-api.ts`
|
||||
|
||||
- [ ] **Step 1: Create the API functions**
|
||||
|
||||
Create `client/src/entities/test-checklist/api/test-checklist-api.ts`:
|
||||
|
||||
```ts
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export interface ChecklistResultDto {
|
||||
passed: boolean
|
||||
checkedAt: string
|
||||
}
|
||||
|
||||
export interface TestChecklistResponse {
|
||||
results: Record<string, ChecklistResultDto>
|
||||
}
|
||||
|
||||
export async function fetchTestChecklistResults(): Promise<TestChecklistResponse> {
|
||||
const { data } = await apiClient.get<TestChecklistResponse>('admin/test-checklist')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateTestChecklistItem(itemKey: string, passed: boolean): Promise<{ itemKey: string; passed: boolean; checkedAt: string }> {
|
||||
const { data } = await apiClient.patch<{ result: { itemKey: string; passed: boolean; checkedAt: string } }>('admin/test-checklist', { itemKey, passed })
|
||||
return data.result
|
||||
}
|
||||
|
||||
export async function resetTestChecklist(): Promise<void> {
|
||||
await apiClient.post('admin/test-checklist/reset')
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/entities/test-checklist/api/test-checklist-api.ts
|
||||
git commit -m "feat: add test-checklist API client functions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Client — Admin Test Checklist Page
|
||||
|
||||
**Files:**
|
||||
- Create: `client/src/pages/admin-test-checklist/ui/AdminTestChecklistPage.tsx`
|
||||
- Create: `client/src/pages/admin-test-checklist/index.ts`
|
||||
|
||||
- [ ] **Step 1: Create the page component**
|
||||
|
||||
Create `client/src/pages/admin-test-checklist/ui/AdminTestChecklistPage.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useMemo, useState } from 'react'
|
||||
import Accordion from '@mui/material/Accordion'
|
||||
import AccordionDetails from '@mui/material/AccordionDetails'
|
||||
import AccordionSummary from '@mui/material/AccordionSummary'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogContentText from '@mui/material/DialogContentText'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Table from '@mui/material/Table'
|
||||
import TableBody from '@mui/material/TableBody'
|
||||
import TableCell from '@mui/material/TableCell'
|
||||
import TableContainer from '@mui/material/TableContainer'
|
||||
import TableHead from '@mui/material/TableHead'
|
||||
import TableRow from '@mui/material/TableRow'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { TEST_CHECKLIST_ITEMS } from '@shared/constants/test-checklist-items'
|
||||
import { fetchTestChecklistResults, resetTestChecklist, updateTestChecklistItem } from '@/entities/test-checklist/api/test-checklist-api'
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function AdminTestChecklistPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [expanded, setExpanded] = useState<string | false>(false)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'test-checklist'],
|
||||
queryFn: fetchTestChecklistResults,
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ itemKey, passed }: { itemKey: string; passed: boolean }) => updateTestChecklistItem(itemKey, passed),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'test-checklist'] })
|
||||
},
|
||||
})
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: resetTestChecklist,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'test-checklist'] })
|
||||
setConfirmOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const map = new Map<string, typeof TEST_CHECKLIST_ITEMS>()
|
||||
for (const item of TEST_CHECKLIST_ITEMS) {
|
||||
const existing = map.get(item.section) || []
|
||||
existing.push(item)
|
||||
map.set(item.section, existing)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
}, [])
|
||||
|
||||
const results = data?.results ?? {}
|
||||
|
||||
const total = TEST_CHECKLIST_ITEMS.length
|
||||
const passedCount = TEST_CHECKLIST_ITEMS.filter((i) => results[i.key]?.passed).length
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
Тест-чеклист
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Пройдено: {passedCount} из {total}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="outlined" color="warning" onClick={() => setConfirmOpen(true)} disabled={resetMutation.isPending}>
|
||||
Сбросить все
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
sections.map(([section, items]) => (
|
||||
<Accordion key={section} expanded={expanded === section} onChange={(_, isExpanded) => setExpanded(isExpanded ? section : false)}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography sx={{ fontWeight: 600 }}>{section}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 0 }}>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox" sx={{ width: 56 }} />
|
||||
<TableCell sx={{ fontWeight: 600 }}>Действие</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Ожидаемый результат</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, whiteSpace: 'nowrap' }}>Дата проверки</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
const r = results[item.key]
|
||||
return (
|
||||
<TableRow key={item.key} hover>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
checked={r?.passed ?? false}
|
||||
onChange={(_, checked) => updateMutation.mutate({ itemKey: item.key, passed: checked })}
|
||||
color="success"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{item.action}</TableCell>
|
||||
<TableCell>{item.expectedResult}</TableCell>
|
||||
<TableCell sx={{ whiteSpace: 'nowrap', color: r ? 'text.primary' : 'text.disabled' }}>
|
||||
{r ? formatDate(r.checkedAt) : '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))
|
||||
)}
|
||||
|
||||
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)}>
|
||||
<DialogTitle>Сбросить все проверки?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>Все отметки будут удалены. Это действие нельзя отменить.</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmOpen(false)}>Отмена</Button>
|
||||
<Button color="warning" onClick={() => resetMutation.mutate()} autoFocus>
|
||||
Сбросить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the barrel export**
|
||||
|
||||
Create `client/src/pages/admin-test-checklist/index.ts`:
|
||||
|
||||
```ts
|
||||
export { AdminTestChecklistPage } from './ui/AdminTestChecklistPage'
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/pages/admin-test-checklist/
|
||||
git commit -m "feat: add admin test checklist page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Client — Routing & Navigation
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/pages/admin-layout/ui/AdminLayoutPage.tsx` (add nav item + route)
|
||||
|
||||
- [ ] **Step 1: Add nav item and route to AdminLayoutPage**
|
||||
|
||||
In `client/src/pages/admin-layout/ui/AdminLayoutPage.tsx`:
|
||||
|
||||
1. Add import for `ClipboardCheck` from `lucide-react`:
|
||||
```tsx
|
||||
import { Bell, ClipboardCheck, Image, LayoutGrid, ListOrdered, MessageSquare, Settings, Store, Users } from 'lucide-react'
|
||||
```
|
||||
|
||||
2. Add import for the page:
|
||||
```tsx
|
||||
import { AdminTestChecklistPage } from '@/pages/admin-test-checklist'
|
||||
```
|
||||
|
||||
3. Add nav item to `navItems` array (after настройки):
|
||||
```tsx
|
||||
{ to: '/admin/test-checklist', label: 'Тест-чеклист', icon: <ClipboardCheck /> },
|
||||
```
|
||||
|
||||
4. Add route to `<Routes>` (before the catch-all `*`):
|
||||
```tsx
|
||||
<Route path="test-checklist" element={<AdminTestChecklistPage />} />
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/pages/admin-layout/ui/AdminLayoutPage.tsx
|
||||
git commit -m "feat: add test-checklist to admin navigation and routing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Verification
|
||||
|
||||
- [ ] **Step 1: Run server tests**
|
||||
|
||||
```bash
|
||||
cd /mnt/d/my_projects/shop/server && npm test
|
||||
```
|
||||
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 2: Run client lint**
|
||||
|
||||
```bash
|
||||
cd /mnt/d/my_projects/shop/client && npm run lint
|
||||
```
|
||||
|
||||
Expected: No errors.
|
||||
|
||||
- [ ] **Step 3: Run client format check**
|
||||
|
||||
```bash
|
||||
cd /mnt/d/my_projects/shop/client && npm run format:check
|
||||
```
|
||||
|
||||
Expected: All files formatted correctly.
|
||||
|
||||
- [ ] **Step 4: Run client tests**
|
||||
|
||||
```bash
|
||||
cd /mnt/d/my_projects/shop/client && npm test
|
||||
```
|
||||
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 5: Run client build**
|
||||
|
||||
```bash
|
||||
cd /mnt/d/my_projects/shop/client && npm run build
|
||||
```
|
||||
|
||||
Expected: Build succeeds with no type errors.
|
||||
@@ -0,0 +1,507 @@
|
||||
# Test Checklist v2 — 3-State Status + Error Comments Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add "failed with error" state to checklist items with a mandatory error comment dialog.
|
||||
|
||||
**Architecture:** Add `comment String?` field to `ChecklistResult` model. Update PATCH API to accept optional comment. Replace checkbox with 3-state status selector (passed/failed/not-checked). Show error comment dialog when marking as failed.
|
||||
|
||||
**Tech Stack:** Prisma (SQLite), Fastify, React + MUI, @tanstack/react-query, apiClient (axios)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Prisma Migration — Add comment field
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/prisma/schema.prisma`
|
||||
|
||||
- [ ] **Step 1: Add comment field to ChecklistResult model**
|
||||
|
||||
In `server/prisma/schema.prisma`, find the `ChecklistResult` model and add `comment String?` after `passed`:
|
||||
|
||||
```prisma
|
||||
/// Результат ручной проверки тест-чеклиста
|
||||
model ChecklistResult {
|
||||
id String @id @default(cuid())
|
||||
itemKey String @unique
|
||||
passed Boolean
|
||||
comment String?
|
||||
checkedAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run migration**
|
||||
|
||||
```bash
|
||||
cd /mnt/d/my_projects/shop/server && npx prisma migrate dev --name add_checklist_comment
|
||||
```
|
||||
|
||||
Expected: Migration created and applied successfully.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/prisma/schema.prisma server/prisma/migrations/
|
||||
git commit -m "feat: add comment field to ChecklistResult for error descriptions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Server API — Support comment in PATCH and GET
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/src/routes/api/admin/test-checklist.js`
|
||||
|
||||
- [ ] **Step 1: Update PATCH to accept and store comment**
|
||||
|
||||
Replace the PATCH handler in `server/src/routes/api/admin/test-checklist.js`:
|
||||
|
||||
```js
|
||||
fastify.patch('/api/admin/test-checklist', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const { itemKey, passed, comment } = request.body || {}
|
||||
if (!itemKey || typeof passed !== 'boolean') {
|
||||
return reply.code(400).send({ error: 'itemKey и passed (boolean) обязательны' })
|
||||
}
|
||||
if (comment !== undefined && typeof comment !== 'string') {
|
||||
return reply.code(400).send({ error: 'comment должен быть строкой' })
|
||||
}
|
||||
if (comment !== undefined && comment.length > 2000) {
|
||||
return reply.code(400).send({ error: 'Комментарий слишком длинный (макс. 2000 символов)' })
|
||||
}
|
||||
|
||||
const result = await prisma.checklistResult.upsert({
|
||||
where: { itemKey },
|
||||
create: { itemKey, passed, comment: passed ? null : comment || null },
|
||||
update: { passed, comment: passed ? null : comment ?? undefined, checkedAt: new Date() },
|
||||
})
|
||||
|
||||
return { result: { itemKey: result.itemKey, passed: result.passed, comment: result.comment, checkedAt: result.checkedAt.toISOString() } }
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update GET to include comment in response**
|
||||
|
||||
Replace the GET handler's result mapping:
|
||||
|
||||
```js
|
||||
fastify.get('/api/admin/test-checklist', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const results = await prisma.checklistResult.findMany()
|
||||
const resultMap = {}
|
||||
for (const r of results) {
|
||||
resultMap[r.itemKey] = { passed: r.passed, comment: r.comment, checkedAt: r.checkedAt.toISOString() }
|
||||
}
|
||||
return { results: resultMap }
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/src/routes/api/admin/test-checklist.js
|
||||
git commit -m "feat: support comment field in test-checklist API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Client API Layer — Add comment to types and functions
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/entities/test-checklist/api/test-checklist-api.ts`
|
||||
|
||||
- [ ] **Step 1: Update types and update function**
|
||||
|
||||
Replace the entire file content:
|
||||
|
||||
```ts
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export type ChecklistResultDto = {
|
||||
passed: boolean
|
||||
comment: string | null
|
||||
checkedAt: string
|
||||
}
|
||||
|
||||
export type TestChecklistResponse = {
|
||||
results: Record<string, ChecklistResultDto>
|
||||
}
|
||||
|
||||
export type UpdateChecklistItemResponse = {
|
||||
itemKey: string
|
||||
passed: boolean
|
||||
comment: string | null
|
||||
checkedAt: string
|
||||
}
|
||||
|
||||
export async function fetchTestChecklistResults(): Promise<TestChecklistResponse> {
|
||||
const { data } = await apiClient.get<TestChecklistResponse>('admin/test-checklist')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateTestChecklistItem(
|
||||
itemKey: string,
|
||||
passed: boolean,
|
||||
comment?: string | null,
|
||||
): Promise<UpdateChecklistItemResponse> {
|
||||
const { data } = await apiClient.patch<{ result: UpdateChecklistItemResponse }>('admin/test-checklist', {
|
||||
itemKey,
|
||||
passed,
|
||||
comment: passed ? null : comment ?? null,
|
||||
})
|
||||
return data.result
|
||||
}
|
||||
|
||||
export async function resetTestChecklist(): Promise<void> {
|
||||
await apiClient.post('admin/test-checklist/reset')
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/entities/test-checklist/api/test-checklist-api.ts
|
||||
git commit -m "feat: add comment to test-checklist API client types and function"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Client Page — 3-state status + error comment dialog
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/pages/admin-test-checklist/ui/AdminTestChecklistPage.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace the entire page component**
|
||||
|
||||
Replace the entire file content:
|
||||
|
||||
```tsx
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Accordion from '@mui/material/Accordion'
|
||||
import AccordionDetails from '@mui/material/AccordionDetails'
|
||||
import AccordionSummary from '@mui/material/AccordionSummary'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Table from '@mui/material/Table'
|
||||
import TableBody from '@mui/material/TableBody'
|
||||
import TableCell from '@mui/material/TableCell'
|
||||
import TableContainer from '@mui/material/TableContainer'
|
||||
import TableHead from '@mui/material/TableHead'
|
||||
import TableRow from '@mui/material/TableRow'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
|
||||
import CancelIcon from '@mui/icons-material/Cancel'
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { TEST_CHECKLIST_ITEMS } from '@shared/constants/test-checklist-items'
|
||||
import {
|
||||
fetchTestChecklistResults,
|
||||
resetTestChecklist,
|
||||
updateTestChecklistItem,
|
||||
} from '@/entities/test-checklist/api/test-checklist-api'
|
||||
|
||||
type Status = 'passed' | 'failed' | 'unchecked'
|
||||
|
||||
function statusFromResult(r: { passed: boolean; comment: string | null } | undefined): Status {
|
||||
if (!r) return 'unchecked'
|
||||
return r.passed ? 'passed' : 'failed'
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function StatusIcon({ status, onClick }: { status: Status; onClick: () => void }) {
|
||||
const icons = {
|
||||
passed: <CheckCircleIcon sx={{ color: 'success.main' }} />,
|
||||
failed: <CancelIcon sx={{ color: 'error.main' }} />,
|
||||
unchecked: <Box sx={{ width: 24, height: 24, borderRadius: '50%', border: 2, borderColor: 'action.disabled' }} />,
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={status === 'passed' ? 'Пройдено' : status === 'failed' ? 'Не пройдено' : 'Не проверено'}>
|
||||
<IconButton size="small" onClick={onClick} sx={{ p: 0.5 }}>
|
||||
{icons[status]}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminTestChecklistPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [expanded, setExpanded] = useState<string | false>(false)
|
||||
const [errorDialogOpen, setErrorDialogOpen] = useState(false)
|
||||
const [errorItemKey, setErrorItemKey] = useState<string | null>(null)
|
||||
const [errorComment, setErrorComment] = useState('')
|
||||
const [errorCommentError, setErrorCommentError] = useState(false)
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['admin', 'test-checklist'],
|
||||
queryFn: fetchTestChecklistResults,
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ itemKey, passed, comment }: { itemKey: string; passed: boolean; comment?: string | null }) =>
|
||||
updateTestChecklistItem(itemKey, passed, comment),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'test-checklist'] })
|
||||
},
|
||||
})
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: resetTestChecklist,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'test-checklist'] })
|
||||
setConfirmOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
const sections = useMemo(() => {
|
||||
const map = new Map<string, (typeof TEST_CHECKLIST_ITEMS)[number][]>()
|
||||
for (const item of TEST_CHECKLIST_ITEMS) {
|
||||
const existing = map.get(item.section) || []
|
||||
existing.push(item)
|
||||
map.set(item.section, existing)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
}, [])
|
||||
|
||||
const results = data?.results ?? {}
|
||||
|
||||
const total = TEST_CHECKLIST_ITEMS.length
|
||||
const passedCount = TEST_CHECKLIST_ITEMS.filter((i) => results[i.key]?.passed).length
|
||||
|
||||
const handleStatusClick = useCallback(
|
||||
(itemKey: string) => {
|
||||
const current = statusFromResult(results[itemKey])
|
||||
if (current === 'failed') {
|
||||
// Clicking on failed → reset to unchecked
|
||||
updateMutation.mutate({ itemKey, passed: false, comment: null })
|
||||
} else if (current === 'unchecked') {
|
||||
// Clicking on unchecked → mark as passed
|
||||
updateMutation.mutate({ itemKey, passed: true })
|
||||
} else {
|
||||
// Clicking on passed → open error dialog
|
||||
setErrorItemKey(itemKey)
|
||||
setErrorComment('')
|
||||
setErrorCommentError(false)
|
||||
setErrorDialogOpen(true)
|
||||
}
|
||||
},
|
||||
[results, updateMutation],
|
||||
)
|
||||
|
||||
const handleSaveError = () => {
|
||||
if (!errorItemKey) return
|
||||
if (!errorComment.trim()) {
|
||||
setErrorCommentError(true)
|
||||
return
|
||||
}
|
||||
updateMutation.mutate({ itemKey: errorItemKey, passed: false, comment: errorComment.trim() })
|
||||
setErrorDialogOpen(false)
|
||||
setErrorItemKey(null)
|
||||
setErrorComment('')
|
||||
}
|
||||
|
||||
const errorItem = errorItemKey ? TEST_CHECKLIST_ITEMS.find((i) => i.key === errorItemKey) : null
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" sx={{ mb: 3, justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
Тест-чеклист
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Пройдено: {passedCount} из {total}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
disabled={resetMutation.isPending}
|
||||
>
|
||||
Сбросить все
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : isError ? (
|
||||
<Alert severity="error">Не удалось загрузить чеклист.</Alert>
|
||||
) : (
|
||||
sections.map(([section, items]) => (
|
||||
<Accordion
|
||||
key={section}
|
||||
expanded={expanded === section}
|
||||
onChange={(_, isExpanded) => setExpanded(isExpanded ? section : false)}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography sx={{ fontWeight: 600 }}>{section}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ px: 0 }}>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ width: 56 }} />
|
||||
<TableCell sx={{ fontWeight: 600 }}>Действие</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Ожидаемый результат</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Комментарий</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, whiteSpace: 'nowrap' }}>Дата проверки</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
const r = results[item.key]
|
||||
const status = statusFromResult(r)
|
||||
return (
|
||||
<TableRow key={item.key} hover>
|
||||
<TableCell>
|
||||
<StatusIcon status={status} onClick={() => handleStatusClick(item.key)} />
|
||||
</TableCell>
|
||||
<TableCell>{item.action}</TableCell>
|
||||
<TableCell>{item.expectedResult}</TableCell>
|
||||
<TableCell sx={{ maxWidth: 200 }}>
|
||||
{r?.comment ? (
|
||||
<Tooltip title={r.comment} arrow>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="error.main"
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{r.comment}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.disabled">
|
||||
—
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ whiteSpace: 'nowrap', color: r ? 'text.primary' : 'text.disabled' }}>
|
||||
{r ? formatDate(r.checkedAt) : '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))
|
||||
)}
|
||||
|
||||
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)}>
|
||||
<DialogTitle>Сбросить все проверки?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>Все отметки будут удалены. Это действие нельзя отменить.</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmOpen(false)}>Отмена</Button>
|
||||
<Button color="warning" onClick={() => resetMutation.mutate()}>
|
||||
Сбросить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={errorDialogOpen} onClose={() => setErrorDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
Описание ошибки — {errorItem?.action}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
value={errorComment}
|
||||
onChange={(e) => {
|
||||
setErrorComment(e.target.value)
|
||||
if (errorCommentError) setErrorCommentError(false)
|
||||
}}
|
||||
placeholder="Опишите, что пошло не так..."
|
||||
error={errorCommentError}
|
||||
helperText={errorCommentError ? 'Обязательное поле' : ''}
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setErrorDialogOpen(false)}>Отмена</Button>
|
||||
<Button color="error" onClick={handleSaveError}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/pages/admin-test-checklist/ui/AdminTestChecklistPage.tsx
|
||||
git commit -m "feat: add 3-state status and error comment dialog to test checklist"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Verification
|
||||
|
||||
- [ ] **Step 1: Run server tests**
|
||||
|
||||
```bash
|
||||
cd /mnt/d/my_projects/shop/server && npm test
|
||||
```
|
||||
|
||||
Expected: All tests pass (pre-existing failures unrelated).
|
||||
|
||||
- [ ] **Step 2: Run client lint**
|
||||
|
||||
```bash
|
||||
cd /mnt/d/my_projects/shop/client && npm run lint
|
||||
```
|
||||
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 3: Run client format check**
|
||||
|
||||
```bash
|
||||
cd /mnt/d/my_projects/shop/client && npm run format:check
|
||||
```
|
||||
|
||||
Expected: All files formatted correctly.
|
||||
|
||||
- [ ] **Step 4: Run client build**
|
||||
|
||||
```bash
|
||||
cd /mnt/d/my_projects/shop/client && npm run build
|
||||
```
|
||||
|
||||
Expected: Build succeeds with no type errors.
|
||||
@@ -0,0 +1,149 @@
|
||||
# Design: Admin Test Checklist
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Status:** Approved
|
||||
**Updated:** 2026-05-24 (v2 — 3-state status + error comments)
|
||||
|
||||
## Summary
|
||||
|
||||
Админ-страница с чек-листом для ручного тестирования функционала сайта. Каждая проверка имеет описание действия и ожидаемый результат. Отметка сохраняется в SQLite с автоматической датой. Поддерживает 3 состояния: пройдено / не пройдено (с комментарием об ошибке) / не проверено.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Model
|
||||
|
||||
Модель `ChecklistResult` в Prisma schema:
|
||||
|
||||
```prisma
|
||||
model ChecklistResult {
|
||||
id String @id @default(cuid())
|
||||
itemKey String @unique // уникальный ключ проверки
|
||||
passed Boolean // true = пройдено, false = не пройдено
|
||||
comment String? // описание ошибки (при passed: false)
|
||||
checkedAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
### Elements Source
|
||||
|
||||
Элементы чек-листа — хардкод в `shared/constants/test-checklist-items.js` (JS + .d.ts). Версионируются с кодом.
|
||||
|
||||
Структура элемента:
|
||||
```ts
|
||||
{
|
||||
key: string // уникальный идентификатор
|
||||
section: string // раздел (группировка)
|
||||
action: string // что сделать
|
||||
expectedResult: string // что должно произойти
|
||||
}
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
**Server route:** `server/src/routes/api/admin/test-checklist.js`
|
||||
|
||||
- `GET /api/admin/test-checklist` — возвращает все элементы + их результаты из БД (включая `comment`)
|
||||
- `PATCH /api/admin/test-checklist` — body: `{ itemKey, passed, comment? }`, обновляет/создаёт результат, `checkedAt = now()`. При `passed: true` комментарий очищается.
|
||||
- `POST /api/admin/test-checklist/reset` — сбрасывает все результаты (удаляет записи)
|
||||
|
||||
Только для админов.
|
||||
|
||||
### Frontend
|
||||
|
||||
**Page:** `client/src/pages/admin-test-checklist/ui/AdminTestChecklistPage.tsx`
|
||||
|
||||
- Аккордеон по разделам (MUI Accordion)
|
||||
- Каждая строка: 3-состоятельный статус (✅ пройдено / ❌ не пройдено / ⬜ не проверено), колонка "Действие", колонка "Ожидаемый результат", колонка "Комментарий" (показывает текст ошибки с tooltip), дата проверки
|
||||
- При выборе «Не пройдено» — открывается диалог с обязательным полем «Описание ошибки» (textarea)
|
||||
- Диалог: заголовок «Описание ошибки — {название проверки}», placeholder «Опишите, что пошло не так...», кнопки «Отмена» / «Сохранить», валидация — поле обязательно при ❌
|
||||
- Кнопка «Сбросить все» для полного ре-теста
|
||||
- `@tanstack/react-query` для данных, `apiClient` для запросов
|
||||
|
||||
**Navigation:** новый пункт в `AdminLayoutPage` — «Тест-чеклист» с иконкой `ClipboardCheck`, роут `/admin/test-checklist`
|
||||
|
||||
## Sections & Items
|
||||
|
||||
### Авторизация (auth)
|
||||
- `auth.register-email` — Регистрация по email → код приходит, аккаунт создаётся
|
||||
- `auth.login-password` — Вход по паролю → корректный пускает, неправильный — ошибка
|
||||
- `auth.oauth-vk` — OAuth VK → редирект, callback, авторизация
|
||||
- `auth.oauth-yandex` — OAuth Yandex → редирект, callback, авторизация
|
||||
- `auth.reset-password` — Сброс пароля → письмо приходит, ссылка работает
|
||||
- `auth.logout` — Выход → сессия очищается
|
||||
|
||||
### Каталог и товары (catalog)
|
||||
- `catalog.homepage` — Главная → слайдер грузится, товары отображаются
|
||||
- `catalog.filters` — Фильтры → по категории, цене, материалам работают
|
||||
- `catalog.product-page` — Страница товара → фото, описание, цена, кнопка "В корзину"
|
||||
- `catalog.seo` — SEO → title, meta, slug корректные
|
||||
|
||||
### Корзина (cart)
|
||||
- `cart.add` — Добавление товара → счётчик обновляется
|
||||
- `cart.change-qty` — Изменение количества → пересчёт суммы
|
||||
- `cart.remove` — Удаление → товар убирается, сумма пересчитывается
|
||||
|
||||
### Чекаут (checkout)
|
||||
- `checkout.address` — Выбор адреса → из сохранённых / новый
|
||||
- `checkout.delivery` — Выбор доставки → Почта, OZON, Яндекс, 5post
|
||||
- `checkout.payment` — Выбор оплаты → онлайн / при получении
|
||||
- `checkout.comment` — Комментарий к заказу → поле работает
|
||||
- `checkout.create` — Создание заказа → заказ создаётся, статус DRAFT
|
||||
|
||||
### Оплата (payment)
|
||||
- `payment.yookassa` — ЮKassa → редирект на оплату, webhook обрабатывается
|
||||
- `payment.status` — Статус платежа → обновляется после webhook
|
||||
|
||||
### Профиль пользователя (profile)
|
||||
- `profile.avatar` — Аватар → загрузка, отображение, удаление
|
||||
- `profile.settings` — Настройки → email, имя, способы входа
|
||||
- `profile.addresses` — Адреса → добавление, редактирование, удаление, по умолчанию
|
||||
- `profile.orders` — Заказы → список, детали, статусы
|
||||
- `profile.messages` — Сообщения по заказу → отправка, получение, read state
|
||||
- `profile.notifications` — Уведомления → вкл/выкл каналы
|
||||
- `profile.delete-account` — Удаление аккаунта → данные удаляются
|
||||
|
||||
### Админ — Товары (admin-products)
|
||||
- `admin-products.list` — Список → пагинация, поиск
|
||||
- `admin-products.create` — Создание → все поля, загрузка фото, публикация
|
||||
- `admin-products.edit` — Редактирование → изменения сохраняются
|
||||
- `admin-products.delete` — Удаление → товар удаляется
|
||||
- `admin-products.images` — Изображения → добавление, сортировка, удаление
|
||||
|
||||
### Админ — Категории (admin-categories)
|
||||
- `admin-categories.crud` — CRUD категорий, сортировка
|
||||
|
||||
### Админ — Заказы (admin-orders)
|
||||
- `admin-orders.list` — Список → фильтрация по статусу, внимание
|
||||
- `admin-orders.details` — Детали → состав, статус, смена статуса
|
||||
- `admin-orders.messages` — Сообщения → ответ пользователю
|
||||
|
||||
### Админ — Отзывы (admin-reviews)
|
||||
- `admin-reviews.list` — Список → pending/approved/rejected
|
||||
- `admin-reviews.moderate` — Модерация → approve/reject
|
||||
|
||||
### Админ — Пользователи (admin-users)
|
||||
- `admin-users.list` — Список → email, дата регистрации
|
||||
- `admin-users.orders` — Просмотр заказов пользователя
|
||||
|
||||
### Админ — Галерея (admin-gallery)
|
||||
- `admin-gallery.upload` — Загрузка, удаление, использование в слайдере
|
||||
|
||||
### Админ — Настройки (admin-settings)
|
||||
- `admin-settings.notifications` — Уведомления → email, telegram
|
||||
|
||||
### Инфо-страницы (info)
|
||||
- `info.pages` — Доставка, оплата, как заказать, статусы заказов
|
||||
- `info.legal` — Политика конфиденциальности, условия использования
|
||||
|
||||
### SSE / Realtime (sse)
|
||||
- `sse.notifications` — Уведомления приходят в реальном времени
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. Prisma migration — добавить модель `ChecklistResult`
|
||||
2. Shared constants — `test-checklist-items.ts`
|
||||
3. Server API route — GET/PATCH/POST
|
||||
4. Client page — `AdminTestChecklistPage`
|
||||
5. Client routing — добавить в `AdminLayoutPage`
|
||||
6. Register route in `client/src/app/routes/index.tsx`
|
||||
@@ -0,0 +1,11 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ChecklistResult" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"itemKey" TEXT NOT NULL,
|
||||
"passed" BOOLEAN NOT NULL,
|
||||
"checkedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ChecklistResult_itemKey_key" ON "ChecklistResult"("itemKey");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ChecklistResult" ADD COLUMN "comment" TEXT;
|
||||
@@ -340,3 +340,13 @@ model NotificationLog {
|
||||
@@index([status, createdAt])
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
/// Результат ручной проверки тест-чеклиста
|
||||
model ChecklistResult {
|
||||
id String @id @default(cuid())
|
||||
itemKey String @unique
|
||||
passed Boolean
|
||||
comment String?
|
||||
checkedAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mapProductForApi, parseMaterialsInput, slugify } from './api/_product-helpers.js'
|
||||
import { registerAdminNotificationRoutes } from './api/admin/notifications.js'
|
||||
import { registerAdminTestChecklistRoutes } from './api/admin/test-checklist.js'
|
||||
import { registerAdminCategoryRoutes } from './api/admin-categories.js'
|
||||
import { registerAdminGalleryRoutes } from './api/admin-gallery.js'
|
||||
import { registerAdminOrderRoutes } from './api/admin-orders.js'
|
||||
@@ -31,6 +32,7 @@ export async function registerApiRoutes(fastify) {
|
||||
await registerAdminReviewRoutes(fastify)
|
||||
await registerAdminUserRoutes(fastify)
|
||||
await registerAdminNotificationRoutes(fastify)
|
||||
await registerAdminTestChecklistRoutes(fastify)
|
||||
await registerAdminProfileRoutes(fastify)
|
||||
|
||||
await registerAuthRoutes(fastify)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { prisma } from '../../../lib/prisma.js'
|
||||
|
||||
export async function registerAdminTestChecklistRoutes(fastify) {
|
||||
fastify.get('/api/admin/test-checklist', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const results = await prisma.checklistResult.findMany()
|
||||
const resultMap = {}
|
||||
for (const r of results) {
|
||||
resultMap[r.itemKey] = { passed: r.passed, comment: r.comment, checkedAt: r.checkedAt.toISOString() }
|
||||
}
|
||||
return { results: resultMap }
|
||||
})
|
||||
|
||||
fastify.patch('/api/admin/test-checklist', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
const { itemKey, passed, comment } = request.body || {}
|
||||
if (!itemKey || typeof passed !== 'boolean') {
|
||||
return reply.code(400).send({ error: 'itemKey и passed (boolean) обязательны' })
|
||||
}
|
||||
if (comment !== undefined && comment !== null && typeof comment !== 'string') {
|
||||
return reply.code(400).send({ error: 'comment должен быть строкой' })
|
||||
}
|
||||
if (comment !== undefined && comment !== null && comment.length > 2000) {
|
||||
return reply.code(400).send({ error: 'Комментарий слишком длинный (макс. 2000 символов)' })
|
||||
}
|
||||
|
||||
const result = await prisma.checklistResult.upsert({
|
||||
where: { itemKey },
|
||||
create: { itemKey, passed, comment: passed ? null : comment || null },
|
||||
update: { passed, comment: passed ? null : comment ?? undefined, checkedAt: new Date() },
|
||||
})
|
||||
|
||||
return { result: { itemKey: result.itemKey, passed: result.passed, comment: result.comment, checkedAt: result.checkedAt.toISOString() } }
|
||||
})
|
||||
|
||||
fastify.post('/api/admin/test-checklist/reset', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
|
||||
await prisma.checklistResult.deleteMany({})
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
export interface TestChecklistItem {
|
||||
key: string;
|
||||
section: string;
|
||||
action: string;
|
||||
expectedResult: string;
|
||||
}
|
||||
|
||||
export declare const TEST_CHECKLIST_ITEMS: readonly TestChecklistItem[];
|
||||
@@ -0,0 +1,304 @@
|
||||
export const TEST_CHECKLIST_ITEMS = Object.freeze([
|
||||
// Авторизация
|
||||
{
|
||||
key: "auth.register-email",
|
||||
section: "Авторизация",
|
||||
action: "Зарегистрироваться по email",
|
||||
expectedResult: "Код приходит на почту, аккаунт создаётся",
|
||||
},
|
||||
{
|
||||
key: "auth.login-password",
|
||||
section: "Авторизация",
|
||||
action: "Войти по паролю",
|
||||
expectedResult: "Корректный пароль пускает, неправильный — ошибка",
|
||||
},
|
||||
{
|
||||
key: "auth.oauth-vk",
|
||||
section: "Авторизация",
|
||||
action: "Войти через OAuth VK",
|
||||
expectedResult: "Редирект на VK, callback, авторизация успешна",
|
||||
},
|
||||
{
|
||||
key: "auth.oauth-yandex",
|
||||
section: "Авторизация",
|
||||
action: "Войти через OAuth Yandex",
|
||||
expectedResult: "Редирект на Yandex, callback, авторизация успешна",
|
||||
},
|
||||
{
|
||||
key: "auth.reset-password",
|
||||
section: "Авторизация",
|
||||
action: "Сбросить пароль",
|
||||
expectedResult: "Письмо приходит, ссылка работает, пароль меняется",
|
||||
},
|
||||
{
|
||||
key: "auth.logout",
|
||||
section: "Авторизация",
|
||||
action: "Выйти из аккаунта",
|
||||
expectedResult: "Сессия очищается, редирект на страницу входа",
|
||||
},
|
||||
|
||||
// Каталог и товары
|
||||
{
|
||||
key: "catalog.homepage",
|
||||
section: "Каталог и товары",
|
||||
action: "Открыть главную страницу",
|
||||
expectedResult: "Слайдер грузится, товары отображаются",
|
||||
},
|
||||
{
|
||||
key: "catalog.filters",
|
||||
section: "Каталог и товары",
|
||||
action: "Применить фильтры",
|
||||
expectedResult: "Фильтры по категории, цене, материалам работают",
|
||||
},
|
||||
{
|
||||
key: "catalog.product-page",
|
||||
section: "Каталог и товары",
|
||||
action: "Открыть страницу товара",
|
||||
expectedResult: 'Фото, описание, цена, кнопка "В корзину" отображаются',
|
||||
},
|
||||
{
|
||||
key: "catalog.seo",
|
||||
section: "Каталог и товары",
|
||||
action: "Проверить SEO-метаданные",
|
||||
expectedResult: "Title, meta, slug корректные",
|
||||
},
|
||||
|
||||
// Корзина
|
||||
{
|
||||
key: "cart.add",
|
||||
section: "Корзина",
|
||||
action: "Добавить товар в корзину",
|
||||
expectedResult: "Счётчик корзины обновляется",
|
||||
},
|
||||
{
|
||||
key: "cart.change-qty",
|
||||
section: "Корзина",
|
||||
action: "Изменить количество товара",
|
||||
expectedResult: "Сумма пересчитывается",
|
||||
},
|
||||
{
|
||||
key: "cart.remove",
|
||||
section: "Корзина",
|
||||
action: "Удалить товар из корзины",
|
||||
expectedResult: "Товар убирается, сумма пересчитывается",
|
||||
},
|
||||
|
||||
// Чекаут
|
||||
{
|
||||
key: "checkout.address",
|
||||
section: "Чекаут",
|
||||
action: "Выбрать адрес доставки",
|
||||
expectedResult: "Можно выбрать из сохранённых или добавить новый",
|
||||
},
|
||||
{
|
||||
key: "checkout.delivery",
|
||||
section: "Чекаут",
|
||||
action: "Выбрать способ доставки",
|
||||
expectedResult: "Почта, OZON, Яндекс, 5post — доступны",
|
||||
},
|
||||
{
|
||||
key: "checkout.payment",
|
||||
section: "Чекаут",
|
||||
action: "Выбрать способ оплаты",
|
||||
expectedResult: "Онлайн / при получении — доступны",
|
||||
},
|
||||
{
|
||||
key: "checkout.comment",
|
||||
section: "Чекаут",
|
||||
action: "Добавить комментарий к заказу",
|
||||
expectedResult: "Поле работает, текст сохраняется",
|
||||
},
|
||||
{
|
||||
key: "checkout.create",
|
||||
section: "Чекаут",
|
||||
action: "Создать заказ",
|
||||
expectedResult: "Заказ создаётся, статус DRAFT",
|
||||
},
|
||||
|
||||
// Оплата
|
||||
{
|
||||
key: "payment.yookassa",
|
||||
section: "Оплата",
|
||||
action: "Оплатить через ЮKassa",
|
||||
expectedResult: "Редирект на оплату, webhook обрабатывается",
|
||||
},
|
||||
{
|
||||
key: "payment.status",
|
||||
section: "Оплата",
|
||||
action: "Проверить статус платежа",
|
||||
expectedResult: "Статус обновляется после webhook",
|
||||
},
|
||||
|
||||
// Профиль пользователя
|
||||
{
|
||||
key: "profile.avatar",
|
||||
section: "Профиль пользователя",
|
||||
action: "Управление аватаром",
|
||||
expectedResult: "Загрузка, отображение, удаление работают",
|
||||
},
|
||||
{
|
||||
key: "profile.settings",
|
||||
section: "Профиль пользователя",
|
||||
action: "Изменить настройки профиля",
|
||||
expectedResult: "Email, имя, способы входа обновляются",
|
||||
},
|
||||
{
|
||||
key: "profile.addresses",
|
||||
section: "Профиль пользователя",
|
||||
action: "Управление адресами",
|
||||
expectedResult: "Добавление, редактирование, удаление, по умолчанию",
|
||||
},
|
||||
{
|
||||
key: "profile.orders",
|
||||
section: "Профиль пользователя",
|
||||
action: "Просмотр заказов",
|
||||
expectedResult: "Список, детали, статусы отображаются",
|
||||
},
|
||||
{
|
||||
key: "profile.messages",
|
||||
section: "Профиль пользователя",
|
||||
action: "Сообщения по заказу",
|
||||
expectedResult: "Отправка, получение, read state работают",
|
||||
},
|
||||
{
|
||||
key: "profile.notifications",
|
||||
section: "Профиль пользователя",
|
||||
action: "Настройки уведомлений",
|
||||
expectedResult: "Вкл/выкл каналов работают",
|
||||
},
|
||||
{
|
||||
key: "profile.delete-account",
|
||||
section: "Профиль пользователя",
|
||||
action: "Удалить аккаунт",
|
||||
expectedResult: "Данные удаляются",
|
||||
},
|
||||
|
||||
// Админ — Товары
|
||||
{
|
||||
key: "admin-products.list",
|
||||
section: "Админ — Товары",
|
||||
action: "Открыть список товаров",
|
||||
expectedResult: "Пагинация, поиск работают",
|
||||
},
|
||||
{
|
||||
key: "admin-products.create",
|
||||
section: "Админ — Товары",
|
||||
action: "Создать товар",
|
||||
expectedResult:
|
||||
"Все поля сохраняются, фото загружаются, публикация работает",
|
||||
},
|
||||
{
|
||||
key: "admin-products.edit",
|
||||
section: "Админ — Товары",
|
||||
action: "Редактировать товар",
|
||||
expectedResult: "Изменения сохраняются",
|
||||
},
|
||||
{
|
||||
key: "admin-products.delete",
|
||||
section: "Админ — Товары",
|
||||
action: "Удалить товар",
|
||||
expectedResult: "Товар удаляется",
|
||||
},
|
||||
{
|
||||
key: "admin-products.images",
|
||||
section: "Админ — Товары",
|
||||
action: "Управление изображениями товара",
|
||||
expectedResult: "Добавление, сортировка, удаление работают",
|
||||
},
|
||||
|
||||
// Админ — Категории
|
||||
{
|
||||
key: "admin-categories.crud",
|
||||
section: "Админ — Категории",
|
||||
action: "CRUD категорий",
|
||||
expectedResult: "Создание, редактирование, удаление, сортировка работают",
|
||||
},
|
||||
|
||||
// Админ — Заказы
|
||||
{
|
||||
key: "admin-orders.list",
|
||||
section: "Админ — Заказы",
|
||||
action: "Открыть список заказов",
|
||||
expectedResult: "Фильтрация по статусу, внимание отображается",
|
||||
},
|
||||
{
|
||||
key: "admin-orders.details",
|
||||
section: "Админ — Заказы",
|
||||
action: "Открыть детали заказа",
|
||||
expectedResult: "Состав, статус, смена статуса работают",
|
||||
},
|
||||
{
|
||||
key: "admin-orders.messages",
|
||||
section: "Админ — Заказы",
|
||||
action: "Ответить на сообщение заказа",
|
||||
expectedResult: "Сообщение отправляется пользователю",
|
||||
},
|
||||
|
||||
// Админ — Отзывы
|
||||
{
|
||||
key: "admin-reviews.list",
|
||||
section: "Админ — Отзывы",
|
||||
action: "Открыть список отзывов",
|
||||
expectedResult: "Фильтрация pending/approved/rejected работает",
|
||||
},
|
||||
{
|
||||
key: "admin-reviews.moderate",
|
||||
section: "Админ — Отзывы",
|
||||
action: "Модерировать отзыв",
|
||||
expectedResult: "Approve/reject работают",
|
||||
},
|
||||
|
||||
// Админ — Пользователи
|
||||
{
|
||||
key: "admin-users.list",
|
||||
section: "Админ — Пользователи",
|
||||
action: "Открыть список пользователей",
|
||||
expectedResult: "Email, дата регистрации отображаются",
|
||||
},
|
||||
{
|
||||
key: "admin-users.orders",
|
||||
section: "Админ — Пользователи",
|
||||
action: "Просмотр заказов пользователя",
|
||||
expectedResult: "Заказы пользователя отображаются",
|
||||
},
|
||||
|
||||
// Админ — Галерея
|
||||
{
|
||||
key: "admin-gallery.upload",
|
||||
section: "Админ — Галерея",
|
||||
action: "Управление галереей",
|
||||
expectedResult: "Загрузка, удаление, использование в слайдере работают",
|
||||
},
|
||||
|
||||
// Админ — Настройки
|
||||
{
|
||||
key: "admin-settings.notifications",
|
||||
section: "Админ — Настройки",
|
||||
action: "Настройки уведомлений админа",
|
||||
expectedResult: "Email, telegram настраиваются",
|
||||
},
|
||||
|
||||
// Инфо-страницы
|
||||
{
|
||||
key: "info.pages",
|
||||
section: "Инфо-страницы",
|
||||
action: "Открыть инфо-страницы",
|
||||
expectedResult:
|
||||
"Доставка, оплата, как заказать, статусы заказов отображаются",
|
||||
},
|
||||
{
|
||||
key: "info.legal",
|
||||
section: "Инфо-страницы",
|
||||
action: "Открыть юридические страницы",
|
||||
expectedResult:
|
||||
"Политика конфиденциальности, условия использования отображаются",
|
||||
},
|
||||
|
||||
// SSE / Realtime
|
||||
{
|
||||
key: "sse.notifications",
|
||||
section: "SSE / Realtime",
|
||||
action: "Проверить SSE-уведомления",
|
||||
expectedResult: "Уведомления приходят в реальном времени",
|
||||
},
|
||||
]);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,157 +0,0 @@
|
||||
---
|
||||
title: "Требования Роскомнадзора к сайтам 2026: чек-лист для бизнеса"
|
||||
source: "https://www.klerk.ru/blogs/roskom24/650389/#chapter--4-cookie-uvedomlenie-i-politika"
|
||||
author:
|
||||
- "[[Закон и бизнес | Онлайн услуги 24]]"
|
||||
published: 2025-06-10
|
||||
created: 2026-05-23
|
||||
description: "С 30 мая 2025 года в России вступили в силу изменения в законодательство о персональных данных. Для бизнеса — это не просто очередная «формальность», а вопрос безопасности и выживания."
|
||||
tags:
|
||||
- "clippings"
|
||||
---
|
||||
Роскомнадзор усилил контроль за сайтами, включая автоматическую проверку с использованием ИИ. Теперь даже незначительные, по мнению бизнеса, нарушения могут привести **к штрафам до 18 миллионов рублей или блокировке сайта**.
|
||||
|
||||
Если у вас есть сайт, вы — оператор персональных данных. А значит, обязаны соблюдать [ФЗ-152 «О персональных данных»](https://www.klerk.ru/cdoc/view/federalnyj-zakon-ot-27072006-no-152-fz-o-personalnyh-dannyh/). Даже если вы ИП, самозанятый или оказываете услуги онлайн. Ниже — актуальный чек-лист на 2026 год, который поможет не попасть под штраф и не дать конкурентам «закопать» ваш бизнес через жалобу в Роскомнадзор.
|
||||
|
||||
## ✅ 1. Политика обработки персональных данных на сайте
|
||||
|
||||
### Что это
|
||||
|
||||
Официальный документ, размещенный на сайте (*обычно в подвале*), который описывает:
|
||||
|
||||
- какие данные вы собираете;
|
||||
- как их обрабатываете;
|
||||
- кому передаете и на каких основаниях.
|
||||
|
||||
### Требования 2026
|
||||
|
||||
- Обязательно актуальная редакция.
|
||||
- Полный перечень обрабатываемых данных (*имя, email, телефон, cookies и т.д.*).
|
||||
- Указание целей и оснований обработки.
|
||||
- Контактные данные оператора.
|
||||
- Ссылки на формы согласия и порядок отзыва.
|
||||
|
||||
📌 **Ошибка №1** — скачать шаблон и забыть про него. Политика должна соответствовать именно вашему бизнесу и интеграциям на сайте.
|
||||
|
||||
## ✅ 2. Согласие на обработку персональных данных
|
||||
|
||||
### Где должно быть
|
||||
|
||||
- во всех формах на сайте: заявки, обратная связь, регистрация, квизы, покупка, консультации;
|
||||
- при подписке на рассылку;
|
||||
- в онлайн-чате, если сохраняются данные.
|
||||
|
||||
### Требования 2026
|
||||
|
||||
- Согласие должно быть **добровольным, конкретным, информированным и однозначным**.
|
||||
- Включает: ФИО, перечень данных, цель обработки, срок хранения, право отзыва.
|
||||
- Отдельное согласие на передачу данных третьим лицам (*например, CRM-системам*).
|
||||
- Техническая реализация: **отдельный чекбокс с обязательной активацией**, а не просто фраза «нажимая кнопку, вы соглашаетесь».
|
||||
|
||||
📌 **Ошибка №2** — отсутствие чекбокса или невидимый текст, отсутствие Log файлов позволяющих доказать получение согласия на обработку персональных данных от пользователя сайта.
|
||||
|
||||
## ✅ 3. Уведомление Роскомнадзора о начале обработки персональных данных
|
||||
|
||||
### Кто обязан
|
||||
|
||||
Любой, кто собирает ПДн через сайт — даже ИП и самозанятые.
|
||||
|
||||
### Требования 2026
|
||||
|
||||
- До начала обработки нужно подать уведомление через портал Роскомнадзора.
|
||||
- Указать все сведения: цели, способы обработки, меры безопасности, перечень используемых информационных систем.
|
||||
- Отдельно — факт трансграничной передачи, если используете иностранные сервисы.
|
||||
|
||||
📌 **Ошибка №3** — не уведомили Роскомнадзор, потому что «сайт только визитка». Даже форма обратной связи — уже обработка ПДн.
|
||||
|
||||
## ✅ 5. Юридическая информация в подвале сайта
|
||||
|
||||
Что должно быть ([*ч. 2 ст. 10 ФЗ № 149-ФЗ «Об информации, информационных технологиях и о защите информации»*](https://www.klerk.ru/cdoc/view/federalnyj-zakon-ot-27072006-no-149-fz-ob-informacii-informacionnyh-tehnologiah-i-o-zasite-informacii/stata-10-rasprostranenie-informacii-ili-predostavlenie-informacii/#p_64595)):
|
||||
|
||||
- Полное наименование владельца сайта.
|
||||
- Адрес места нахождения.
|
||||
- Актуальные контактные данные.
|
||||
|
||||
### Почему это важно
|
||||
|
||||
Размещение недостоверных сведений может привести к привлечению к административной ответственности по [статье 14.4 КоАП](https://www.klerk.ru/cdoc/view/kodeks-ob-administrativnyh-pravonaruseniah-koap-rf/stata-144-prodaza-tovarov-vypolnenie-rabot-libo-okazanie-naseleniu-uslug-nenadlezasego-kacestva-ili-s-naruseniem-ustanovlennyh-zakonodatelstvom-rossijskoj-federacii-trebovanij/) (*нарушение законодательства о рекламе*), а также к гражданско-правовой ответственности за причиненный ущерб.
|
||||
|
||||
📌 **Ошибка №5** — указание только бренда или торговой марки без юр. лица.
|
||||
|
||||
## ✅ 6. Российский хостинг и запрет трансграничной передачи
|
||||
|
||||
### Суть
|
||||
|
||||
Использование **иностранных серверов и облаков** приравнивается к трансграничной передаче ПДн.
|
||||
|
||||
### Требования
|
||||
|
||||
- Хостинг сайта — только на серверах, физически размещенных в России.
|
||||
- Подтверждение от хостинг-провайдера.
|
||||
- Запрет на хранение ПДн в Google Drive, Notion, Dropbox и т.д.
|
||||
- Meta <sup>1</sup> Pixel, Google Analytics, сайты размещенные на иностранных хостингах — повод для штрафа, если не отражены в документах.
|
||||
|
||||
📌 **Ошибка №7** — сайт на Tilda или REG.RU, но физически размещен в Европе. Это нарушение.
|
||||
|
||||
Материалы по теме[Топ вопросов и ответов про работу с персональными данными в 2025 году](https://www.klerk.ru/buh/articles/660617/?utm_source=klerk&utm_medium=article&utm_campaign=recommendation&utm_content=blocklinks&utm_term=650389)
|
||||
|
||||
[
|
||||
|
||||
Главные изменения в законе о персональных данных с 1 сентября 2025
|
||||
|
||||
](https://www.klerk.ru/buh/articles/660820/?utm_source=klerk&utm_medium=article&utm_campaign=recommendation&utm_content=blocklinks&utm_term=650389)[
|
||||
|
||||
152-ФЗ о персональных данных: требования закона для бизнеса в 2026 году
|
||||
|
||||
](https://www.klerk.ru/blogs/roskom24/674017/?utm_source=klerk&utm_medium=article&utm_campaign=recommendation&utm_content=blocklinks&utm_term=650389)
|
||||
|
||||
## ✅ 8. Проверка Роскомнадзора: автоматизированная, быстрая, без предупреждения
|
||||
|
||||
### Как работает
|
||||
|
||||
- Искусственный интеллект сканирует сайт 24/7.
|
||||
- Проверяет не только текст, но и **код сайта, скрытые скрипты, cookie, формы**.
|
||||
- Не требует предварительного уведомления.
|
||||
- Фиксация нарушений → **предписание или моментальный штраф**.
|
||||
|
||||
📌 **Важно**: проверить сайт глазами — недостаточно. Нарушения могут быть «внутри» — в интеграциях, скриптах и DOM-структуре.
|
||||
|
||||
## ⚠️ Жалобы конкурентов — новый инструмент давления
|
||||
|
||||
- Предприниматели уже используют жалобы в Роскомнадзор как способ атаковать конкурентов.
|
||||
- Роскомнадзор обязан реагировать на любую жалобу, даже анонимную.
|
||||
- Уже есть случаи блокировки сайтов и штрафов по жалобе «доброжелателей».
|
||||
|
||||
📌 **Вывод** — не дать конкурентам повода. Даже мелкая недоработка — риск для бизнеса.
|
||||
|
||||
## 🛠 Что делать бизнесу уже сейчас
|
||||
|
||||
### Шаги
|
||||
|
||||
1. Провести аудит сайта (*юридический + технический*).
|
||||
2. Проверить наличие и актуальность всех обязательных документов.
|
||||
3. Зарегистрироваться в Роскомнадзоре как оператор ПДн.
|
||||
4. Обновить Политику ПДн и Cookie-согласие.
|
||||
5. Убедиться, что сайт размещен на российских серверах.
|
||||
6. Устранить трансграничные риски (*зарубежные сервисы, скрипты*).
|
||||
7. Назначить ответственного за ПДн внутри компании.
|
||||
|
||||
## 💼 Как мы можем помочь
|
||||
|
||||
Сервис [«Роском 24»](https://roskom24.ru/?utm_source=klerkru-blog&utm_medium=trebovaniya_roskomnadzora_k_saitam) — это команда юристов и технических специалистов, которые:
|
||||
|
||||
- Проводят аудит сайта на соответствие [ФЗ-152](https://www.klerk.ru/cdoc/view/federalnyj-zakon-ot-27072006-no-152-fz-o-personalnyh-dannyh/).
|
||||
- Подготавливают полный комплект документов.
|
||||
- Помогают зарегистрироваться в Роскомнадзоре.
|
||||
- Настраивают cookie-уведомления и формы согласия.
|
||||
- Защищают бизнес от штрафов и блокировок.
|
||||
|
||||
## 📌 Итог
|
||||
|
||||
В 2026 году **невозможно заниматься бизнесом онлайн и игнорировать закон о персональных данных**. Сайт — это уже зона юридической ответственности. И чем раньше вы проведете аудит и приведете все в порядок, тем больше шансов избежать штрафов, блокировок и атак конкурентов.
|
||||
|
||||
👉 Проверьте свой сайт прямо сейчас. Или [доверьтесь профессионалам](https://roskom24.ru/?utm_source=klerkru-blog&utm_medium=trebovaniya_roskomnadzora_k_saitam).
|
||||
|
||||
*Реклама: ООО «ОНЛАЙН УСЛУГИ 24», ИНН 7751227590, erid: 2W5zFJLb1J8*
|
||||
|
||||
1. Деятельность компании Meta Platforms Inc. (Facebook и Instagram) на территории РФ запрещена
|
||||
Reference in New Issue
Block a user