update goods

This commit is contained in:
Kirill
2026-05-15 14:58:47 +05:00
parent 551c9b027c
commit be48606ae3
10 changed files with 73 additions and 881 deletions
@@ -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() {
<Controller
control={productForm.control}
name="quantity"
render={({ field }) => (
<TextField label="Количество" fullWidth {...field} inputMode="numeric" helperText="0 = нет в наличии" />
rules={{
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
control={productForm.control}
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>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
@@ -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
}
-1
View File
@@ -1 +0,0 @@
export { AdminPage } from './ui/AdminPage'
-860
View File
@@ -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')
?.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')
})
})
+18 -10
View File
@@ -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)
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