Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4641ad83a | |||
| e696e6779a | |||
| c7d4b1f0c5 | |||
| 75be0504ab | |||
| 40fcfc303e | |||
| 67388d9628 | |||
| 90779fdc08 |
@@ -1,8 +1,17 @@
|
||||
name: adminpanel-ci-cd
|
||||
|
||||
# CI/CD: только push git-тега (ручное тегирование на ветке prod|test|stage).
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: 'Ветка для прогона тестов'
|
||||
required: true
|
||||
default: test
|
||||
type: choice
|
||||
options:
|
||||
- prod
|
||||
- test
|
||||
- stage
|
||||
push:
|
||||
tags:
|
||||
- 'adminpanel-v*'
|
||||
@@ -14,9 +23,12 @@ env:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/adminpanel-v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || github.ref }}
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
@@ -26,6 +38,7 @@ jobs:
|
||||
- run: npm run build
|
||||
|
||||
parse-tag:
|
||||
if: startsWith(github.ref, 'refs/tags/adminpanel-v')
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
full_tag: ${{ steps.meta.outputs.full_tag }}
|
||||
@@ -42,6 +55,7 @@ jobs:
|
||||
|
||||
build-and-push:
|
||||
needs: [test, parse-tag]
|
||||
if: startsWith(github.ref, 'refs/tags/adminpanel-v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -60,6 +74,7 @@ jobs:
|
||||
|
||||
deploy-gitops:
|
||||
needs: [build-and-push, parse-tag]
|
||||
if: startsWith(github.ref, 'refs/tags/adminpanel-v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Bump image tag in sova-deploy
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
<link rel="icon" type="image/png" href="/src/assets/icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin panel</title>
|
||||
<script src="/env.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
window.__ENV__ = window.__ENV__ || {
|
||||
API_BASE_URL: 'http://localhost:8081',
|
||||
};
|
||||
+39
-1
@@ -21,6 +21,26 @@ import { EditStockPage } from './pages/EditStockPage';
|
||||
import { AddStockPage } from './pages/AddStockPage';
|
||||
import { InfoclinicListPage } from './pages/InfoclinicListPage';
|
||||
import { LostDoctorsPage } from './pages/LostDoctorsPage';
|
||||
import {
|
||||
NewsListPage,
|
||||
NewsEditPage,
|
||||
NewsCreatePage,
|
||||
SitePromoListPage,
|
||||
SitePromoEditPage,
|
||||
SitePromoCreatePage,
|
||||
DiseaseListPage,
|
||||
DiseaseEditPage,
|
||||
DiseaseCreatePage,
|
||||
MedicalCenterListPage,
|
||||
MedicalCenterEditPage,
|
||||
MedicalCenterCreatePage,
|
||||
ArticleListPage,
|
||||
ArticleEditPage,
|
||||
ArticleCreatePage,
|
||||
SiteServicesListPage,
|
||||
SiteServicesEditPage,
|
||||
SiteServicesCreatePage,
|
||||
} from './pages/content';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -50,7 +70,25 @@ function App() {
|
||||
<Route path="prices" element={<PricesListPage/>} />
|
||||
<Route path="promotions" element={<StocksListPage/>} />
|
||||
<Route path="promotions/edit/:id" element={<EditStockPage/>} />
|
||||
<Route path="promotions/create" element={<AddStockPage/>} />
|
||||
<Route path="promotions/create" element={<AddStockPage/>} />
|
||||
<Route path="news" element={<NewsListPage />} />
|
||||
<Route path="news/edit/:id" element={<NewsEditPage />} />
|
||||
<Route path="news/create" element={<NewsCreatePage />} />
|
||||
<Route path="site-promo" element={<SitePromoListPage />} />
|
||||
<Route path="site-promo/edit/:id" element={<SitePromoEditPage />} />
|
||||
<Route path="site-promo/create" element={<SitePromoCreatePage />} />
|
||||
<Route path="disease" element={<DiseaseListPage />} />
|
||||
<Route path="disease/edit/:id" element={<DiseaseEditPage />} />
|
||||
<Route path="disease/create" element={<DiseaseCreatePage />} />
|
||||
<Route path="medical-center" element={<MedicalCenterListPage />} />
|
||||
<Route path="medical-center/edit/:id" element={<MedicalCenterEditPage />} />
|
||||
<Route path="medical-center/create" element={<MedicalCenterCreatePage />} />
|
||||
<Route path="article" element={<ArticleListPage />} />
|
||||
<Route path="article/edit/:id" element={<ArticleEditPage />} />
|
||||
<Route path="article/create" element={<ArticleCreatePage />} />
|
||||
<Route path="site-services" element={<SiteServicesListPage />} />
|
||||
<Route path="site-services/edit/:id" element={<SiteServicesEditPage />} />
|
||||
<Route path="site-services/create" element={<SiteServicesCreatePage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { API, authHeader } from './apiSlice'
|
||||
import { CONTENT_RESOURCES } from '../config/contentResources'
|
||||
|
||||
const buildListQuery = (basePath, { usesLimit = false } = {}) =>
|
||||
({ search = '', page = 1, perPage = 20 } = {}) => {
|
||||
let queryString = `?page=${page}`
|
||||
if (usesLimit) {
|
||||
queryString += `&limit=${perPage}`
|
||||
} else {
|
||||
queryString += `&perPage=${perPage}`
|
||||
}
|
||||
if (search) {
|
||||
queryString += `&search=${encodeURIComponent(search)}`
|
||||
}
|
||||
return {
|
||||
url: `${basePath}/list${queryString}`,
|
||||
}
|
||||
}
|
||||
|
||||
const injectResource = (resourceKey) => {
|
||||
const { basePath, listUsesLimit } = CONTENT_RESOURCES[resourceKey]
|
||||
const cap = resourceKey
|
||||
.split('-')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join('')
|
||||
|
||||
return API.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
[`get${cap}List`]: build.query({
|
||||
query: buildListQuery(basePath, { usesLimit: listUsesLimit }),
|
||||
refetchOnMountOrArgChange: true,
|
||||
keepUnusedDataFor: 0,
|
||||
}),
|
||||
[`get${cap}Item`]: build.query({
|
||||
query: (id) => ({
|
||||
url: `${basePath}/${id}`,
|
||||
}),
|
||||
}),
|
||||
[`create${cap}`]: build.mutation({
|
||||
query: (data) => ({
|
||||
url: `${basePath}/create`,
|
||||
method: 'POST',
|
||||
headers: authHeader(),
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
}),
|
||||
[`update${cap}`]: build.mutation({
|
||||
query: ({ id, data }) => ({
|
||||
url: `${basePath}/${id}`,
|
||||
method: 'PUT',
|
||||
headers: authHeader(),
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
}),
|
||||
[`delete${cap}`]: build.mutation({
|
||||
query: (id) => ({
|
||||
url: `${basePath}/${id}`,
|
||||
method: 'DELETE',
|
||||
headers: authHeader(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
})
|
||||
}
|
||||
|
||||
const newsApi = injectResource('news')
|
||||
const promoApi = injectResource('promo')
|
||||
const diseaseApi = injectResource('disease')
|
||||
const medicalCenterApi = injectResource('medical-center')
|
||||
const articleApi = injectResource('article')
|
||||
const siteServicesApi = injectResource('site-services')
|
||||
|
||||
export const contentHooks = {
|
||||
news: {
|
||||
useListQuery: newsApi.useGetNewsListQuery,
|
||||
useItemQuery: newsApi.useGetNewsItemQuery,
|
||||
useCreateMutation: newsApi.useCreateNewsMutation,
|
||||
useUpdateMutation: newsApi.useUpdateNewsMutation,
|
||||
useDeleteMutation: newsApi.useDeleteNewsMutation,
|
||||
},
|
||||
promo: {
|
||||
useListQuery: promoApi.useGetPromoListQuery,
|
||||
useItemQuery: promoApi.useGetPromoItemQuery,
|
||||
useCreateMutation: promoApi.useCreatePromoMutation,
|
||||
useUpdateMutation: promoApi.useUpdatePromoMutation,
|
||||
useDeleteMutation: promoApi.useDeletePromoMutation,
|
||||
},
|
||||
disease: {
|
||||
useListQuery: diseaseApi.useGetDiseaseListQuery,
|
||||
useItemQuery: diseaseApi.useGetDiseaseItemQuery,
|
||||
useCreateMutation: diseaseApi.useCreateDiseaseMutation,
|
||||
useUpdateMutation: diseaseApi.useUpdateDiseaseMutation,
|
||||
useDeleteMutation: diseaseApi.useDeleteDiseaseMutation,
|
||||
},
|
||||
'medical-center': {
|
||||
useListQuery: medicalCenterApi.useGetMedicalCenterListQuery,
|
||||
useItemQuery: medicalCenterApi.useGetMedicalCenterItemQuery,
|
||||
useCreateMutation: medicalCenterApi.useCreateMedicalCenterMutation,
|
||||
useUpdateMutation: medicalCenterApi.useUpdateMedicalCenterMutation,
|
||||
useDeleteMutation: medicalCenterApi.useDeleteMedicalCenterMutation,
|
||||
},
|
||||
article: {
|
||||
useListQuery: articleApi.useGetArticleListQuery,
|
||||
useItemQuery: articleApi.useGetArticleItemQuery,
|
||||
useCreateMutation: articleApi.useCreateArticleMutation,
|
||||
useUpdateMutation: articleApi.useUpdateArticleMutation,
|
||||
useDeleteMutation: articleApi.useDeleteArticleMutation,
|
||||
},
|
||||
'site-services': {
|
||||
useListQuery: siteServicesApi.useGetSiteServicesListQuery,
|
||||
useItemQuery: siteServicesApi.useGetSiteServicesItemQuery,
|
||||
useCreateMutation: siteServicesApi.useCreateSiteServicesMutation,
|
||||
useUpdateMutation: siteServicesApi.useUpdateSiteServicesMutation,
|
||||
useDeleteMutation: siteServicesApi.useDeleteSiteServicesMutation,
|
||||
},
|
||||
}
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||
import { API_BASE_URL } from '@/config/api'
|
||||
|
||||
export const authHeader = () => {
|
||||
const token = localStorage.getItem('token')
|
||||
@@ -8,7 +9,7 @@ export const authHeader = () => {
|
||||
export const API = createApi({
|
||||
reducerPath: 'API',
|
||||
baseQuery: fetchBaseQuery({
|
||||
baseUrl: 'https://api.sovamed.ru',
|
||||
baseUrl: API_BASE_URL,
|
||||
credentials: 'include',
|
||||
}),
|
||||
endpoints: (builder) => ({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
import { apiUrl } from '@/config/api';
|
||||
import { CertEditor } from '../Editors/CertEditor'
|
||||
|
||||
const isValidImage = (file) => {
|
||||
@@ -15,7 +16,7 @@ export function CertificatesForm({ initCertificates, onChange }) {
|
||||
const [certificates, setCertificates] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const certificatesWithPictureUrl = initCertificates.map((init) => ({ ...init, picture: `https://api.sovamed.ru/uploads/${init.picture}`}))
|
||||
const certificatesWithPictureUrl = initCertificates.map((init) => ({ ...init, picture: apiUrl(`/uploads/${init.picture}`)}))
|
||||
setInitialCertificates([...certificatesWithPictureUrl])
|
||||
setCertificates([...certificatesWithPictureUrl])
|
||||
}, [initCertificates])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
import { apiUrl } from '@/config/api';
|
||||
import { CertEditor } from '../Editors/CertEditor'
|
||||
|
||||
const isValidImage = (file) => {
|
||||
@@ -17,7 +18,7 @@ export function PortfolioForm({ initPortfolios, onChange }) {
|
||||
useEffect(() => {
|
||||
// console.log(initPortfolios)
|
||||
const portfolioWithPictureUrl = initPortfolios.map((init) => {
|
||||
if ( init.picture ) return ({ ...init, picture: `https://api.sovamed.ru/uploads/${init.picture}`})
|
||||
if ( init.picture ) return ({ ...init, picture: apiUrl(`/uploads/${init.picture}`)})
|
||||
return { ...init }
|
||||
})
|
||||
setInitialPorfolios([...portfolioWithPictureUrl])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
import { apiUrl } from '@/config/api';
|
||||
import { CertEditor } from '../Editors/CertEditor'
|
||||
|
||||
const isValidImage = (file) => {
|
||||
@@ -22,7 +23,7 @@ export function StocksForm({ initStocks, onChange }) {
|
||||
// console.log(initStocks[0]?.startDate)
|
||||
// console.log(typeof initStocks[0]?.startDate)
|
||||
const portfolioWithPictureUrl = initStocks.map((init) => {
|
||||
if ( init.picture ) return ({ ...init, picture: `https://api.sovamed.ru/uploads/${init.picture}`})
|
||||
if ( init.picture ) return ({ ...init, picture: apiUrl(`/uploads/${init.picture}`)})
|
||||
return { ...init }
|
||||
}).map(init => {
|
||||
const dateStart = new Date(init.startDate);
|
||||
|
||||
@@ -15,6 +15,12 @@ export const Navbar = () => {
|
||||
{ to: '/promotions',icon: 'fas fa-percent', label: 'Акции' },
|
||||
{ to: '/departments',icon: 'fas fa-stethoscope', label: 'Отделения' },
|
||||
{ to: '/filials',icon: 'fas fa-building', label: 'Филиалы' },
|
||||
{ to: '/news', icon: 'fas fa-newspaper', label: 'Новости' },
|
||||
{ to: '/site-promo', icon: 'fas fa-bullhorn', label: 'Промо (контент)' },
|
||||
{ to: '/disease', icon: 'fas fa-heartbeat', label: 'Заболевания' },
|
||||
{ to: '/medical-center', icon: 'fas fa-hospital', label: 'Медцентры' },
|
||||
{ to: '/article', icon: 'fas fa-file-alt', label: 'Статьи' },
|
||||
{ to: '/site-services', icon: 'fas fa-concierge-bell', label: 'Услуги сайта' },
|
||||
];
|
||||
const [open, setOpen] = useState(false);
|
||||
const toggleRef = useRef(null);
|
||||
|
||||
@@ -10,6 +10,12 @@ export const Sidebar = () => {
|
||||
{ to: '/promotions',icon: 'fas fa-percent', label: 'Акции' },
|
||||
{ to: '/departments',icon: 'fas fa-stethoscope', label: 'Отделения' },
|
||||
{ to: '/filials',icon: 'fas fa-building', label: 'Филиалы' },
|
||||
{ to: '/news', icon: 'fas fa-newspaper', label: 'Новости' },
|
||||
{ to: '/site-promo', icon: 'fas fa-bullhorn', label: 'Промо (контент)' },
|
||||
{ to: '/disease', icon: 'fas fa-heartbeat', label: 'Заболевания' },
|
||||
{ to: '/medical-center', icon: 'fas fa-hospital', label: 'Медцентры' },
|
||||
{ to: '/article', icon: 'fas fa-file-alt', label: 'Статьи' },
|
||||
{ to: '/site-services', icon: 'fas fa-concierge-bell', label: 'Услуги сайта' },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.sovamed.ru'
|
||||
|
||||
export const apiUrl = (path) => {
|
||||
const base = API_BASE_URL.replace(/\/$/, '')
|
||||
const suffix = path.startsWith('/') ? path : `/${path}`
|
||||
return `${base}${suffix}`
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
const baseContentFields = [
|
||||
{ key: 'name', label: 'Название', type: 'text' },
|
||||
{ key: 'active', label: 'Активно', type: 'checkbox' },
|
||||
{ key: 'regionId', label: 'Регион', type: 'region' },
|
||||
{ key: 'alias', label: 'Alias', type: 'text' },
|
||||
{ key: 'anons', label: 'Анонс', type: 'html' },
|
||||
{ key: 'content', label: 'Контент', type: 'html' },
|
||||
]
|
||||
|
||||
const json = (key, label) => ({ key, label: `${label} (JSON)`, type: 'json' })
|
||||
const text = (key, label) => ({ key, label, type: 'text' })
|
||||
|
||||
export const CONTENT_RESOURCES = {
|
||||
news: {
|
||||
slug: 'news',
|
||||
basePath: '/news',
|
||||
title: 'Новости',
|
||||
titleSingle: 'новость',
|
||||
icon: 'fas fa-newspaper',
|
||||
listColumns: [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'name', label: 'Название' },
|
||||
{ key: 'alias', label: 'Alias' },
|
||||
{ key: 'active', label: 'Активно', format: 'bool' },
|
||||
{ key: 'regionId', label: 'Регион' },
|
||||
],
|
||||
fields: [
|
||||
...baseContentFields,
|
||||
text('shortName', 'Короткое название'),
|
||||
text('linkElPrice', 'Ссылка на прайс'),
|
||||
text('timer', 'Таймер'),
|
||||
text('timerBg', 'Фон таймера'),
|
||||
json('formOrder', 'formOrder'),
|
||||
json('linkServices', 'linkServices'),
|
||||
json('linkStaff', 'linkStaff'),
|
||||
json('photos', 'photos'),
|
||||
],
|
||||
},
|
||||
promo: {
|
||||
slug: 'site-promo',
|
||||
basePath: '/promo',
|
||||
title: 'Промо (контент)',
|
||||
titleSingle: 'промо',
|
||||
icon: 'fas fa-bullhorn',
|
||||
listColumns: [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'name', label: 'Название' },
|
||||
{ key: 'alias', label: 'Alias' },
|
||||
{ key: 'active', label: 'Активно', format: 'bool' },
|
||||
{ key: 'regionId', label: 'Регион' },
|
||||
],
|
||||
fields: [
|
||||
...baseContentFields,
|
||||
text('shortName', 'Короткое название'),
|
||||
text('period', 'Период'),
|
||||
text('timer', 'Таймер'),
|
||||
text('timerBg', 'Фон таймера'),
|
||||
json('clinics', 'clinics'),
|
||||
json('linkServices', 'linkServices'),
|
||||
json('linkStaff', 'linkStaff'),
|
||||
json('photos', 'photos'),
|
||||
],
|
||||
},
|
||||
disease: {
|
||||
slug: 'disease',
|
||||
basePath: '/disease',
|
||||
title: 'Заболевания',
|
||||
titleSingle: 'заболевание',
|
||||
icon: 'fas fa-heartbeat',
|
||||
listColumns: [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'name', label: 'Название' },
|
||||
{ key: 'alias', label: 'Alias' },
|
||||
{ key: 'active', label: 'Активно', format: 'bool' },
|
||||
],
|
||||
fields: [
|
||||
...baseContentFields,
|
||||
text('previewPicture', 'previewPicture'),
|
||||
{ key: 'hidePicture', label: 'hidePicture', type: 'checkbox' },
|
||||
text('readTime', 'readTime'),
|
||||
text('diseasesName', 'diseasesName'),
|
||||
text('diseasesOtherName', 'diseasesOtherName'),
|
||||
text('symptom', 'symptom'),
|
||||
text('staff', 'staff'),
|
||||
text('bibliography', 'bibliography'),
|
||||
json('tagsImportant', 'tagsImportant'),
|
||||
json('tags', 'tags'),
|
||||
json('linkServices', 'linkServices'),
|
||||
json('staffList', 'staffList'),
|
||||
json('staffPost', 'staffPost'),
|
||||
json('staffPostExclude', 'staffPostExclude'),
|
||||
json('linkFaq', 'linkFaq'),
|
||||
json('staffCheck', 'staffCheck'),
|
||||
],
|
||||
},
|
||||
'medical-center': {
|
||||
slug: 'medical-center',
|
||||
basePath: '/medical-center',
|
||||
title: 'Медцентры',
|
||||
titleSingle: 'медцентр',
|
||||
icon: 'fas fa-hospital',
|
||||
listColumns: [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'name', label: 'Название' },
|
||||
{ key: 'alias', label: 'Alias' },
|
||||
{ key: 'active', label: 'Активно', format: 'bool' },
|
||||
],
|
||||
fields: [
|
||||
...baseContentFields,
|
||||
text('mainLinkStaff', 'mainLinkStaff'),
|
||||
text('plusText', 'plusText'),
|
||||
text('plusTitle', 'plusTitle'),
|
||||
text('processText', 'processText'),
|
||||
text('processTitle', 'processTitle'),
|
||||
text('servicesTitle', 'servicesTitle'),
|
||||
text('trainingText', 'trainingText'),
|
||||
text('trainingTextTitle', 'trainingTextTitle'),
|
||||
text('whyText', 'whyText'),
|
||||
text('whyTitle', 'whyTitle'),
|
||||
{ key: 'hidePicture', label: 'hidePicture', type: 'number' },
|
||||
json('kodUslug', 'kodUslug'),
|
||||
json('doctors', 'doctors'),
|
||||
json('services', 'services'),
|
||||
json('articles', 'articles'),
|
||||
json('txtUp', 'txtUp'),
|
||||
json('contraindications', 'contraindications'),
|
||||
json('indications', 'indications'),
|
||||
json('linkSale', 'linkSale'),
|
||||
json('plusList', 'plusList'),
|
||||
json('servicesList', 'servicesList'),
|
||||
json('servicesPhotos', 'servicesPhotos'),
|
||||
json('sortStaff', 'sortStaff'),
|
||||
],
|
||||
},
|
||||
article: {
|
||||
slug: 'article',
|
||||
basePath: '/article',
|
||||
title: 'Статьи',
|
||||
titleSingle: 'статью',
|
||||
icon: 'fas fa-file-alt',
|
||||
listUsesMeta: true,
|
||||
listUsesLimit: true,
|
||||
listColumns: [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'name', label: 'Название' },
|
||||
{ key: 'alias', label: 'Alias' },
|
||||
{ key: 'active', label: 'Активно', format: 'bool' },
|
||||
],
|
||||
fields: [
|
||||
...baseContentFields,
|
||||
text('previewPicture', 'previewPicture'),
|
||||
json('doctors', 'doctors'),
|
||||
json('services', 'services'),
|
||||
],
|
||||
},
|
||||
'site-services': {
|
||||
slug: 'site-services',
|
||||
basePath: '/site-services',
|
||||
title: 'Услуги сайта',
|
||||
titleSingle: 'услугу',
|
||||
icon: 'fas fa-concierge-bell',
|
||||
listColumns: [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'name', label: 'Название' },
|
||||
{ key: 'alias', label: 'Alias' },
|
||||
{ key: 'active', label: 'Активно', format: 'bool' },
|
||||
],
|
||||
fields: [
|
||||
...baseContentFields,
|
||||
text('previewImg', 'previewImg'),
|
||||
text('partPrice', 'partPrice'),
|
||||
text('pokazaniya', 'pokazaniya'),
|
||||
text('preparation', 'preparation'),
|
||||
text('protivopokazaniya', 'protivopokazaniya'),
|
||||
text('bannerImg', 'bannerImg'),
|
||||
text('bannerImgM', 'bannerImgM'),
|
||||
text('bannerImgUrl', 'bannerImgUrl'),
|
||||
text('downloadFile', 'downloadFile'),
|
||||
text('fullWidthBanner', 'fullWidthBanner'),
|
||||
text('kodUslug', 'kodUslug'),
|
||||
text('linkPrice', 'linkPrice'),
|
||||
text('photosTitle', 'photosTitle'),
|
||||
text('contraindicationsList', 'contraindicationsList'),
|
||||
text('customBlockText', 'customBlockText'),
|
||||
text('customBlockText2', 'customBlockText2'),
|
||||
text('customBlockTitle', 'customBlockTitle'),
|
||||
text('customBlockTitle2', 'customBlockTitle2'),
|
||||
text('indicationsList', 'indicationsList'),
|
||||
text('plusList', 'plusList'),
|
||||
text('plusText', 'plusText'),
|
||||
text('plusTitle', 'plusTitle'),
|
||||
text('prepareTitle', 'prepareTitle'),
|
||||
text('processText', 'processText'),
|
||||
text('processTitle', 'processTitle'),
|
||||
text('servicesList', 'servicesList'),
|
||||
text('servicesTitle', 'servicesTitle'),
|
||||
text('textUp', 'textUp'),
|
||||
text('trainingText', 'trainingText'),
|
||||
text('whyText', 'whyText'),
|
||||
text('whyTitle', 'whyTitle'),
|
||||
{ key: 'hidePicture', label: 'hidePicture', type: 'number' },
|
||||
json('linkVideoreviews', 'linkVideoreviews'),
|
||||
json('faq', 'faq'),
|
||||
json('hideSignBtn', 'hideSignBtn'),
|
||||
json('quiz', 'quiz'),
|
||||
json('tags', 'tags'),
|
||||
json('tagsImportant', 'tagsImportant'),
|
||||
json('clinics', 'clinics'),
|
||||
json('staffUp', 'staffUp'),
|
||||
json('advantages', 'advantages'),
|
||||
json('saleId', 'saleId'),
|
||||
json('sortStaff', 'sortStaff'),
|
||||
json('linkArticlesServices', 'linkArticlesServices'),
|
||||
json('servicesPhotos', 'servicesPhotos'),
|
||||
json('linkFaq', 'linkFaq'),
|
||||
json('linkServices', 'linkServices'),
|
||||
json('linkStaff', 'linkStaff'),
|
||||
json('photos', 'photos'),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const CONTENT_RESOURCE_KEYS = Object.keys(CONTENT_RESOURCES)
|
||||
@@ -2,6 +2,8 @@ import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { apiUrl } from '@/config/api';
|
||||
|
||||
import { useGetFilialsQuery } from '../api/apiFilial';
|
||||
import { useGetDepartmentsQuery } from '../api/apiDepartment';
|
||||
import { selectRegions } from '../store/slice/regionSlice';
|
||||
@@ -31,7 +33,7 @@ export function useLostDoctors() {
|
||||
Promise.all(
|
||||
data.data.map(item => {
|
||||
const fetchString = fetchParams.filter((param => Boolean(item[param]))).map(param => `${param}=${item[param]}`).join('&');
|
||||
return axios.get(`https://api.sovamed.ru/idoctor/list?${fetchString}`, {
|
||||
return axios.get(apiUrl(`/idoctor/list?${fetchString}`), {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
})
|
||||
.then(res => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { apiUrl } from '@/config/api';
|
||||
|
||||
import { useGetSpecialistsQuery, useGetSpecialistQuery } from '../api/apiSpecialist';
|
||||
import { useGetKodopersQuery } from '../api/apiKodoper';
|
||||
import { useGetFilialsQuery } from '../api/apiFilial';
|
||||
@@ -27,7 +29,7 @@ export function useSpecialist(id) {
|
||||
|
||||
Promise.all(
|
||||
specialist.kodoper.map(code =>
|
||||
axios.get(`https://api.sovamed.ru/pricelist/list?search=${code}`, {
|
||||
axios.get(apiUrl(`/pricelist/list?search=${code}`), {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
})
|
||||
.then(res => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useEffect, useState, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { apiUrl } from '@/config/api';
|
||||
|
||||
import { useGetFilialsQuery, useUpdateFilialMutation, useUploadFilialPictureMutation } from '../api/apiFilial';
|
||||
import { selectUtils } from '../store/slice/utilsSlice';
|
||||
import { selectRegions } from '../store/slice/regionSlice';
|
||||
@@ -69,7 +71,7 @@ export const EditFilialPage = () => {
|
||||
email: filial.email,
|
||||
fid: filial.fid,
|
||||
origin: filial.origin,
|
||||
picture: `https://api.sovamed.ru${filial.pictureLink}`,
|
||||
picture: apiUrl(filial.pictureLink),
|
||||
policy: filial.policy,
|
||||
}
|
||||
setForm({... filialData})
|
||||
|
||||
@@ -2,6 +2,8 @@ import {useEffect, useState, useMemo, useRef} from 'react'
|
||||
import {useParams, useNavigate, useLocation } from 'react-router-dom'
|
||||
import DatePicker from "react-datepicker";
|
||||
|
||||
import { apiUrl } from '@/config/api';
|
||||
|
||||
import { useUpdateSpecialistMutation, useDeleteSpecialistMutation, useUploadSpecialistPictureMutation } from '../api/apiSpecialist'
|
||||
import { useCreateLocationMutation, useUpdateLocationMutation, useDeleteLocationMutation } from '/src/api/apiLocation.js'
|
||||
import {
|
||||
@@ -386,7 +388,7 @@ export const EditSpecialistPage = () => {
|
||||
}
|
||||
updateField('category', formatCategory(specialist.category));
|
||||
|
||||
updateField('previewPicture', `https://api.sovamed.ru${specialist.pictureLink}`);
|
||||
updateField('previewPicture', apiUrl(specialist.pictureLink));
|
||||
|
||||
const formattedDate = specialist.experience
|
||||
? `${specialist.experience}-01-01`
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { apiUrl } from '@/config/api';
|
||||
|
||||
import {
|
||||
useGetStockQuery,
|
||||
useUpdateStockMutation,
|
||||
@@ -85,7 +87,7 @@ export function EditStockPage() {
|
||||
name: stock.name ?? '',
|
||||
startDate: datetimeLocalStart,
|
||||
endDate: datetimeLocalEnd,
|
||||
picture: stock.picture ? `https://api.sovamed.ru/uploads/${stock.picture}` : '',
|
||||
picture: stock.picture ? apiUrl(`/uploads/${stock.picture}`) : '',
|
||||
}));
|
||||
|
||||
setAnons( stock.anons )
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
import { selectRegions } from '../../store/slice/regionSlice'
|
||||
import { TextEditor } from '../../components/Editors/TextEditor'
|
||||
import { LoadingComponent } from '../../components/Placeholders/LoadingComponent'
|
||||
import { ErrorComponent } from '../../components/Placeholders/ErrorComponent'
|
||||
import { NotFindElement } from '../../components/Placeholders/NotFindElement'
|
||||
import { EditElementForm } from '../../components/Forms/EditElementForm'
|
||||
import Modal from '../../components/Modals/Modal'
|
||||
import { parseSaveError } from '../../utils/parseSaveError'
|
||||
|
||||
const SUCCESS_MODAL_MS = 2000
|
||||
|
||||
const emptyFormFromConfig = (fields) => {
|
||||
const form = {}
|
||||
fields.forEach((field) => {
|
||||
if (field.type === 'checkbox') {
|
||||
form[field.key] = false
|
||||
} else if (field.type === 'number' || field.type === 'region') {
|
||||
form[field.key] = ''
|
||||
} else {
|
||||
form[field.key] = ''
|
||||
}
|
||||
})
|
||||
return form
|
||||
}
|
||||
|
||||
const itemToForm = (item, fields) => {
|
||||
const form = {}
|
||||
fields.forEach((field) => {
|
||||
const value = item[field.key]
|
||||
if (field.type === 'json') {
|
||||
form[field.key] = value == null ? '' : JSON.stringify(value, null, 2)
|
||||
} else if (field.type === 'checkbox') {
|
||||
form[field.key] = Boolean(value)
|
||||
} else if (field.type === 'region' || field.type === 'number') {
|
||||
form[field.key] = value ?? ''
|
||||
} else {
|
||||
form[field.key] = value ?? ''
|
||||
}
|
||||
})
|
||||
return form
|
||||
}
|
||||
|
||||
const formToPayload = (form, fields) => {
|
||||
const data = {}
|
||||
fields.forEach((field) => {
|
||||
const raw = form[field.key]
|
||||
if (field.type === 'json') {
|
||||
if (!raw || !String(raw).trim()) {
|
||||
data[field.key] = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
data[field.key] = JSON.parse(raw)
|
||||
} catch {
|
||||
const error = new Error('Невалидный JSON — проверьте синтаксис')
|
||||
error.fieldKey = field.key
|
||||
throw error
|
||||
}
|
||||
return
|
||||
}
|
||||
if (field.type === 'checkbox') {
|
||||
data[field.key] = Boolean(raw)
|
||||
return
|
||||
}
|
||||
if (field.type === 'region' || field.type === 'number') {
|
||||
data[field.key] = raw === '' ? null : Number(raw)
|
||||
return
|
||||
}
|
||||
data[field.key] = raw === '' ? null : raw
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
const FieldHint = ({ message }) =>
|
||||
message ? <span className="content-field-error-msg" role="alert">{message}</span> : null
|
||||
|
||||
const fieldWrapperClass = (hasError, extra = '') =>
|
||||
['form-group', extra, hasError ? 'content-field--has-error' : ''].filter(Boolean).join(' ')
|
||||
|
||||
const ContentField = ({ field, form, updateField, regions, fieldErrors }) => {
|
||||
const value = form[field.key]
|
||||
const errorMessage = fieldErrors[field.key]
|
||||
const hasError = Boolean(errorMessage)
|
||||
const labelClass = hasError ? 'content-field-error-label' : undefined
|
||||
const controlClass = `form-control${hasError ? ' is-invalid' : ''}`
|
||||
|
||||
if (field.type === 'checkbox') {
|
||||
return (
|
||||
<div className={fieldWrapperClass(hasError, 'form-check')} data-field-key={field.key}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={`form-check-input${hasError ? ' is-invalid' : ''}`}
|
||||
id={field.key}
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => updateField(field.key, e.target.checked)}
|
||||
/>
|
||||
<label className={`form-check-label${hasError ? ' content-field-error-label' : ''}`} htmlFor={field.key}>
|
||||
{field.label}
|
||||
</label>
|
||||
<FieldHint message={errorMessage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'region') {
|
||||
const selectValue = value === '' || value == null ? '' : String(value)
|
||||
return (
|
||||
<div className={fieldWrapperClass(hasError)} data-field-key={field.key}>
|
||||
<label className={labelClass}>{field.label}</label>
|
||||
<select
|
||||
className={controlClass}
|
||||
value={selectValue}
|
||||
onChange={(e) => updateField(field.key, e.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{Object.entries(regions).map(([id, name]) => (
|
||||
<option key={id} value={id}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<FieldHint message={errorMessage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'html') {
|
||||
return (
|
||||
<div className={fieldWrapperClass(hasError)} data-field-key={field.key}>
|
||||
<label className={labelClass}>{field.label}</label>
|
||||
<TextEditor content={value} setContent={(html) => updateField(field.key, html)} />
|
||||
<FieldHint message={errorMessage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'json') {
|
||||
return (
|
||||
<div className={fieldWrapperClass(hasError)} data-field-key={field.key}>
|
||||
<label className={labelClass}>{field.label}</label>
|
||||
<textarea
|
||||
className={`${controlClass} font-monospace`}
|
||||
rows={4}
|
||||
value={value}
|
||||
onChange={(e) => updateField(field.key, e.target.value)}
|
||||
/>
|
||||
<FieldHint message={errorMessage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={fieldWrapperClass(hasError)} data-field-key={field.key}>
|
||||
<label className={labelClass}>{field.label}</label>
|
||||
<input
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
className={controlClass}
|
||||
value={value}
|
||||
onChange={(e) => updateField(field.key, e.target.value)}
|
||||
/>
|
||||
<FieldHint message={errorMessage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ContentEditPage = ({ config, hooks, isCreate = false }) => {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const regions = useSelector(selectRegions)
|
||||
const navigateBack = () => navigate(`/${config.slug}`)
|
||||
const formTopRef = useRef(null)
|
||||
const successTimerRef = useRef(null)
|
||||
|
||||
const skip = isCreate || !id
|
||||
const { data: item, isLoading, error, refetch } = hooks.useItemQuery(id, { skip })
|
||||
const [createItem] = hooks.useCreateMutation()
|
||||
const [updateItem] = hooks.useUpdateMutation()
|
||||
const [deleteItem] = hooks.useDeleteMutation()
|
||||
|
||||
const [form, setForm] = useState(() => emptyFormFromConfig(config.fields))
|
||||
const [fieldErrors, setFieldErrors] = useState({})
|
||||
const [globalError, setGlobalError] = useState(null)
|
||||
const [isModalSuccess, setModalSuccess] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (successTimerRef.current) {
|
||||
window.clearTimeout(successTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreate || !item) {
|
||||
return
|
||||
}
|
||||
setForm(itemToForm(item, config.fields))
|
||||
}, [item, isCreate, config.fields])
|
||||
|
||||
const showSuccessModal = (onClose) => {
|
||||
setModalSuccess(true)
|
||||
if (successTimerRef.current) {
|
||||
window.clearTimeout(successTimerRef.current)
|
||||
}
|
||||
successTimerRef.current = window.setTimeout(() => {
|
||||
setModalSuccess(false)
|
||||
onClose?.()
|
||||
}, SUCCESS_MODAL_MS)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const keys = Object.keys(fieldErrors)
|
||||
if (!keys.length && !globalError) {
|
||||
return
|
||||
}
|
||||
formTopRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
const firstKey = keys[0]
|
||||
if (firstKey) {
|
||||
document
|
||||
.querySelector(`[data-field-key="${firstKey}"]`)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}, [fieldErrors, globalError])
|
||||
|
||||
const updateField = (key, value) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
setFieldErrors((prev) => {
|
||||
if (!prev[key]) {
|
||||
return prev
|
||||
}
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
})
|
||||
setGlobalError(null)
|
||||
}
|
||||
|
||||
const validateClient = () => {
|
||||
const next = {}
|
||||
const has = (key) => config.fields.some((f) => f.key === key)
|
||||
if (has('name') && !String(form.name ?? '').trim()) {
|
||||
next.name = 'Название не может быть пустым'
|
||||
}
|
||||
if (has('alias') && !String(form.alias ?? '').trim()) {
|
||||
next.alias = 'Alias не может быть пустым'
|
||||
}
|
||||
if (has('regionId') && (form.regionId === '' || form.regionId == null)) {
|
||||
next.regionId = 'Укажите регион'
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setFieldErrors({})
|
||||
setGlobalError(null)
|
||||
|
||||
const clientErrors = validateClient()
|
||||
if (Object.keys(clientErrors).length) {
|
||||
setFieldErrors(clientErrors)
|
||||
return
|
||||
}
|
||||
|
||||
let data
|
||||
try {
|
||||
data = formToPayload(form, config.fields)
|
||||
} catch (err) {
|
||||
const key = err.fieldKey
|
||||
if (key) {
|
||||
setFieldErrors({ [key]: err.message || 'Ошибка в поле' })
|
||||
} else {
|
||||
setGlobalError(err.message || 'Ошибка в форме')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (isCreate) {
|
||||
const created = await createItem(data).unwrap()
|
||||
showSuccessModal(() => navigate(`/${config.slug}/edit/${created.id}`))
|
||||
return
|
||||
}
|
||||
await updateItem({ id: Number(id), data }).unwrap()
|
||||
showSuccessModal(() => refetch())
|
||||
} catch (err) {
|
||||
const { fieldErrors: nextFieldErrors, globalMessage } = parseSaveError(err)
|
||||
setFieldErrors(nextFieldErrors)
|
||||
if (globalMessage) {
|
||||
setGlobalError(globalMessage)
|
||||
} else if (!Object.keys(nextFieldErrors).length) {
|
||||
setGlobalError('Не удалось сохранить запись')
|
||||
} else {
|
||||
setGlobalError(null)
|
||||
}
|
||||
console.error('Ошибка сохранения контента:', err, nextFieldErrors)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('Удалить запись?')) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteItem(Number(id)).unwrap()
|
||||
navigateBack()
|
||||
} catch (err) {
|
||||
const { fieldErrors: nextFieldErrors, globalMessage } = parseSaveError(err)
|
||||
setFieldErrors(nextFieldErrors)
|
||||
setGlobalError(globalMessage || 'Не удалось удалить запись')
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
const fieldsWithErrors = config.fields.filter((field) => fieldErrors[field.key])
|
||||
|
||||
if (!isCreate && isLoading) {
|
||||
return <LoadingComponent />
|
||||
}
|
||||
if (!isCreate && error) {
|
||||
return <ErrorComponent />
|
||||
}
|
||||
if (!isCreate && !isLoading && !item) {
|
||||
return <NotFindElement />
|
||||
}
|
||||
|
||||
return (
|
||||
<EditElementForm
|
||||
navigateBack={navigateBack}
|
||||
header={isCreate ? `Добавление: ${config.titleSingle}` : `Редактирование: ${config.titleSingle}`}
|
||||
handleSave={handleSave}
|
||||
handleDelete={isCreate ? null : handleDelete}
|
||||
isAddSpecialist={isCreate}
|
||||
>
|
||||
<div ref={formTopRef} />
|
||||
{globalError ? (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{globalError}
|
||||
</div>
|
||||
) : null}
|
||||
{fieldsWithErrors.length > 0 ? (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
<strong>Исправьте поля:</strong>
|
||||
<ul className="mb-0 mt-2">
|
||||
{fieldsWithErrors.map((field) => (
|
||||
<li key={field.key}>
|
||||
{field.label}: {fieldErrors[field.key]}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{config.fields.map((field) => (
|
||||
<ContentField
|
||||
key={field.key}
|
||||
field={field}
|
||||
form={form}
|
||||
updateField={updateField}
|
||||
regions={regions}
|
||||
fieldErrors={fieldErrors}
|
||||
/>
|
||||
))}
|
||||
<Modal isOpen={isModalSuccess} title="Изменения внесены" hasButtons={false}>
|
||||
<p className="mb-1">Изменения успешно внесены.</p>
|
||||
</Modal>
|
||||
</EditElementForm>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Fragment, useState, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
import { selectRegions } from '../../store/slice/regionSlice'
|
||||
import { useOutsideClick } from '../../hooks/useOutsideClick'
|
||||
import { LoadingComponent } from '../../components/Placeholders/LoadingComponent'
|
||||
import { ErrorComponent } from '../../components/Placeholders/ErrorComponent'
|
||||
|
||||
const normalizePagination = (response) => {
|
||||
if (response.pagination) {
|
||||
return response.pagination
|
||||
}
|
||||
if (response.meta) {
|
||||
const page = response.meta.page ?? 1
|
||||
const totalPages = response.meta.totalPages ?? 1
|
||||
return {
|
||||
total_pages: totalPages,
|
||||
current_page: page,
|
||||
has_previous_page: page > 1,
|
||||
has_next_page: page < totalPages,
|
||||
}
|
||||
}
|
||||
return {
|
||||
total_pages: 1,
|
||||
current_page: 1,
|
||||
has_previous_page: false,
|
||||
has_next_page: false,
|
||||
}
|
||||
}
|
||||
|
||||
const formatCell = (item, column, regions) => {
|
||||
const value = item[column.key]
|
||||
if (column.format === 'bool') {
|
||||
return value ? 'Да' : 'Нет'
|
||||
}
|
||||
if (column.key === 'regionId' && regions[value]) {
|
||||
return regions[value]
|
||||
}
|
||||
return value ?? ''
|
||||
}
|
||||
|
||||
export const ContentListPage = ({ config, hooks }) => {
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [expandedId, setExpandedId] = useState(null)
|
||||
const navigate = useNavigate()
|
||||
const regions = useSelector(selectRegions)
|
||||
const tableRef = useRef(null)
|
||||
useOutsideClick(tableRef, () => setExpandedId(null))
|
||||
|
||||
const { data: response = {}, isFetching, error: queryError } = hooks.useListQuery({
|
||||
search: searchValue,
|
||||
page: currentPage,
|
||||
perPage: 20,
|
||||
})
|
||||
|
||||
const items = response.data ?? []
|
||||
const pagination = normalizePagination(response)
|
||||
|
||||
const renderPagination = () => {
|
||||
const total = pagination.total_pages || 1
|
||||
const current = pagination.current_page || 1
|
||||
const pages = new Set([1, total])
|
||||
|
||||
for (let page = current - 2; page <= current + 2; page += 1) {
|
||||
if (page > 1 && page < total) {
|
||||
pages.add(page)
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = Array.from(pages).sort((a, b) => a - b)
|
||||
const elements = []
|
||||
let last = 0
|
||||
|
||||
sorted.forEach((page) => {
|
||||
if (last && page - last > 1) {
|
||||
elements.push(
|
||||
<li key={`dots-${last}`} className="page-item disabled">
|
||||
<span className="page-link">…</span>
|
||||
</li>,
|
||||
)
|
||||
}
|
||||
elements.push(
|
||||
<li key={page} className={`page-item ${page === current ? 'active' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="page-link"
|
||||
onClick={() => page !== current && setCurrentPage(page)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
</li>,
|
||||
)
|
||||
last = page
|
||||
})
|
||||
|
||||
return (
|
||||
<nav>
|
||||
<ul className="pagination justify-content-center">
|
||||
<li className={`page-item ${!pagination.has_previous_page ? 'disabled' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="page-link"
|
||||
onClick={() => pagination.has_previous_page && setCurrentPage(current - 1)}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
</li>
|
||||
{elements}
|
||||
<li className={`page-item ${!pagination.has_next_page ? 'disabled' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="page-link"
|
||||
onClick={() => pagination.has_next_page && setCurrentPage(current + 1)}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<h1 className="h3 mb-4 text-gray-800">{config.title}</h1>
|
||||
<div
|
||||
className="d-flex justify-content-between mb-3"
|
||||
style={{ marginRight: '0.1rem', marginLeft: '0.1rem' }}
|
||||
>
|
||||
<div className="form-group align-self-end mr-3">
|
||||
<input
|
||||
type="button"
|
||||
className="btn btn-outline-primary"
|
||||
value="Добавить"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate(`/${config.slug}/create`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group flex-grow-1">
|
||||
<label>Поиск</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={searchValue}
|
||||
onChange={(e) => {
|
||||
setSearchValue(e.target.value)
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFetching ? (
|
||||
<LoadingComponent />
|
||||
) : queryError ? (
|
||||
<ErrorComponent />
|
||||
) : (
|
||||
<>
|
||||
<div className="table-responsive" ref={tableRef}>
|
||||
<table className="table table-hover table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
{config.listColumns.map((col) => (
|
||||
<th key={col.key}>{col.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<Fragment key={item.id}>
|
||||
<tr
|
||||
className={`cursor-pointer${expandedId === item.id ? ' table-success' : ''}`}
|
||||
onClick={() => {
|
||||
setExpandedId(expandedId === item.id ? null : item.id)
|
||||
}}
|
||||
>
|
||||
{config.listColumns.map((col) => (
|
||||
<td key={col.key}>{formatCell(item, col, regions)}</td>
|
||||
))}
|
||||
</tr>
|
||||
{expandedId === item.id && (
|
||||
<tr className="table-success">
|
||||
<td colSpan={config.listColumns.length}>
|
||||
<input
|
||||
type="button"
|
||||
className="btn btn-outline-primary"
|
||||
value="Редактировать"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate(`/${config.slug}/edit/${item.id}`)
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{renderPagination()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { CONTENT_RESOURCES } from '../../config/contentResources'
|
||||
import { contentHooks } from '../../api/apiContent'
|
||||
import { ContentListPage } from './ContentListPage'
|
||||
import { ContentEditPage } from './ContentEditPage'
|
||||
|
||||
const bind = (resourceKey) => {
|
||||
const config = CONTENT_RESOURCES[resourceKey]
|
||||
const hooks = contentHooks[resourceKey]
|
||||
|
||||
return {
|
||||
ListPage: () => <ContentListPage config={config} hooks={hooks} />,
|
||||
EditPage: () => <ContentEditPage config={config} hooks={hooks} isCreate={false} />,
|
||||
CreatePage: () => <ContentEditPage config={config} hooks={hooks} isCreate />,
|
||||
}
|
||||
}
|
||||
|
||||
const news = bind('news')
|
||||
const promo = bind('promo')
|
||||
const disease = bind('disease')
|
||||
const medicalCenter = bind('medical-center')
|
||||
const article = bind('article')
|
||||
const siteServices = bind('site-services')
|
||||
|
||||
export const NewsListPage = news.ListPage
|
||||
export const NewsEditPage = news.EditPage
|
||||
export const NewsCreatePage = news.CreatePage
|
||||
|
||||
export const SitePromoListPage = promo.ListPage
|
||||
export const SitePromoEditPage = promo.EditPage
|
||||
export const SitePromoCreatePage = promo.CreatePage
|
||||
|
||||
export const DiseaseListPage = disease.ListPage
|
||||
export const DiseaseEditPage = disease.EditPage
|
||||
export const DiseaseCreatePage = disease.CreatePage
|
||||
|
||||
export const MedicalCenterListPage = medicalCenter.ListPage
|
||||
export const MedicalCenterEditPage = medicalCenter.EditPage
|
||||
export const MedicalCenterCreatePage = medicalCenter.CreatePage
|
||||
|
||||
export const ArticleListPage = article.ListPage
|
||||
export const ArticleEditPage = article.EditPage
|
||||
export const ArticleCreatePage = article.CreatePage
|
||||
|
||||
export const SiteServicesListPage = siteServices.ListPage
|
||||
export const SiteServicesEditPage = siteServices.EditPage
|
||||
export const SiteServicesCreatePage = siteServices.CreatePage
|
||||
@@ -7,6 +7,7 @@ import { API } from '../api/apiSlice';
|
||||
import '../api/apiDepartment'
|
||||
import '../api/apiFilial'
|
||||
import '../api/apiSpecialist'
|
||||
import '../api/apiContent'
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
|
||||
@@ -147,3 +147,31 @@ input[type="number"]:focus {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
// Ошибки валидации в формах контента (SB Admin перебивает .text-danger на label)
|
||||
.content-field--has-error {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #e74a3b;
|
||||
border-radius: 0.35rem;
|
||||
background-color: #fff5f5;
|
||||
}
|
||||
|
||||
.content-field--has-error > label,
|
||||
.content-field--has-error .content-field-error-label {
|
||||
color: #e74a3b !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.content-field--has-error .form-control,
|
||||
.content-field--has-error .form-check-input {
|
||||
border-color: #e74a3b !important;
|
||||
}
|
||||
|
||||
.content-field-error-msg {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
color: #e74a3b !important;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
const snakeToCamel = (key) =>
|
||||
key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
|
||||
|
||||
const normalizePropertyPath = (path) => {
|
||||
if (!path) {
|
||||
return ''
|
||||
}
|
||||
const last = String(path).replace(/^\[|\]$/g, '').split('.').pop()
|
||||
return snakeToCamel(last)
|
||||
}
|
||||
|
||||
const addViolation = (fieldErrors, path, message) => {
|
||||
const key = normalizePropertyPath(path)
|
||||
if (!key || !message) {
|
||||
return
|
||||
}
|
||||
if (!fieldErrors[key]) {
|
||||
fieldErrors[key] = message
|
||||
}
|
||||
}
|
||||
|
||||
const extractFieldFromMessage = (message) => {
|
||||
const text = String(message)
|
||||
const patterns = [
|
||||
/"(\w+)" attribute/,
|
||||
/attribute "\w+" of type "\w+" expects [\w\\]+, "(\w+)" given/,
|
||||
/at property path "([^"]+)"/,
|
||||
/for property "(\w+)"/,
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern)
|
||||
if (match?.[1]) {
|
||||
return normalizePropertyPath(match[1])
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбирает ошибку сохранения (клиент, RTK Query, Symfony validation / denormalize).
|
||||
* @returns {{ fieldErrors: Record<string, string>, globalMessage: string | null }}
|
||||
*/
|
||||
export const parseSaveError = (err) => {
|
||||
const fieldErrors = {}
|
||||
|
||||
const fieldKey = err?.fieldKey
|
||||
if (fieldKey) {
|
||||
fieldErrors[fieldKey] = err?.message || 'Ошибка поля'
|
||||
return { fieldErrors, globalMessage: null }
|
||||
}
|
||||
|
||||
let data = err?.data
|
||||
if (data == null && err?.error && typeof err.error === 'object') {
|
||||
data = err.error.data ?? err.error
|
||||
}
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
data = JSON.parse(data)
|
||||
} catch {
|
||||
const fieldFromText = extractFieldFromMessage(data)
|
||||
if (fieldFromText) {
|
||||
fieldErrors[fieldFromText] = data
|
||||
return { fieldErrors, globalMessage: null }
|
||||
}
|
||||
return { fieldErrors, globalMessage: data }
|
||||
}
|
||||
}
|
||||
|
||||
const collectViolations = (violations) => {
|
||||
if (!Array.isArray(violations)) {
|
||||
return
|
||||
}
|
||||
violations.forEach((violation) => {
|
||||
addViolation(
|
||||
fieldErrors,
|
||||
violation.propertyPath ?? violation.property_path,
|
||||
violation.message ?? violation.title,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
collectViolations(data)
|
||||
return {
|
||||
fieldErrors,
|
||||
globalMessage: Object.keys(fieldErrors).length ? null : 'Ошибка сохранения',
|
||||
}
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
collectViolations(data.violations ?? data['@violations'])
|
||||
|
||||
if (data.error) {
|
||||
const message = String(data.error)
|
||||
const fieldFromText = extractFieldFromMessage(message)
|
||||
if (fieldFromText) {
|
||||
const detail = message.replace(/^Ошибка десериализации:\s*/u, '').trim()
|
||||
fieldErrors[fieldFromText] = detail
|
||||
}
|
||||
return {
|
||||
fieldErrors,
|
||||
globalMessage: Object.keys(fieldErrors).length ? null : message,
|
||||
}
|
||||
}
|
||||
|
||||
if (data.message && !data.error) {
|
||||
return { fieldErrors, globalMessage: String(data.message) }
|
||||
}
|
||||
}
|
||||
|
||||
if (err instanceof Error && err.status == null) {
|
||||
return { fieldErrors, globalMessage: err.message || 'Ошибка сохранения' }
|
||||
}
|
||||
|
||||
return {
|
||||
fieldErrors,
|
||||
globalMessage:
|
||||
(typeof err?.data === 'string' ? err.data : null) ||
|
||||
err?.error ||
|
||||
err?.message ||
|
||||
'Ошибка сохранения',
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user