diff --git a/client/src/entities/catalog-slider/api/catalog-slider-api.ts b/client/src/entities/catalog-slider/api/catalog-slider-api.ts new file mode 100644 index 0000000..1042b69 --- /dev/null +++ b/client/src/entities/catalog-slider/api/catalog-slider-api.ts @@ -0,0 +1,28 @@ +import { apiClient } from '@/shared/api/client' + +export type CatalogSliderSlide = { + id: string + url: string + caption: string +} + +export type AdminCatalogSliderSlide = CatalogSliderSlide & { + galleryImageId: string +} + +export async function fetchCatalogSlider(): Promise<{ slides: CatalogSliderSlide[] }> { + const { data } = await apiClient.get<{ slides: CatalogSliderSlide[] }>('catalog-slider') + return data +} + +export async function fetchAdminCatalogSlider(): Promise<{ slides: AdminCatalogSliderSlide[] }> { + const { data } = await apiClient.get<{ slides: AdminCatalogSliderSlide[] }>('admin/catalog-slider') + return data +} + +export async function putAdminCatalogSlider(body: { + slides: Array<{ galleryImageId: string; caption: string }> +}): Promise<{ slides: AdminCatalogSliderSlide[] }> { + const { data } = await apiClient.put<{ slides: AdminCatalogSliderSlide[] }>('admin/catalog-slider', body) + return data +} diff --git a/client/src/entities/product/api/product-api.ts b/client/src/entities/product/api/product-api.ts index b082713..43ee36a 100644 --- a/client/src/entities/product/api/product-api.ts +++ b/client/src/entities/product/api/product-api.ts @@ -62,7 +62,8 @@ export async function createProduct(body: { published: boolean inStock?: boolean leadTimeDays?: number | null - categoryId: string + /** Пустая строка / отсутствует — категория «Не указано» на сервере */ + categoryId?: string }): Promise { const { data } = await apiClient.post('admin/products', body) return data @@ -83,7 +84,7 @@ export async function updateProduct( published: boolean inStock: boolean leadTimeDays: number | null - categoryId: string + categoryId?: string }>, ): Promise { const { data } = await apiClient.patch(`admin/products/${id}`, body) @@ -99,6 +100,23 @@ export async function createCategory(body: { name: string; slug?: string; sort?: return data } +export async function fetchAdminCategories(): Promise { + const { data } = await apiClient.get<{ items: Category[] }>('admin/categories') + return data.items +} + +export async function updateAdminCategory( + id: string, + body: Partial<{ name: string; slug: string; sort: number }>, +): Promise { + const { data } = await apiClient.patch(`admin/categories/${id}`, body) + return data +} + +export async function deleteAdminCategory(id: string): Promise { + await apiClient.delete(`admin/categories/${id}`) +} + /** FormData: не задавать Content-Type вручную (boundary задаёт браузер). */ export async function uploadAdminProductImages(files: FileList | readonly File[]): Promise { const fd = new FormData() diff --git a/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx b/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx index 08b87fb..93ce407 100644 --- a/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx +++ b/client/src/pages/admin-gallery/ui/AdminGalleryPage.tsx @@ -2,19 +2,27 @@ import { useRef } from 'react' import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined' import Box from '@mui/material/Box' import Button from '@mui/material/Button' +import Divider from '@mui/material/Divider' import IconButton from '@mui/material/IconButton' import Stack from '@mui/material/Stack' import Tooltip from '@mui/material/Tooltip' import Typography from '@mui/material/Typography' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { fetchAdminCatalogSlider } from '@/entities/catalog-slider/api/catalog-slider-api' import { deleteGalleryImage, fetchAdminGallery } from '@/entities/gallery/api/gallery-api' import { uploadAdminProductImages } from '@/entities/product/api/product-api' import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' +import { GallerySliderSection } from './GallerySliderSection' export function AdminGalleryPage() { const queryClient = useQueryClient() const fileInputRef = useRef(null) + const sliderQuery = useQuery({ + queryKey: ['admin', 'catalog-slider'], + queryFn: fetchAdminCatalogSlider, + }) + const galleryQuery = useQuery({ queryKey: ['admin', 'gallery'], queryFn: fetchAdminGallery, @@ -33,7 +41,7 @@ export function AdminGalleryPage() { const deleteMut = useMutation({ mutationFn: (id: string) => deleteGalleryImage(id), onSuccess: () => { - void invalidateQueryKeys(queryClient, [['admin', 'gallery']]) + void invalidateQueryKeys(queryClient, [['admin', 'gallery'], ['admin', 'catalog-slider'], ['catalog-slider']]) }, }) @@ -49,6 +57,29 @@ export function AdminGalleryPage() { галереи». Удаление из списка стирает файл с диска, если оно не используется в товаре. + {sliderQuery.isError && ( + + Не удалось загрузить настройки слайдера. + + )} + {sliderQuery.isLoading && ( + + Загрузка настроек слайдера… + + )} + {sliderQuery.isSuccess && ( + ({ + galleryImageId: s.galleryImageId, + caption: s.caption, + }))} + galleryItems={items} + /> + )} + + + + + {saveSliderMut.isError && ( + + {saveSliderMut.error instanceof Error ? saveSliderMut.error.message : 'Ошибка сохранения'} + + )} + + + + setPickOpen(false)} fullWidth maxWidth="sm"> + Выберите изображение + + {pickCandidates.length === 0 ? ( + Нет доступных файлов (все уже в слайдере или галерея пуста). + ) : ( + + {pickCandidates.map((item) => ( + + ))} + + )} + + + + + + + ) +} diff --git a/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx b/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx index 748743a..05ed4ae 100644 --- a/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx +++ b/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx @@ -59,7 +59,7 @@ function DeliveryFeeAdjustmentForm({ orderId, deliveryFeeCents }: { orderId: str type="number" value={rub} onChange={(e) => setRub(e.target.value)} - inputProps={{ min: 0, step: 1 }} + slotProps={{ htmlInput: { min: 0, step: 1 } }} sx={{ width: { xs: '100%', sm: 200 } }} /> - - - {productsQuery.isError && ( + { + if (v === 'products' || v === 'categories') setAdminSection(v) + }} + size="small" + sx={{ mb: 2 }} + > + Товары + Категории + + + {adminSection === 'products' && ( + + + + )} + + {adminSection === 'categories' && ( + + + + )} + + {adminSection === 'products' && productsQuery.isError && ( Ошибка загрузки. Проверьте, что сервер запущен, и у вас есть права администратора. @@ -318,30 +405,67 @@ export function AdminPage() { )} - - - - Название - Категория - Цена - Витрина - Действия - - - - {(productsQuery.data ?? []).map((p) => ( - - {p.title} - {p.category?.name ?? '—'} - {formatPriceRub(p.priceCents)} - {p.published ? 'да' : 'нет'} - - openEdit(p)} onDelete={() => deleteMut.mutate(p.id)} /> - + {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 ? 'Редактировать товар' : 'Новый товар'} @@ -500,9 +624,12 @@ export function AdminPage() { control={productForm.control} name="categoryId" render={({ field }) => ( - + Категория