update goods
This commit is contained in:
@@ -127,12 +127,13 @@ export function AdminProductsPage() {
|
|||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const form = productForm.getValues()
|
const form = productForm.getValues()
|
||||||
const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100)
|
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('Выберите категорию')
|
if (!form.categoryId) throw new Error('Выберите категорию')
|
||||||
const qty = form.quantity.trim()
|
const qty = form.quantity.trim()
|
||||||
if (!qty) throw new Error('Укажите количество')
|
if (!qty) throw new Error('Укажите количество')
|
||||||
const qtyNum = Number(qty)
|
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
|
const materials = form.materials
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((x) => x.trim())
|
.map((x) => x.trim())
|
||||||
@@ -160,12 +161,13 @@ export function AdminProductsPage() {
|
|||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const form = productForm.getValues()
|
const form = productForm.getValues()
|
||||||
const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100)
|
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('Выберите категорию')
|
if (!form.categoryId) throw new Error('Выберите категорию')
|
||||||
const qty = form.quantity.trim()
|
const qty = form.quantity.trim()
|
||||||
if (!qty) throw new Error('Укажите количество')
|
if (!qty) throw new Error('Укажите количество')
|
||||||
const qtyNum = Number(qty)
|
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
|
const materials = form.materials
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((x) => x.trim())
|
.map((x) => x.trim())
|
||||||
@@ -347,14 +349,55 @@ export function AdminProductsPage() {
|
|||||||
<Controller
|
<Controller
|
||||||
control={productForm.control}
|
control={productForm.control}
|
||||||
name="quantity"
|
name="quantity"
|
||||||
render={({ field }) => (
|
rules={{
|
||||||
<TextField label="Количество" fullWidth {...field} inputMode="numeric" helperText="0 = нет в наличии" />
|
validate: (v) => {
|
||||||
|
const n = Number(v)
|
||||||
|
if (!Number.isInteger(n) || n < 0 || n > 10) return 'Целое число от 0 до 10'
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextField
|
||||||
|
label="Количество"
|
||||||
|
fullWidth
|
||||||
|
{...field}
|
||||||
|
inputMode="numeric"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value.replace(/[^0-9]/g, '')
|
||||||
|
field.onChange(v)
|
||||||
|
}}
|
||||||
|
helperText={fieldState.error?.message ?? '0 = нет в наличии'}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Controller
|
<Controller
|
||||||
control={productForm.control}
|
control={productForm.control}
|
||||||
name="priceRub"
|
name="priceRub"
|
||||||
render={({ field }) => <TextField label="Цена, ₽" fullWidth {...field} />}
|
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 }) => (
|
||||||
|
<TextField
|
||||||
|
label="Цена, ₽"
|
||||||
|
fullWidth
|
||||||
|
{...field}
|
||||||
|
inputMode="decimal"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value.replace(/[^0-9.,]/g, '')
|
||||||
|
field.onChange(v)
|
||||||
|
}}
|
||||||
|
helperText={fieldState.error?.message}
|
||||||
|
error={!!fieldState.error}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||||
@@ -486,6 +529,8 @@ export function AdminProductsPage() {
|
|||||||
!titleValue.trim() ||
|
!titleValue.trim() ||
|
||||||
!productForm.watch('categoryId') ||
|
!productForm.watch('categoryId') ||
|
||||||
!productForm.watch('quantity').trim() ||
|
!productForm.watch('quantity').trim() ||
|
||||||
|
!productForm.watch('priceRub').trim() ||
|
||||||
|
!productForm.formState.isValid ||
|
||||||
createMut.isPending ||
|
createMut.isPending ||
|
||||||
updateMut.isPending
|
updateMut.isPending
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { AdminPage } from './ui/AdminPage'
|
|
||||||
@@ -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<Product>()
|
|
||||||
const [adminSection, setAdminSection] = useState<'products' | 'categories'>('products')
|
|
||||||
const [catOpen, setCatOpen] = useState(false)
|
|
||||||
const [categoryEditOpen, setCategoryEditOpen] = useState(false)
|
|
||||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
|
|
||||||
const [categoryDeleteTarget, setCategoryDeleteTarget] = useState<Category | null>(null)
|
|
||||||
const [galleryPickOpen, setGalleryPickOpen] = useState(false)
|
|
||||||
const [gallerySelectedUrls, setGallerySelectedUrls] = useState<Set<string>>(() => new Set())
|
|
||||||
|
|
||||||
const productForm = useForm<FormState>({
|
|
||||||
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<HTMLInputElement>(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 (
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h4" gutterBottom>
|
|
||||||
Админка
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
||||||
Управление товарами и категориями. Доступно пользователю с правами администратора.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<ToggleButtonGroup
|
|
||||||
exclusive
|
|
||||||
value={adminSection}
|
|
||||||
onChange={(_, v) => {
|
|
||||||
if (v === 'products' || v === 'categories') setAdminSection(v)
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
sx={{ mb: 2 }}
|
|
||||||
>
|
|
||||||
<ToggleButton value="products">Товары</ToggleButton>
|
|
||||||
<ToggleButton value="categories">Категории</ToggleButton>
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
|
|
||||||
{adminSection === 'products' && (
|
|
||||||
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
|
|
||||||
<Button variant="contained" onClick={openCreate}>
|
|
||||||
Новый товар
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{adminSection === 'categories' && (
|
|
||||||
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
|
|
||||||
<Button variant="contained" onClick={() => setCatOpen(true)}>
|
|
||||||
Новая категория
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{adminSection === 'products' && productsQuery.isError && (
|
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
|
||||||
Ошибка загрузки. Проверьте, что сервер запущен, и у вас есть права администратора.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mutationError && (
|
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
|
||||||
{getErrorMessage(mutationError)}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{adminSection === 'products' && (
|
|
||||||
<Table size="small">
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>Название</TableCell>
|
|
||||||
<TableCell>Категория</TableCell>
|
|
||||||
<TableCell>Цена</TableCell>
|
|
||||||
<TableCell>Витрина</TableCell>
|
|
||||||
<TableCell align="right">Действия</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{(productsQuery.data ?? []).map((p) => (
|
|
||||||
<TableRow key={p.id} hover>
|
|
||||||
<TableCell>{p.title}</TableCell>
|
|
||||||
<TableCell>{p.category?.name ?? '—'}</TableCell>
|
|
||||||
<TableCell>{formatPriceRub(p.priceCents)}</TableCell>
|
|
||||||
<TableCell>{p.published ? 'да' : 'нет'}</TableCell>
|
|
||||||
<TableCell align="right">
|
|
||||||
<EntityRowActions onEdit={() => openEdit(p)} onDelete={() => deleteMut.mutate(p.id)} />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{adminSection === 'categories' && (
|
|
||||||
<>
|
|
||||||
{adminCategoriesQuery.isError && (
|
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
|
||||||
Не удалось загрузить категории.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<Table size="small">
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>Название</TableCell>
|
|
||||||
<TableCell>Slug</TableCell>
|
|
||||||
<TableCell>Порядок</TableCell>
|
|
||||||
<TableCell align="right">Действия</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{(adminCategoriesQuery.data ?? []).map((c) => (
|
|
||||||
<TableRow key={c.id} hover>
|
|
||||||
<TableCell>{c.name}</TableCell>
|
|
||||||
<TableCell>{c.slug}</TableCell>
|
|
||||||
<TableCell>{c.sort}</TableCell>
|
|
||||||
<TableCell align="right">
|
|
||||||
<EntityRowActions
|
|
||||||
onEdit={() => openCategoryEdit(c)}
|
|
||||||
onDelete={c.slug === UNSPECIFIED_CATEGORY_SLUG ? undefined : () => setCategoryDeleteTarget(c)}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="sm">
|
|
||||||
<DialogTitle>{editing ? 'Редактировать товар' : 'Новый товар'}</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
|
||||||
<Controller
|
|
||||||
control={productForm.control}
|
|
||||||
name="title"
|
|
||||||
render={({ field }) => <TextField label="Название" fullWidth required {...field} />}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={productForm.control}
|
|
||||||
name="slug"
|
|
||||||
render={({ field }) => (
|
|
||||||
<TextField
|
|
||||||
label="Slug (URL)"
|
|
||||||
fullWidth
|
|
||||||
{...field}
|
|
||||||
helperText="Можно оставить пустым при создании — сгенерируется из названия"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={productForm.control}
|
|
||||||
name="shortDescription"
|
|
||||||
render={({ field }) => (
|
|
||||||
<TextField label="Краткое описание (для каталога)" fullWidth multiline minRows={2} {...field} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={productForm.control}
|
|
||||||
name="description"
|
|
||||||
render={({ field }) => <TextField label="Описание" fullWidth multiline minRows={2} {...field} />}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={productForm.control}
|
|
||||||
name="materials"
|
|
||||||
render={({ field }) => (
|
|
||||||
<TextField
|
|
||||||
label="Материалы"
|
|
||||||
fullWidth
|
|
||||||
{...field}
|
|
||||||
helperText="Список через запятую (например: хлопок, дерево, акрил)"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={productForm.control}
|
|
||||||
name="quantity"
|
|
||||||
render={({ field }) => (
|
|
||||||
<TextField label="Количество" fullWidth {...field} inputMode="numeric" helperText="0 = нет в наличии" />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={productForm.control}
|
|
||||||
name="priceRub"
|
|
||||||
render={({ field }) => <TextField label="Цена, ₽" fullWidth {...field} />}
|
|
||||||
/>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
|
||||||
Фото (загрузка)
|
|
||||||
</Typography>
|
|
||||||
<FormHelperText sx={{ mt: 0, mb: 1 }}>
|
|
||||||
PNG, JPEG или WebP, до {formatAdminImageMaxSizeHint()} на файл. Крестик на превью убирает фото только из
|
|
||||||
карточки; файл остаётся на сервере и в галерее.
|
|
||||||
</FormHelperText>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: 2,
|
|
||||||
alignItems: { sm: 'center' },
|
|
||||||
flexDirection: { xs: 'column', sm: 'row' },
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button component="label" variant="outlined" disabled={uploadImagesMut.isPending}>
|
|
||||||
Выбрать файлы
|
|
||||||
<input
|
|
||||||
ref={productImagesInputRef}
|
|
||||||
hidden
|
|
||||||
type="file"
|
|
||||||
accept="image/png,image/jpeg,image/webp"
|
|
||||||
multiple
|
|
||||||
onChange={(e) => {
|
|
||||||
const files = e.target.files
|
|
||||||
if (!files || files.length === 0) return
|
|
||||||
uploadImagesMut.mutate(Array.from(files))
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => {
|
|
||||||
setGallerySelectedUrls(new Set())
|
|
||||||
setGalleryPickOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Из галереи
|
|
||||||
</Button>
|
|
||||||
{uploadImagesMut.isPending && <Typography color="text.secondary">Загрузка…</Typography>}
|
|
||||||
{uploadImagesMut.isError && <Typography color="error">Не удалось загрузить фото</Typography>}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{productForm.watch('imageUrls').length > 0 && (
|
|
||||||
<Box sx={{ mt: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
|
||||||
{productForm.watch('imageUrls').map((url) => (
|
|
||||||
<Box
|
|
||||||
key={url}
|
|
||||||
sx={{
|
|
||||||
width: 92,
|
|
||||||
height: 92,
|
|
||||||
borderRadius: 1,
|
|
||||||
border: 1,
|
|
||||||
borderColor: 'divider',
|
|
||||||
overflow: 'hidden',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
title={url}
|
|
||||||
>
|
|
||||||
<OptimizedImage
|
|
||||||
src={url}
|
|
||||||
alt="Фото товара"
|
|
||||||
widths={[320, 640]}
|
|
||||||
sizes="80px"
|
|
||||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
color="error"
|
|
||||||
variant="contained"
|
|
||||||
onClick={() => removeImage(url)}
|
|
||||||
aria-label="Убрать из карточки"
|
|
||||||
title="Убрать из карточки"
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 4,
|
|
||||||
right: 4,
|
|
||||||
minWidth: 0,
|
|
||||||
px: 0.75,
|
|
||||||
py: 0,
|
|
||||||
lineHeight: 1.2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Controller
|
|
||||||
control={productForm.control}
|
|
||||||
name="categoryId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormControl fullWidth error={!field.value}>
|
|
||||||
<InputLabel id="cat-label">Категория</InputLabel>
|
|
||||||
<Select labelId="cat-label" label="Категория" {...field}>
|
|
||||||
{(categoriesQuery.data ?? []).map((c) => (
|
|
||||||
<MenuItem key={c.id} value={c.id}>
|
|
||||||
{c.name}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{!field.value && <FormHelperText>Выберите категорию</FormHelperText>}
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={productForm.control}
|
|
||||||
name="published"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Switch checked={field.value} onChange={(_, v) => field.onChange(v)} />}
|
|
||||||
label="Показывать в каталоге"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={closeDialog}>Отмена</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={
|
|
||||||
!titleValue.trim() ||
|
|
||||||
!productForm.watch('categoryId') ||
|
|
||||||
!productForm.watch('quantity').trim() ||
|
|
||||||
createMut.isPending ||
|
|
||||||
updateMut.isPending
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{editing ? 'Сохранить' : 'Создать'}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={galleryPickOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setGalleryPickOpen(false)
|
|
||||||
setGallerySelectedUrls(new Set())
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
maxWidth="sm"
|
|
||||||
>
|
|
||||||
<DialogTitle>Изображения из галереи</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
|
||||||
{galleryForPickQuery.isLoading && <Typography color="text.secondary">Загрузка списка…</Typography>}
|
|
||||||
{galleryForPickQuery.isError && (
|
|
||||||
<Alert severity="error">Не удалось загрузить галерею. Попробуйте ещё раз.</Alert>
|
|
||||||
)}
|
|
||||||
{galleryForPickQuery.data?.items.length === 0 && !galleryForPickQuery.isLoading && (
|
|
||||||
<Typography color="text.secondary">В галерее пока нет файлов. Загрузите их в разделе «Галерея».</Typography>
|
|
||||||
)}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
|
||||||
gap: 1.5,
|
|
||||||
pt: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(galleryForPickQuery.data?.items ?? []).map((item) => {
|
|
||||||
const alreadyInCard = productForm.watch('imageUrls').includes(item.url)
|
|
||||||
return (
|
|
||||||
<FormControlLabel
|
|
||||||
key={item.id}
|
|
||||||
sx={{ m: 0, alignItems: 'flex-start' }}
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={alreadyInCard || gallerySelectedUrls.has(item.url)}
|
|
||||||
disabled={alreadyInCard}
|
|
||||||
onChange={() => toggleGalleryPickUrl(item.url)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={
|
|
||||||
<Box sx={{ width: '100%', maxHeight: 100, borderRadius: 1, overflow: 'hidden' }}>
|
|
||||||
<OptimizedImage
|
|
||||||
src={item.url}
|
|
||||||
alt=""
|
|
||||||
widths={[320, 640]}
|
|
||||||
sizes="120px"
|
|
||||||
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setGalleryPickOpen(false)
|
|
||||||
setGallerySelectedUrls(new Set())
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={appendGalleryUrlsToForm}
|
|
||||||
disabled={![...gallerySelectedUrls].some((u) => !productForm.watch('imageUrls').includes(u))}
|
|
||||||
>
|
|
||||||
Добавить
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog open={catOpen} onClose={() => setCatOpen(false)} fullWidth maxWidth="xs">
|
|
||||||
<DialogTitle>Новая категория</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
|
||||||
<Controller
|
|
||||||
control={categoryForm.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => <TextField label="Название" fullWidth required {...field} />}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={categoryForm.control}
|
|
||||||
name="slug"
|
|
||||||
render={({ field }) => (
|
|
||||||
<TextField
|
|
||||||
label="Slug"
|
|
||||||
fullWidth
|
|
||||||
{...field}
|
|
||||||
helperText="Необязательно — можно сгенерировать из названия на сервере"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setCatOpen(false)}>Отмена</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
disabled={!categoryForm.watch('name').trim() || createCategoryMut.isPending}
|
|
||||||
onClick={() => createCategoryMut.mutate()}
|
|
||||||
>
|
|
||||||
Создать
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={categoryEditOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setCategoryEditOpen(false)
|
|
||||||
setEditingCategory(null)
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
maxWidth="xs"
|
|
||||||
>
|
|
||||||
<DialogTitle>Редактировать категорию</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
|
||||||
<Controller
|
|
||||||
control={categoryEditForm.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => <TextField label="Название" fullWidth required {...field} />}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={categoryEditForm.control}
|
|
||||||
name="slug"
|
|
||||||
render={({ field }) => (
|
|
||||||
<TextField
|
|
||||||
label="Slug"
|
|
||||||
fullWidth
|
|
||||||
{...field}
|
|
||||||
disabled={editingCategory?.slug === UNSPECIFIED_CATEGORY_SLUG}
|
|
||||||
helperText={
|
|
||||||
editingCategory?.slug === UNSPECIFIED_CATEGORY_SLUG
|
|
||||||
? 'Служебный slug нельзя изменить'
|
|
||||||
: 'Идентификатор в URL'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={categoryEditForm.control}
|
|
||||||
name="sort"
|
|
||||||
render={({ field }) => (
|
|
||||||
<TextField
|
|
||||||
label="Порядок сортировки"
|
|
||||||
fullWidth
|
|
||||||
type="number"
|
|
||||||
{...field}
|
|
||||||
slotProps={{ htmlInput: { step: 1 } }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setCategoryEditOpen(false)
|
|
||||||
setEditingCategory(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
disabled={!categoryEditForm.watch('name').trim() || updateCategoryMut.isPending || !editingCategory}
|
|
||||||
onClick={() => updateCategoryMut.mutate()}
|
|
||||||
>
|
|
||||||
Сохранить
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={Boolean(categoryDeleteTarget)}
|
|
||||||
onClose={() => setCategoryDeleteTarget(null)}
|
|
||||||
maxWidth="xs"
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
<DialogTitle>Удалить категорию?</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Typography variant="body2">
|
|
||||||
{categoryDeleteTarget && (
|
|
||||||
<>
|
|
||||||
Категория «{categoryDeleteTarget.name}» будет удалена. Все товары из неё получат категорию «Не указано».
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Typography>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setCategoryDeleteTarget(null)}>Отмена</Button>
|
|
||||||
<Button
|
|
||||||
color="error"
|
|
||||||
variant="contained"
|
|
||||||
disabled={deleteCategoryMut.isPending}
|
|
||||||
onClick={() => {
|
|
||||||
if (categoryDeleteTarget) deleteCategoryMut.mutate(categoryDeleteTarget.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -47,8 +47,8 @@ describe('OptimizedImage', () => {
|
|||||||
.closest('picture')
|
.closest('picture')
|
||||||
?.querySelector('source[type="image/avif"]') as HTMLSourceElement
|
?.querySelector('source[type="image/avif"]') as HTMLSourceElement
|
||||||
const srcSet = avifSource?.getAttribute('srcset') ?? ''
|
const srcSet = avifSource?.getAttribute('srcset') ?? ''
|
||||||
expect(srcSet).toContain('?w=200')
|
expect(srcSet).toContain('?w=320')
|
||||||
expect(srcSet).toContain('?w=400')
|
expect(srcSet).toContain('?w=640')
|
||||||
expect(srcSet).not.toContain('?w=640')
|
expect(srcSet).not.toContain('?w=1024')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -107,8 +107,12 @@ export async function registerAdminProductRoutes(fastify) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const priceCents = Number(body.priceCents)
|
const priceCents = Number(body.priceCents)
|
||||||
if (!Number.isFinite(priceCents) || priceCents < 0) {
|
if (!Number.isFinite(priceCents) || priceCents <= 0) {
|
||||||
reply.code(400).send({ error: 'Некорректная цена (priceCents ≥ 0)' })
|
reply.code(400).send({ error: 'Цена должна быть больше 0' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (priceCents > 10_000_00) {
|
||||||
|
reply.code(400).send({ error: 'Цена не может превышать 10 000 ₽' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const exists = await prisma.product.findUnique({ where: { slug } })
|
const exists = await prisma.product.findUnique({ where: { slug } })
|
||||||
@@ -118,11 +122,11 @@ export async function registerAdminProductRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const n = Number(body.quantity)
|
const n = Number(body.quantity)
|
||||||
if (!Number.isFinite(n) || n < 0) {
|
if (!Number.isInteger(n) || n < 0 || n > 10) {
|
||||||
reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' })
|
reply.code(400).send({ error: 'Количество — целое число от 0 до 10' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const quantity = Math.floor(n)
|
const quantity = n
|
||||||
|
|
||||||
const product = await prisma.product.create({
|
const product = await prisma.product.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -184,19 +188,23 @@ export async function registerAdminProductRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
if (body.quantity !== undefined) {
|
if (body.quantity !== undefined) {
|
||||||
const n = Number(body.quantity)
|
const n = Number(body.quantity)
|
||||||
if (!Number.isFinite(n) || n < 0) {
|
if (!Number.isInteger(n) || n < 0 || n > 10) {
|
||||||
reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' })
|
reply.code(400).send({ error: 'Количество — целое число от 0 до 10' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data.quantity = Math.floor(n)
|
data.quantity = n
|
||||||
}
|
}
|
||||||
if (body.materials !== undefined) {
|
if (body.materials !== undefined) {
|
||||||
data.materials = JSON.stringify(request.server.parseMaterialsInput(body.materials))
|
data.materials = JSON.stringify(request.server.parseMaterialsInput(body.materials))
|
||||||
}
|
}
|
||||||
if (body.priceCents !== undefined) {
|
if (body.priceCents !== undefined) {
|
||||||
const p = Number(body.priceCents)
|
const p = Number(body.priceCents)
|
||||||
if (!Number.isFinite(p) || p < 0) {
|
if (!Number.isFinite(p) || p <= 0) {
|
||||||
reply.code(400).send({ error: 'Некорректная цена' })
|
reply.code(400).send({ error: 'Цена должна быть больше 0' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (p > 10_000_00) {
|
||||||
|
reply.code(400).send({ error: 'Цена не может превышать 10 000 ₽' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data.priceCents = Math.round(p)
|
data.priceCents = Math.round(p)
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 104 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB |
Reference in New Issue
Block a user