chore: initial import for test contour
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
name: adminpanel-ci-cd
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'adminpanel-v*'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
REGISTRY: git.sova.local
|
||||
IMAGE: git.sova.local/sova/adminpanel
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
- run: npm ci
|
||||
- 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 }}
|
||||
env: ${{ steps.meta.outputs.env }}
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
steps:
|
||||
- name: Parse tag
|
||||
id: meta
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
echo "full_tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "env=$(echo "$TAG" | sed -E 's/adminpanel-v([0-9.]+)-([a-z]+)/\2/')" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$(echo "$TAG" | sed -E 's/adminpanel-v([0-9.]+).*/\1/')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-and-push:
|
||||
needs: [test, parse-tag]
|
||||
if: startsWith(github.ref, 'refs/tags/adminpanel-v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Docker login
|
||||
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY" -u sova-ci --password-stdin
|
||||
- name: Build and push
|
||||
run: |
|
||||
TAG="${{ needs.parse-tag.outputs.full_tag }}"
|
||||
docker build -f Dockerfile -t "$IMAGE:${TAG}" -t "$IMAGE:${{ needs.parse-tag.outputs.version }}" .
|
||||
docker push "$IMAGE:${TAG}"
|
||||
docker push "$IMAGE:${{ needs.parse-tag.outputs.version }}"
|
||||
|
||||
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
|
||||
env:
|
||||
DEPLOY_KEY: ${{ secrets.SOVA_DEPLOY_KEY }}
|
||||
run: |
|
||||
eval "$(ssh-agent -s)"
|
||||
echo "$DEPLOY_KEY" | ssh-add -
|
||||
git clone git@gitea.sova.local:sova/sova-deploy.git
|
||||
cd sova-deploy
|
||||
ENV="${{ needs.parse-tag.outputs.env }}"
|
||||
TAG="${{ needs.parse-tag.outputs.full_tag }}"
|
||||
git config user.email "ci-bot@sova.local"
|
||||
git config user.name "sova-ci"
|
||||
MAX_RETRIES=5
|
||||
for attempt in $(seq 1 $MAX_RETRIES); do
|
||||
git pull --rebase origin main
|
||||
yq -i ".image.tag = \"${TAG}\"" "apps/adminpanel/values-${ENV}.yaml"
|
||||
git add "apps/adminpanel/values-${ENV}.yaml"
|
||||
git diff --cached --quiet && { echo "No changes"; exit 0; }
|
||||
git commit -m "chore(adminpanel): bump ${ENV} to ${TAG}"
|
||||
if git push origin main; then
|
||||
echo "Push OK on attempt ${attempt}"
|
||||
exit 0
|
||||
fi
|
||||
echo "Push failed, retry ${attempt}/${MAX_RETRIES}..."
|
||||
git reset --hard HEAD~1
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
echo "Failed to push after ${MAX_RETRIES} attempts"
|
||||
exit 1
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
coverage
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.vscode
|
||||
*.lock
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY public/env.js /usr/share/nginx/html/env.js
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
EXPOSE 80
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -0,0 +1,5 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
presets: [
|
||||
['@babel/preset-env'],
|
||||
['@babel/preset-react', { runtime: 'automatic' }]
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
HTML_DIR="/usr/share/nginx/html"
|
||||
|
||||
if [ -f /config/env.js ]; then
|
||||
cp /config/env.js "$HTML_DIR/env.js"
|
||||
echo "Applied runtime env.js from /config/env.js"
|
||||
fi
|
||||
|
||||
exec nginx -g 'daemon off;'
|
||||
@@ -0,0 +1,14 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location = /env.js {
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import js from '@eslint/js';
|
||||
import pluginReact from 'eslint-plugin-react';
|
||||
import pluginReactHooks from 'eslint-plugin-react-hooks';
|
||||
import pluginJsxA11y from 'eslint-plugin-jsx-a11y';
|
||||
import pluginImport from 'eslint-plugin-import';
|
||||
import pluginJest from 'eslint-plugin-jest';
|
||||
|
||||
import { afterEach, beforeEach, describe } from 'node:test';
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist', 'coverage'] },
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['**/*.jsx', '**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
JSX: 'readonly',
|
||||
document: true,
|
||||
Document: true,
|
||||
localStorage: true,
|
||||
window: true,
|
||||
console: true,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
react: pluginReact,
|
||||
'react-hooks': pluginReactHooks,
|
||||
'jsx-a11y': pluginJsxA11y,
|
||||
import: pluginImport,
|
||||
jest: pluginJest,
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
'import/resolver': {
|
||||
node: {
|
||||
extensions: ['.js', '.jsx'],
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/prop-types': 'off',
|
||||
|
||||
'react-hooks/rules-of-hooks': 'warn',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
|
||||
'jsx-a11y/anchor-is-valid': 'warn',
|
||||
|
||||
'import/order': ['warn', {
|
||||
groups: ['builtin', 'external', 'internal'],
|
||||
'newlines-between': 'always',
|
||||
}],
|
||||
|
||||
'jest/no-disabled-tests': 'warn',
|
||||
'jest/no-identical-title': 'error',
|
||||
|
||||
'no-unused-vars': 'off',
|
||||
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['jest.setup.js', '**/*.test.js', '**/*.test.jsx'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
test: true,
|
||||
expect: true,
|
||||
jest: true,
|
||||
global: true,
|
||||
describe: true,
|
||||
it: true,
|
||||
beforeEach: true,
|
||||
afterEach: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
export default {
|
||||
testEnvironment: 'jsdom',
|
||||
transform: {
|
||||
'^.+\\.(js|jsx)?$': 'babel-jest',
|
||||
},
|
||||
moduleFileExtensions: ['js', 'jsx'],
|
||||
moduleNameMapper: {
|
||||
'\\.(css|scss)$': 'identity-obj-proxy',
|
||||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { TextEncoder, TextDecoder } from 'util';
|
||||
|
||||
global.TextEncoder = TextEncoder;
|
||||
global.TextDecoder = TextDecoder;
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "adminpanel",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"lint-fix": "eslint . --fix",
|
||||
"preview": "vite preview",
|
||||
"test": "jest --runInBand",
|
||||
"test-watch": "jest --watch",
|
||||
"test-clear-cache": "jest --clearCache"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"axios": "^1.10.0",
|
||||
"bootstrap": "^4.6.2",
|
||||
"dompurify": "^3.2.6",
|
||||
"express": "^5.1.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"jodit": "^4.6.12",
|
||||
"jodit-react": "^5.2.25",
|
||||
"react": "^19.1.0",
|
||||
"react-datepicker": "^8.7.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-google-recaptcha": "^3.1.0",
|
||||
"react-imask": "^7.6.1",
|
||||
"react-input-mask": "^3.0.0-alpha.2",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.6.2",
|
||||
"sass": "^1.89.2",
|
||||
"startbootstrap-sb-admin-2": "^4.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.27.4",
|
||||
"@babel/preset-env": "^7.27.2",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"babel-jest": "^30.0.2",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jest": "^29.0.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^30.0.3",
|
||||
"jest-environment-jsdom": "^30.0.2",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-eslint": "^1.8.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
window.__ENV__ = window.__ENV__ || {
|
||||
API_BASE_URL: 'http://localhost:8081',
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { store } from './store/store';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { MainPage } from './pages/MainPage';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { UserPage } from './pages/UserPage';
|
||||
import { SpecialistListPage } from './pages/SpecialistListPage';
|
||||
import { DepartmentsListPage } from './pages/DepartmentsListPage';
|
||||
import { FilialsListPage } from './pages/FilialsListPage';
|
||||
import { NotFoundPage } from './pages/NotFoundPage';
|
||||
import { ProtectedRoute } from './routes/ProtectedRoute';
|
||||
import { EditDepartmentPage } from './pages/EditDepartmentPage'
|
||||
import { EditFilialPage } from './pages/EditFilialPage'
|
||||
import { EditSpecialistPage } from './pages/EditSpecialistPage'
|
||||
import { AddSpecialistPage } from './pages/AddSpecialistPage'
|
||||
import { PricesListPage } from './pages/PricesListPage';
|
||||
import { StocksListPage } from './pages/StoksListPage';
|
||||
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 (
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MainPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="user" element={<UserPage />} />
|
||||
<Route path="specialist" element={<SpecialistListPage />} />
|
||||
<Route path="specialist/edit/:id" element={<EditSpecialistPage/>} />
|
||||
<Route path="specialist/add" element={<AddSpecialistPage/>} />
|
||||
<Route path="infoclinic" element={<InfoclinicListPage />} />
|
||||
<Route path="lostDoctors" element={<LostDoctorsPage />} />
|
||||
<Route path="departments" element={<DepartmentsListPage />} />
|
||||
<Route path="departments/edit/:id" element={<EditDepartmentPage/>} />
|
||||
<Route path="filials" element={<FilialsListPage />} />
|
||||
<Route path="filials/edit/:id" element={<EditFilialPage/>} />
|
||||
<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="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 />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,73 @@
|
||||
/* global FormData */
|
||||
|
||||
import { API } from './apiSlice';
|
||||
|
||||
const authHeader = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
};
|
||||
|
||||
export const certificateApi = API.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
getCertificate: build.query({
|
||||
query: ({id}) => {
|
||||
return {
|
||||
url: `/specialist-docs/${id}`,
|
||||
method: 'GET',
|
||||
// headers: authHeader(),
|
||||
};
|
||||
},
|
||||
}),
|
||||
createCertificate: build.mutation({
|
||||
query: ({specialistId, data}) => {
|
||||
return {
|
||||
url: `/specialist/${specialistId}/specialist-docs/create`,
|
||||
method: 'POST',
|
||||
headers: authHeader(),
|
||||
body: JSON.stringify(data),
|
||||
};
|
||||
},
|
||||
}),
|
||||
updateCertificate: build.mutation({
|
||||
query: ({specialistId, id, data}) => {
|
||||
return {
|
||||
url: `/specialist/${specialistId}/specialist-docs/${id}`,
|
||||
method: 'PUT',
|
||||
headers: authHeader(),
|
||||
body: JSON.stringify(data),
|
||||
};
|
||||
},
|
||||
}),
|
||||
uploadSertificatePicture: build.mutation({
|
||||
query: ({ id, file }) => {
|
||||
const formData = new FormData();
|
||||
formData.append('picture', file);
|
||||
return {
|
||||
url: `/specialist-docs/picture/${id}`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...authHeader(),
|
||||
},
|
||||
body: formData,
|
||||
};
|
||||
},
|
||||
}),
|
||||
deleteCertificate: build.mutation({
|
||||
query: ({id}) => {
|
||||
return {
|
||||
url: `/specialist-docs/${id}`,
|
||||
method: 'DELETE',
|
||||
headers: authHeader(),
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetCertificateQuery,
|
||||
useCreateCertificateMutation,
|
||||
useUpdateCertificateMutation,
|
||||
useUploadSertificatePictureMutation,
|
||||
useDeleteCertificateMutation,
|
||||
} = certificateApi;
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { API, authHeader } from './apiSlice'
|
||||
/*
|
||||
const authHeader = () => {
|
||||
const token = localStorage.getItem('token')
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
*/
|
||||
export const departmentApi = API.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
getDepartments: build.query({
|
||||
query: () => '/department/list',
|
||||
}),
|
||||
updateDepartments: build.mutation({
|
||||
query: ({ departmentId, data }) => {
|
||||
return ({
|
||||
url: `/department/${departmentId}`,
|
||||
method: 'PUT',
|
||||
headers: authHeader(),
|
||||
body: JSON.stringify(data),
|
||||
})},
|
||||
}),
|
||||
createDepartments: build.mutation({
|
||||
query: ({ data }) => {
|
||||
return ({
|
||||
url: `/department/create`,
|
||||
method: 'POST',
|
||||
headers: authHeader(),
|
||||
body: JSON.stringify(data),
|
||||
})},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export const {
|
||||
useGetDepartmentsQuery,
|
||||
useUpdateDepartmentsMutation,
|
||||
useCreateDepartmentsMutation,
|
||||
} = departmentApi
|
||||
@@ -0,0 +1,53 @@
|
||||
/* global FormData */
|
||||
|
||||
import { API, authHeader } from './apiSlice'
|
||||
|
||||
export const filialApi = API.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
getFilials: build.query({
|
||||
query: () => '/filial/list',
|
||||
refetchOnMountOrArgChange: true,
|
||||
keepUnusedDataFor: 0,
|
||||
}),
|
||||
updateFilial: build.mutation({
|
||||
query: ({ filialId, data }) => {
|
||||
return ({
|
||||
url: `/filial/${filialId}`,
|
||||
method: 'PUT',
|
||||
headers: authHeader(),
|
||||
body: JSON.stringify(data),
|
||||
})},
|
||||
}),
|
||||
uploadFilialPicture: build.mutation({
|
||||
query: ({ id, file }) => {
|
||||
const formData = new FormData();
|
||||
formData.append('picture', file);
|
||||
return {
|
||||
url: `/filial/picture/${id}`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...authHeader(),
|
||||
},
|
||||
body: formData,
|
||||
};
|
||||
},
|
||||
}),
|
||||
createFilial: build.mutation({
|
||||
query: ({ data }) => {
|
||||
return ({
|
||||
url: `/filial/create`,
|
||||
method: 'POST',
|
||||
headers: authHeader(),
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export const {
|
||||
useGetFilialsQuery,
|
||||
useUpdateFilialMutation,
|
||||
useUploadFilialPictureMutation,
|
||||
useCreateFilialMutation,
|
||||
} = filialApi
|
||||
@@ -0,0 +1,22 @@
|
||||
import { API } from './apiSlice';
|
||||
|
||||
const authHeader = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
};
|
||||
|
||||
export const iDoctorApi = API.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
getIDoctors: build.query({
|
||||
query: ({ value = '', type, page = 1 }) => {
|
||||
return {
|
||||
url: `/idoctor/list?page=${page}&perPage=100${ type ? ( type === 'search' ? `&search=${value}` : ( type === 'dcode' ? `&dcode=${value}` : '' )) : '' }`,
|
||||
headers: authHeader(),
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const { useGetIDoctorsQuery } = iDoctorApi;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { API } from './apiSlice';
|
||||
|
||||
const authHeader = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
};
|
||||
|
||||
export const KodoperApi = API.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
getKodopers: build.query({
|
||||
query: ({value, filialId, page}) => {
|
||||
const querySearch = value ? `search=${value}` : '';
|
||||
let queryFilial = '';
|
||||
if (filialId) {
|
||||
if (filialId >= 0) {
|
||||
queryFilial = `filial=${filialId}`
|
||||
}
|
||||
}
|
||||
const queryPage = page ? `page=${page}` : '';
|
||||
return {
|
||||
url: `/pricelist/list?${queryFilial ? `${querySearch}&` : querySearch}${queryPage ? `${queryFilial}&` : queryFilial}${queryPage}`,
|
||||
headers: authHeader(),
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const { useGetKodopersQuery } = KodoperApi;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { API } from './apiSlice'
|
||||
|
||||
const authHeader = () => {
|
||||
const token = localStorage.getItem('token')
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
export const locationApi = API.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
createLocation: build.mutation({
|
||||
query: ({ specialistId, data }) => ({
|
||||
url: `/specialist/${specialistId}/location/create`,
|
||||
method: 'POST',
|
||||
headers: authHeader(),
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
invalidatesTags: ['Specialist'],
|
||||
}),
|
||||
updateLocation: build.mutation({
|
||||
query: ({ specialistId, locationId, data }) => {
|
||||
return ({
|
||||
url: `/specialist/${specialistId}/location/${locationId}`,
|
||||
method: 'PUT',
|
||||
headers: authHeader(),
|
||||
body: JSON.stringify(data),
|
||||
})},
|
||||
}),
|
||||
deleteLocation: build.mutation({
|
||||
query: (locationId) => ({
|
||||
url: `/location/${locationId}`,
|
||||
method: 'DELETE',
|
||||
headers: authHeader(),
|
||||
}),
|
||||
}),
|
||||
getEmptyLocations: build.query({
|
||||
query: () => {
|
||||
return {
|
||||
url: `/locations/empty`,
|
||||
headers: authHeader(),
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export const {
|
||||
useCreateLocationMutation,
|
||||
useUpdateLocationMutation,
|
||||
useDeleteLocationMutation,
|
||||
useGetEmptyLocationsQuery,
|
||||
} = locationApi
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
|
||||
import { API_BASE_URL } from '@/config/api'
|
||||
|
||||
export const authHeader = () => {
|
||||
const token = localStorage.getItem('token')
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
export const API = createApi({
|
||||
reducerPath: 'API',
|
||||
baseQuery: fetchBaseQuery({
|
||||
baseUrl: API_BASE_URL,
|
||||
credentials: 'include',
|
||||
}),
|
||||
endpoints: (builder) => ({
|
||||
login: builder.mutation({
|
||||
query: (data) => ({
|
||||
url: '/user/login',
|
||||
method: 'POST',
|
||||
body: data,
|
||||
}),
|
||||
async onQueryStarted(arg, { queryFulfilled }) {
|
||||
try {
|
||||
const { data } = await queryFulfilled
|
||||
console.log(data)
|
||||
localStorage.setItem('token', data.token)
|
||||
localStorage.setItem('admSovamedUserUID', data.user.uid)
|
||||
localStorage.setItem('admSovamedUserRegionId', data.user.regionId)
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
logout: builder.mutation({
|
||||
async queryFn(_arg, _api, _extraOptions, _baseQuery) {
|
||||
localStorage.removeItem('token')
|
||||
return { data: undefined }
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export const { useLoginMutation, useLogoutMutation } = API;
|
||||
@@ -0,0 +1,119 @@
|
||||
/* global FormData */
|
||||
|
||||
import { API } from './apiSlice'
|
||||
|
||||
const authHeader = () => {
|
||||
const token = localStorage.getItem('token')
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
export const specialistApi = API.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
getSpecialist: build.query({
|
||||
query: (specialistId) => ({
|
||||
url: `/specialist/${specialistId}`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...authHeader(),
|
||||
},
|
||||
}),
|
||||
refetchOnMountOrArgChange: true,
|
||||
keepUnusedDataFor: 0,
|
||||
}),
|
||||
getSpecialists: build.query({
|
||||
query: (regionId) => {
|
||||
return regionId === 'all'
|
||||
? '/specialist/list'
|
||||
: `/specialist/list?regionId=${regionId}`
|
||||
},
|
||||
refetchOnMountOrArgChange: true,
|
||||
keepUnusedDataFor: 0,
|
||||
}),
|
||||
getSpecialistsList: build.query({
|
||||
query: ({ regionId = '', search = '', page = '' }) => {
|
||||
let queryString = '';
|
||||
|
||||
if (regionId) queryString += `regionId=${regionId}`
|
||||
|
||||
if (search) {
|
||||
if (regionId) queryString += '&'
|
||||
queryString += `search=${search}`
|
||||
}
|
||||
|
||||
if (page) {
|
||||
if (search || regionId) queryString += '&';
|
||||
queryString += `page=${page}`
|
||||
}
|
||||
|
||||
return `/specialist/list${queryString ? `?${queryString}` : ''}`
|
||||
},
|
||||
refetchOnMountOrArgChange: true,
|
||||
keepUnusedDataFor: 0,
|
||||
}),
|
||||
uploadSpecialistPicture: build.mutation({
|
||||
query: ({ id, file }) => {
|
||||
console.log(file)
|
||||
const formData = new FormData();
|
||||
formData.append('previewPicture', file);
|
||||
console.log([...formData.entries()])
|
||||
return {
|
||||
url: `/specialist/picture/${id}`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...authHeader(),
|
||||
},
|
||||
body: formData,
|
||||
};
|
||||
},
|
||||
}),
|
||||
createSpecialist: build.mutation({
|
||||
query: ({ data }) => ({
|
||||
url: '/specialist/create',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...authHeader(),
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
invalidatesTags: ['Specialist'],
|
||||
}),
|
||||
updateSpecialist: build.mutation({
|
||||
query: ({ specialistId, data }) => {
|
||||
return ({
|
||||
url: `/specialist/${specialistId}`,
|
||||
method: 'PUT',
|
||||
headers: authHeader(),
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
},
|
||||
invalidatesTags: (result, error, { specialistId }) => [
|
||||
{ type: 'Specialist', id: specialistId },
|
||||
'Specialist',
|
||||
]
|
||||
}),
|
||||
deleteSpecialist: build.mutation({
|
||||
query: (specialistId) => ({
|
||||
url: `/specialist/${specialistId}`,
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
...authHeader(),
|
||||
},
|
||||
}),
|
||||
invalidatesTags: (result, error, id) => [
|
||||
{ type: 'Specialist', id },
|
||||
'Specialist',
|
||||
],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
})
|
||||
|
||||
export const {
|
||||
useGetSpecialistQuery,
|
||||
useGetSpecialistsQuery,
|
||||
useGetSpecialistsListQuery,
|
||||
useUploadSpecialistPictureMutation,
|
||||
useCreateSpecialistMutation,
|
||||
useUpdateSpecialistMutation,
|
||||
useDeleteSpecialistMutation,
|
||||
} = specialistApi
|
||||
@@ -0,0 +1,102 @@
|
||||
/* global FormData */
|
||||
|
||||
import { API, authHeader } from './apiSlice';
|
||||
|
||||
export const stockApi = API.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
getStocks: build.query({
|
||||
query: ({ search = '', page = '' }) => {
|
||||
let queryString = '';
|
||||
if (search || page) queryString += '?'
|
||||
if (search) queryString += `search=${search}`
|
||||
if (search && page) queryString += `&`
|
||||
if (page) queryString += `page=${page}`
|
||||
return {
|
||||
url: `stock/list${queryString}`,
|
||||
//headers: authHeader(),
|
||||
};
|
||||
},
|
||||
refetchOnMountOrArgChange: true,
|
||||
keepUnusedDataFor: 0,
|
||||
}),
|
||||
getStock: build.query({
|
||||
query: ({ stockId }) => {
|
||||
console.log(stockId)
|
||||
return {
|
||||
url: `/stock/${stockId}`,
|
||||
//headers: authHeader(),
|
||||
};
|
||||
},
|
||||
}),
|
||||
createStock: build.mutation({
|
||||
query: ({ data }) => {
|
||||
return {
|
||||
url: `/stock/create`,
|
||||
method: 'POST',
|
||||
headers: authHeader(),
|
||||
body: JSON.stringify(data),
|
||||
};
|
||||
},
|
||||
}),
|
||||
updateStock: build.mutation({
|
||||
query: ({ stockId, data }) => {
|
||||
return {
|
||||
url: `/stock/${stockId}`,
|
||||
method: 'PUT',
|
||||
headers: authHeader(),
|
||||
body: JSON.stringify(data),
|
||||
};
|
||||
},
|
||||
}),
|
||||
uploadStockPicture: build.mutation({
|
||||
query: ({ id, file }) => {
|
||||
const formData = new FormData();
|
||||
formData.append('picture', file);
|
||||
return {
|
||||
url: `/stock/picture/${id}`,
|
||||
method: 'POST',
|
||||
headers: authHeader(),
|
||||
body: formData,
|
||||
};
|
||||
},
|
||||
}),
|
||||
deleteStock: build.mutation({
|
||||
query: ({ stockId }) => {
|
||||
return {
|
||||
url: `/stock/${stockId}`,
|
||||
method: 'DELETE',
|
||||
headers: authHeader(),
|
||||
};
|
||||
},
|
||||
}),
|
||||
addSpecialist: build.mutation({
|
||||
query: ({ stockId, specialistId }) => {
|
||||
return {
|
||||
url: `/stock/${stockId}/specialist/${specialistId}`,
|
||||
method: 'PUT',
|
||||
headers: authHeader(),
|
||||
};
|
||||
},
|
||||
}),
|
||||
removeSpecialist: build.mutation({
|
||||
query: ({ stockId, specialistId }) => {
|
||||
return {
|
||||
url: `/stock/${stockId}/specialist/${specialistId}`,
|
||||
method: 'DELETE',
|
||||
headers: authHeader(),
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetStockQuery,
|
||||
useGetStocksQuery,
|
||||
useCreateStockMutation,
|
||||
useUpdateStockMutation,
|
||||
useUploadStockPictureMutation,
|
||||
useDeleteStockMutation,
|
||||
useAddSpecialistMutation,
|
||||
useRemoveSpecialistMutation,
|
||||
} = stockApi;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 233 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,13 @@
|
||||
import styles from './Button.module.scss';
|
||||
|
||||
export const Button = ({ children, onClick, type = 'button', className = '' }) => {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
className={`${styles.button} btn btn-success ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
.button {
|
||||
background-color: #1cc88a !important;
|
||||
border-color: #169f6f !important;
|
||||
}
|
||||
|
||||
.button:hover,
|
||||
.button:focus {
|
||||
background-color: #169f6f !important;
|
||||
border-color: #12855b !important;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
import { apiUrl } from '@/config/api';
|
||||
import { CertEditor } from '../Editors/CertEditor'
|
||||
|
||||
const isValidImage = (file) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png'];
|
||||
if (allowedTypes.includes(file.type)) return true;
|
||||
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
return ['jpg', 'jpeg', 'png'].includes(ext);
|
||||
};
|
||||
|
||||
export function CertificatesForm({ initCertificates, onChange }) {
|
||||
const [initialCertificates, setInitialCertificates] = useState([]);
|
||||
const [certificates, setCertificates] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const certificatesWithPictureUrl = initCertificates.map((init) => ({ ...init, picture: apiUrl(`/uploads/${init.picture}`)}))
|
||||
setInitialCertificates([...certificatesWithPictureUrl])
|
||||
setCertificates([...certificatesWithPictureUrl])
|
||||
}, [initCertificates])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(certificates);
|
||||
}
|
||||
}, [certificates, onChange]);
|
||||
|
||||
const updateField = (idx, field, value) => {
|
||||
setCertificates(prev =>
|
||||
prev.map((cert, i) =>
|
||||
i === idx
|
||||
? { ...cert, [field]: value }
|
||||
: cert
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const replaceImage = (e, idx) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
if (!isValidImage(file)) {
|
||||
return window.alert('Изображения должны быть только формата JPG, JPEG или PNG');
|
||||
}
|
||||
const url = window.URL.createObjectURL(file);
|
||||
setCertificates(prev =>
|
||||
prev.map((cert, i) =>
|
||||
i === idx
|
||||
? { ...cert, picture: url, _file: file }
|
||||
: cert
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const addCertificate = () => {
|
||||
setCertificates(prev => [
|
||||
...prev,
|
||||
{ name: '', description: '', picture: '', type: 'certificate', active: false, }
|
||||
]);
|
||||
};
|
||||
|
||||
const deleteCertificate = idx => {
|
||||
setCertificates(prev => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{certificates.map((cert, idx) => (
|
||||
<div className="card mb-3 p-3" key={idx}>
|
||||
<div className="form-group">
|
||||
<label>Название</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={cert.name}
|
||||
onChange={e => updateField(idx, 'name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='d-flex' style={{gap: '2rem'}}>
|
||||
<div className="form-group flex-grow-1">
|
||||
<label>Описание</label>
|
||||
<CertEditor
|
||||
content={cert.description}
|
||||
setContent={newVal => updateField(idx, 'description', newVal)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="form-check mb-3">
|
||||
<input
|
||||
id={`active-cert${idx}`}
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={ cert.active }
|
||||
onChange={e => updateField(idx, 'active', e.target.checked)}
|
||||
/>
|
||||
<label htmlFor={`active-cert${idx}`} className="form-check-label">
|
||||
Отображать на сайте
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group d-flex flex-column align-items-center" style={{ gap: '1rem' }}>
|
||||
<label
|
||||
htmlFor={`file-${idx}`}
|
||||
className="btn btn-outline-primary"
|
||||
style={{ cursor: 'pointer', height: '2.5rem', width: '13rem' }}
|
||||
>
|
||||
Загрузить сертификат
|
||||
</label>
|
||||
<input
|
||||
id={`file-${idx}`}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => replaceImage(e, idx)}
|
||||
/>
|
||||
{cert.picture && (
|
||||
<img
|
||||
src={cert.picture}
|
||||
alt="Сертификат"
|
||||
style={{ width: '10rem', height: '10rem', objectFit: 'cover', borderRadius: '4px' }}
|
||||
onError={e => e.currentTarget.src = '/placeholder.png'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='d-flex justify-content-start'>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => deleteCertificate(idx)}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="btn btn-outline-primary mb-3"
|
||||
onClick={addCertificate}
|
||||
>
|
||||
Добавить сертификат
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
import { apiUrl } from '@/config/api';
|
||||
import { CertEditor } from '../Editors/CertEditor'
|
||||
|
||||
const isValidImage = (file) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png'];
|
||||
if (allowedTypes.includes(file.type)) return true;
|
||||
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
return ['jpg', 'jpeg', 'png'].includes(ext);
|
||||
};
|
||||
|
||||
export function PortfolioForm({ initPortfolios, onChange }) {
|
||||
const [initialPortfolios, setInitialPorfolios] = useState([]);
|
||||
const [portfolios, setPortfolios] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
// console.log(initPortfolios)
|
||||
const portfolioWithPictureUrl = initPortfolios.map((init) => {
|
||||
if ( init.picture ) return ({ ...init, picture: apiUrl(`/uploads/${init.picture}`)})
|
||||
return { ...init }
|
||||
})
|
||||
setInitialPorfolios([...portfolioWithPictureUrl])
|
||||
setPortfolios([...portfolioWithPictureUrl])
|
||||
}, [initPortfolios])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(portfolios);
|
||||
}
|
||||
}, [portfolios, onChange]);
|
||||
|
||||
const updateField = (idx, field, value) => {
|
||||
setPortfolios(prev =>
|
||||
prev.map((cert, i) =>
|
||||
i === idx
|
||||
? { ...cert, [field]: value }
|
||||
: cert
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const replaceImage = (e, idx) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
if (!isValidImage(file)) {
|
||||
return window.alert('Изображения должны быть только формата JPG, JPEG или PNG');
|
||||
}
|
||||
const url = window.URL.createObjectURL(file);
|
||||
setPortfolios(prev =>
|
||||
prev.map((cert, i) =>
|
||||
i === idx
|
||||
? { ...cert, picture: url, _file: file }
|
||||
: cert
|
||||
)
|
||||
);
|
||||
// console.log('==================')
|
||||
// console.log(file)
|
||||
};
|
||||
|
||||
const addCertificate = () => {
|
||||
setPortfolios(prev => [
|
||||
...prev,
|
||||
{ name: '', description: '', picture: '', type: 'portfolio', active: false }
|
||||
]);
|
||||
};
|
||||
|
||||
const deleteCertificate = idx => {
|
||||
setPortfolios(prev => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{portfolios.map((cert, idx) => (
|
||||
<div className="card mb-3 p-3" key={idx}>
|
||||
<div className="form-group">
|
||||
<label>Название</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={cert.name}
|
||||
onChange={e => updateField(idx, 'name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='d-flex' style={{gap: '2rem'}}>
|
||||
<div className="form-group flex-grow-1">
|
||||
<label>Описание</label>
|
||||
<CertEditor
|
||||
content={cert.description}
|
||||
setContent={newVal => updateField(idx, 'description', newVal)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="form-check mb-3">
|
||||
<input
|
||||
id={`active-port${idx}`}
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={ cert.active }
|
||||
onChange={e => updateField(idx, 'active', e.target.checked)}
|
||||
/>
|
||||
<label htmlFor={`active-port${idx}`} className="form-check-label">
|
||||
Отображать на сайте
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group d-flex flex-column align-items-center" style={{ gap: '1rem' }}>
|
||||
<label
|
||||
htmlFor={`file-port-${idx}`}
|
||||
className="btn btn-outline-primary"
|
||||
style={{ cursor: 'pointer', height: '2.5rem', width: '13rem' }}
|
||||
>
|
||||
Загрузить фото
|
||||
</label>
|
||||
<input
|
||||
id={`file-port-${idx}`}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => replaceImage(e, idx)}
|
||||
/>
|
||||
{cert.picture && (
|
||||
<img
|
||||
src={cert.picture}
|
||||
alt="Портфолио"
|
||||
style={{ width: '10rem', height: '10rem', objectFit: 'cover', borderRadius: '4px' }}
|
||||
onError={e => e.currentTarget.src = '/placeholder.png'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='d-flex justify-content-start'>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => deleteCertificate(idx)}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="btn btn-outline-primary mb-3"
|
||||
onClick={addCertificate}
|
||||
>
|
||||
Добавить запись
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
import { apiUrl } from '@/config/api';
|
||||
import { CertEditor } from '../Editors/CertEditor'
|
||||
|
||||
const isValidImage = (file) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png'];
|
||||
if (allowedTypes.includes(file.type)) return true;
|
||||
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
return ['jpg', 'jpeg', 'png'].includes(ext);
|
||||
};
|
||||
|
||||
export function StocksForm({ initStocks, onChange }) {
|
||||
const [ initialStocks, setInitialStocks ] = useState([]);
|
||||
const [ stocks, setStocks ] = useState([]);
|
||||
|
||||
const startDateInputRef = useRef(null);
|
||||
const endDateInputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// console.log(initStocks)
|
||||
// console.log(initStocks[0]?.startDate)
|
||||
// console.log(typeof initStocks[0]?.startDate)
|
||||
const portfolioWithPictureUrl = initStocks.map((init) => {
|
||||
if ( init.picture ) return ({ ...init, picture: apiUrl(`/uploads/${init.picture}`)})
|
||||
return { ...init }
|
||||
}).map(init => {
|
||||
const dateStart = new Date(init.startDate);
|
||||
|
||||
const year = dateStart.getFullYear();
|
||||
const month = String(dateStart.getMonth() + 1).padStart(2, '0'); // Month is 0-indexed
|
||||
const day = String(dateStart.getDate()).padStart(2, '0');
|
||||
const hours = String(dateStart.getHours()).padStart(2, '0');
|
||||
const minutes = String(dateStart.getMinutes()).padStart(2, '0');
|
||||
const datetimeLocalValue = `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
|
||||
const dateEnd = new Date(init.endDate);
|
||||
|
||||
const year1 = dateEnd.getFullYear();
|
||||
const month1 = String(dateEnd.getMonth() + 1).padStart(2, '0'); // Month is 0-indexed
|
||||
const day1 = String(dateEnd.getDate()).padStart(2, '0');
|
||||
const hours1 = String(dateEnd.getHours()).padStart(2, '0');
|
||||
const minutes1 = String(dateEnd.getMinutes()).padStart(2, '0');
|
||||
const datetimeLocalValue1 = `${year1}-${month1}-${day1}T${hours1}:${minutes1}`;
|
||||
|
||||
return ({ ...init, startDate: datetimeLocalValue, endDate: datetimeLocalValue1 });
|
||||
})
|
||||
setInitialStocks([...portfolioWithPictureUrl])
|
||||
setStocks([...portfolioWithPictureUrl])
|
||||
}, [initStocks])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(stocks);
|
||||
}
|
||||
}, [stocks, onChange]);
|
||||
|
||||
const updateField = (idx, field, value) => {
|
||||
setStocks(prev =>
|
||||
prev.map((cert, i) =>
|
||||
i === idx
|
||||
? { ...cert, [field]: value }
|
||||
: cert
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const replaceImage = (e, idx) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
if (!isValidImage(file)) {
|
||||
return window.alert('Изображения должны быть только формата JPG, JPEG или PNG');
|
||||
}
|
||||
const url = window.URL.createObjectURL(file);
|
||||
setStocks(prev =>
|
||||
prev.map((cert, i) =>
|
||||
i === idx
|
||||
? { ...cert, picture: url, _file: file }
|
||||
: cert
|
||||
)
|
||||
);
|
||||
// console.log('==================')
|
||||
// console.log(file)
|
||||
};
|
||||
|
||||
const addCertificate = () => {
|
||||
setStocks(prev => [
|
||||
...prev,
|
||||
{ name: '', description: '', picture: '', type: 'portfolio', active: false }
|
||||
]);
|
||||
};
|
||||
|
||||
const deleteCertificate = idx => {
|
||||
setStocks(prev => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{stocks.map((cert, idx) => (
|
||||
<div className="card mb-3 p-3" key={idx}>
|
||||
<div className="form-group" style={{ pointerEvents: 'none' }}>
|
||||
<label>Название</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={cert.name}
|
||||
onChange={e => updateField(idx, 'name', e.target.value)}
|
||||
// readOnly={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ pointerEvents: 'none' }}>
|
||||
<label>Анонс</label>
|
||||
<CertEditor
|
||||
content={cert.anons}
|
||||
setContent={newVal => updateField(idx, 'anons', newVal)}
|
||||
readOnly={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ pointerEvents: 'none' }}>
|
||||
<label>Описание</label>
|
||||
<CertEditor
|
||||
content={cert.content}
|
||||
setContent={newVal => updateField(idx, 'contnet', newVal)}
|
||||
readOnly={true}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<div className='d-flex justify-content-between' style={{ gap: '1rem' }}>
|
||||
<div className='form-group d-flex flex-column'>
|
||||
|
||||
<div className="form-group" style={{ pointerEvents: 'none' }}>
|
||||
<label>Начало акции:</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
ref={startDateInputRef}
|
||||
className="form-control"
|
||||
value={cert.startDate}
|
||||
onClick={() => {
|
||||
if (startDateInputRef.current?.showPicker) {
|
||||
startDateInputRef.current.showPicker();
|
||||
}
|
||||
}}
|
||||
onChange={e => updateField(idx, 'startDate', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ pointerEvents: 'none' }}>
|
||||
<label>Окончание акции:</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
ref={endDateInputRef}
|
||||
className="form-control"
|
||||
value={cert.endDate}
|
||||
onClick={() => {
|
||||
if (endDateInputRef.current?.showPicker) {
|
||||
endDateInputRef.current.showPicker();
|
||||
}
|
||||
}}
|
||||
onChange={e => updateField(idx, 'endDate', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label>
|
||||
*Время указывается по МСК
|
||||
</label>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="form-group d-flex flex-column align-items-center" style={{ gap: '1rem' }}>
|
||||
{cert.picture && (
|
||||
<img
|
||||
src={cert.picture}
|
||||
alt="Портфолио"
|
||||
style={{ width: '10rem', height: '10rem', objectFit: 'cover', borderRadius: '4px' }}
|
||||
onError={e => e.currentTarget.src = '/placeholder.png'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className='d-flex justify-content-start'>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => deleteCertificate(idx)}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import JoditEditor from 'jodit-react';
|
||||
|
||||
export function CertEditor({ content, setContent, readOnly = false }) {
|
||||
const config = {
|
||||
readonly: readOnly,
|
||||
height: 400,
|
||||
toolbarButtonSize: 'medium',
|
||||
buttons: [
|
||||
'bold', 'italic', 'underline', 'strikethrough',
|
||||
'ul', 'ol', 'indent', 'outdent',
|
||||
'font', 'fontsize', 'brush', 'paragraph',
|
||||
'align', 'link', 'undo', 'redo'
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<JoditEditor
|
||||
value={content}
|
||||
config={config}
|
||||
onBlur={newContent => setContent(newContent)}
|
||||
onChange={newContent => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import JoditEditor from 'jodit-react';
|
||||
|
||||
export function TextEditor( { content, setContent, readOnly = false } ) {
|
||||
const config = {
|
||||
readonly: readOnly,
|
||||
height: 400,
|
||||
toolbarButtonSize: 'medium',
|
||||
buttons: [
|
||||
'source',
|
||||
'bold', 'italic', 'underline', 'strikethrough',
|
||||
'ul', 'ol', 'indent', 'outdent',
|
||||
'font', 'fontsize', 'brush', 'paragraph',
|
||||
'align', 'link', 'undo', 'redo'
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<JoditEditor
|
||||
value={content}
|
||||
config={config}
|
||||
onBlur={newContent => setContent(newContent)}
|
||||
onChange={newContent => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export const EditElementForm = ({
|
||||
navigateBack,
|
||||
header,
|
||||
handleSave,
|
||||
handleDelete = null,
|
||||
isAddSpecialist = false,
|
||||
children
|
||||
}) => (
|
||||
<div className="container-fluid">
|
||||
<button
|
||||
className="btn btn-link mb-3"
|
||||
onClick={ () => navigateBack() }
|
||||
>
|
||||
← Назад
|
||||
</button>
|
||||
<div className="card shadow mb-4">
|
||||
<div className="card-header py-3 d-flex justify-content-between align-items-center">
|
||||
<h6 className="m-0 font-weight-bold text-primary">
|
||||
{ header }
|
||||
</h6>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{ children }
|
||||
<div className="d-flex justify-content-start">
|
||||
<button
|
||||
className="btn btn-primary mr-2"
|
||||
onClick={ handleSave }
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={ () => navigateBack() }
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
{!isAddSpecialist &&
|
||||
<button
|
||||
className="btn btn-danger ml-auto"
|
||||
onClick={ handleDelete }
|
||||
>
|
||||
Удалить
|
||||
</button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
export const Input = ({ value, onChange, placeholder, type = 'text' }) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className="form-control"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import React, { useState, forwardRef } from 'react';
|
||||
import { IMaskInput } from 'react-imask';
|
||||
|
||||
export const PhoneInput = forwardRef(function PhoneInput(props, ref) {
|
||||
const { value, onChange, className = 'form-control', placeholder = '+7 (___) ___-__-__', ...rest } = props;
|
||||
const [internal, setInternal] = useState(value ?? '');
|
||||
|
||||
return (
|
||||
<IMaskInput
|
||||
mask="+7 (000) 000-00-00"
|
||||
value={value ?? internal}
|
||||
onAccept={(val, mask) => {
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(val);
|
||||
} else {
|
||||
setInternal(val);
|
||||
}
|
||||
}}
|
||||
ref={ref}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
export function TagKodoperStaticInput({ initialTags = [], tags, disabled = false }) {
|
||||
// const [tags, setTags] = useState(initialTags);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
// useEffect(() => {
|
||||
// setTags(initialTags);
|
||||
// }, [initialTags]);
|
||||
/*
|
||||
const addTag = tag => {
|
||||
const trimmed = tag.trim();
|
||||
if (trimmed && !tags.includes(trimmed)) {
|
||||
const newTags = [...tags, trimmed];
|
||||
setTags(newTags);
|
||||
onChange(newTags);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = idx => {
|
||||
const newTags = tags.filter((_, i) => i !== idx);
|
||||
setTags(newTags);
|
||||
onChange(newTags);
|
||||
};
|
||||
*/
|
||||
/*
|
||||
const handleKeyDown = e => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
addTag(inputValue);
|
||||
setInputValue('');
|
||||
}
|
||||
if (e.key === 'Backspace' && !inputValue) {
|
||||
removeTag(tags.length - 1);
|
||||
}
|
||||
};
|
||||
*/
|
||||
return (
|
||||
<div
|
||||
className="form-control d-flex flex-wrap align-items-center text-muted"
|
||||
style={{ minHeight: 'auto', cursor: 'text', height: 'auto' }}
|
||||
// onClick={() => inputRef.current.focus()}
|
||||
disabled={ disabled }
|
||||
>
|
||||
{tags.map((tag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
style={{ fontSize: '1rem' }}
|
||||
className="badge bg-info me-1 mb-1 d-flex align-items-center text-white mr-1"
|
||||
>
|
||||
<strong>{tag}</strong>
|
||||
</span>
|
||||
))}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
export function TagStaticInput({ initTags, disabled = false, onRemove }) {
|
||||
const [tags, setTags] = useState([])
|
||||
useEffect(() => {
|
||||
setTags([...initTags])
|
||||
}, [initTags])
|
||||
|
||||
const removeTag = idx => {
|
||||
const removingTag = tags.find((_, i) => i === idx);
|
||||
const newTags = tags.filter((_, i) => i !== idx);
|
||||
// setTags(newTags);
|
||||
onRemove(removingTag);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="form-control d-flex flex-wrap align-items-center text-muted"
|
||||
style={{ minHeight: 'auto', cursor: 'text', height: 'auto' }}
|
||||
disabled={ disabled }
|
||||
>
|
||||
{tags.map((tag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
style={{ fontSize: '1rem' }}
|
||||
className="badge bg-info me-1 mb-1 d-flex align-items-center text-white mr-1"
|
||||
>
|
||||
<strong>{tag}</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 mb-1 close text-red"
|
||||
aria-label="Close"
|
||||
onClick={() => removeTag(idx)}
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
export function TagInput({ initialTags = [], onChange, placeholder, disabled = false }) {
|
||||
const [tags, setTags] = useState(initialTags);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTags(initialTags);
|
||||
}, [initialTags]);
|
||||
|
||||
const addTag = tag => {
|
||||
const trimmed = tag.trim();
|
||||
if (trimmed && !tags.includes(trimmed)) {
|
||||
const newTags = [...tags, trimmed];
|
||||
setTags(newTags);
|
||||
onChange(newTags);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = idx => {
|
||||
const newTags = tags.filter((_, i) => i !== idx);
|
||||
setTags(newTags);
|
||||
onChange(newTags);
|
||||
};
|
||||
|
||||
const handleKeyDown = e => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
addTag(inputValue);
|
||||
setInputValue('');
|
||||
}
|
||||
if (e.key === 'Backspace' && !inputValue) {
|
||||
removeTag(tags.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="form-control d-flex flex-wrap align-items-center text-muted"
|
||||
style={{ minHeight: 'auto', cursor: 'text', height: 'auto' }}
|
||||
onClick={() => inputRef.current.focus()}
|
||||
disabled={ disabled }
|
||||
>
|
||||
{tags.map((tag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
style={{ fontSize: '1rem' }}
|
||||
className="badge bg-info me-1 mb-1 d-flex align-items-center text-white mr-1"
|
||||
>
|
||||
{tag}
|
||||
{ !disabled &&
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 mb-1 close text-red"
|
||||
aria-label="Close"
|
||||
onClick={() => removeTag(idx)}
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{ !disabled &&
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="border-0 flex-grow-1"
|
||||
style={{ minWidth: '8ch', outline: 'none', textOverflow: 'ellipsis' }}
|
||||
placeholder={placeholder}
|
||||
value={inputValue}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { useGetIDoctorsQuery } from '/src/api/apiIDoctor';
|
||||
import { selectRegions } from '/src/store/slice/regionSlice';
|
||||
|
||||
function DcodeModal({ isOpen, onCancel, onConfirm, initialSelectedItems=[], departments=[], filials=[]}) {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [searchParam, setSearchParam] = useState('search');
|
||||
const [selectedItems, setSelectedItems] = useState([]);
|
||||
|
||||
const regions = useSelector( selectRegions );
|
||||
|
||||
const getAdress = (filial) => {
|
||||
const curentFilial = filials.find(item => Number(item.id) === Number(filial));
|
||||
if (curentFilial) return `${regions[curentFilial.regionId]}, ${curentFilial.shortName}`
|
||||
}
|
||||
|
||||
const getDepartment = (department) => {
|
||||
const currentDepartment = departments.find(item => Number(item.id) === Number(department))
|
||||
if (currentDepartment) return currentDepartment.name
|
||||
}
|
||||
|
||||
const {
|
||||
data: response,
|
||||
isFetching,
|
||||
error,
|
||||
} = useGetIDoctorsQuery(
|
||||
{ type: searchParam, value: searchValue },
|
||||
{ skip: searchValue.length < 3 }
|
||||
);
|
||||
|
||||
const items = response?.data ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.classList.add('modal-open');
|
||||
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
||||
} else {
|
||||
document.body.classList.remove('modal-open');
|
||||
document.body.style.paddingRight = '';
|
||||
setSearchValue('');
|
||||
setSearchParam('search');
|
||||
setSelectedItems([...initialSelectedItems.map(item => ({ ...item, key: `${item.dcode}${item.department}${item.filial}${item.name}` }))]);
|
||||
}
|
||||
return () => {
|
||||
document.body.classList.remove('modal-open');
|
||||
document.body.style.paddingRight = '';
|
||||
};
|
||||
}, [isOpen, initialSelectedItems]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const toggleSelect = (item) => {
|
||||
const key = `${item.dcode}${item.department}${item.filial}${item.name}`;
|
||||
|
||||
if ( selectedItems.find( item => item.key === key ) ) toggleRemove(item);
|
||||
else {
|
||||
toggleAdd(item);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAdd = (item) => {
|
||||
setSelectedItems([ ...selectedItems, { ...item, key: `${item.dcode}${item.department}${item.filial}${item.name}`} ]);
|
||||
}
|
||||
|
||||
const toggleRemove = (removingItem) => {
|
||||
setSelectedItems( selectedItems.filter( item => item.key !== removingItem.key ));
|
||||
}
|
||||
|
||||
|
||||
const renderItem = (item) => {
|
||||
const key = `${item.dcode}${item.department}${item.filial}${item.name}`;
|
||||
const isActive = selectedItems.some(i => i.key === key);
|
||||
return (
|
||||
<div key={key}
|
||||
className={`list-group-item pl-1 ${isActive ? 'active' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
toggleSelect(item)
|
||||
}}
|
||||
>
|
||||
<ul style={{ listStyle: 'none' }}>
|
||||
<li><strong>dcode: </strong>{item.dcode}</li>
|
||||
<li><strong>ФИО: </strong>{item.name}</li>
|
||||
<li>
|
||||
<strong>ID отделения: </strong>{item.department}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Отделение: </strong>{departments.find(department => Number(department.id) === Number(item.department))?.name}
|
||||
</li>
|
||||
|
||||
<li><strong>Адрес: </strong>{getAdress(item.filial)}</li>
|
||||
<li>
|
||||
<label className="form-check-label"
|
||||
>
|
||||
<strong>Отображать на сайте:</strong>
|
||||
</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input ml-2"
|
||||
checked={ !item.onlineMode }
|
||||
/>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<>
|
||||
<div className="modal-backdrop fade show"></div>
|
||||
<div className="modal fade show" tabIndex="-1" role="dialog" style={{ display: 'block' }} aria-modal="true">
|
||||
<div className="modal-dialog" role="document">
|
||||
<div className="modal-content">
|
||||
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Добавить расписание из Инфоклиники</h5>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
|
||||
<div className="form-row mb-3">
|
||||
<div className="mr-3">
|
||||
<label>Параметр поиска</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={searchParam}
|
||||
onChange={e => {
|
||||
setSearchValue('');
|
||||
setSearchParam(e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="dcode">dcode</option>
|
||||
<option value="search">ФИО</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col">
|
||||
<label>Поиск</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={searchValue}
|
||||
onChange={e => {
|
||||
setSearchValue(e.target.value)
|
||||
}}
|
||||
placeholder="Введите не менее 3 символов..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="mb-3">
|
||||
<h6>Расписание из Инфоклиники:</h6>
|
||||
<div className="list-group">
|
||||
<div
|
||||
className="d-flex flex-column align-items-start"
|
||||
>
|
||||
{selectedItems.map(item => (
|
||||
<div className="card-body bg-primary text-white border rounded mt-2"
|
||||
style={{
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}}
|
||||
key={item.key}
|
||||
>
|
||||
<ul style={{ listStyle: 'none' }} className='mb-0'>
|
||||
<li><strong>dcode: </strong>{item.dcode}</li>
|
||||
<li><strong>ФИО: </strong>{item.name}</li>
|
||||
<li><strong>Отделение: </strong>{departments.find(department => Number(department.id) === Number(item.department))?.name ?? 'не найдено'}</li>
|
||||
<li><strong>Адрес: </strong>{getAdress(item.filial)}</li>
|
||||
<li>
|
||||
<label className="form-check-label">
|
||||
<strong>Отображать на сайте:</strong>
|
||||
</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input ml-2"
|
||||
checked={ !item.onlineMode }
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 mb-1 close text-white"
|
||||
style={{ position: 'absolute', top: '0', right: '0.4rem' }}
|
||||
aria-label="Close"
|
||||
onClick={() => toggleRemove(item)}
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
{searchValue.length < 3 ? (
|
||||
<small className="text-muted">Введите не менее 3 символов для поиска</small>
|
||||
) : isFetching ? (
|
||||
<p>Идёт поиск…</p>
|
||||
) : error ? (
|
||||
<p className="text-danger">Ошибка при запросе</p>
|
||||
) : items.length > 0 ? (
|
||||
<>
|
||||
<h6>Найдено:</h6>
|
||||
<ul className="list-group">
|
||||
{items.map((item, inx) => renderItem(item, inx))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p>Не найдено</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
className="btn btn-success mr-auto"
|
||||
onClick={() => {
|
||||
onConfirm(selectedItems)
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Отменить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export default DcodeModal;
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { useGetKodopersQuery } from '/src/api/apiKodoper';
|
||||
import { useGetFilialsQuery } from '/src/api/apiFilial';
|
||||
import { selectRegions } from '/src/store/slice/regionSlice';
|
||||
|
||||
function KodoperModal({ isOpen, onCancel, onConfirm, initialSelectedItems = [] }) {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [currentFilial, setCurrentFilial] = useState(-1);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedItems, setSelectedItems] = useState([]);
|
||||
const [selectedObjs, setSelectedObjs] = useState([]);
|
||||
|
||||
const { data: filialsRaw = {}, isLoading: loadingFilials } = useGetFilialsQuery();
|
||||
const filials = filialsRaw.data || [];
|
||||
|
||||
const filialOptions = [{ fid: -1, name: 'Все филиалы' }, ...filials];
|
||||
|
||||
// Query parameters include search, filial, and page
|
||||
const skip = searchValue.length < 3;
|
||||
const {
|
||||
data: response = {},
|
||||
isFetching,
|
||||
error: queryError,
|
||||
} = useGetKodopersQuery(
|
||||
{
|
||||
value: searchValue,
|
||||
filialId: currentFilial,
|
||||
page: currentPage
|
||||
},
|
||||
{ skip }
|
||||
);
|
||||
|
||||
const items = response.data || [];
|
||||
const pagination = response.pagination || {};
|
||||
|
||||
// Toggle selection
|
||||
const toggleSelect = (item) => {
|
||||
const code = item.kodoper;
|
||||
const exists = selectedItems.includes(code);
|
||||
if (exists) {
|
||||
setSelectedItems(selectedItems.filter(c => c !== code));
|
||||
setSelectedObjs(selectedObjs.filter(o => o.kodoper !== code));
|
||||
} else {
|
||||
setSelectedItems([...selectedItems, code]);
|
||||
setSelectedObjs([...selectedObjs, item]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.classList.add('modal-open');
|
||||
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
||||
} else {
|
||||
document.body.classList.remove('modal-open');
|
||||
document.body.style.paddingRight = '';
|
||||
setSearchValue('');
|
||||
|
||||
if (initialSelectedItems === null) return
|
||||
setSelectedItems(initialSelectedItems);
|
||||
// setSelectedObjs(initialSelectedItems);
|
||||
}
|
||||
return () => {
|
||||
document.body.classList.remove('modal-open');
|
||||
document.body.style.paddingRight = '';
|
||||
};
|
||||
}, [isOpen, initialSelectedItems]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Pagination controls
|
||||
const renderPagination = () => {
|
||||
const total = pagination.total_pages || 1;
|
||||
const current = pagination.current_page || 1;
|
||||
const pages = new Set();
|
||||
|
||||
// 1 и последняя
|
||||
pages.add(1);
|
||||
pages.add(total);
|
||||
|
||||
// текущая, две до и две после
|
||||
for (let p = current - 2; p <= current + 2; p++) {
|
||||
if (p > 1 && p < total) pages.add(p);
|
||||
}
|
||||
|
||||
// сортируем
|
||||
const sorted = Array.from(pages).sort((a, b) => a - b);
|
||||
|
||||
const items = [];
|
||||
let last = 0;
|
||||
|
||||
sorted.forEach(page => {
|
||||
if (last && page - last > 1) {
|
||||
// разрыв — вставляем «...»
|
||||
items.push(
|
||||
<li key={`dots-${last}`} className="page-item disabled">
|
||||
<span className="page-link">…</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
items.push(
|
||||
<li
|
||||
key={page}
|
||||
className={`page-item ${page === current ? 'active' : ''}`}
|
||||
>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={() => {
|
||||
if (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
|
||||
className="page-link"
|
||||
onClick={() => pagination.has_previous_page && setCurrentPage(current - 1)}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
</li>
|
||||
|
||||
{/* Номера страниц и «...» */}
|
||||
{items}
|
||||
|
||||
{/* Кнопка «следующая» */}
|
||||
<li className={`page-item ${!pagination.has_next_page ? 'disabled' : ''}`}>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={() => pagination.has_next_page && setCurrentPage(current + 1)}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<>
|
||||
<div className="modal-backdrop fade show"></div>
|
||||
<div className="modal fade show" tabIndex="-1" role="dialog" style={{ display: 'block' }} aria-modal="true">
|
||||
<div className="modal-dialog" role="document">
|
||||
<div className="modal-content">
|
||||
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Добавить услугу</h5>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
|
||||
|
||||
<div className="mb-3">
|
||||
<label>Филиал</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={currentFilial}
|
||||
onChange={e => {
|
||||
setCurrentFilial(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
{filialOptions.map(f => <option key={f.fid} value={f.fid}>{f.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="form-row mb-3">
|
||||
<div className="col">
|
||||
<label>Поиск</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={searchValue}
|
||||
onChange={e => { setSearchValue(e.target.value); setCurrentPage(1); }}
|
||||
placeholder="Введите не менее 3 символов..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="mb-3">
|
||||
<h6>Выбранные уникальные медицинские коды:</h6>
|
||||
<div className="list-group">
|
||||
<div
|
||||
className="d-flex flex-wrap align-items-center text-muted"
|
||||
>
|
||||
{selectedItems.map((code, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="badge bg-info me-1 mb-1 d-flex align-items-center text-white mr-1"
|
||||
onClick={() => toggleSelect({ kodoper: code })}
|
||||
style={{ fontSize: '1rem', cursor: 'pointer' }}
|
||||
>
|
||||
{code}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 mb-1 close text-red"
|
||||
aria-label="Close"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
{searchValue.length < 3 ? (
|
||||
<small className="text-muted">Введите не менее 3 символов для поиска</small>
|
||||
) : isFetching ? (
|
||||
<p>Идёт поиск…</p>
|
||||
) : queryError ? (
|
||||
<p className="text-danger">Ошибка при запросе</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-hover table-sm">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Мед.код</th>
|
||||
<th>Услуга</th>
|
||||
<th>Стоимость</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, idx) => (
|
||||
<tr key={idx} className={selectedItems.includes(item.kodoper) ? 'table-primary' : ''} style={{ cursor: 'pointer' }} onClick={() => toggleSelect(item)}>
|
||||
<td>{item.kodoper}</td>
|
||||
<td>{item.schname}</td>
|
||||
<td>{item.priceInfo.price} ₽</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{renderPagination()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
className="btn btn-success mr-auto"
|
||||
onClick={() => {
|
||||
onConfirm(selectedItems, selectedObjs)
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary"
|
||||
onClick={onCancel}
|
||||
// onClick={() => console.log(selectedObjs)}
|
||||
>
|
||||
Отменить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export default KodoperModal;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
export const Modal = ({ isOpen, onCancel, onConfirm, hasDangerButton = true, title, hasButtons, confirmText, children }) => {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.classList.add('modal-open');
|
||||
const scrollbarWidth =
|
||||
window.innerWidth - document.documentElement.clientWidth;
|
||||
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
||||
} else {
|
||||
document.body.classList.remove('modal-open');
|
||||
document.body.style.paddingRight = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.classList.remove('modal-open');
|
||||
document.body.style.paddingRight = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const closeRef = useRef(null);
|
||||
useEffect(() => {
|
||||
if (isOpen && hasButtons) {
|
||||
closeRef.current.focus();
|
||||
}
|
||||
}, [isOpen, hasButtons]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<>
|
||||
<div className="modal-backdrop fade show"></div>
|
||||
|
||||
<div
|
||||
className="modal fade show"
|
||||
tabIndex="-1"
|
||||
role="dialog"
|
||||
style={{ display: 'block' }}
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="modal-dialog" role="document">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
{title && <h5 className="modal-title">{title}</h5>}
|
||||
</div>
|
||||
<div className="modal-body">{children}</div>
|
||||
{hasButtons &&
|
||||
<div className="modal-footer">
|
||||
{ hasDangerButton &&
|
||||
<button
|
||||
className="btn btn-danger mr-auto"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{ confirmText }
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-success"
|
||||
ref={closeRef}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Отменить
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export default Modal;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Modal } from './/Modal';
|
||||
import { LoadingComponent } from '../Placeholders/LoadingComponent';
|
||||
import { ErrorComponent } from '../Placeholders/ErrorComponent';
|
||||
|
||||
export const ResponseModals = ({ modal, setModal }) => {
|
||||
switch (modal) {
|
||||
case 'loading':
|
||||
return (
|
||||
<Modal
|
||||
isOpen={ true }
|
||||
title="Внесение изменений"
|
||||
hasButtons={ false }
|
||||
>
|
||||
<LoadingComponent />
|
||||
</Modal>
|
||||
)
|
||||
|
||||
case 'error':
|
||||
return (
|
||||
<Modal
|
||||
isOpen={ true }
|
||||
title="Ошибка"
|
||||
hasButtons={ true }
|
||||
hasDangerButton={ false }
|
||||
onCancel={() => setModal( undefined )}
|
||||
>
|
||||
<ErrorComponent />
|
||||
</Modal>
|
||||
)
|
||||
|
||||
case 'success':
|
||||
return (
|
||||
<Modal
|
||||
isOpen={ true }
|
||||
title="Изменения внесены"
|
||||
hasButtons={ false }
|
||||
>
|
||||
<p className="mb-1">
|
||||
Изменения успешно внесены.
|
||||
</p>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,388 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { useGetIDoctorsQuery } from '/src/api/apiIDoctor';
|
||||
import { selectRegions } from '/src/store/slice/regionSlice';
|
||||
|
||||
import { useGetStocksQuery } from '/src/api/apiStock';
|
||||
|
||||
export function StockModal({ isOpen, onCancel, onConfirm, initialSelectedItems=[], departments=[], filials=[]}) {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedObjs, setSelectedObjs] = useState([]);
|
||||
const skip = false; //searchValue.length < 3;
|
||||
|
||||
const {
|
||||
data: response = {},
|
||||
isFetching,
|
||||
error: queryError,
|
||||
} = useGetStocksQuery({
|
||||
search: searchValue,
|
||||
page: currentPage,
|
||||
}, {skip});
|
||||
|
||||
const items = response.data ? (response.data.map(init => {
|
||||
const dateStart = new Date(init.startDate);
|
||||
|
||||
const year = dateStart.getFullYear();
|
||||
const month = String(dateStart.getMonth() + 1).padStart(2, '0'); // Month is 0-indexed
|
||||
const day = String(dateStart.getDate()).padStart(2, '0');
|
||||
const hours = String(dateStart.getHours()).padStart(2, '0');
|
||||
const minutes = String(dateStart.getMinutes()).padStart(2, '0');
|
||||
const datetimeLocalValue = `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
|
||||
const dateEnd = new Date(init.endDate);
|
||||
|
||||
const year1 = dateEnd.getFullYear();
|
||||
const month1 = String(dateEnd.getMonth() + 1).padStart(2, '0'); // Month is 0-indexed
|
||||
const day1 = String(dateEnd.getDate()).padStart(2, '0');
|
||||
const hours1 = String(dateEnd.getHours()).padStart(2, '0');
|
||||
const minutes1 = String(dateEnd.getMinutes()).padStart(2, '0');
|
||||
const datetimeLocalValue1 = `${year1}-${month1}-${day1}T${hours1}:${minutes1}`;
|
||||
|
||||
return ({ ...init, startDate: datetimeLocalValue, endDate: datetimeLocalValue1 });
|
||||
})) : [];
|
||||
const pagination = response.pagination || {};
|
||||
|
||||
const renderPagination = () => {
|
||||
const total = pagination.total_pages || 1;
|
||||
const current = pagination.current_page || 1;
|
||||
const pages = new Set([1, total]);
|
||||
|
||||
for (let p = current - 2; p <= current + 2; p++) {
|
||||
if (p > 1 && p < total) pages.add(p);
|
||||
}
|
||||
|
||||
const sorted = Array.from(pages).sort((a, b) => a - b);
|
||||
const elems = [];
|
||||
let last = 0;
|
||||
|
||||
sorted.forEach(page => {
|
||||
if (last && page - last > 1) {
|
||||
elems.push(
|
||||
<li key={`dots-${last}`} className="page-item disabled">
|
||||
<span className="page-link">…</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
elems.push(
|
||||
<li key={page} className={`page-item ${page === current ? 'active' : ''}`}>
|
||||
<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
|
||||
className="page-link"
|
||||
onClick={() =>
|
||||
pagination.has_previous_page && setCurrentPage(current - 1)
|
||||
}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
</li>
|
||||
|
||||
{elems}
|
||||
|
||||
<li className={`page-item ${!pagination.has_next_page ? 'disabled' : ''}`}>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={() =>
|
||||
pagination.has_next_page && setCurrentPage(current + 1)
|
||||
}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
const [searchParam, setSearchParam] = useState('search');
|
||||
const [selectedItems, setSelectedItems] = useState([]);
|
||||
|
||||
const regions = useSelector( selectRegions );
|
||||
|
||||
const getAdress = (filial) => {
|
||||
const curentFilial = filials.find(item => Number(item.id) === Number(filial));
|
||||
if (curentFilial) return `${regions[curentFilial.regionId]}, ${curentFilial.shortName}`
|
||||
}
|
||||
|
||||
const getDepartment = (department) => {
|
||||
const currentDepartment = departments.find(item => Number(item.id) === Number(department))
|
||||
if (currentDepartment) return currentDepartment.name
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.classList.add('modal-open');
|
||||
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
||||
} else {
|
||||
document.body.classList.remove('modal-open');
|
||||
document.body.style.paddingRight = '';
|
||||
//console.log(initialSelectedItems)
|
||||
setSearchValue('');
|
||||
setSearchParam('search');
|
||||
setSelectedObjs([...initialSelectedItems])
|
||||
setSelectedItems([...initialSelectedItems.map(item => ({ ...item, key: `${item.dcode}${item.department}${item.filial}` }))]);
|
||||
}
|
||||
return () => {
|
||||
document.body.classList.remove('modal-open');
|
||||
document.body.style.paddingRight = '';
|
||||
};
|
||||
}, [isOpen, initialSelectedItems]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const toggleSelect = (stock) => {
|
||||
/*const key = `${item.dcode}${item.department}${item.filial}`;
|
||||
|
||||
if ( selectedItems.find( item => item.key === key ) ) toggleRemove(item);
|
||||
else {
|
||||
toggleAdd(item);
|
||||
console.log(2222)
|
||||
}*/
|
||||
//console.log(stock)
|
||||
//if (selectedItems.includes(stock.id)) {
|
||||
// setSelectedItems([selectedItems.filter(({id}) => id !== stock.id)])
|
||||
if (selectedObjs.find(({id}) => stock.id === id)) {
|
||||
setSelectedObjs([...selectedObjs.filter(({id}) => id !== stock.id)])
|
||||
return
|
||||
}
|
||||
// setSelectedItems([...selectedItems, stock.id])
|
||||
setSelectedObjs([...selectedObjs, stock])
|
||||
};
|
||||
|
||||
const toggleAdd = (item) => {
|
||||
/* console.log(item)
|
||||
setSelectedItems([ ...selectedItems, { ...item, key: `${item.dcode}${item.department}${item.filial}`} ]);
|
||||
*/
|
||||
}
|
||||
|
||||
const toggleRemove = (removingItem) => {
|
||||
//setSelectedItems( selectedItems.filter( item => item.key !== removingItem.key ));
|
||||
}
|
||||
|
||||
|
||||
const renderItem = (item) => {
|
||||
// const isActive = selectedItems.some(i => i === item.dcode);
|
||||
//const key = `${item.dcode}${item.department}${item.filial}`;
|
||||
//const isActive = selectedItems.some(i => i.key === key);
|
||||
const isActive = selectedObjs.find(({id}) => item.id === id)
|
||||
return (
|
||||
<div key={item.id}
|
||||
className={`list-group-item pl-1 ${isActive ? 'active' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
//console.log(item)
|
||||
toggleSelect(item)
|
||||
}}
|
||||
>
|
||||
<ul style={{ listStyle: 'none' }}>
|
||||
<li><strong>dcode: </strong>{item.dcode}</li>
|
||||
<li><strong>ФИО: </strong>{item.name}</li>
|
||||
<li><strong>Отделение: </strong>{departments.find(department => Number(department.id) === Number(item.department))?.name}</li>
|
||||
<li><strong>Адрес: </strong>{getAdress(item.filial)}</li>
|
||||
<li>
|
||||
<label className="form-check-label"
|
||||
>
|
||||
<strong>Отображать на сайте:</strong>
|
||||
</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input ml-2"
|
||||
checked={ !item.onlineMode }
|
||||
/>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<>
|
||||
<div className="modal-backdrop fade show"></div>
|
||||
<div className="modal fade show" tabIndex="-1" role="dialog" style={{ display: 'block' }} aria-modal="true">
|
||||
<div className="modal-dialog modal-lg" role="document"
|
||||
>
|
||||
<div className="modal-content">
|
||||
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Добавить акции</h5>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
|
||||
<div className="mb-2">
|
||||
<div className="">
|
||||
<label>Поиск</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder=""
|
||||
value={searchValue}
|
||||
onChange={e => {
|
||||
setSearchValue(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<h6>Выбранные акции:</h6>
|
||||
<div className="list-group">
|
||||
<div
|
||||
className="d-flex flex-wrap align-items-center text-muted"
|
||||
>
|
||||
{selectedObjs.map((stock) => (
|
||||
<span
|
||||
key={stock.id}
|
||||
className="badge bg-info me-1 mb-1 d-flex align-items-center text-white mr-1"
|
||||
onClick={() => toggleSelect(stock)}
|
||||
style={{ fontSize: '1rem', cursor: 'pointer' }}
|
||||
>
|
||||
{stock.name}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 mb-1 close text-red"
|
||||
aria-label="Close"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{/*selectedItems.map(item => (
|
||||
<div className="card-body bg-primary text-white border rounded mt-2"
|
||||
style={{
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}}
|
||||
key={item.key}
|
||||
>
|
||||
<ul style={{ listStyle: 'none' }} className='mb-0'>
|
||||
<li><strong>dcode: </strong>{item.dcode}</li>
|
||||
<li><strong>ФИО: </strong>{item.name}</li>
|
||||
<li><strong>Отделение: </strong>{departments.find(department => Number(department.id) === Number(item.department))?.name ?? 'не найдено'}</li>
|
||||
<li><strong>Адрес: </strong>{getAdress(item.filial)}</li>
|
||||
<li>
|
||||
<label className="form-check-label">
|
||||
<strong>Отображать на сайте:</strong>
|
||||
</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input ml-2"
|
||||
checked={ !item.onlineMode }
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 mb-1 close text-white"
|
||||
style={{ position: 'absolute', top: '0', right: '0.4rem' }}
|
||||
aria-label="Close"
|
||||
onClick={() => toggleRemove(item)}
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
))*/}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
{isFetching ? (
|
||||
<p>Загрузка...</p>
|
||||
) : queryError ? (
|
||||
<p className="text-danger">Ошибка при загрузке: {String(queryError)}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-hover table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Начало акции</th>
|
||||
<th>Окончание акции</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(stock => (
|
||||
<tr key={stock.id} className={/*selectedItems.includes(stock.id)*/ selectedObjs.find(({id}) => stock.id === id) ? 'table-primary' : ''} style={{ cursor: 'pointer'}}
|
||||
onClick={() => toggleSelect(stock)}
|
||||
>
|
||||
<td>{stock.id}</td>
|
||||
<td>{stock.name}</td>
|
||||
<td>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="form-control"
|
||||
style={{ cursor: 'pointer' }}
|
||||
value={stock.startDate}
|
||||
onClick={() => toggleSelect(stock)}
|
||||
readOnly={true}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="form-control"
|
||||
style={{ cursor: 'pointer'}}
|
||||
value={stock.endDate}
|
||||
onClick={() => toggleSelect(stock)}
|
||||
readOnly={true}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{renderPagination()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
className="btn btn-success mr-auto"
|
||||
onClick={() => {
|
||||
console.log(selectedObjs)
|
||||
onConfirm(selectedObjs)
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Отменить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
import { UserBurger } from '../UserBurger/UserBurger';
|
||||
import { SidebarNavItem } from '../SidebarNavItem/SidebarNavItem';
|
||||
import styles from './Navbar.module.scss';
|
||||
|
||||
export const Navbar = () => {
|
||||
const links = [
|
||||
{ to: '/', icon: 'fas fa-home', label: 'Главная', end: true },
|
||||
{ to: '/specialist', icon: 'fas fa-user-md', label: 'Врачи' },
|
||||
{ to: '/lostDoctors', icon: 'fas fa-address-card', label: 'Врачи-потеряшки' },
|
||||
{ to: '/infoclinic', icon: 'fas fa-table', label: 'Расписание ИК' },
|
||||
{ to: '/prices',icon: 'fas fa-receipt', label: 'Цены и услуги' },
|
||||
{ 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);
|
||||
const menuRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOutside = e => {
|
||||
if (
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(e.target) &&
|
||||
toggleRef.current &&
|
||||
!toggleRef.current.contains(e.target)
|
||||
) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleOutside)
|
||||
return () => document.removeEventListener('mousedown', handleOutside)
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav className={`navbar navbar-expand navbar-light topbar mb-4 static-top shadow ${styles['navbar-background']}`}>
|
||||
<div className="d-lg-none mr-3">
|
||||
<button
|
||||
className="btn btn-outline-success"
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
ref={toggleRef}
|
||||
>
|
||||
<span className="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="dropdown-menu dropdown-menu-left shadow animated--grow-in show bg-gradient-primary sidebar-dark"
|
||||
aria-labelledby="userMenu"
|
||||
>
|
||||
{links.map(({ to, icon, label, end }) => (
|
||||
<SidebarNavItem
|
||||
key={to}
|
||||
to={to}
|
||||
icon={icon}
|
||||
label={label}
|
||||
end={end}
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link className="navbar-brand" to="/">
|
||||
<img src="/src/assets/logo.png" alt="" style={{height: '3rem'}}/>
|
||||
</Link>
|
||||
<UserBurger />
|
||||
</nav>
|
||||
)
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
.navbar-background {
|
||||
background-color: #e9f7ef !important;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
|
||||
export const PageNav = React.memo(({ currentPage, totalPages, onPageChange }) =>
|
||||
totalPages === 1 ? null : (
|
||||
<nav aria-label="Page navigation">
|
||||
<ul className="pagination justify-content-center">
|
||||
<li className={ `page-item ${ currentPage === 1 ? 'disabled' : '' }` }>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={ () => onPageChange( cp => Math.max(cp - 1, 1) ) }
|
||||
disabled={ currentPage === 1 }
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
</li>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, index) => index + 1).map(page => (
|
||||
<li
|
||||
key={ page }
|
||||
className={ `page-item ${ currentPage === page ? 'active' : '' }` }
|
||||
>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={ () => onPageChange( () => page ) }
|
||||
>
|
||||
{ page }
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
<li className={ `page-item ${ currentPage === totalPages ? 'disabled' : '' }` }>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={() =>
|
||||
onPageChange((currentPage) => Math.min( currentPage + 1, totalPages ) )
|
||||
}
|
||||
disabled={ currentPage === totalPages }
|
||||
>
|
||||
Вперед
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
export const ErrorComponent = () => {
|
||||
return (
|
||||
<div className="alert alert-danger">
|
||||
Ошибка загрузки
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
export const LoadingComponent = () => {
|
||||
return (
|
||||
<div className="text-center my-5">
|
||||
<div className="spinner-border text-success" role="status">
|
||||
<span className="sr-only">
|
||||
Загрузка...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export const NotFindElement = ({ message, navigateBack }) => (
|
||||
<div className="my-4">
|
||||
<div className="alert alert-warning">
|
||||
{message}
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={() => navigateBack()}>
|
||||
← Назад
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SidebarNavItem } from '../SidebarNavItem/SidebarNavItem';
|
||||
|
||||
export const Sidebar = () => {
|
||||
const links = [
|
||||
{ to: '/', icon: 'fas fa-home', label: 'Главная', end: true },
|
||||
{ to: '/specialist', icon: 'fas fa-user-md', label: 'Врачи' },
|
||||
{ to: '/lostDoctors', icon: 'fas fa-address-card', label: 'Врачи-потеряшки' },
|
||||
{ to: '/infoclinic', icon: 'fas fa-table', label: 'Расписание ИК' },
|
||||
{ to: '/prices',icon: 'fas fa-receipt', label: 'Цены и услуги' },
|
||||
{ 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 (
|
||||
<ul className={`navbar-nav bg-gradient-primary sidebar sidebar-dark accordion d-none d-lg-block`}>
|
||||
{links.map(({ to, icon, label, end }) => (
|
||||
<SidebarNavItem
|
||||
key={to}
|
||||
to={to}
|
||||
icon={icon}
|
||||
label={label}
|
||||
end={end}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import styles from './SidebarNavItem.module.scss';
|
||||
|
||||
export const SidebarNavItem = ({ to, icon, label, end = false, onClick = null, }) => {
|
||||
return (
|
||||
<li className="nav-item">
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
`nav-link${isActive ? ' active' : ''} ${styles['sidebar-nav-link']}`
|
||||
}
|
||||
onClick={onClick}
|
||||
>
|
||||
<i className={icon}></i>
|
||||
<span className="ml-2">{label}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.sidebar-nav-link {
|
||||
color: rgba(21, 87, 36, 0.8);
|
||||
}
|
||||
|
||||
.sidebar-nav-link.active,
|
||||
.sidebar-nav-link:hover {
|
||||
color: white !important;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
|
||||
export const FilterBar = React.memo(({ regions, filials, regionId, filialId, searchValue, onChange, goAddSpecialist }) => (
|
||||
<form
|
||||
className="d-flex justify-content-between mb-3"
|
||||
onSubmit={e => e.preventDefault()}
|
||||
>
|
||||
<div className="form-group align-self-end mr-3">
|
||||
<input
|
||||
type="button"
|
||||
className="btn btn-outline-primary"
|
||||
value="Создать врача"
|
||||
onClick={e => { e.stopPropagation(); goAddSpecialist() }}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
|
||||
<label>Регион:</label>
|
||||
<select
|
||||
className="form-control"
|
||||
style={{ width: '12vw' }}
|
||||
value={regionId}
|
||||
onChange={e => onChange({ regionId: e.target.value, filialId: 'all', page: 1 })}
|
||||
>
|
||||
{regions.map(r => (
|
||||
<option key={r.id} value={r.id}>{r.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group ml-3">
|
||||
<label>Филиал:</label>
|
||||
<select
|
||||
className="form-control"
|
||||
style={{ width: '20vw' }}
|
||||
value={filialId}
|
||||
onChange={e => onChange({ filialId: e.target.value, page: 1 })}
|
||||
>
|
||||
{filials.map(f => <option key={f.id} value={f.id}>{f.shortName}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group flex-grow-1 ml-3">
|
||||
<label>Поиск:</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={searchValue}
|
||||
onChange={e => onChange({ searchValue: e.target.value, page: 1 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
));
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from "react";
|
||||
|
||||
const maskElement = ( element ) => {
|
||||
if ( typeof element === 'boolean' )
|
||||
return element ? 'да' : 'нет'
|
||||
|
||||
if ( typeof element === 'undefined' ) return 'нет данных'
|
||||
|
||||
if ( element === null ) return 'нет данных'
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
const ElementRow = React.memo(({ element, expandedId, setExpandedId, onEdit, onWatch, columns, pageType = '' }) => {
|
||||
const isExpanded = expandedId === element.id;
|
||||
const toggle = () => setExpandedId(prev => (prev === element.id ? null : element.id));
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className={`cursor-pointer ${isExpanded ? 'table-success' : ''}`}
|
||||
onClick={toggle}
|
||||
>
|
||||
{columns.map((column, index) => (
|
||||
<td
|
||||
key={column.key}
|
||||
style={{
|
||||
verticalAlign: 'middle',
|
||||
position: index === columns.length - 1 ? 'relative' : 'initial'
|
||||
}}
|
||||
>
|
||||
{ maskElement(element[column.key]) }
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
{isExpanded && pageType !== 'specialists' && (
|
||||
<tr
|
||||
className='table-success cursor-pointer'
|
||||
onClick={ toggle }
|
||||
>
|
||||
<td colSpan={ columns.length }>
|
||||
<input
|
||||
type="button"
|
||||
className="btn btn-outline-primary ml-auto"
|
||||
value="Редактировать"
|
||||
onClick={e => { e.stopPropagation(); onEdit(element.id); }}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{( pageType === 'specialists' && isExpanded ) && (
|
||||
<tr
|
||||
className='table-success cursor-pointer'
|
||||
onClick={ toggle }
|
||||
>
|
||||
<td colSpan={ 6 }>
|
||||
<input
|
||||
type="button"
|
||||
className="btn btn-outline-primary"
|
||||
value="Редактировать"
|
||||
onClick={e => { e.stopPropagation(); onEdit(element.id); }}
|
||||
/>
|
||||
</td>
|
||||
<td colSpan={ 1 }>
|
||||
<input
|
||||
type="button"
|
||||
className="btn btn-outline-primary"
|
||||
value="Открыть"
|
||||
onClick={e => { e.stopPropagation(); onWatch(element.id); }}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const TBody = ({ elements, columns, expandedId, setExpandedId, goEdit, goWatch, pageType }) => {
|
||||
return (
|
||||
<tbody>
|
||||
{
|
||||
elements.map(element => (
|
||||
<ElementRow
|
||||
key={ element.id }
|
||||
element={ element }
|
||||
expandedId={ expandedId }
|
||||
setExpandedId={ setExpandedId }
|
||||
onEdit={ goEdit }
|
||||
onWatch={ goWatch }
|
||||
columns={ columns }
|
||||
pageType= { pageType }
|
||||
/>))
|
||||
}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export const THead = ({ columns, sortBy, sortDirection, handleSort }) => (
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map(({ key, label, width }) => (
|
||||
<th
|
||||
key={key}
|
||||
className="cursor-pointer"
|
||||
style={{ width }}
|
||||
onClick={() => handleSort(key)}
|
||||
>
|
||||
{label}
|
||||
{sortBy === key && (
|
||||
<span className={`column-header ${sortDirection}`} />
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useLogoutMutation } from '/src/api/apiSlice'
|
||||
|
||||
export const UserBurger = () => {
|
||||
const username = useSelector( s => s.auth.username );
|
||||
const [ logout ] = useLogoutMutation()
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const toggleRef = useRef(null);
|
||||
const menuRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOutside = e => {
|
||||
if (
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(e.target) &&
|
||||
toggleRef.current &&
|
||||
!toggleRef.current.contains(e.target)
|
||||
) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleOutside)
|
||||
return () => document.removeEventListener('mousedown', handleOutside)
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ul className="navbar-nav ml-auto">
|
||||
<li className="nav-item dropdown no-arrow">
|
||||
<button
|
||||
ref={toggleRef}
|
||||
className="nav-link dropdown-toggle btn btn-link p-0"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<i className="fas fa-user-circle fa-fw"></i>
|
||||
<span className="ml-2 d-none d-lg-inline text-gray-600 small">
|
||||
{username}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="dropdown-menu dropdown-menu-right shadow animated--grow-in show"
|
||||
aria-labelledby="userMenu"
|
||||
>
|
||||
<Link
|
||||
className="dropdown-item"
|
||||
to="/user"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<i className="fas fa-user fa-sm fa-fw mr-2 text-gray-400"></i>
|
||||
Профиль
|
||||
</Link>
|
||||
<div className="dropdown-divider"></div>
|
||||
<button
|
||||
className="dropdown-item"
|
||||
onClick={() => { setOpen(false); logout() }}
|
||||
>
|
||||
<i className="fas fa-sign-out-alt fa-sm fa-fw mr-2 text-gray-400"></i>
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const API_BASE_URL =
|
||||
(typeof window !== 'undefined' && window.__ENV__?.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)
|
||||
@@ -0,0 +1,65 @@
|
||||
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';
|
||||
import { useGetEmptyLocationsQuery } from '../api/apiLocation';
|
||||
|
||||
export function useLostDoctors() {
|
||||
const { data, isLoading, error } = useGetEmptyLocationsQuery();
|
||||
|
||||
const [ lostDoctors, setLostDoctors ] = useState([]);
|
||||
|
||||
const filialsQuery = useGetFilialsQuery();
|
||||
const filials = filialsQuery.data ?? [];
|
||||
|
||||
const departmentsQuery = useGetDepartmentsQuery();
|
||||
const departments = departmentsQuery.data ?? [];
|
||||
const regions = useSelector(selectRegions);
|
||||
|
||||
const fetchParams = [ 'filial', 'dcode', 'department' ];
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.data.length) {
|
||||
setLostDoctors([]);
|
||||
return;
|
||||
}
|
||||
let canceled = false;
|
||||
|
||||
Promise.all(
|
||||
data.data.map(item => {
|
||||
const fetchString = fetchParams.filter((param => Boolean(item[param]))).map(param => `${param}=${item[param]}`).join('&');
|
||||
return axios.get(apiUrl(`/idoctor/list?${fetchString}`), {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
})
|
||||
.then(res => {
|
||||
return ({ doctor: res?.data?.data[0], emptyLocation: item });
|
||||
})
|
||||
}
|
||||
)
|
||||
).then(results => {
|
||||
if (!canceled) {
|
||||
setLostDoctors(results.filter(Boolean));
|
||||
}
|
||||
}).catch(() => {
|
||||
if (!canceled) {
|
||||
setLostDoctors([]);
|
||||
}
|
||||
});
|
||||
return () => { canceled = true };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data]);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
lostDoctors,
|
||||
filials: filials?.data || [],
|
||||
departments: departments?.data || [],
|
||||
regions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useGetSpecialistsQuery } from '../api/apiSpecialist';
|
||||
import { selectRegions } from '../store/slice/regionSlice';
|
||||
|
||||
export function useNewSpecialistId() {
|
||||
const regions = useSelector(selectRegions);
|
||||
const regionIds = Object.keys(regions);
|
||||
|
||||
const queries = regionIds.map(rid => useGetSpecialistsQuery(rid));
|
||||
const isLoading = queries.some(q => q.isLoading);
|
||||
const error = queries.find(q => q.error)?.error;
|
||||
const allData = queries.flatMap(q => q.data ?? []);
|
||||
const newId = Math.max.apply(Math,
|
||||
allData.map(( specialist ) => Number(specialist.id))
|
||||
) + 1;
|
||||
|
||||
return newId
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
|
||||
export const useOutsideClick = (ref, onOutsideClick) => {
|
||||
React.useEffect(() => {
|
||||
const handleClick = (e) => {
|
||||
if (ref.current && !ref.current.contains(e.target)) {
|
||||
onOutsideClick();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [ref, onOutsideClick]);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const useSortedPaginated = (data, itemsPerPage, sortBy, sortDirection, currentPage) => {
|
||||
const sorted = useMemo(() => {
|
||||
return [...data].sort((a, b) => {
|
||||
let aValue = a[sortBy], bValue = b[sortBy];
|
||||
|
||||
if (sortBy === 'experience') {
|
||||
aValue = typeof aValue === 'number' ? aValue : 0;
|
||||
bValue = typeof bValue === 'number' ? bValue : 0;
|
||||
}
|
||||
|
||||
if (sortBy === 'locations') {
|
||||
aValue = aValue === 'нет данных' ? 'я' : aValue;
|
||||
bValue = bValue === 'нет данных' ? 'я' : bValue;
|
||||
}
|
||||
|
||||
if (aValue == null || bValue == null) return 0
|
||||
|
||||
if (typeof aValue === 'number') return sortDirection === 'asc'
|
||||
? aValue - bValue
|
||||
: bValue - aValue
|
||||
|
||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||
aValue = aValue.replaceAll(' ', '');
|
||||
bValue = bValue.replaceAll(' ', '');
|
||||
}
|
||||
|
||||
return sortDirection === 'asc'
|
||||
? String(aValue).localeCompare(String(bValue))
|
||||
: String(bValue).localeCompare(String(aValue))
|
||||
});
|
||||
}, [data, sortBy, sortDirection]);
|
||||
|
||||
const totalPages = Math.ceil(sorted.length / itemsPerPage);
|
||||
|
||||
const paginated = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sorted.slice(start, start + itemsPerPage);
|
||||
}, [sorted, currentPage, itemsPerPage]);
|
||||
|
||||
return { paginated, totalPages };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export const useSorting = (initialKey = 'id', initialDir = 'asc') => {
|
||||
const [sortBy, setSortBy] = useState(initialKey);
|
||||
const [sortDirection, setSortDirection] = useState(initialDir);
|
||||
|
||||
const handleSort = useCallback(
|
||||
key => {
|
||||
setSortDirection(prev => (sortBy === key ? (prev === 'asc' ? 'desc' : 'asc') : 'asc'));
|
||||
setSortBy(key);
|
||||
},
|
||||
[sortBy]
|
||||
);
|
||||
|
||||
return { sortBy, sortDirection, handleSort };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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';
|
||||
import { useGetDepartmentsQuery } from '../api/apiDepartment';
|
||||
import { selectRegions } from '../store/slice/regionSlice';
|
||||
|
||||
export function useSpecialist(id) {
|
||||
const { data: specialist, isLoading, error } = useGetSpecialistQuery(id);
|
||||
const [kodoperDetails, setKodoperDetails] = useState([]);
|
||||
const filialsQuery = useGetFilialsQuery();
|
||||
const filials = filialsQuery.data ?? [];
|
||||
|
||||
const departmentsQuery = useGetDepartmentsQuery();
|
||||
const departments = departmentsQuery.data ?? [];
|
||||
const regions = useSelector(selectRegions);
|
||||
|
||||
useEffect(() => {
|
||||
if (!specialist?.kodoper?.length) {
|
||||
setKodoperDetails([]);
|
||||
return;
|
||||
}
|
||||
let canceled = false;
|
||||
|
||||
Promise.all(
|
||||
specialist.kodoper.map(code =>
|
||||
axios.get(apiUrl(`/pricelist/list?search=${code}`), {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
})
|
||||
.then(res => {
|
||||
return (res.data.data || []).find(item => String(item.kodoper) === String(code)) || null;
|
||||
})
|
||||
)
|
||||
).then(results => {
|
||||
if (!canceled) {
|
||||
setKodoperDetails(results.filter(Boolean));
|
||||
}
|
||||
}).catch(() => {
|
||||
if (!canceled) {
|
||||
setKodoperDetails([]);
|
||||
}
|
||||
});
|
||||
return () => { canceled = true };
|
||||
}, [specialist]);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
specialist: specialist ?? null,
|
||||
filials,
|
||||
departments,
|
||||
regions,
|
||||
kodoperDetails,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function useSpecialistFilter({ regionId, filialId, searchValue }, queries, filials, regionsMap, currentYear) {
|
||||
return useMemo(() => {
|
||||
const flatData =
|
||||
regionId === 'all'
|
||||
? queries.flatMap(q => q.data || [])
|
||||
: queries.find(q => q.originalArgs === regionId)?.data || [];
|
||||
|
||||
console.log(queries)
|
||||
|
||||
const formatted = flatData.map(s => {
|
||||
const experience = s.experience ? currentYear - s.experience : null;
|
||||
const location = s.locations[0];
|
||||
const filial = filials.find(f => f.id === location?.filial);
|
||||
return {
|
||||
...s,
|
||||
experience,
|
||||
locations: filial ? `${regionsMap[filial.regionId]}, ${filial.shortName}` : 'нет данных',
|
||||
filialId: filial?.id || null,
|
||||
activeLabel: s.active ? 'да' : 'нет',
|
||||
scheduleLabel: s.displaySchedule ? 'да' : 'нет',
|
||||
};
|
||||
});
|
||||
|
||||
const byFilial = filialId === 'all' ? formatted : formatted.filter(s => s.filialId === Number(filialId));
|
||||
|
||||
const search = searchValue.trim().toLowerCase().replaceAll(' ', '');
|
||||
if (!search) return byFilial;
|
||||
return byFilial.filter(s => {
|
||||
if ( String(s.id).includes(search) ) return true
|
||||
if ( s.nameString.replaceAll(' ', '').toLowerCase().includes(search) ) return true
|
||||
if ( s.experience ) {
|
||||
if ( String(s.experience ?? '').includes(search) ) return true
|
||||
}
|
||||
if ( s.post ) {
|
||||
if ( s.post.replaceAll(' ', '').toLowerCase().includes(search) ) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
);
|
||||
}, [regionId, filialId, searchValue, queries, filials, regionsMap, currentYear]);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import App from './App';
|
||||
import { store } from './store/store';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import '@fortawesome/fontawesome-free/css/all.min.css';
|
||||
import 'startbootstrap-sb-admin-2/css/sb-admin-2.min.css';
|
||||
import './styles/theme-override.scss';
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,208 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
useCreateStockMutation,
|
||||
useGetStockQuery,
|
||||
useUpdateStockMutation,
|
||||
useUploadStockPictureMutation,
|
||||
} from '/src/api/apiStock';
|
||||
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';
|
||||
|
||||
const isValidImage = (file) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png'];
|
||||
if (allowedTypes.includes(file.type)) return true;
|
||||
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
return ['jpg', 'jpeg', 'png'].includes(ext);
|
||||
};
|
||||
|
||||
export function AddStockPage() {
|
||||
const navigate = useNavigate();
|
||||
const navigateBack = () => navigate( `/promotions` );
|
||||
|
||||
const [createStock] = useCreateStockMutation();
|
||||
const [uploadPicture] = useUploadStockPictureMutation();
|
||||
|
||||
const [anons, setAnons] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [isModalSuccess, setModalSuccess] = useState(false);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
picture: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
});
|
||||
const fileInputRef = useRef();
|
||||
|
||||
const startDateInputRef = useRef(null);
|
||||
const endDateInputRef = useRef(null);
|
||||
|
||||
|
||||
const handleChange = (key) => (e) => {
|
||||
setForm(f => ({ ...f, [key]: e.target.value }));
|
||||
};
|
||||
|
||||
const handleFile = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return window.alert('Файл не выбран')
|
||||
if (!isValidImage(file)) {
|
||||
return window.alert('Изображения должны быть только формата JPG, JPEG или PNG');
|
||||
}
|
||||
const url = window.URL.createObjectURL(file);
|
||||
setForm(f => ({ ...f, _file: file, picture: url }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const response = await createStock({
|
||||
data: {
|
||||
name: form.name,
|
||||
anons: anons,
|
||||
content: content,
|
||||
startDate: String(form.startDate),
|
||||
endDate: String(form.endDate),
|
||||
}
|
||||
}).unwrap();
|
||||
|
||||
if (form._file) {
|
||||
await uploadPicture({ id: response.id, file: form._file }).unwrap();
|
||||
}
|
||||
|
||||
|
||||
setModalSuccess(true)
|
||||
window.setTimeout(() => {
|
||||
navigate( `/promotions/edit/${response.id}` );
|
||||
window.location.reload()
|
||||
}, 2000);
|
||||
console.log('success update promotion')
|
||||
|
||||
} catch (err) {
|
||||
console.error('Ошибка при обновлении акции:', err)
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<EditElementForm
|
||||
navigateBack={ navigateBack }
|
||||
header={ `Добавление акции` }
|
||||
handleSave={ handleSave }
|
||||
handleDelete={ () => {} }
|
||||
isAddSpecialist = {true}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label>Название</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={form.name}
|
||||
onChange={handleChange('name')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='d-flex flex-column mb-1'>
|
||||
<div className='d-flex justify-content-between' style={{ gap: '1rem'}}>
|
||||
<div className="form-group flex-grow-1">
|
||||
<label>Начало акции:</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
ref={startDateInputRef}
|
||||
className="form-control"
|
||||
value={form.startDate}
|
||||
onClick={() => {
|
||||
if (startDateInputRef.current?.showPicker) {
|
||||
startDateInputRef.current.showPicker();
|
||||
}
|
||||
}}
|
||||
onChange={handleChange('startDate')}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group flex-grow-1">
|
||||
<label>Окончание акции:</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
ref={endDateInputRef}
|
||||
className="form-control"
|
||||
value={form.endDate}
|
||||
onClick={() => {
|
||||
if (endDateInputRef.current?.showPicker) {
|
||||
endDateInputRef.current.showPicker();
|
||||
}
|
||||
}}
|
||||
onChange={handleChange('endDate')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className='mt-0'>*Время указывается по МСК</label>
|
||||
</div>
|
||||
|
||||
<div className='d-flex justify-content-between' style={{ gap: '1rem'}}>
|
||||
<div className="form-group flex-grow-1">
|
||||
<label>Анонс</label>
|
||||
<TextEditor
|
||||
content={anons}
|
||||
setContent={setAnons}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Изображение</label>
|
||||
<div className="d-flex align-items-center flex-column" style={{ gap: '1rem'}}>
|
||||
{form.picture && (
|
||||
<img
|
||||
src={form.picture}
|
||||
alt=""
|
||||
style={{ width: '13rem', height: '13rem', objectFit: 'cover', marginRight: 16 }}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
onClick={() => fileInputRef.current.click()}
|
||||
>
|
||||
Загрузить…
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Полное описание</label>
|
||||
<TextEditor
|
||||
content={content}
|
||||
setContent={setContent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/*
|
||||
<button className="btn btn-primary me-2" onClick={handleSave}>
|
||||
Сохранить
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => navigate(-1)}>
|
||||
Отмена
|
||||
</button>
|
||||
*/}
|
||||
<Modal
|
||||
isOpen={isModalSuccess}
|
||||
title="Изменения внесены"
|
||||
hasButtons={false}
|
||||
confirmText={'Удалить врача'}
|
||||
>
|
||||
<p className='mb-1'>Изменения успешно внесены.</p>
|
||||
</Modal>
|
||||
</EditElementForm>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useGetDepartmentsQuery } from '../api/apiDepartment';
|
||||
import { selectUtils } from '../store/slice/utilsSlice';
|
||||
/**/
|
||||
import { useSortedPaginated } from '../hooks/useSortedPaginated';
|
||||
import { useOutsideClick } from '../hooks/useOutsideClick';
|
||||
import { useSorting } from '../hooks/useSorting';
|
||||
/**/
|
||||
import { LoadingComponent } from '../components/Placeholders/LoadingComponent';
|
||||
import { ErrorComponent } from '../components/Placeholders/ErrorComponent';
|
||||
import { PageNav } from '../components/Paginations/PageNav';
|
||||
import { THead } from '../components/Table/THead';
|
||||
import { TBody } from '../components/Table/TBody';
|
||||
|
||||
export const DepartmentsListPage = () => {
|
||||
const { data: departmentsRaw = [], isLoading, error } = useGetDepartmentsQuery();
|
||||
const departments = departmentsRaw.length === 0 ? [] : departmentsRaw.data;
|
||||
const DEPARTMENTS_COLUMN = useSelector(selectUtils).DEPARTMENTS_COLUMN;
|
||||
|
||||
const ITEMS_PER_PAGE = useSelector(selectUtils).ITEMS_PER_PAGE;
|
||||
const navigate = useNavigate();
|
||||
const goEdit = ( id ) => navigate( `/departments/edit/${ id }` );
|
||||
|
||||
const [ expandedId, setExpandedId ] = useState( null );
|
||||
const [ currentPage, setCurrentPage ] = useState( 1 );
|
||||
|
||||
const tableRef = useRef( null );
|
||||
useOutsideClick( tableRef, () => setExpandedId( null ) );
|
||||
|
||||
const { sortBy, sortDirection, handleSort } = useSorting();
|
||||
|
||||
const { paginated, totalPages } = useSortedPaginated(
|
||||
departments.map(({ did, name, middleName, alias }) =>
|
||||
({ id: did, name, middleName, alias })),
|
||||
ITEMS_PER_PAGE,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
currentPage
|
||||
);
|
||||
|
||||
if (error) return <ErrorComponent />
|
||||
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<h1 className="h3 mb-4 text-gray-800">
|
||||
Отделения
|
||||
</h1>
|
||||
|
||||
{ isLoading &&
|
||||
<LoadingComponent />
|
||||
}
|
||||
|
||||
{ !isLoading &&
|
||||
<>
|
||||
<div className="table-responsive" ref={ tableRef }>
|
||||
<table className="table table-bordered table-hover">
|
||||
<THead
|
||||
columns={ DEPARTMENTS_COLUMN }
|
||||
sortBy={ sortBy }
|
||||
sortDirection={ sortDirection }
|
||||
handleSort={ handleSort }
|
||||
/>
|
||||
<TBody
|
||||
elements={ paginated }
|
||||
columns={ DEPARTMENTS_COLUMN }
|
||||
expandedId={ expandedId }
|
||||
setExpandedId={ setExpandedId }
|
||||
goEdit={ goEdit }
|
||||
/>
|
||||
</table>
|
||||
</div>
|
||||
<PageNav
|
||||
currentPage={ currentPage }
|
||||
totalPages={ totalPages }
|
||||
onPageChange={ setCurrentPage }
|
||||
/>
|
||||
</>
|
||||
}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useGetDepartmentsQuery, useUpdateDepartmentsMutation } from '../api/apiDepartment';
|
||||
import { selectUtils } from '../store/slice/utilsSlice';
|
||||
/**/
|
||||
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 { ResponseModals } from '../components/Modals/ResponseModals';
|
||||
|
||||
export const EditDepartmentPage = () => {
|
||||
const { id } = useParams();
|
||||
const { data: departmentsRaw = [], isLoading, error } = useGetDepartmentsQuery();
|
||||
|
||||
const departments = departmentsRaw.data ?? [];
|
||||
const department = departments.find(( dept ) => String( dept.did ) === id);
|
||||
|
||||
const RELOAD_TIMEOUT = useSelector(selectUtils).RELOAD_TIMEOUT;
|
||||
|
||||
const [ updateDepartment ] = useUpdateDepartmentsMutation();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const navigateBack = () => navigate( `/departments` );
|
||||
|
||||
const [ form, setForm ] = useState({
|
||||
middleName: '',
|
||||
alias: '',
|
||||
});
|
||||
const updateField = ( key, value ) => {
|
||||
const normalized = value === undefined || value === null ? '' : value;
|
||||
setForm(prev => ({ ...prev, [key]: normalized }));
|
||||
};
|
||||
|
||||
const [ modal, setModal ] = useState( undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!department) return;
|
||||
|
||||
const keys = ['middleName','alias'];
|
||||
const next = {};
|
||||
keys.forEach(k => {
|
||||
const v = department[k];
|
||||
next[k] = v === undefined || v === null ? '' : (typeof v === 'number' ? String(v) : v);
|
||||
});
|
||||
setForm(prev => ({ ...prev, ...next }));
|
||||
}, [ department ]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setModal('loading');
|
||||
|
||||
const data = {};
|
||||
Object.keys( form ).forEach( key => {
|
||||
if ( key === 'regionId' ) data[ key ] = Number( form[ key ] );
|
||||
else data[ key ] = form[ key ];
|
||||
});
|
||||
|
||||
try {
|
||||
await updateDepartment( { departmentId: id, data: data } ).unwrap();
|
||||
|
||||
setModal('success');
|
||||
window.setTimeout(() => { window.location.reload() }, RELOAD_TIMEOUT);
|
||||
}
|
||||
catch (err) {
|
||||
setModal('error')
|
||||
console.error('Filial update error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if ( isLoading ) return <LoadingComponent />
|
||||
if ( error ) return <ErrorComponent />
|
||||
if ( !department ) return (
|
||||
<NotFindElement
|
||||
message={ `Отдел с ID=${id} не найден.` }
|
||||
navigateBack={ navigateBack }
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<EditElementForm
|
||||
navigateBack={ navigateBack }
|
||||
header={ `Редактировать отделение #${ department.did }` }
|
||||
handleSave={ handleSave }
|
||||
isAddSpecialist={ true }
|
||||
>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Сокращенное название
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={ form.middleName }
|
||||
onChange={( e ) => updateField( 'middleName', e.target.value )}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Символьный код
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={ form.alias }
|
||||
onChange={( e ) => updateField( 'alias', e.target.value )}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ResponseModals
|
||||
modal={ modal }
|
||||
setModal={ setModal }
|
||||
/>
|
||||
|
||||
</EditElementForm>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
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';
|
||||
/**/
|
||||
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 { ResponseModals } from '../components/Modals/ResponseModals';
|
||||
import { PhoneInput } from '../components/Input/PhoneInput';
|
||||
|
||||
export const EditFilialPage = () => {
|
||||
const { id } = useParams();
|
||||
const { data: filialsRaw = [], isLoading, loadingError } = useGetFilialsQuery();
|
||||
const PHOTO_PLACEHOLDER = '/src/assets/photo-placeholder.png';
|
||||
const photoInputRef = useRef(null);
|
||||
|
||||
const filials = filialsRaw.length === 0 ? [] : filialsRaw.data;
|
||||
const filial = filials.find( ( filial ) => String( filial.fid ) === id );
|
||||
|
||||
const regions = useSelector( selectRegions );
|
||||
const RELOAD_TIMEOUT = useSelector(selectUtils).RELOAD_TIMEOUT;
|
||||
|
||||
const [ updateFilial ] = useUpdateFilialMutation();
|
||||
const [uploadPicture] = useUploadFilialPictureMutation();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const navigateBack = () => navigate( `/filials` );
|
||||
|
||||
const [ form, setForm ] = useState({
|
||||
id: '',
|
||||
regionId: '',
|
||||
address: '',
|
||||
name: '',
|
||||
siteId: '',
|
||||
company: '',
|
||||
phone: '',
|
||||
active: '',
|
||||
email: '',
|
||||
fid: '',
|
||||
origin: '',
|
||||
picture: null,
|
||||
policy: '',
|
||||
});
|
||||
const updateField = ( key, value ) => {
|
||||
const normalized = value === undefined || value === null ? '' : value;
|
||||
setForm(prev => ({ ...prev, [key]: normalized }));
|
||||
};
|
||||
|
||||
const [ modal, setModal ] = useState( undefined );
|
||||
const [previewFile, setPreviewFile] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filial) return;
|
||||
|
||||
const filialData = {
|
||||
id: filial.id,
|
||||
regionId: filial.regionId,
|
||||
address: filial.address,
|
||||
name: filial.name,
|
||||
siteId: filial.siteId,
|
||||
company: filial.company,
|
||||
phone: filial.phone,
|
||||
active: filial.active,
|
||||
email: filial.email,
|
||||
fid: filial.fid,
|
||||
origin: filial.origin,
|
||||
picture: apiUrl(filial.pictureLink),
|
||||
policy: filial.policy,
|
||||
}
|
||||
setForm({... filialData})
|
||||
}, [ filial ]);
|
||||
|
||||
const isValidImage = (file) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png'];
|
||||
if (allowedTypes.includes(file.type)) return true;
|
||||
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
return ['jpg', 'jpeg', 'png'].includes(ext);
|
||||
};
|
||||
|
||||
const handlePhotoUpload = () => {
|
||||
const file = photoInputRef.current.files[0]
|
||||
if (!file) return window.alert('Файл не выбран')
|
||||
if (!isValidImage(file)) {
|
||||
return window.alert('Изображения должны быть только формата JPG, JPEG или PNG');
|
||||
}
|
||||
updateField('picture', window.URL.createObjectURL(file));
|
||||
setPreviewFile(file);
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setModal('loading');
|
||||
|
||||
const data = {
|
||||
id: form.id,
|
||||
fid: form.fid,
|
||||
active: form.active,
|
||||
regionId: Number(form.regionId),
|
||||
address: form.address || '',
|
||||
name: form.name || '',
|
||||
company: form.company || '',
|
||||
phone: form.phone ? form.phone.replaceAll(' ', '') : '',
|
||||
email: form.email || '',
|
||||
policy: form.policy || '',
|
||||
origin: form.origin || '',
|
||||
};
|
||||
|
||||
try {
|
||||
await updateFilial( { filialId: form.fid, data: form.siteId ? { ...data, siteId: form.siteId } : data } ).unwrap();
|
||||
|
||||
if (previewFile) {
|
||||
await uploadPicture({ id: form.id, file: previewFile }).unwrap();
|
||||
console.log('success photo update')
|
||||
}
|
||||
|
||||
setModal('success');
|
||||
window.setTimeout(() => { window.location.reload() }, RELOAD_TIMEOUT);
|
||||
}
|
||||
catch ( error ) {
|
||||
setModal('error')
|
||||
console.error('Filial update error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if ( isLoading ) return <LoadingComponent />
|
||||
if ( loadingError ) return <ErrorComponent />
|
||||
if ( !filial ) return (
|
||||
<NotFindElement
|
||||
message={ `Филиал с ID=${id} не найден.` }
|
||||
navigateBack={ navigateBack }
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<EditElementForm
|
||||
navigateBack={ navigateBack }
|
||||
header={ `Редактировать филиал #${ filial.fid }` }
|
||||
handleSave={ handleSave }
|
||||
isAddSpecialist={ true }
|
||||
>
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem' }} className='photo-filial-block'>
|
||||
|
||||
<div className='align-self-start'>
|
||||
<div className="form-group d-flex flex-column" style={{ gap: '0.75rem' }}>
|
||||
<img
|
||||
src={ form.picture }
|
||||
alt="Фото врача"
|
||||
style={{ width: '12rem', height: '12rem', objectFit: 'cover', borderRadius: '3%' }}
|
||||
className=""
|
||||
onError={ e => e.currentTarget.src = PHOTO_PLACEHOLDER }
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary"
|
||||
style={{ width: '100%' }}
|
||||
onClick={ () => photoInputRef.current.click() }
|
||||
>
|
||||
Загрузить фото
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
ref={ photoInputRef }
|
||||
style={{ display: 'none' }}
|
||||
onChange={handlePhotoUpload}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flexGrow: 1 }}>
|
||||
|
||||
<div style={{ flexGrow: 1 }}>
|
||||
|
||||
<div className='d-flex filial-input-string' style={{ gap: '1rem' }}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="regionSelect">
|
||||
Город
|
||||
</label>
|
||||
<select
|
||||
id="regionSelect"
|
||||
className="form-control w-auto"
|
||||
value={ form.regionId }
|
||||
onChange={( e ) => updateField( 'regionId', e.target.value )}
|
||||
>
|
||||
{
|
||||
Object.entries( regions ).map(( [ key, label ] ) => (
|
||||
<option key={ key } value={ key }>
|
||||
{ label }
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{ flexGrow: 1 }}>
|
||||
<label>
|
||||
ID филиала
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={ form.fid }
|
||||
onChange={( e ) => updateField( 'fid', e.target.value )}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ flexGrow: 1 }}>
|
||||
<label>
|
||||
Calltouch ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={ form.siteId }
|
||||
onChange={( e ) => updateField( 'siteId', e.target.value )}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Название
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={ form.name }
|
||||
onChange={( e ) => updateField( 'name', e.target.value )}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Компания
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={ form.company }
|
||||
onChange={( e ) => updateField( 'company', e.target.value )}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Адрес
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={ form.address }
|
||||
onChange={( e ) => updateField( 'address', e.target.value )}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Телефон
|
||||
</label>
|
||||
<PhoneInput
|
||||
value={form.phone}
|
||||
onChange={(val) => updateField('phone', val)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Электронная почта филиала
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={ form.email }
|
||||
onChange={( e ) => updateField( 'email', e.target.value )}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Ссылка на сайт
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={ form.origin }
|
||||
onChange={( e ) => updateField( 'origin', e.target.value )}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Ссылка на политику кониденциальности
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={ form.policy }
|
||||
onChange={( e ) => updateField( 'policy', e.target.value )}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ResponseModals
|
||||
modal={ modal }
|
||||
setModal={ setModal }
|
||||
/>
|
||||
|
||||
</EditElementForm>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { apiUrl } from '@/config/api';
|
||||
|
||||
import {
|
||||
useGetStockQuery,
|
||||
useUpdateStockMutation,
|
||||
useUploadStockPictureMutation,
|
||||
useDeleteStockMutation,
|
||||
} from '/src/api/apiStock';
|
||||
/**/
|
||||
import { selectUtils } from '../store/slice/utilsSlice';
|
||||
/**/
|
||||
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 { ResponseModals } from '../components/Modals/ResponseModals';
|
||||
/**/
|
||||
import { Modal} from '../components/Modals/Modal';
|
||||
|
||||
const isValidImage = (file) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png'];
|
||||
if (allowedTypes.includes(file.type)) return true;
|
||||
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
return ['jpg', 'jpeg', 'png'].includes(ext);
|
||||
};
|
||||
|
||||
export function EditStockPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const navigateBack = () => navigate( `/promotions` );
|
||||
|
||||
const { data: stock, isFetching, error } = useGetStockQuery( { stockId: id } );
|
||||
const [ updateStock ] = useUpdateStockMutation();
|
||||
const [ uploadPicture ] = useUploadStockPictureMutation();
|
||||
const [ deleteStock ] = useDeleteStockMutation();
|
||||
|
||||
const [ isModalSuccess, setModalSuccess ] = useState( false );
|
||||
|
||||
const [ errors, setErrors ] = useState({
|
||||
name: '',
|
||||
dateRange: ''
|
||||
});
|
||||
|
||||
const [ form, setForm ] = useState({
|
||||
name: '',
|
||||
anons: '',
|
||||
picture: '',
|
||||
});
|
||||
const [ anons, setAnons ] = useState( '' );
|
||||
const [ content, setContent ] = useState( '' );
|
||||
|
||||
const fileInputRef = useRef();
|
||||
|
||||
useEffect( () => {
|
||||
if ( !stock ) return;
|
||||
|
||||
let datetimeLocalStart = '';
|
||||
let datetimeLocalEnd = '';
|
||||
|
||||
if ( stock.startDate ) {
|
||||
const dateStart = new Date( stock.startDate );
|
||||
const yearStart = dateStart.getFullYear();
|
||||
const monthStart = String( dateStart.getMonth() + 1 ).padStart( 2, '0' );
|
||||
const dayStart = String( dateStart.getDate() ).padStart( 2, '0' );
|
||||
const hoursStart = String( dateStart.getHours() ).padStart( 2, '0' );
|
||||
const minutesStart = String( dateStart.getMinutes() ).padStart( 2, '0' );
|
||||
datetimeLocalStart = `${ yearStart }-${ monthStart }-${ dayStart }T${ hoursStart }:${ minutesStart }`;
|
||||
}
|
||||
|
||||
if ( stock.endDate ) {
|
||||
const dateEnd = new Date( stock.endDate );
|
||||
const yearEnd = dateEnd.getFullYear();
|
||||
const monthEnd = String( dateEnd.getMonth() + 1 ).padStart( 2, '0' );
|
||||
const dayEnd = String( dateEnd.getDate() ).padStart( 2, '0' );
|
||||
const hoursEnd = String( dateEnd.getHours() ).padStart( 2, '0' );
|
||||
const minutesEnd = String( dateEnd.getMinutes() ).padStart( 2, '0' );
|
||||
datetimeLocalEnd = `${ yearEnd }-${ monthEnd }-${ dayEnd }T${ hoursEnd }:${ minutesEnd }`;
|
||||
}
|
||||
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
name: stock.name ?? '',
|
||||
startDate: datetimeLocalStart,
|
||||
endDate: datetimeLocalEnd,
|
||||
picture: stock.picture ? apiUrl(`/uploads/${stock.picture}`) : '',
|
||||
}));
|
||||
|
||||
setAnons( stock.anons )
|
||||
setContent( stock.content )
|
||||
}, [ stock ]);
|
||||
|
||||
const startDateInputRef = useRef(null);
|
||||
const endDateInputRef = useRef(null);
|
||||
|
||||
|
||||
const handleChange = (key) => (e) => {
|
||||
setForm(f => ({ ...f, [key]: e.target.value }));
|
||||
setErrors(err => ({ ...err, [key === 'name' ? 'name' : 'dateRange']: '' }));
|
||||
};
|
||||
|
||||
const handleFile = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return window.alert('Файл не выбран')
|
||||
if (!isValidImage(file)) {
|
||||
return window.alert('Изображения должны быть только формата JPG, JPEG или PNG');
|
||||
}
|
||||
const url = window.URL.createObjectURL(file);
|
||||
setForm(f => ({ ...f, _file: file, picture: url }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
|
||||
let hasError = false;
|
||||
const newErrors = { name:'', dateRange:'' };
|
||||
|
||||
if (!form.name.trim()) {
|
||||
newErrors.name = 'Название не может быть пустым';
|
||||
hasError = true;
|
||||
}
|
||||
const start = new Date(form.startDate);
|
||||
const end = new Date(form.endDate);
|
||||
if (isNaN(start) || isNaN(end) || start > end) {
|
||||
newErrors.dateRange = 'Дата начал акции должна быть не позже даты окончания';
|
||||
hasError = true;
|
||||
}
|
||||
if (hasError) {
|
||||
setErrors(newErrors);
|
||||
window.alert('Пожалуйста исправьте ошибки в форме.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateStock({
|
||||
stockId: id,
|
||||
data: {
|
||||
name: form.name,
|
||||
anons: anons,
|
||||
content: content,
|
||||
startDate: String(form.startDate),
|
||||
endDate: String(form.endDate),
|
||||
}
|
||||
}).unwrap();
|
||||
|
||||
if (form._file) {
|
||||
await uploadPicture({ id, file: form._file }).unwrap();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при обновлении акции:', err)
|
||||
}
|
||||
setModalSuccess(true)
|
||||
window.setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 2000);
|
||||
console.log('success update promotion')
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteStock({stockId: id}).unwrap()
|
||||
console.log('success delete')
|
||||
setModalSuccess(true)
|
||||
window.setTimeout(() => {
|
||||
navigateBack()
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при удалении специалиста:', err)
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
if ( isFetching ) return <LoadingComponent />
|
||||
if ( error ) return <ErrorComponent />
|
||||
if ( !stock ) return (
|
||||
<NotFindElement
|
||||
message={ `Акция с ID=${id} не найдена.` }
|
||||
navigateBack={ navigateBack }
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<EditElementForm
|
||||
navigateBack={ navigateBack }
|
||||
header={ `Редактирование акции #${ id }` }
|
||||
handleSave={ handleSave }
|
||||
handleDelete={ handleDelete }
|
||||
>
|
||||
<div className="form-group">
|
||||
<label>Название</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={form.name}
|
||||
onChange={handleChange('name')}
|
||||
/>
|
||||
{errors.name && <small className="text-danger">{errors.name}</small>}
|
||||
</div>
|
||||
|
||||
<div className='d-flex flex-column mb-1'>
|
||||
<div className='d-flex justify-content-between' style={{ gap: '1rem'}}>
|
||||
<div className="form-group flex-grow-1">
|
||||
<label>Начало акции:</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
ref={startDateInputRef}
|
||||
className="form-control"
|
||||
value={form.startDate}
|
||||
onClick={() => {
|
||||
if (startDateInputRef.current?.showPicker) {
|
||||
startDateInputRef.current.showPicker();
|
||||
}
|
||||
}}
|
||||
onChange={handleChange('startDate')}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group flex-grow-1">
|
||||
<label>Окончание акции:</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
ref={endDateInputRef}
|
||||
className="form-control"
|
||||
value={form.endDate}
|
||||
onClick={() => {
|
||||
if (endDateInputRef.current?.showPicker) {
|
||||
endDateInputRef.current.showPicker();
|
||||
}
|
||||
}}
|
||||
onChange={handleChange('endDate')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{errors.dateRange && (
|
||||
<div className="mb-3">
|
||||
<small className="text-danger">{errors.dateRange}</small>
|
||||
</div>
|
||||
)}
|
||||
<label className='mt-0'>*Время указывается по МСК</label>
|
||||
</div>
|
||||
|
||||
<div className='d-flex justify-content-between' style={{ gap: '1rem'}}>
|
||||
<div className="form-group flex-grow-1">
|
||||
<label>Анонс</label>
|
||||
<TextEditor
|
||||
content={anons}
|
||||
setContent={setAnons}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Изображение</label>
|
||||
<div className="d-flex align-items-center flex-column" style={{ gap: '1rem'}}>
|
||||
{form.picture && (
|
||||
<img
|
||||
src={form.picture}
|
||||
alt=""
|
||||
style={{ width: '13rem', height: '13rem', objectFit: 'cover', marginRight: 16 }}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
onClick={() => fileInputRef.current.click()}
|
||||
>
|
||||
Загрузить…
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Полное описание</label>
|
||||
<TextEditor
|
||||
content={content}
|
||||
setContent={setContent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={isModalSuccess}
|
||||
title="Изменения внесены"
|
||||
hasButtons={false}
|
||||
confirmText={'Удалить врача'}
|
||||
>
|
||||
<p className='mb-1'>Изменения успешно внесены.</p>
|
||||
</Modal>
|
||||
</EditElementForm>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useGetFilialsQuery } from '../api/apiFilial';
|
||||
import { selectRegions } from '../store/slice/regionSlice';
|
||||
import { selectUtils } from '../store/slice/utilsSlice';
|
||||
/**/
|
||||
import { useSortedPaginated } from '../hooks/useSortedPaginated';
|
||||
import { useOutsideClick } from '../hooks/useOutsideClick';
|
||||
import { useSorting } from '../hooks/useSorting';
|
||||
/**/
|
||||
import { LoadingComponent } from '../components/Placeholders/LoadingComponent';
|
||||
import { ErrorComponent } from '../components/Placeholders/ErrorComponent';
|
||||
import { PageNav } from '../components/Paginations/PageNav';
|
||||
import { THead } from '../components/Table/THead';
|
||||
import { TBody } from '../components/Table/TBody';
|
||||
|
||||
export const FilialsListPage = () => {
|
||||
const { data: filialsRaw = [], isLoading, error } = useGetFilialsQuery();
|
||||
const filials = filialsRaw.length === 0 ? [] : filialsRaw.data;
|
||||
const regions = useSelector(selectRegions);
|
||||
const FILIALS_COLUMN = useSelector(selectUtils).FILIALS_COLUMN;
|
||||
|
||||
const ITEMS_PER_PAGE = useSelector(selectUtils).ITEMS_PER_PAGE;
|
||||
const navigate = useNavigate();
|
||||
const goEdit = id => navigate(`/filials/edit/${id}`);
|
||||
|
||||
const [expandedId, setExpandedId] = useState(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const tableRef = useRef( null );
|
||||
useOutsideClick(tableRef, () => setExpandedId( null ));
|
||||
|
||||
const { sortBy, sortDirection, handleSort } = useSorting();
|
||||
|
||||
const { paginated, totalPages } = useSortedPaginated(
|
||||
filials.map((filial) => ({
|
||||
regionName: regions[ filial.regionId ],
|
||||
id: filial.fid,
|
||||
shortName: filial.shortName,
|
||||
})
|
||||
),
|
||||
ITEMS_PER_PAGE,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
currentPage
|
||||
);
|
||||
|
||||
if ( error ) return <ErrorComponent />
|
||||
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<h1 className="h3 mb-4 text-gray-800">
|
||||
Филиалы
|
||||
</h1>
|
||||
|
||||
{ isLoading &&
|
||||
<LoadingComponent />
|
||||
}
|
||||
|
||||
{ !isLoading &&
|
||||
<>
|
||||
<div className="table-responsive" ref={ tableRef }>
|
||||
<table className="table table-bordered table-hover">
|
||||
<THead
|
||||
columns={ FILIALS_COLUMN }
|
||||
sortBy={ sortBy }
|
||||
sortDirection={ sortDirection }
|
||||
handleSort={ handleSort }
|
||||
/>
|
||||
<TBody
|
||||
elements={ paginated }
|
||||
columns={ FILIALS_COLUMN }
|
||||
expandedId={ expandedId }
|
||||
setExpandedId={ setExpandedId }
|
||||
goEdit={ goEdit }
|
||||
/>
|
||||
</table>
|
||||
</div>
|
||||
<PageNav
|
||||
currentPage={ currentPage }
|
||||
totalPages={ totalPages }
|
||||
onPageChange={ setCurrentPage }
|
||||
/>
|
||||
</>
|
||||
}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const HomePage = () => (
|
||||
<div className="container-fluid">
|
||||
<h1 className="h3 mb-4 text-gray-800">
|
||||
Главная
|
||||
</h1>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,203 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { useGetDepartmentsQuery } from '../api/apiDepartment';
|
||||
import { useGetFilialsQuery } from '../api/apiFilial';
|
||||
import { useGetIDoctorsQuery } from '../api/apiIDoctor';
|
||||
|
||||
export const InfoclinicListPage = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [searchValue, setSearchValue] = useState(() => searchParams.get('search') || '');
|
||||
const [currentPage, setCurrentPage] = useState(() => {
|
||||
const p = Number(searchParams.get('page'));
|
||||
return Number.isFinite(p) && p > 0 ? p : 1;
|
||||
});
|
||||
const [debouncedSearch, setDebouncedSearch] = useState(searchValue);
|
||||
|
||||
const isFirstLoadRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstLoadRef.current) {
|
||||
isFirstLoadRef.current = false;
|
||||
return;
|
||||
}
|
||||
window.scrollTo(0, 0)
|
||||
}, [currentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => {
|
||||
if (searchValue.length >= 3 || searchValue.length === 0) {
|
||||
setDebouncedSearch(searchValue);
|
||||
}
|
||||
}, 400);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [searchValue]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = {};
|
||||
if (debouncedSearch) params.search = debouncedSearch;
|
||||
if (currentPage > 1) params.page = String(currentPage);
|
||||
setSearchParams(params);
|
||||
}, [debouncedSearch, currentPage, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const search = searchParams.get('search') || '';
|
||||
const page = Number(searchParams.get('page')) || 1;
|
||||
|
||||
setSearchValue(search);
|
||||
setCurrentPage(page);
|
||||
}, [searchParams]);
|
||||
|
||||
const { data: filialsRaw = [] } = useGetFilialsQuery();
|
||||
const filials = filialsRaw.length === 0 ? [] : filialsRaw.data;
|
||||
|
||||
const { data: departmentsRaw = [] } = useGetDepartmentsQuery();
|
||||
const departments = departmentsRaw.length === 0 ? [] : departmentsRaw.data;
|
||||
|
||||
const {
|
||||
data: response = {},
|
||||
isFetching,
|
||||
error
|
||||
} = useGetIDoctorsQuery({ value: debouncedSearch, page: currentPage, type: 'search' });
|
||||
|
||||
const items = response?.data ?? [];
|
||||
|
||||
const pagination = response.pagination || {};
|
||||
|
||||
const renderPagination = () => {
|
||||
const total = pagination.total_pages || 1;
|
||||
const current = pagination.current_page || 1;
|
||||
const pages = new Set([1, total]);
|
||||
|
||||
for (let p = Math.max(2, current - 2); p <= Math.min(total - 1, current + 2); p++) {
|
||||
pages.add(p);
|
||||
}
|
||||
|
||||
const sorted = Array.from(pages).sort((a, b) => a - b);
|
||||
const elems = [];
|
||||
let last = 0;
|
||||
|
||||
sorted.forEach(page => {
|
||||
if (last && page - last > 1) {
|
||||
elems.push(
|
||||
<li key={`dots-${last}`} className="page-item disabled">
|
||||
<span className="page-link">…</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
elems.push(
|
||||
<li key={page} className={`page-item ${page === current ? 'active' : ''}`}>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={() => page !== current && setCurrentPage(page)}
|
||||
disabled={page === current}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
last = page;
|
||||
});
|
||||
|
||||
return (
|
||||
<nav>
|
||||
<ul className="pagination justify-content-center">
|
||||
<li className={`page-item ${!pagination.has_previous_page ? 'disabled' : ''}`}>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={() => pagination.has_previous_page && setCurrentPage(current - 1)}
|
||||
disabled={!pagination.has_previous_page}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
</li>
|
||||
|
||||
{elems}
|
||||
|
||||
<li className={`page-item ${!pagination.has_next_page ? 'disabled' : ''}`}>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={() => pagination.has_next_page && setCurrentPage(current + 1)}
|
||||
disabled={!pagination.has_next_page}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<h1 className="h3 mb-4 text-gray-800">Расписание ИК</h1>
|
||||
|
||||
<div className="d-flex justify-content-between mb-3 gap-3 align-items-end">
|
||||
<div className="form-group flex-grow-1">
|
||||
<label>Поиск по ФИО</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Поиск врачей"
|
||||
value={searchValue}
|
||||
onChange={e => {
|
||||
setSearchValue(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFetching ? (
|
||||
<p>Загрузка...</p>
|
||||
) : error ? (
|
||||
<p className="text-danger">Ошибка при загрузке: {error.message || String(error)}</p>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered text-center mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowSpan="2" className='text-align-center'>dcode</th>
|
||||
<th rowSpan="2" className='text-align-center'>ФИО</th>
|
||||
<th colSpan="2">Филиал</th>
|
||||
<th colSpan="2">Отделение</th>
|
||||
<th rowSpan="2" className='text-align-center'>Ближайшая дата приема</th>
|
||||
<th rowSpan="2" className='text-align-center'>Онлайн запись</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>ID</th>
|
||||
<th>Адрес</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((specialist, index) => {
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td>{specialist.dcode}</td>
|
||||
<td className='table-left-align'>{specialist.name}</td>
|
||||
<td>{specialist.filial}</td>
|
||||
<td className='table-left-align'>{
|
||||
filials.length > 0 ? filials.find(filial => String(filial.fid) === String(specialist.filial))?.shortName : null
|
||||
}</td>
|
||||
<td>{specialist.department}</td>
|
||||
<td className='table-left-align'>{
|
||||
departments.length > 0 ? departments.find(department => String(department.did) === String(specialist.department))?.name : null
|
||||
}</td>
|
||||
<td>{specialist.nearestDate}</td>
|
||||
<td>{specialist.onlineMode ? 'да' : 'нет'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{renderPagination()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
import { useLoginMutation } from '../api/apiSlice'
|
||||
import { Input } from '../components/Input/Input';
|
||||
import { Button } from '../components/Button/Button';
|
||||
|
||||
export const LoginPage = () => {
|
||||
const [ login, { isLoading, error }] = useLoginMutation();
|
||||
const auth = useSelector((state) => state.auth);
|
||||
|
||||
const [ user, setUser ] = useState('');
|
||||
const [ pass, setPass ] = useState('');
|
||||
const [ validationError, setValidationError ] = useState('');
|
||||
|
||||
if (auth.token) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setValidationError('');
|
||||
|
||||
if (!user.trim() || !pass.trim()) {
|
||||
setValidationError('Пожалуйста, заполните оба поля.');
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await login({
|
||||
username: user,
|
||||
password: pass,
|
||||
}).unwrap();
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="d-flex justify-content-center align-items-center vh-100 bg-light">
|
||||
<div className="login_wrapper container p-4 shadow-sm border rounded bg-white">
|
||||
<h2 className="text-center mb-4 text-success">
|
||||
Вход
|
||||
</h2>
|
||||
<form onSubmit={ handleSubmit }>
|
||||
<div className="form-group mb-3">
|
||||
<Input placeholder="Логин" value={user} onChange={ setUser }/>
|
||||
</div>
|
||||
<div className="form-group mb-3">
|
||||
<Input placeholder="Пароль" type="password" value={pass} onChange={ setPass } />
|
||||
</div>
|
||||
{!validationError && (
|
||||
error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{ error.status === 500 || error.status === 400
|
||||
? 'Неверный логин или пароль' : null }
|
||||
{ error.status === 'FETCH_ERROR' ? 'Ошибка сети' : null }
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
{ validationError && (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
{ validationError }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className="text-center">
|
||||
<Button type="submit" className="btn-primary" disabled={ isLoading }>
|
||||
{ isLoading ? 'Загрузка…' : 'Войти' }
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
|
||||
import { useLostDoctors } from '../hooks/useLostLocations';
|
||||
|
||||
const getFormatDate = (rawDate) => {
|
||||
const date = new Date(rawDate);
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const year = date.getUTCFullYear();
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
function scrollToTop({ smooth = true } = {}) {
|
||||
if ('scrollBehavior' in document.documentElement.style && smooth) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else {
|
||||
document.documentElement.scrollTop = 0;
|
||||
document.body.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const LostDoctorsPage = () => {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
error,
|
||||
lostDoctors = [],
|
||||
filials,
|
||||
departments,
|
||||
regions,
|
||||
} = useLostDoctors();
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchValue, lostDoctors]);
|
||||
|
||||
// Живой поиск по ФИО (case-insensitive). Если doctor отсутствует — исключаем элемент.
|
||||
const filtered = useMemo(() => {
|
||||
const q = searchValue.trim().toLowerCase();
|
||||
if (!q) return lostDoctors;
|
||||
return lostDoctors.filter(ld => {
|
||||
const name = String(ld?.doctor?.name ?? '').toLowerCase();
|
||||
return name.includes(q);
|
||||
});
|
||||
}, [lostDoctors, searchValue]);
|
||||
|
||||
const totalItems = filtered.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE));
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage > totalPages) {
|
||||
setCurrentPage(totalPages);
|
||||
}
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToTop();
|
||||
}, [ currentPage ])
|
||||
|
||||
const paged = useMemo(() => {
|
||||
const start = (currentPage - 1) * PAGE_SIZE;
|
||||
return filtered.slice(start, start + PAGE_SIZE);
|
||||
}, [filtered, currentPage]);
|
||||
|
||||
const visiblePageNumbers = useMemo(() => {
|
||||
const maxButtons = 9;
|
||||
const pages = [];
|
||||
if (totalPages <= maxButtons) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
return pages;
|
||||
}
|
||||
let start = Math.max(1, currentPage - Math.floor(maxButtons / 2));
|
||||
let end = start + maxButtons - 1;
|
||||
if (end > totalPages) {
|
||||
end = totalPages;
|
||||
start = end - maxButtons + 1;
|
||||
}
|
||||
for (let i = start; i <= end; i++) pages.push(i);
|
||||
return pages;
|
||||
}, [totalPages, currentPage]);
|
||||
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<h1 className="h3 mb-4 text-gray-800">Врачи-потеряшки</h1>
|
||||
|
||||
<div className="d-flex justify-content-between mb-3 gap-3 align-items-end">
|
||||
<div className="form-group flex-grow-1">
|
||||
<label>Поиск по ФИО</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Поиск врачей"
|
||||
value={searchValue}
|
||||
onChange={e => {
|
||||
setSearchValue(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group ml-2">
|
||||
<input
|
||||
type="button"
|
||||
className="btn btn-outline-primary"
|
||||
value="Сбросить"
|
||||
onClick={() => setSearchValue('')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading || lostDoctors.length === 0 ? (
|
||||
<p>Загрузка...</p>
|
||||
) : error ? (
|
||||
<p className="text-danger">Ошибка при загрузке: {error.message || String(error)}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-wrapper">
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered text-center mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowSpan="2" className='text-align-center'>dcode</th>
|
||||
<th rowSpan="2" className='text-align-center'>ФИО</th>
|
||||
<th colSpan="2">Филиал</th>
|
||||
<th colSpan="2">Отделение</th>
|
||||
<th rowSpan="2" className='text-align-center'>Ближайшая дата приема</th>
|
||||
<th rowSpan="2" className='text-align-center'>Онлайн запись</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>ID</th>
|
||||
<th>Адрес</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="8">Ничего не найдено.</td>
|
||||
</tr>
|
||||
) : (
|
||||
paged.map((lostDoctor, index) =>
|
||||
<tr key={index}>
|
||||
<td>{lostDoctor.doctor.dcode}</td>
|
||||
<td className='table-left-align'>
|
||||
{lostDoctor.doctor.name}
|
||||
</td>
|
||||
<td>
|
||||
{lostDoctor.emptyLocation.filial}
|
||||
</td>
|
||||
<td className='table-left-align'>
|
||||
{ filials.length > 0 ? filials.find(filial => String(filial.fid) === String(lostDoctor.emptyLocation.filial))?.shortName : null }
|
||||
</td>
|
||||
<td>
|
||||
{lostDoctor.emptyLocation.department}
|
||||
</td>
|
||||
<td className='table-left-align'>
|
||||
{ departments.length > 0 ? departments.find(department => String(department.did) === String(lostDoctor.emptyLocation.department))?.name : null }
|
||||
</td>
|
||||
<td>
|
||||
{getFormatDate(lostDoctor.emptyLocation.nearestDate)}
|
||||
</td>
|
||||
<td>
|
||||
{lostDoctor.emptyLocation.onlineMode ? 'да' : 'нет'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<nav>
|
||||
<ul className="pagination justify-content-center">
|
||||
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
</li>
|
||||
|
||||
{visiblePageNumbers.map(page => (
|
||||
<li key={page} className={`page-item ${page === currentPage ? 'active' : ''}`}>
|
||||
<button
|
||||
key={page}
|
||||
className="page-link"
|
||||
onClick={() => setCurrentPage(page)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
<li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { Sidebar } from '../components/Sidebar/Sidebar';
|
||||
import { Navbar } from '../components/Navbar/Navbar';
|
||||
|
||||
export const MainPage = () => {
|
||||
return (
|
||||
<div id="wrapper">
|
||||
<Sidebar />
|
||||
<div id="content-wrapper" className="d-flex flex-column page-body">
|
||||
<div id="content">
|
||||
<Navbar />
|
||||
<div className="container-fluid">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export const NotFoundPage = () => {
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<div className="container mt-5 text-center">
|
||||
<h1 className="display-4 text-gray-800">
|
||||
404
|
||||
</h1>
|
||||
<p className="lead mb-4">
|
||||
Страница не найдена
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useGetKodopersQuery } from '/src/api/apiKodoper';
|
||||
import { useGetFilialsQuery } from '/src/api/apiFilial';
|
||||
import { LoadingComponent } from '../components/Placeholders/LoadingComponent';
|
||||
import { ErrorComponent } from '../components/Placeholders/ErrorComponent';
|
||||
|
||||
const getDateInfo = (dateString) => {
|
||||
const dateObj = new Date(dateString);
|
||||
|
||||
const date = dateObj.toLocaleDateString('ru-RU');
|
||||
const time = dateObj.toLocaleTimeString('ru-RU', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
return { date, time }
|
||||
}
|
||||
|
||||
export const PricesListPage = () => {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [currentFilial, setCurrentFilial] = useState(-1);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const { data: filialsRaw = {} } = useGetFilialsQuery();
|
||||
const filials = filialsRaw.data || [];
|
||||
const filialOptions = [ { fid: -1, name: 'Все филиалы' }, ...filials ];
|
||||
|
||||
const { data: response = {}, isFetching, error: queryError } = useGetKodopersQuery(
|
||||
{ value: searchValue, filialId: currentFilial, page: currentPage },
|
||||
{ skip: false }
|
||||
);
|
||||
|
||||
const prices = response.data || [];
|
||||
const pagination = response.pagination || {};
|
||||
|
||||
const renderPagination = () => {
|
||||
const total = pagination.total_pages || 1;
|
||||
const current = pagination.current_page || 1;
|
||||
const pages = new Set();
|
||||
|
||||
pages.add( 1 );
|
||||
pages.add( total );
|
||||
|
||||
for ( let p = current - 2; p <= current + 2; p++ ) {
|
||||
if ( p > 1 && p < total ) pages.add( p );
|
||||
}
|
||||
|
||||
const sorted = Array.from( pages ).sort( ( a, b ) => a - b );
|
||||
|
||||
const items = [];
|
||||
let last = 0;
|
||||
|
||||
sorted.forEach( page => {
|
||||
if ( last && page - last > 1 ) {
|
||||
items.push(
|
||||
<li key={ `dots-${ last }` } className="page-item disabled">
|
||||
<span className="page-link">…</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
items.push(
|
||||
<li
|
||||
key={ page }
|
||||
className={ `page-item ${ page === current ? 'active' : '' }` }
|
||||
>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={() => {
|
||||
if ( 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
|
||||
className="page-link"
|
||||
onClick={() => pagination.has_previous_page && setCurrentPage( current - 1 )}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
</li>
|
||||
|
||||
{ items }
|
||||
|
||||
<li className={ `page-item ${ !pagination.has_next_page ? 'disabled' : '' }` }>
|
||||
<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">
|
||||
Цены и услуги
|
||||
</h1>
|
||||
<div>
|
||||
<div className='d-flex'>
|
||||
<div className="form-row mb-3 mr-3 flex-grow-1">
|
||||
<div className="col">
|
||||
<label>
|
||||
Поиск (по названию услуги или мед. коду)
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={ searchValue }
|
||||
onChange={ e => {
|
||||
setSearchValue( e.target.value );
|
||||
setCurrentPage( 1 );
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label>
|
||||
Филиал
|
||||
</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={ currentFilial }
|
||||
onChange={ e => {
|
||||
setCurrentFilial( Number( e.target.value ));
|
||||
setCurrentPage( 1 );
|
||||
}}
|
||||
>
|
||||
{
|
||||
filialOptions.map( f =>
|
||||
<option key={ f.fid } value={ f.fid }>
|
||||
{ f.name }
|
||||
</option>
|
||||
)
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{ isFetching
|
||||
? (
|
||||
<LoadingComponent />
|
||||
)
|
||||
: queryError
|
||||
? (
|
||||
<ErrorComponent />
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-hover table-bordered">
|
||||
<thead className=''>
|
||||
<tr>
|
||||
<th>Мед.код</th>
|
||||
<th>Услуга</th>
|
||||
<th>Дата обновления</th>
|
||||
<th>Время обновления</th>
|
||||
<th>Филиал</th>
|
||||
<th>Стоимость</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ prices.length > 0 ?
|
||||
prices.map( ( item, idx ) => {
|
||||
const updateDate = getDateInfo(item.dateUpdate);
|
||||
return (
|
||||
<tr key={ idx }>
|
||||
<td>{ item.kodoper }</td>
|
||||
<td>{ item.schname }</td>
|
||||
<td>{ updateDate.date }</td>
|
||||
<td>{ updateDate.time }</td>
|
||||
<td>{ item.fname }</td>
|
||||
<td>{ item.priceInfo.price } ₽</td>
|
||||
</tr>
|
||||
)
|
||||
}) : (
|
||||
<tr>
|
||||
<td colspan='4' className='text-center'>
|
||||
По данному запросу ничего не найдено
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{ renderPagination() }
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useOutsideClick } from '../hooks/useOutsideClick';
|
||||
import { useGetSpecialistsListQuery } from '../api/apiSpecialist';
|
||||
|
||||
export const SpecialistListPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [searchValue, setSearchValue] = useState(() => searchParams.get('search') || '');
|
||||
const [regionId, setRegionId] = useState(() => searchParams.get('region') || '');
|
||||
const [currentPage, setCurrentPage] = useState(() => {
|
||||
const p = Number(searchParams.get('page'));
|
||||
return Number.isFinite(p) && p > 0 ? p : 1;
|
||||
});
|
||||
const [expandedId, setExpandedId] = useState('');
|
||||
|
||||
const regionOptions = [
|
||||
{ id: '', label: 'Все регионы' },
|
||||
{ id: '91', label: 'Саратов' },
|
||||
{ id: '92', label: 'Волгоград' },
|
||||
{ id: '93', label: 'Воронеж' },
|
||||
{ id: '94', label: 'Краснодар' },
|
||||
];
|
||||
|
||||
const sTypeOptions = {
|
||||
0: 'Взрослый врач',
|
||||
1: 'Детский врач',
|
||||
2: 'Администрация',
|
||||
3: 'Стоматология',
|
||||
4: 'Мед. сестра',
|
||||
};
|
||||
|
||||
const tableRef = useRef(null);
|
||||
useOutsideClick(tableRef, () => setExpandedId(''));
|
||||
|
||||
const [debouncedSearch, setDebouncedSearch] = useState(searchValue);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => {
|
||||
if (searchValue.length >= 3 || searchValue.length === 0) {
|
||||
setDebouncedSearch(searchValue);
|
||||
}
|
||||
}, 400);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [searchValue]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = {};
|
||||
if (debouncedSearch) params.search = debouncedSearch;
|
||||
if (regionId) params.region = regionId;
|
||||
if (currentPage > 1) params.page = String(currentPage);
|
||||
setSearchParams(params);
|
||||
}, [debouncedSearch, regionId, currentPage, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const search = searchParams.get('search') || '';
|
||||
const region = searchParams.get('region') || '';
|
||||
const page = Number(searchParams.get('page')) || 1;
|
||||
|
||||
setSearchValue(search);
|
||||
setRegionId(region);
|
||||
setCurrentPage(page);
|
||||
}, [searchParams]);
|
||||
|
||||
const {
|
||||
data: response = {},
|
||||
isFetching,
|
||||
error
|
||||
} = useGetSpecialistsListQuery({
|
||||
regionId,
|
||||
search: debouncedSearch,
|
||||
page: currentPage,
|
||||
});
|
||||
|
||||
const formatDateToLocal = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
const items = response.data
|
||||
? response.data.map(item => ({
|
||||
...item,
|
||||
startDate: formatDateToLocal(item.startDate),
|
||||
endDate: formatDateToLocal(item.endDate)
|
||||
}))
|
||||
: [];
|
||||
|
||||
const pagination = response.pagination || {};
|
||||
|
||||
const renderPagination = () => {
|
||||
const total = pagination.total_pages || 1;
|
||||
const current = pagination.current_page || 1;
|
||||
const pages = new Set([1, total]);
|
||||
|
||||
for (let p = Math.max(2, current - 2); p <= Math.min(total - 1, current + 2); p++) {
|
||||
pages.add(p);
|
||||
}
|
||||
|
||||
const sorted = Array.from(pages).sort((a, b) => a - b);
|
||||
const elems = [];
|
||||
let last = 0;
|
||||
|
||||
sorted.forEach(page => {
|
||||
if (last && page - last > 1) {
|
||||
elems.push(
|
||||
<li key={`dots-${last}`} className="page-item disabled">
|
||||
<span className="page-link">…</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
elems.push(
|
||||
<li key={page} className={`page-item ${page === current ? 'active' : ''}`}>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={() => page !== current && setCurrentPage(page)}
|
||||
disabled={page === current}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
last = page;
|
||||
});
|
||||
|
||||
return (
|
||||
<nav>
|
||||
<ul className="pagination justify-content-center">
|
||||
<li className={`page-item ${!pagination.has_previous_page ? 'disabled' : ''}`}>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={() => pagination.has_previous_page && setCurrentPage(current - 1)}
|
||||
disabled={!pagination.has_previous_page}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
</li>
|
||||
|
||||
{elems}
|
||||
|
||||
<li className={`page-item ${!pagination.has_next_page ? 'disabled' : ''}`}>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={() => pagination.has_next_page && setCurrentPage(current + 1)}
|
||||
disabled={!pagination.has_next_page}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<h1 className="h3 mb-4 text-gray-800">Врачи</h1>
|
||||
|
||||
<div className="d-flex justify-content-between mb-3 gap-3 align-items-end">
|
||||
<div className="form-group mr-2">
|
||||
<input
|
||||
type="button"
|
||||
className="btn btn-outline-primary"
|
||||
value="Добавить"
|
||||
onClick={() => navigate('/specialist/add')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group mr-2">
|
||||
<label>Регион</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={regionId}
|
||||
onChange={e => {
|
||||
setRegionId(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
{regionOptions.map(r => (
|
||||
<option key={r.id} value={r.id}>{r.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group flex-grow-1">
|
||||
<label>Поиск</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Поиск врачей"
|
||||
value={searchValue}
|
||||
onChange={e => {
|
||||
setSearchValue(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFetching ? (
|
||||
<p>Загрузка...</p>
|
||||
) : error ? (
|
||||
<p className="text-danger">Ошибка при загрузке: {error.message || String(error)}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-responsive" ref={tableRef}>
|
||||
<table className="table table-hover table-bordered">
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'center' }}>
|
||||
<th style={{ alignContent: 'center' }}>ID</th>
|
||||
<th style={{ alignContent: 'center' }}>ФИО</th>
|
||||
<th style={{ alignContent: 'center' }}>Должность</th>
|
||||
<th style={{ alignContent: 'center' }}>Город</th>
|
||||
<th style={{ alignContent: 'center' }}>Категория</th>
|
||||
<th style={{ alignContent: 'center' }}>Наличие услуг</th>
|
||||
<th style={{ alignContent: 'center' }}>Наличие расписания</th>
|
||||
<th style={{ alignContent: 'center' }}>Отображать на сайте</th>
|
||||
<th style={{ alignContent: 'center' }}>Отображать расписание</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(specialist => {
|
||||
return (
|
||||
<React.Fragment key={specialist.id}>
|
||||
<tr
|
||||
className={`cursor-pointer${expandedId === specialist.id ? ' table-success' : ''}`}
|
||||
onClick={() => {
|
||||
setExpandedId(expandedId === specialist.id ? '' : specialist.id);
|
||||
}}
|
||||
>
|
||||
<td>{specialist.id}</td>
|
||||
<td>{specialist.nameString}</td>
|
||||
<td>{specialist.post}</td>
|
||||
<td style={{ textAlign: 'center', alignContent: 'center' }}>{regionOptions.find(region => Number(region.id) === specialist.regionId)?.label || 'не указан'}</td>
|
||||
<td style={{ textAlign: 'center', alignContent: 'center' }}>{specialist?.sType || specialist?.sType === 0 ? sTypeOptions[Number(specialist.sType)] : 'не указан'}</td>
|
||||
<td style={{ textAlign: 'center', alignContent: 'center' }}>{Array.isArray(specialist.kodoper) ? ( specialist.kodoper.length > 0 ? 'есть' : 'нет' ) : 'нет'}</td>
|
||||
<td style={{ textAlign: 'center', alignContent: 'center' }}>{Array.isArray(specialist.locations) ? ( specialist.locations.length > 0 ? 'есть' : 'нет' ) : 'нет'}</td>
|
||||
<td style={{ textAlign: 'center', alignContent: 'center' }}>{specialist.active ? 'да' : 'нет'}</td>
|
||||
<td style={{ textAlign: 'center', alignContent: 'center' }}>{specialist.displaySchedule ? 'да' : 'нет'}</td>
|
||||
</tr>
|
||||
|
||||
{expandedId === specialist.id && (
|
||||
<tr className="table-success">
|
||||
<td colSpan={10}>
|
||||
<input
|
||||
type="button"
|
||||
className="btn btn-outline-primary"
|
||||
value="Редактировать"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
navigate(`/specialist/edit/${specialist.id}`, {
|
||||
state: { search: searchValue, region: regionId, page: currentPage }
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{renderPagination()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { useGetSpecialistsListQuery } from '../api/apiSpecialist';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useOutsideClick } from '../hooks/useOutsideClick';
|
||||
|
||||
export const SpecialistTable = React.memo(({ regionId, search, currentPage, onPageChange }) => {
|
||||
const navigate = useNavigate();
|
||||
const [expandedId, setExpandedId] = React.useState(null);
|
||||
const tableRef = useRef(null);
|
||||
useOutsideClick(tableRef, () => setExpandedId(null));
|
||||
|
||||
|
||||
const { data: resp = {}, isFetching, error } =
|
||||
useGetSpecialistsListQuery({ regionId, search, page: currentPage });
|
||||
|
||||
const items = resp.data || [];
|
||||
const pagination = resp.pagination || {};
|
||||
|
||||
if (isFetching) return <p>Загрузка...</p>;
|
||||
if (error) return <p className="text-danger">Ошибка: {String(error)}</p>;
|
||||
|
||||
const formatExp = y => y ? String(y) : '—';
|
||||
|
||||
const renderPagination = () => {
|
||||
const total = pagination.total_pages || 1;
|
||||
const current = pagination.current_page || 1;
|
||||
const pages = new Set([1, total]);
|
||||
for (let p = current - 2; p <= current + 2; p++) {
|
||||
if (p > 1 && p < total) pages.add(p);
|
||||
}
|
||||
const sorted = [...pages].sort((a,b)=>a-b);
|
||||
|
||||
let last = 0;
|
||||
const elems = sorted.flatMap(p => {
|
||||
const dots = (last && p - last > 1)
|
||||
? [<li key={`dots-${last}`} className="page-item disabled">
|
||||
<span className="page-link">…</span>
|
||||
</li>]
|
||||
: [];
|
||||
const btn = (
|
||||
<li key={p} className={`page-item ${p===current?'active':''}`}>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={() => p !== current && onPageChange(p)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
last = p;
|
||||
return [...dots, btn];
|
||||
});
|
||||
|
||||
return (
|
||||
<nav>
|
||||
<ul className="pagination justify-content-center">
|
||||
<li className={`page-item ${!pagination.has_previous_page?'disabled':''}`}>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={() => pagination.has_previous_page && onPageChange(current-1)}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
</li>
|
||||
{elems}
|
||||
<li className={`page-item ${!pagination.has_next_page?'disabled':''}`}>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={() => pagination.has_next_page && onPageChange(current+1)}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={tableRef}>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-hover table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>ФИО</th><th>Должность</th><th>Опыт</th><th>Активен</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(d => (
|
||||
<React.Fragment key={d.id}>
|
||||
<tr
|
||||
className={expandedId===d.id?'table-success':''}
|
||||
style={{cursor:'pointer'}}
|
||||
onClick={()=>setExpandedId(expandedId===d.id?null:d.id)}
|
||||
>
|
||||
<td>{d.id}</td>
|
||||
<td>{d.nameString}</td>
|
||||
<td>{d.post}</td>
|
||||
<td>{formatExp(d.experience)}</td>
|
||||
<td>{d.active?'✓':'—'}</td>
|
||||
</tr>
|
||||
{expandedId===d.id && (
|
||||
<tr className="table-success">
|
||||
<td colSpan={5}>
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
navigate(`/specialist/edit/${d.id}`);
|
||||
}}
|
||||
>
|
||||
Редактировать
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{renderPagination()}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useGetStocksQuery } from '/src/api/apiStock';
|
||||
/**/
|
||||
import { useOutsideClick } from '../hooks/useOutsideClick';
|
||||
/**/
|
||||
import { LoadingComponent } from '../components/Placeholders/LoadingComponent';
|
||||
import { ErrorComponent } from '../components/Placeholders/ErrorComponent';
|
||||
|
||||
export const StocksListPage = () => {
|
||||
const [ searchValue, setSearchValue ] = useState( '' );
|
||||
const [ currentPage, setCurrentPage ] = useState( 1 );
|
||||
const [ expandedId, setExpandedId ] = useState( '' );
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const tableRef = useRef( null );
|
||||
useOutsideClick( tableRef, () => setExpandedId( null ));
|
||||
|
||||
const { data: response = {}, isFetching, error: queryError } =
|
||||
useGetStocksQuery( { search: searchValue, page: currentPage } );
|
||||
|
||||
const stocks = response.data
|
||||
? ( response.data.map( init => {
|
||||
const dateStart = new Date( init.startDate );
|
||||
const yearStart = dateStart.getFullYear();
|
||||
const monthStart = String( dateStart.getMonth() + 1 ).padStart( 2, '0' );
|
||||
const dayStart = String( dateStart.getDate() ).padStart( 2, '0' );
|
||||
const hoursStart = String( dateStart.getHours() ).padStart( 2, '0' );
|
||||
const minutesStart = String( dateStart.getMinutes() ).padStart( 2, '0' );
|
||||
const datetimeLocalStart = `${ yearStart }-${ monthStart }-${ dayStart }T${ hoursStart }:${ minutesStart }`;
|
||||
|
||||
const dateEnd = new Date( init.endDate );
|
||||
const yearEnd = dateEnd.getFullYear();
|
||||
const monthEnd = String( dateEnd.getMonth() + 1 ).padStart( 2, '0' );
|
||||
const dayEnd = String( dateEnd.getDate() ).padStart( 2, '0' );
|
||||
const hoursEnd = String( dateEnd.getHours() ).padStart( 2, '0' );
|
||||
const minutesEnd = String( dateEnd.getMinutes() ).padStart( 2, '0' );
|
||||
const datetimeLocalEnd = `${ yearEnd }-${ monthEnd }-${ dayEnd }T${ hoursEnd }:${ minutesEnd }`;
|
||||
|
||||
return ({ ...init, startDate: datetimeLocalStart, endDate: datetimeLocalEnd })
|
||||
}))
|
||||
: [];
|
||||
const pagination = response.pagination || {};
|
||||
|
||||
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
|
||||
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
|
||||
className="page-link"
|
||||
onClick={ () =>
|
||||
pagination.has_previous_page && setCurrentPage(current - 1)
|
||||
}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
</li>
|
||||
{ elements }
|
||||
<li className={`page-item ${ !pagination.has_next_page ? 'disabled' : '' }`}>
|
||||
<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">
|
||||
Акции
|
||||
</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(`/promotions/create`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group flex-grow-1">
|
||||
<label>
|
||||
Поиск
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder=""
|
||||
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>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Начало акции</th>
|
||||
<th>Окончание акции</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
{stocks.map( stock => (
|
||||
<>
|
||||
<tr
|
||||
key={ stock.id }
|
||||
className={ `cursor-pointer${ expandedId === stock.id ? ' table-success' : '' }` }
|
||||
onClick={ () => {
|
||||
if ( expandedId === stock.id ) {
|
||||
setExpandedId( null )
|
||||
return
|
||||
}
|
||||
setExpandedId( stock.id )
|
||||
}}
|
||||
>
|
||||
<td>{ stock.id }</td>
|
||||
<td>{ stock.name }</td>
|
||||
<td>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="form-control cursor-pointer"
|
||||
value={ stock.startDate }
|
||||
readOnly={ true }
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="form-control cursor-pointer"
|
||||
value={ stock.endDate }
|
||||
readOnly={ true }
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{ expandedId === stock.id &&
|
||||
<tr
|
||||
className='table-success cursor-pointer'
|
||||
onClick={ () => {
|
||||
if ( expandedId === stock.id ) {
|
||||
setExpandedId( null )
|
||||
return
|
||||
}
|
||||
setExpandedId( stock.id )
|
||||
}}
|
||||
>
|
||||
<td colSpan={ 4 }>
|
||||
<input
|
||||
type="button"
|
||||
className="btn btn-outline-primary"
|
||||
value="Редактировать"
|
||||
onClick={e => { e.stopPropagation(); navigate(`/promotions/edit/${stock.id}`)}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
</>
|
||||
))}
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{ renderPagination() }
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
export const UserPage = () => {
|
||||
const userUID = localStorage.getItem('admSovamedUserUID');
|
||||
const userRegionId = localStorage.getItem('admSovamedUserRegionId');
|
||||
const regions = useSelector( ( state ) => state.region.regions );
|
||||
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<h1 className="h3 mb-4 text-gray-800">
|
||||
Профиль пользователя
|
||||
</h1>
|
||||
<p>
|
||||
<strong>ID:</strong> {userUID}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Регион:</strong> {regions[userRegionId]}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
|
||||
import { store } from '../../store/store';
|
||||
import { LoginPage } from '../LoginPage';
|
||||
|
||||
test('shows validation error on empty submit', () => {
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<LoginPage />
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /войти/i }));
|
||||
expect(screen.getByText(/пожалуйста, заполните оба поля/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
export const ProtectedRoute = ({ children }) => {
|
||||
const token = useSelector(state => state.auth.token);
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
return children;
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||
import { Provider } from 'react-redux'
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
|
||||
import authReducer from '../../store/slice/authSlice'
|
||||
import { ProtectedRoute } from '../ProtectedRoute'
|
||||
|
||||
describe('ProtectedRoute', () => {
|
||||
const renderWithStore = (preloadedState, initialEntries) => {
|
||||
const store = configureStore({
|
||||
reducer: { auth: authReducer },
|
||||
preloadedState: { auth: preloadedState },
|
||||
})
|
||||
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/protected"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div>Protected Content</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
test('redirects to /login when no token in state', () => {
|
||||
const preloaded = { username: null, token: null, status: 'idle', error: null }
|
||||
renderWithStore(preloaded, ['/protected'])
|
||||
|
||||
expect(screen.getByText('Login Page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders protected content when token is present', () => {
|
||||
const preloaded = { username: 'admin', token: 'fake-token', status: 'succeeded', error: null }
|
||||
renderWithStore(preloaded, ['/protected'])
|
||||
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import authReducer, { loginAsync, logout } from '../authSlice';
|
||||
|
||||
describe('authSlice reducer', () => {
|
||||
const initialState = {
|
||||
username: null,
|
||||
token: null,
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
it('should handle pending', () => {
|
||||
const action = { type: loginAsync.pending.type };
|
||||
const state = authReducer(initialState, action);
|
||||
expect(state).toMatchObject({ status: 'loading', error: null });
|
||||
});
|
||||
|
||||
it('should handle fulfilled', () => {
|
||||
const payload = { username: 'admin', token: 't' };
|
||||
const action = { type: loginAsync.fulfilled.type, payload };
|
||||
const state = authReducer(initialState, action);
|
||||
expect(state).toMatchObject({
|
||||
status: 'succeeded',
|
||||
username: 'admin',
|
||||
token: 't',
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle rejected', () => {
|
||||
const action = { type: loginAsync.rejected.type, error: { message: 'err' } };
|
||||
const state = authReducer(initialState, action);
|
||||
expect(state).toMatchObject({ status: 'failed', error: 'err' });
|
||||
});
|
||||
|
||||
it('should handle logout', () => {
|
||||
const loggedIn = { username: 'u', token: 't', status: 'succeeded', error: null };
|
||||
const state = authReducer(loggedIn, logout());
|
||||
expect(state).toMatchObject(initialState);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
import { API } from '../../api/apiSlice'
|
||||
|
||||
const storedToken = localStorage.getItem('token')
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState: {
|
||||
token: storedToken,
|
||||
user: null,
|
||||
status: 'idle',
|
||||
error: null,
|
||||
},
|
||||
reducers: {
|
||||
clearAuth(state) {
|
||||
state.token = null
|
||||
state.user = null
|
||||
state.status = 'idle'
|
||||
state.error = null
|
||||
localStorage.removeItem('token')
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addMatcher(
|
||||
API.endpoints.login.matchPending,
|
||||
(state) => {
|
||||
state.status = 'loading'
|
||||
state.error = null
|
||||
}
|
||||
)
|
||||
.addMatcher(
|
||||
API.endpoints.login.matchFulfilled,
|
||||
(state, { payload }) => {
|
||||
state.status = 'succeeded'
|
||||
state.token = payload.token
|
||||
state.user = payload.user
|
||||
}
|
||||
)
|
||||
|
||||
.addMatcher(
|
||||
API.endpoints.login.matchRejected,
|
||||
(state, { error }) => {
|
||||
state.status = 'failed'
|
||||
state.error = error?.data?.message || error.error
|
||||
}
|
||||
)
|
||||
|
||||
.addMatcher(
|
||||
API.endpoints.logout.matchFulfilled,
|
||||
(state) => {
|
||||
state.token = null
|
||||
state.user = null
|
||||
state.status = 'idle'
|
||||
}
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
export const { clearAuth } = authSlice.actions
|
||||
export default authSlice.reducer
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
const initialState = {
|
||||
regions: {
|
||||
91: 'Саратов',
|
||||
92: 'Волгоград',
|
||||
93: 'Воронеж',
|
||||
94: 'Краснодар',
|
||||
}
|
||||
}
|
||||
|
||||
const regionSlice = createSlice({
|
||||
name: 'region',
|
||||
initialState,
|
||||
reducers: {
|
||||
},
|
||||
})
|
||||
|
||||
export default regionSlice.reducer
|
||||
export const selectRegions = state => state.region.regions
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
const initialState = {
|
||||
utils: {
|
||||
'ITEMS_PER_PAGE': 50,
|
||||
'FILIALS_COLUMN': [
|
||||
{ key: 'id', label: 'ID', width: '12%' },
|
||||
{ key: 'regionName', label: 'Город', width: '15%' },
|
||||
{ key: 'shortName', label: 'Адрес', width: '73%' },
|
||||
],
|
||||
'DEPARTMENTS_COLUMN': [
|
||||
{ key: 'id', label: 'ID', width: '20%' },
|
||||
{ key: 'name', label: 'Название', width: '80%' },
|
||||
],
|
||||
'RELOAD_TIMEOUT': 1500,
|
||||
},
|
||||
}
|
||||
|
||||
const utilsSlice = createSlice({
|
||||
name: 'util',
|
||||
initialState,
|
||||
reducers: {
|
||||
},
|
||||
})
|
||||
|
||||
export default utilsSlice.reducer
|
||||
export const selectUtils = state => state.util.utils
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user