diff --git a/client/src/pages/admin-products/ui/AdminProductsPage.tsx b/client/src/pages/admin-products/ui/AdminProductsPage.tsx index 8037f68..ac71b31 100644 --- a/client/src/pages/admin-products/ui/AdminProductsPage.tsx +++ b/client/src/pages/admin-products/ui/AdminProductsPage.tsx @@ -127,12 +127,13 @@ export function AdminProductsPage() { mutationFn: async () => { const form = productForm.getValues() const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) - if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') + if (!Number.isFinite(priceCents) || priceCents <= 0) throw new Error('Цена должна быть больше 0') + if (priceCents > 10_000_00) throw new Error('Цена не может превышать 10 000 ₽') if (!form.categoryId) throw new Error('Выберите категорию') const qty = form.quantity.trim() if (!qty) throw new Error('Укажите количество') const qtyNum = Number(qty) - if (!Number.isFinite(qtyNum) || qtyNum < 0) throw new Error('Некорректное количество') + if (!Number.isInteger(qtyNum) || qtyNum < 0 || qtyNum > 10) throw new Error('Количество — целое число от 0 до 10') const materials = form.materials .split(',') .map((x) => x.trim()) @@ -160,12 +161,13 @@ export function AdminProductsPage() { mutationFn: async () => { const form = productForm.getValues() const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) - if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') + if (!Number.isFinite(priceCents) || priceCents <= 0) throw new Error('Цена должна быть больше 0') + if (priceCents > 10_000_00) throw new Error('Цена не может превышать 10 000 ₽') if (!form.categoryId) throw new Error('Выберите категорию') const qty = form.quantity.trim() if (!qty) throw new Error('Укажите количество') const qtyNum = Number(qty) - if (!Number.isFinite(qtyNum) || qtyNum < 0) throw new Error('Некорректное количество') + if (!Number.isInteger(qtyNum) || qtyNum < 0 || qtyNum > 10) throw new Error('Количество — целое число от 0 до 10') const materials = form.materials .split(',') .map((x) => x.trim()) @@ -347,14 +349,55 @@ export function AdminProductsPage() { ( - + rules={{ + validate: (v) => { + const n = Number(v) + if (!Number.isInteger(n) || n < 0 || n > 10) return 'Целое число от 0 до 10' + return true + }, + }} + render={({ field, fieldState }) => ( + { + const v = e.target.value.replace(/[^0-9]/g, '') + field.onChange(v) + }} + helperText={fieldState.error?.message ?? '0 = нет в наличии'} + error={!!fieldState.error} + /> )} /> } + rules={{ + required: 'Укажите цену', + validate: (v) => { + const n = Number(v.replace(',', '.')) + if (!Number.isFinite(n) || n <= 0) return 'Цена должна быть больше 0' + if (n > 10_000) return 'Цена не может превышать 10 000 ₽' + if (!Number.isInteger(Math.round(n * 100))) return 'Не более 2 знаков после запятой' + return true + }, + }} + render={({ field, fieldState }) => ( + { + const v = e.target.value.replace(/[^0-9.,]/g, '') + field.onChange(v) + }} + helperText={fieldState.error?.message} + error={!!fieldState.error} + /> + )} /> @@ -486,6 +529,8 @@ export function AdminProductsPage() { !titleValue.trim() || !productForm.watch('categoryId') || !productForm.watch('quantity').trim() || + !productForm.watch('priceRub').trim() || + !productForm.formState.isValid || createMut.isPending || updateMut.isPending } diff --git a/client/src/pages/admin/index.ts b/client/src/pages/admin/index.ts deleted file mode 100644 index 7f56083..0000000 --- a/client/src/pages/admin/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AdminPage } from './ui/AdminPage' diff --git a/client/src/pages/admin/ui/AdminPage.tsx b/client/src/pages/admin/ui/AdminPage.tsx deleted file mode 100644 index 18bb99d..0000000 --- a/client/src/pages/admin/ui/AdminPage.tsx +++ /dev/null @@ -1,860 +0,0 @@ -import { useRef, useState } from 'react' -import Alert from '@mui/material/Alert' -import Box from '@mui/material/Box' -import Button from '@mui/material/Button' -import Checkbox from '@mui/material/Checkbox' -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 FormControl from '@mui/material/FormControl' -import FormControlLabel from '@mui/material/FormControlLabel' -import FormHelperText from '@mui/material/FormHelperText' -import InputLabel from '@mui/material/InputLabel' -import MenuItem from '@mui/material/MenuItem' -import Select from '@mui/material/Select' -import Stack from '@mui/material/Stack' -import Switch from '@mui/material/Switch' -import Table from '@mui/material/Table' -import TableBody from '@mui/material/TableBody' -import TableCell from '@mui/material/TableCell' -import TableHead from '@mui/material/TableHead' -import TableRow from '@mui/material/TableRow' -import TextField from '@mui/material/TextField' -import ToggleButton from '@mui/material/ToggleButton' -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup' -import Typography from '@mui/material/Typography' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Controller, useForm } from 'react-hook-form' -import { fetchAdminGallery } from '@/entities/gallery' -import { - createCategory, - createProduct, - deleteAdminCategory, - deleteProduct, - fetchAdminCategories, - fetchAdminProducts, - fetchCategories, - updateAdminCategory, - updateProduct, - uploadAdminProductImages, -} from '@/entities/product/api/product-api' -import type { Category, Product } from '@/entities/product/model/types' -import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits' -import { formatPriceRub } from '@/shared/lib/format-price' -import { getErrorMessage } from '@/shared/lib/get-error-message' -import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' -import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state' -import { EntityRowActions } from '@/shared/ui/EntityRowActions' -import { OptimizedImage } from '@/shared/ui/OptimizedImage' - -const UNSPECIFIED_CATEGORY_SLUG = 'ne-ukazano' - -type FormState = { - title: string - slug: string - shortDescription: string - description: string - quantity: string - materials: string - priceRub: string - imageUrls: string[] - published: boolean - categoryId: string -} - -const emptyForm = (): FormState => ({ - title: '', - slug: '', - shortDescription: '', - description: '', - quantity: '0', - materials: '', - priceRub: '', - imageUrls: [], - published: true, - categoryId: '', -}) - -export function AdminPage() { - const queryClient = useQueryClient() - const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState() - const [adminSection, setAdminSection] = useState<'products' | 'categories'>('products') - const [catOpen, setCatOpen] = useState(false) - const [categoryEditOpen, setCategoryEditOpen] = useState(false) - const [editingCategory, setEditingCategory] = useState(null) - const [categoryDeleteTarget, setCategoryDeleteTarget] = useState(null) - const [galleryPickOpen, setGalleryPickOpen] = useState(false) - const [gallerySelectedUrls, setGallerySelectedUrls] = useState>(() => new Set()) - - const productForm = useForm({ - defaultValues: emptyForm(), - mode: 'onChange', - }) - - const categoryForm = useForm<{ name: string; slug: string }>({ - defaultValues: { name: '', slug: '' }, - mode: 'onChange', - }) - - const titleValue = productForm.watch('title') - - const categoriesQuery = useQuery({ - queryKey: ['categories'], - queryFn: () => fetchCategories(), - }) - - const productsQuery = useQuery({ - queryKey: ['admin', 'products'], - queryFn: fetchAdminProducts, - }) - - const galleryForPickQuery = useQuery({ - queryKey: ['admin', 'gallery'], - queryFn: fetchAdminGallery, - enabled: galleryPickOpen, - }) - - const adminCategoriesQuery = useQuery({ - queryKey: ['admin', 'categories'], - queryFn: fetchAdminCategories, - enabled: adminSection === 'categories', - }) - - const categoryEditForm = useForm<{ name: string; slug: string; sort: string }>({ - defaultValues: { name: '', slug: '', sort: '0' }, - mode: 'onChange', - }) - - const openCreate = () => { - productForm.reset(emptyForm()) - openCreateDialog() - } - - const openEdit = (p: Product) => { - openEditDialog(p) - const urls = - (p.images ?? []) - .slice() - .sort((a, b) => a.sort - b.sort) - .map((x) => x.url) ?? (p.imageUrl ? [p.imageUrl] : []) - productForm.reset({ - title: p.title, - slug: p.slug, - shortDescription: p.shortDescription ?? '', - description: p.description ?? '', - quantity: String(p.quantity), - materials: (p.materials ?? []).join(', '), - priceRub: String(p.priceCents / 100), - imageUrls: urls, - published: p.published, - categoryId: p.categoryId, - }) - } - - const createMut = useMutation({ - mutationFn: async () => { - const form = productForm.getValues() - const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) - if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') - if (!form.categoryId) throw new Error('Выберите категорию') - const qty = form.quantity.trim() - if (!qty) throw new Error('Укажите количество') - const qtyNum = Number(qty) - if (!Number.isFinite(qtyNum) || qtyNum < 0) throw new Error('Некорректное количество') - const materials = form.materials - .split(',') - .map((x) => x.trim()) - .filter(Boolean) - await createProduct({ - title: form.title.trim(), - slug: form.slug.trim() || undefined, - shortDescription: form.shortDescription.trim() || null, - description: form.description.trim() || null, - quantity: Math.floor(qtyNum), - materials, - priceCents, - imageUrls: form.imageUrls, - published: form.published, - categoryId: form.categoryId, - }) - }, - onSuccess: () => { - void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']]) - closeDialog() - }, - }) - - const updateMut = useMutation({ - mutationFn: async () => { - const form = productForm.getValues() - const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100) - if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена') - if (!form.categoryId) throw new Error('Выберите категорию') - const qty = form.quantity.trim() - if (!qty) throw new Error('Укажите количество') - const qtyNum = Number(qty) - if (!Number.isFinite(qtyNum) || qtyNum < 0) throw new Error('Некорректное количество') - const materials = form.materials - .split(',') - .map((x) => x.trim()) - .filter(Boolean) - await updateProduct(editing!.id, { - title: form.title.trim(), - slug: form.slug.trim(), - shortDescription: form.shortDescription.trim() || null, - description: form.description.trim() || null, - quantity: Math.floor(qtyNum), - materials, - priceCents, - imageUrls: form.imageUrls, - published: form.published, - categoryId: form.categoryId, - }) - }, - onSuccess: () => { - void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']]) - closeDialog() - }, - }) - - const deleteMut = useMutation({ - mutationFn: (id: string) => deleteProduct(id), - onSuccess: () => { - void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']]) - }, - }) - - const createCategoryMut = useMutation({ - mutationFn: () => { - const v = categoryForm.getValues() - return createCategory({ - name: v.name.trim(), - slug: v.slug.trim() || undefined, - }) - }, - onSuccess: () => { - void invalidateQueryKeys(queryClient, [['categories'], ['admin', 'categories']]) - setCatOpen(false) - categoryForm.reset({ name: '', slug: '' }) - }, - }) - - const updateCategoryMut = useMutation({ - mutationFn: async () => { - if (!editingCategory) return - const v = categoryEditForm.getValues() - const payload: { name: string; slug?: string; sort: number } = { - name: v.name.trim(), - sort: Number(v.sort), - } - if (!Number.isFinite(payload.sort)) throw new Error('Некорректный порядок sort') - if (editingCategory.slug !== UNSPECIFIED_CATEGORY_SLUG) { - payload.slug = v.slug.trim() - } - return updateAdminCategory(editingCategory.id, payload) - }, - onSuccess: () => { - void invalidateQueryKeys(queryClient, [['categories'], ['admin', 'categories'], ['admin', 'products']]) - setCategoryEditOpen(false) - setEditingCategory(null) - }, - }) - - const deleteCategoryMut = useMutation({ - mutationFn: (id: string) => deleteAdminCategory(id), - onSuccess: () => { - void invalidateQueryKeys(queryClient, [['categories'], ['admin', 'categories'], ['admin', 'products']]) - setCategoryDeleteTarget(null) - }, - }) - - const handleSubmit = () => { - if (editing) updateMut.mutate() - else createMut.mutate() - } - - const productImagesInputRef = useRef(null) - - const uploadImagesMut = useMutation({ - mutationFn: (picked: File[]) => uploadAdminProductImages(picked), - onSuccess: (urls) => { - const current = productForm.getValues('imageUrls') - productForm.setValue('imageUrls', [...current, ...urls], { shouldDirty: true }) - if (productImagesInputRef.current) { - productImagesInputRef.current.value = '' - } - }, - }) - - const mutationError = - createMut.error ?? - updateMut.error ?? - deleteMut.error ?? - createCategoryMut.error ?? - updateCategoryMut.error ?? - deleteCategoryMut.error ?? - uploadImagesMut.error - - const openCategoryEdit = (c: Category) => { - setEditingCategory(c) - categoryEditForm.reset({ - name: c.name, - slug: c.slug, - sort: String(c.sort), - }) - setCategoryEditOpen(true) - } - - const removeImage = (url: string) => { - const current = productForm.getValues('imageUrls') - productForm.setValue( - 'imageUrls', - current.filter((u) => u !== url), - { shouldDirty: true }, - ) - } - - const toggleGalleryPickUrl = (url: string) => { - setGallerySelectedUrls((prev) => { - const next = new Set(prev) - if (next.has(url)) { - next.delete(url) - } else { - next.add(url) - } - return next - }) - } - - const appendGalleryUrlsToForm = () => { - const current = productForm.getValues('imageUrls') - const merged = [...current] - for (const url of gallerySelectedUrls) { - if (!merged.includes(url)) { - merged.push(url) - } - } - productForm.setValue('imageUrls', merged, { shouldDirty: true }) - setGalleryPickOpen(false) - setGallerySelectedUrls(new Set()) - } - - return ( - - - Админка - - - Управление товарами и категориями. Доступно пользователю с правами администратора. - - - { - if (v === 'products' || v === 'categories') setAdminSection(v) - }} - size="small" - sx={{ mb: 2 }} - > - Товары - Категории - - - {adminSection === 'products' && ( - - - - )} - - {adminSection === 'categories' && ( - - - - )} - - {adminSection === 'products' && productsQuery.isError && ( - - Ошибка загрузки. Проверьте, что сервер запущен, и у вас есть права администратора. - - )} - - {mutationError && ( - - {getErrorMessage(mutationError)} - - )} - - {adminSection === 'products' && ( - - - - Название - Категория - Цена - Витрина - Действия - - - - {(productsQuery.data ?? []).map((p) => ( - - {p.title} - {p.category?.name ?? '—'} - {formatPriceRub(p.priceCents)} - {p.published ? 'да' : 'нет'} - - openEdit(p)} onDelete={() => deleteMut.mutate(p.id)} /> - - - ))} - -
- )} - - {adminSection === 'categories' && ( - <> - {adminCategoriesQuery.isError && ( - - Не удалось загрузить категории. - - )} - - - - Название - Slug - Порядок - Действия - - - - {(adminCategoriesQuery.data ?? []).map((c) => ( - - {c.name} - {c.slug} - {c.sort} - - openCategoryEdit(c)} - onDelete={c.slug === UNSPECIFIED_CATEGORY_SLUG ? undefined : () => setCategoryDeleteTarget(c)} - /> - - - ))} - -
- - )} - - - {editing ? 'Редактировать товар' : 'Новый товар'} - - - } - /> - ( - - )} - /> - ( - - )} - /> - } - /> - ( - - )} - /> - ( - - )} - /> - } - /> - - - Фото (загрузка) - - - PNG, JPEG или WebP, до {formatAdminImageMaxSizeHint()} на файл. Крестик на превью убирает фото только из - карточки; файл остаётся на сервере и в галерее. - - - - - {uploadImagesMut.isPending && Загрузка…} - {uploadImagesMut.isError && Не удалось загрузить фото} - - - {productForm.watch('imageUrls').length > 0 && ( - - {productForm.watch('imageUrls').map((url) => ( - - - - - ))} - - )} - - ( - - Категория - - {!field.value && Выберите категорию} - - )} - /> - ( - field.onChange(v)} />} - label="Показывать в каталоге" - /> - )} - /> - - - - - - - - - { - setGalleryPickOpen(false) - setGallerySelectedUrls(new Set()) - }} - fullWidth - maxWidth="sm" - > - Изображения из галереи - - {galleryForPickQuery.isLoading && Загрузка списка…} - {galleryForPickQuery.isError && ( - Не удалось загрузить галерею. Попробуйте ещё раз. - )} - {galleryForPickQuery.data?.items.length === 0 && !galleryForPickQuery.isLoading && ( - В галерее пока нет файлов. Загрузите их в разделе «Галерея». - )} - - {(galleryForPickQuery.data?.items ?? []).map((item) => { - const alreadyInCard = productForm.watch('imageUrls').includes(item.url) - return ( - toggleGalleryPickUrl(item.url)} - /> - } - label={ - - - - } - /> - ) - })} - - - - - - - - - setCatOpen(false)} fullWidth maxWidth="xs"> - Новая категория - - - } - /> - ( - - )} - /> - - - - - - - - - { - setCategoryEditOpen(false) - setEditingCategory(null) - }} - fullWidth - maxWidth="xs" - > - Редактировать категорию - - - } - /> - ( - - )} - /> - ( - - )} - /> - - - - - - - - - setCategoryDeleteTarget(null)} - maxWidth="xs" - fullWidth - > - Удалить категорию? - - - {categoryDeleteTarget && ( - <> - Категория «{categoryDeleteTarget.name}» будет удалена. Все товары из неё получат категорию «Не указано». - - )} - - - - - - - -
- ) -} diff --git a/client/src/shared/ui/__tests__/OptimizedImage.test.tsx b/client/src/shared/ui/__tests__/OptimizedImage.test.tsx index 2ed4136..12eeac8 100644 --- a/client/src/shared/ui/__tests__/OptimizedImage.test.tsx +++ b/client/src/shared/ui/__tests__/OptimizedImage.test.tsx @@ -47,8 +47,8 @@ describe('OptimizedImage', () => { .closest('picture') ?.querySelector('source[type="image/avif"]') as HTMLSourceElement const srcSet = avifSource?.getAttribute('srcset') ?? '' - expect(srcSet).toContain('?w=200') - expect(srcSet).toContain('?w=400') - expect(srcSet).not.toContain('?w=640') + expect(srcSet).toContain('?w=320') + expect(srcSet).toContain('?w=640') + expect(srcSet).not.toContain('?w=1024') }) }) diff --git a/server/src/routes/api/admin-products.js b/server/src/routes/api/admin-products.js index e0097fd..404f70b 100644 --- a/server/src/routes/api/admin-products.js +++ b/server/src/routes/api/admin-products.js @@ -107,8 +107,12 @@ export async function registerAdminProductRoutes(fastify) { return } const priceCents = Number(body.priceCents) - if (!Number.isFinite(priceCents) || priceCents < 0) { - reply.code(400).send({ error: 'Некорректная цена (priceCents ≥ 0)' }) + if (!Number.isFinite(priceCents) || priceCents <= 0) { + reply.code(400).send({ error: 'Цена должна быть больше 0' }) + return + } + if (priceCents > 10_000_00) { + reply.code(400).send({ error: 'Цена не может превышать 10 000 ₽' }) return } const exists = await prisma.product.findUnique({ where: { slug } }) @@ -118,11 +122,11 @@ export async function registerAdminProductRoutes(fastify) { } const n = Number(body.quantity) - if (!Number.isFinite(n) || n < 0) { - reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' }) + if (!Number.isInteger(n) || n < 0 || n > 10) { + reply.code(400).send({ error: 'Количество — целое число от 0 до 10' }) return } - const quantity = Math.floor(n) + const quantity = n const product = await prisma.product.create({ data: { @@ -184,19 +188,23 @@ export async function registerAdminProductRoutes(fastify) { } if (body.quantity !== undefined) { const n = Number(body.quantity) - if (!Number.isFinite(n) || n < 0) { - reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' }) + if (!Number.isInteger(n) || n < 0 || n > 10) { + reply.code(400).send({ error: 'Количество — целое число от 0 до 10' }) return } - data.quantity = Math.floor(n) + data.quantity = n } if (body.materials !== undefined) { data.materials = JSON.stringify(request.server.parseMaterialsInput(body.materials)) } if (body.priceCents !== undefined) { const p = Number(body.priceCents) - if (!Number.isFinite(p) || p < 0) { - reply.code(400).send({ error: 'Некорректная цена' }) + if (!Number.isFinite(p) || p <= 0) { + reply.code(400).send({ error: 'Цена должна быть больше 0' }) + return + } + if (p > 10_000_00) { + reply.code(400).send({ error: 'Цена не может превышать 10 000 ₽' }) return } data.priceCents = Math.round(p) diff --git a/server/uploads/.cache/3e43e7ab-82e3-4877-a267-9f2145c8981f_w1024.avif b/server/uploads/.cache/3e43e7ab-82e3-4877-a267-9f2145c8981f_w1024.avif deleted file mode 100644 index 2ec70dc..0000000 Binary files a/server/uploads/.cache/3e43e7ab-82e3-4877-a267-9f2145c8981f_w1024.avif and /dev/null differ diff --git a/server/uploads/.cache/64b49aa8-a33a-4702-8c59-0ac5facbdaa7_w1024.avif b/server/uploads/.cache/64b49aa8-a33a-4702-8c59-0ac5facbdaa7_w1024.avif deleted file mode 100644 index 7c615f6..0000000 Binary files a/server/uploads/.cache/64b49aa8-a33a-4702-8c59-0ac5facbdaa7_w1024.avif and /dev/null differ diff --git a/server/uploads/.cache/ce802fe2-062b-4310-b93b-a1b0f24e4cd5_w1024.avif b/server/uploads/.cache/ce802fe2-062b-4310-b93b-a1b0f24e4cd5_w1024.avif deleted file mode 100644 index de16c59..0000000 Binary files a/server/uploads/.cache/ce802fe2-062b-4310-b93b-a1b0f24e4cd5_w1024.avif and /dev/null differ diff --git a/server/uploads/.cache/d26c640b-6016-44f2-a94d-65a94fd5934e_w1024.avif b/server/uploads/.cache/d26c640b-6016-44f2-a94d-65a94fd5934e_w1024.avif deleted file mode 100644 index 532428e..0000000 Binary files a/server/uploads/.cache/d26c640b-6016-44f2-a94d-65a94fd5934e_w1024.avif and /dev/null differ diff --git a/server/uploads/.cache/df304f0b-92d2-47ee-a676-64d6f88ea6f0_w1024.avif b/server/uploads/.cache/df304f0b-92d2-47ee-a676-64d6f88ea6f0_w1024.avif deleted file mode 100644 index 9354544..0000000 Binary files a/server/uploads/.cache/df304f0b-92d2-47ee-a676-64d6f88ea6f0_w1024.avif and /dev/null differ