commit ffd4cf9031ee2a8d5e4466c226b182799d1c0fe0 Author: sova-bootstrap Date: Wed May 27 19:36:33 2026 +0300 chore: initial import for test contour diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..56b91f8 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9084229 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e474971 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6be0fed --- /dev/null +++ b/README.md @@ -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: \ No newline at end of file diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..fcfc5f2 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,6 @@ +export default { + presets: [ + ['@babel/preset-env'], + ['@babel/preset-react', { runtime: 'automatic' }] + ], +}; diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..b196602 --- /dev/null +++ b/docker/entrypoint.sh @@ -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;' diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..b8f9d39 --- /dev/null +++ b/docker/nginx.conf @@ -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"; + } +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..7f89791 --- /dev/null +++ b/eslint.config.js @@ -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, + }, + }, + }, +]; diff --git a/index.html b/index.html new file mode 100644 index 0000000..22ff2f9 --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + + + + Admin panel + + + +
+ + + diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..164e746 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,11 @@ +export default { + testEnvironment: 'jsdom', + transform: { + '^.+\\.(js|jsx)?$': 'babel-jest', + }, + moduleFileExtensions: ['js', 'jsx'], + moduleNameMapper: { + '\\.(css|scss)$': 'identity-obj-proxy', + }, + setupFilesAfterEnv: ['/jest.setup.js'], +} diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..4b78857 --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,6 @@ +import { TextEncoder, TextDecoder } from 'util'; + +global.TextEncoder = TextEncoder; +global.TextDecoder = TextDecoder; + +import '@testing-library/jest-dom'; diff --git a/package.json b/package.json new file mode 100644 index 0000000..2a79f31 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/env.js b/public/env.js new file mode 100644 index 0000000..c7a5eeb --- /dev/null +++ b/public/env.js @@ -0,0 +1,3 @@ +window.__ENV__ = window.__ENV__ || { + API_BASE_URL: 'http://localhost:8081', +}; diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..77470cb --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..8b7cf4a --- /dev/null +++ b/src/App.jsx @@ -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 ( + + + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + + + ) +} + +export default App; diff --git a/src/api/apiCertificate.js b/src/api/apiCertificate.js new file mode 100644 index 0000000..053a0e5 --- /dev/null +++ b/src/api/apiCertificate.js @@ -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; diff --git a/src/api/apiContent.js b/src/api/apiContent.js new file mode 100644 index 0000000..271c9a7 --- /dev/null +++ b/src/api/apiContent.js @@ -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, + }, +} diff --git a/src/api/apiDepartment.js b/src/api/apiDepartment.js new file mode 100644 index 0000000..93b3b87 --- /dev/null +++ b/src/api/apiDepartment.js @@ -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 diff --git a/src/api/apiFilial.js b/src/api/apiFilial.js new file mode 100644 index 0000000..5e9061d --- /dev/null +++ b/src/api/apiFilial.js @@ -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 diff --git a/src/api/apiIDoctor.js b/src/api/apiIDoctor.js new file mode 100644 index 0000000..81d17a5 --- /dev/null +++ b/src/api/apiIDoctor.js @@ -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; diff --git a/src/api/apiKodoper.js b/src/api/apiKodoper.js new file mode 100644 index 0000000..c5520d3 --- /dev/null +++ b/src/api/apiKodoper.js @@ -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; diff --git a/src/api/apiLocation.js b/src/api/apiLocation.js new file mode 100644 index 0000000..f2a39a0 --- /dev/null +++ b/src/api/apiLocation.js @@ -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 diff --git a/src/api/apiSlice.js b/src/api/apiSlice.js new file mode 100644 index 0000000..1058946 --- /dev/null +++ b/src/api/apiSlice.js @@ -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; diff --git a/src/api/apiSpecialist.js b/src/api/apiSpecialist.js new file mode 100644 index 0000000..989f7f1 --- /dev/null +++ b/src/api/apiSpecialist.js @@ -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 diff --git a/src/api/apiStock.js b/src/api/apiStock.js new file mode 100644 index 0000000..8be3fad --- /dev/null +++ b/src/api/apiStock.js @@ -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; diff --git a/src/assets/icon.png b/src/assets/icon.png new file mode 100644 index 0000000..79af38c Binary files /dev/null and b/src/assets/icon.png differ diff --git a/src/assets/image-placeholder.png b/src/assets/image-placeholder.png new file mode 100644 index 0000000..021ca58 Binary files /dev/null and b/src/assets/image-placeholder.png differ diff --git a/src/assets/logo.png b/src/assets/logo.png new file mode 100644 index 0000000..e28063b Binary files /dev/null and b/src/assets/logo.png differ diff --git a/src/assets/photo-placeholder.png b/src/assets/photo-placeholder.png new file mode 100644 index 0000000..afa8541 Binary files /dev/null and b/src/assets/photo-placeholder.png differ diff --git a/src/assets/video-placeholder.png b/src/assets/video-placeholder.png new file mode 100644 index 0000000..77e3218 Binary files /dev/null and b/src/assets/video-placeholder.png differ diff --git a/src/components/Button/Button.jsx b/src/components/Button/Button.jsx new file mode 100644 index 0000000..4d330e5 --- /dev/null +++ b/src/components/Button/Button.jsx @@ -0,0 +1,13 @@ +import styles from './Button.module.scss'; + +export const Button = ({ children, onClick, type = 'button', className = '' }) => { + return ( + + ); +}; \ No newline at end of file diff --git a/src/components/Button/Button.module.scss b/src/components/Button/Button.module.scss new file mode 100644 index 0000000..957e1ad --- /dev/null +++ b/src/components/Button/Button.module.scss @@ -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; +} \ No newline at end of file diff --git a/src/components/Docs/Certificates.jsx b/src/components/Docs/Certificates.jsx new file mode 100644 index 0000000..bfcbfa6 --- /dev/null +++ b/src/components/Docs/Certificates.jsx @@ -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) => ( +
+
+ + updateField(idx, 'name', e.target.value)} + /> +
+ +
+
+ + updateField(idx, 'description', newVal)} + /> +
+
+
+ updateField(idx, 'active', e.target.checked)} + /> + +
+
+ + replaceImage(e, idx)} + /> + {cert.picture && ( + Сертификат e.currentTarget.src = '/placeholder.png'} + /> + )} +
+
+
+
+ +
+
+ ))} + + + + ); +} diff --git a/src/components/Docs/Portfolio.jsx b/src/components/Docs/Portfolio.jsx new file mode 100644 index 0000000..ff8897f --- /dev/null +++ b/src/components/Docs/Portfolio.jsx @@ -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) => ( +
+
+ + updateField(idx, 'name', e.target.value)} + /> +
+ +
+
+ + updateField(idx, 'description', newVal)} + /> +
+
+
+ updateField(idx, 'active', e.target.checked)} + /> + +
+
+ + replaceImage(e, idx)} + /> + {cert.picture && ( + Портфолио e.currentTarget.src = '/placeholder.png'} + /> + )} +
+
+
+
+ +
+
+ ))} + + + + ); +} diff --git a/src/components/Docs/Stocks.jsx b/src/components/Docs/Stocks.jsx new file mode 100644 index 0000000..edd1471 --- /dev/null +++ b/src/components/Docs/Stocks.jsx @@ -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) => ( +
+
+ + updateField(idx, 'name', e.target.value)} + // readOnly={true} + /> +
+ +
+ + updateField(idx, 'anons', newVal)} + readOnly={true} + /> +
+
+ + updateField(idx, 'contnet', newVal)} + readOnly={true} + /> +
+
+ +
+
+ +
+ + { + if (startDateInputRef.current?.showPicker) { + startDateInputRef.current.showPicker(); + } + }} + onChange={e => updateField(idx, 'startDate', e.target.value)} + /> +
+ +
+ + { + if (endDateInputRef.current?.showPicker) { + endDateInputRef.current.showPicker(); + } + }} + onChange={e => updateField(idx, 'endDate', e.target.value)} + /> +
+ + +
+ +
+ {cert.picture && ( + Портфолио e.currentTarget.src = '/placeholder.png'} + /> + )} +
+
+ +
+ +
+ +
+
+ ))} + + ); +} diff --git a/src/components/Editors/CertEditor.jsx b/src/components/Editors/CertEditor.jsx new file mode 100644 index 0000000..35007b3 --- /dev/null +++ b/src/components/Editors/CertEditor.jsx @@ -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 ( + setContent(newContent)} + onChange={newContent => {}} + /> + ); +} \ No newline at end of file diff --git a/src/components/Editors/TextEditor.jsx b/src/components/Editors/TextEditor.jsx new file mode 100644 index 0000000..0472ee2 --- /dev/null +++ b/src/components/Editors/TextEditor.jsx @@ -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 ( + setContent(newContent)} + onChange={newContent => {}} + /> + ); +} \ No newline at end of file diff --git a/src/components/Forms/EditElementForm.jsx b/src/components/Forms/EditElementForm.jsx new file mode 100644 index 0000000..a22b613 --- /dev/null +++ b/src/components/Forms/EditElementForm.jsx @@ -0,0 +1,48 @@ +export const EditElementForm = ({ + navigateBack, + header, + handleSave, + handleDelete = null, + isAddSpecialist = false, + children + }) => ( +
+ +
+
+
+ { header } +
+
+
+ { children } +
+ + + {!isAddSpecialist && + } +
+
+
+
+ ); \ No newline at end of file diff --git a/src/components/Input/Input.jsx b/src/components/Input/Input.jsx new file mode 100644 index 0000000..513f999 --- /dev/null +++ b/src/components/Input/Input.jsx @@ -0,0 +1,11 @@ +export const Input = ({ value, onChange, placeholder, type = 'text' }) => { + return ( + onChange(e.target.value)} + placeholder={placeholder} + /> + ); +}; diff --git a/src/components/Input/PhoneInput.jsx b/src/components/Input/PhoneInput.jsx new file mode 100644 index 0000000..a6e8ea1 --- /dev/null +++ b/src/components/Input/PhoneInput.jsx @@ -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 ( + { + if (typeof onChange === 'function') { + onChange(val); + } else { + setInternal(val); + } + }} + ref={ref} + placeholder={placeholder} + className={className} + {...rest} + /> + ); +}); diff --git a/src/components/Input/TagKodoperStatic.jsx b/src/components/Input/TagKodoperStatic.jsx new file mode 100644 index 0000000..be80249 --- /dev/null +++ b/src/components/Input/TagKodoperStatic.jsx @@ -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 ( +
inputRef.current.focus()} + disabled={ disabled } + > + {tags.map((tag, idx) => ( + + {tag} + + ))} + +
+ ); +} \ No newline at end of file diff --git a/src/components/Input/TagStaticInput.jsx b/src/components/Input/TagStaticInput.jsx new file mode 100644 index 0000000..ccbcdd8 --- /dev/null +++ b/src/components/Input/TagStaticInput.jsx @@ -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 ( +
+ {tags.map((tag, idx) => ( + + {tag} + + + ))} + +
+ ); +} \ No newline at end of file diff --git a/src/components/Input/Taginput.jsx b/src/components/Input/Taginput.jsx new file mode 100644 index 0000000..9bbf321 --- /dev/null +++ b/src/components/Input/Taginput.jsx @@ -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 ( +
inputRef.current.focus()} + disabled={ disabled } + > + {tags.map((tag, idx) => ( + + {tag} + { !disabled && + + } + + ))} + + { !disabled && + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + /> + } +
+ ); +} \ No newline at end of file diff --git a/src/components/Modals/DcodeModal.jsx b/src/components/Modals/DcodeModal.jsx new file mode 100644 index 0000000..a115470 --- /dev/null +++ b/src/components/Modals/DcodeModal.jsx @@ -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 ( +
{ + toggleSelect(item) + }} + > +
    +
  • dcode: {item.dcode}
  • +
  • ФИО: {item.name}
  • +
  • + ID отделения: {item.department} +
  • +
  • + Отделение: {departments.find(department => Number(department.id) === Number(item.department))?.name} +
  • + +
  • Адрес: {getAdress(item.filial)}
  • +
  • + + +
  • + +
+ +
+ ); + }; + + return ReactDOM.createPortal( + <> +
+
+
+
+ +
+
Добавить расписание из Инфоклиники
+
+ +
+ +
+
+ + +
+
+ + { + setSearchValue(e.target.value) + }} + placeholder="Введите не менее 3 символов..." + /> +
+
+ + +
+
Расписание из Инфоклиники:
+
+
+ {selectedItems.map(item => ( +
+
    +
  • dcode: {item.dcode}
  • +
  • ФИО: {item.name}
  • +
  • Отделение: {departments.find(department => Number(department.id) === Number(item.department))?.name ?? 'не найдено'}
  • +
  • Адрес: {getAdress(item.filial)}
  • +
  • + + +
  • +
+ +
+ ))} +
+
+
+ +
+ + {searchValue.length < 3 ? ( + Введите не менее 3 символов для поиска + ) : isFetching ? ( +

Идёт поиск…

+ ) : error ? ( +

Ошибка при запросе

+ ) : items.length > 0 ? ( + <> +
Найдено:
+
    + {items.map((item, inx) => renderItem(item, inx))} +
+ + ) : ( +

Не найдено

+ )} +
+ +
+ + +
+ +
+
+
+ , + document.body + ); +} + +export default DcodeModal; diff --git a/src/components/Modals/KodoperModal.jsx b/src/components/Modals/KodoperModal.jsx new file mode 100644 index 0000000..e058604 --- /dev/null +++ b/src/components/Modals/KodoperModal.jsx @@ -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( +
  • + +
  • + ); + } + items.push( +
  • + +
  • + ); + last = page; + }); + + return ( + + ); +}; + + return ReactDOM.createPortal( + <> +
    +
    +
    +
    + +
    +
    Добавить услугу
    +
    + +
    + + +
    + + +
    + + +
    +
    + + { setSearchValue(e.target.value); setCurrentPage(1); }} + placeholder="Введите не менее 3 символов..." + /> +
    +
    + + +
    +
    Выбранные уникальные медицинские коды:
    +
    +
    + {selectedItems.map((code, i) => ( + toggleSelect({ kodoper: code })} + style={{ fontSize: '1rem', cursor: 'pointer' }} + > + {code} + + + ))} +
    +
    +
    + +
    + + {searchValue.length < 3 ? ( + Введите не менее 3 символов для поиска + ) : isFetching ? ( +

    Идёт поиск…

    + ) : queryError ? ( +

    Ошибка при запросе

    + ) : ( + <> +
    + + + + + + + + + + {items.map((item, idx) => ( + toggleSelect(item)}> + + + + + ))} + +
    Мед.кодУслугаСтоимость
    {item.kodoper}{item.schname}{item.priceInfo.price} ₽
    +
    + {renderPagination()} + + )} +
    + +
    + + +
    + +
    +
    +
    + , + document.body + ); +} + +export default KodoperModal; diff --git a/src/components/Modals/Modal.jsx b/src/components/Modals/Modal.jsx new file mode 100644 index 0000000..127f566 --- /dev/null +++ b/src/components/Modals/Modal.jsx @@ -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( + <> +
    + +
    +
    +
    +
    + {title &&
    {title}
    } +
    +
    {children}
    + {hasButtons && +
    + { hasDangerButton && + + } + +
    + } +
    +
    +
    + , + document.body + ); +} + +export default Modal; diff --git a/src/components/Modals/ResponseModals.jsx b/src/components/Modals/ResponseModals.jsx new file mode 100644 index 0000000..ed67d97 --- /dev/null +++ b/src/components/Modals/ResponseModals.jsx @@ -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 ( + + + + ) + + case 'error': + return ( + setModal( undefined )} + > + + + ) + + case 'success': + return ( + +

    + Изменения успешно внесены. +

    +
    + ) + + default: + return null + } +}; \ No newline at end of file diff --git a/src/components/Modals/StockModal.jsx b/src/components/Modals/StockModal.jsx new file mode 100644 index 0000000..33c0d69 --- /dev/null +++ b/src/components/Modals/StockModal.jsx @@ -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( +
  • + +
  • + ); + } + elems.push( +
  • + +
  • + ); + last = page; + }); + + return ( + + ); + }; + 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 ( +
    { + //console.log(item) + toggleSelect(item) + }} + > +
      +
    • dcode: {item.dcode}
    • +
    • ФИО: {item.name}
    • +
    • Отделение: {departments.find(department => Number(department.id) === Number(item.department))?.name}
    • +
    • Адрес: {getAdress(item.filial)}
    • +
    • + + +
    • + +
    + +
    + ); + }; + + return ReactDOM.createPortal( + <> +
    +
    +
    +
    + +
    +
    Добавить акции
    +
    + +
    + +
    +
    + + { + setSearchValue(e.target.value); + setCurrentPage(1); + }} + /> +
    +
    + +
    +
    Выбранные акции:
    +
    +
    + {selectedObjs.map((stock) => ( + toggleSelect(stock)} + style={{ fontSize: '1rem', cursor: 'pointer' }} + > + {stock.name} + + + ))} + {/*selectedItems.map(item => ( +
    +
      +
    • dcode: {item.dcode}
    • +
    • ФИО: {item.name}
    • +
    • Отделение: {departments.find(department => Number(department.id) === Number(item.department))?.name ?? 'не найдено'}
    • +
    • Адрес: {getAdress(item.filial)}
    • +
    • + + +
    • +
    + +
    + ))*/} +
    +
    +
    + +
    + + {isFetching ? ( +

    Загрузка...

    + ) : queryError ? ( +

    Ошибка при загрузке: {String(queryError)}

    + ) : ( + <> +
    + + + + + + + + + + + {items.map(stock => ( + stock.id === id) ? 'table-primary' : ''} style={{ cursor: 'pointer'}} + onClick={() => toggleSelect(stock)} + > + + + + + + ))} + +
    IDНазваниеНачало акцииОкончание акции
    {stock.id}{stock.name} + toggleSelect(stock)} + readOnly={true} + /> + + toggleSelect(stock)} + readOnly={true} + /> +
    +
    + + {renderPagination()} + + )} +
    + +
    + + +
    + +
    +
    +
    + , + document.body + ); +} diff --git a/src/components/Navbar/Navbar.jsx b/src/components/Navbar/Navbar.jsx new file mode 100644 index 0000000..7dd3f23 --- /dev/null +++ b/src/components/Navbar/Navbar.jsx @@ -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 ( + + ) +}; diff --git a/src/components/Navbar/Navbar.module.scss b/src/components/Navbar/Navbar.module.scss new file mode 100644 index 0000000..3debd97 --- /dev/null +++ b/src/components/Navbar/Navbar.module.scss @@ -0,0 +1,3 @@ +.navbar-background { + background-color: #e9f7ef !important; +} \ No newline at end of file diff --git a/src/components/Paginations/PageNav.jsx b/src/components/Paginations/PageNav.jsx new file mode 100644 index 0000000..2d21761 --- /dev/null +++ b/src/components/Paginations/PageNav.jsx @@ -0,0 +1,45 @@ +import React from 'react'; + +export const PageNav = React.memo(({ currentPage, totalPages, onPageChange }) => + totalPages === 1 ? null : ( + + ) +); \ No newline at end of file diff --git a/src/components/Placeholders/ErrorComponent.jsx b/src/components/Placeholders/ErrorComponent.jsx new file mode 100644 index 0000000..981e049 --- /dev/null +++ b/src/components/Placeholders/ErrorComponent.jsx @@ -0,0 +1,7 @@ +export const ErrorComponent = () => { + return ( +
    + Ошибка загрузки +
    + ); +}; \ No newline at end of file diff --git a/src/components/Placeholders/LoadingComponent.jsx b/src/components/Placeholders/LoadingComponent.jsx new file mode 100644 index 0000000..3b31cc2 --- /dev/null +++ b/src/components/Placeholders/LoadingComponent.jsx @@ -0,0 +1,11 @@ +export const LoadingComponent = () => { + return ( +
    +
    + + Загрузка... + +
    +
    + ); +}; \ No newline at end of file diff --git a/src/components/Placeholders/NotFindElement.jsx b/src/components/Placeholders/NotFindElement.jsx new file mode 100644 index 0000000..5b6b3dd --- /dev/null +++ b/src/components/Placeholders/NotFindElement.jsx @@ -0,0 +1,10 @@ +export const NotFindElement = ({ message, navigateBack }) => ( +
    +
    + {message} +
    + +
    +); \ No newline at end of file diff --git a/src/components/Sidebar/Sidebar.jsx b/src/components/Sidebar/Sidebar.jsx new file mode 100644 index 0000000..954808e --- /dev/null +++ b/src/components/Sidebar/Sidebar.jsx @@ -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 ( +
      + {links.map(({ to, icon, label, end }) => ( + + ))} +
    + ) +} \ No newline at end of file diff --git a/src/components/SidebarNavItem/SidebarNavItem.jsx b/src/components/SidebarNavItem/SidebarNavItem.jsx new file mode 100644 index 0000000..5bac31f --- /dev/null +++ b/src/components/SidebarNavItem/SidebarNavItem.jsx @@ -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 ( +
  • + + `nav-link${isActive ? ' active' : ''} ${styles['sidebar-nav-link']}` + } + onClick={onClick} + > + + {label} + +
  • + ) +} \ No newline at end of file diff --git a/src/components/SidebarNavItem/SidebarNavItem.module.scss b/src/components/SidebarNavItem/SidebarNavItem.module.scss new file mode 100644 index 0000000..f10e337 --- /dev/null +++ b/src/components/SidebarNavItem/SidebarNavItem.module.scss @@ -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; +} \ No newline at end of file diff --git a/src/components/Table/FilterBar.jsx b/src/components/Table/FilterBar.jsx new file mode 100644 index 0000000..3d844df --- /dev/null +++ b/src/components/Table/FilterBar.jsx @@ -0,0 +1,53 @@ +import React from "react"; + +export const FilterBar = React.memo(({ regions, filials, regionId, filialId, searchValue, onChange, goAddSpecialist }) => ( +
    e.preventDefault()} + > +
    + { e.stopPropagation(); goAddSpecialist() }} + /> +
    +
    + + + +
    + +
    + + +
    + +
    + + onChange({ searchValue: e.target.value, page: 1 })} + /> +
    + +
    +)); \ No newline at end of file diff --git a/src/components/Table/TBody.jsx b/src/components/Table/TBody.jsx new file mode 100644 index 0000000..c679328 --- /dev/null +++ b/src/components/Table/TBody.jsx @@ -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 ( + <> + + {columns.map((column, index) => ( + + { maskElement(element[column.key]) } + + ))} + + {isExpanded && pageType !== 'specialists' && ( + + + { e.stopPropagation(); onEdit(element.id); }} + /> + + + )} + {( pageType === 'specialists' && isExpanded ) && ( + + + { e.stopPropagation(); onEdit(element.id); }} + /> + + + { e.stopPropagation(); onWatch(element.id); }} + /> + + + )} + + ); +}); + +export const TBody = ({ elements, columns, expandedId, setExpandedId, goEdit, goWatch, pageType }) => { + return ( + + { + elements.map(element => ( + )) + } + + ); +} \ No newline at end of file diff --git a/src/components/Table/THead.jsx b/src/components/Table/THead.jsx new file mode 100644 index 0000000..fcb3a26 --- /dev/null +++ b/src/components/Table/THead.jsx @@ -0,0 +1,19 @@ +export const THead = ({ columns, sortBy, sortDirection, handleSort }) => ( + + + {columns.map(({ key, label, width }) => ( + handleSort(key)} + > + {label} + {sortBy === key && ( + + )} + + ))} + + +) \ No newline at end of file diff --git a/src/components/UserBurger/UserBurger.jsx b/src/components/UserBurger/UserBurger.jsx new file mode 100644 index 0000000..cfde5c8 --- /dev/null +++ b/src/components/UserBurger/UserBurger.jsx @@ -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 ( +
      +
    • + + + {open && ( +
      + setOpen(false)} + > + + Профиль + +
      + +
      + )} +
    • +
    + ) +} diff --git a/src/config/api.js b/src/config/api.js new file mode 100644 index 0000000..6a6ff0d --- /dev/null +++ b/src/config/api.js @@ -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}` +} diff --git a/src/config/contentResources.js b/src/config/contentResources.js new file mode 100644 index 0000000..02795e1 --- /dev/null +++ b/src/config/contentResources.js @@ -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) diff --git a/src/hooks/useLostLocations.jsx b/src/hooks/useLostLocations.jsx new file mode 100644 index 0000000..c054a21 --- /dev/null +++ b/src/hooks/useLostLocations.jsx @@ -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, + }; +} \ No newline at end of file diff --git a/src/hooks/useNewSpecialistId.jsx b/src/hooks/useNewSpecialistId.jsx new file mode 100644 index 0000000..aa276bc --- /dev/null +++ b/src/hooks/useNewSpecialistId.jsx @@ -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 +} diff --git a/src/hooks/useOutsideClick.jsx b/src/hooks/useOutsideClick.jsx new file mode 100644 index 0000000..2ccbc31 --- /dev/null +++ b/src/hooks/useOutsideClick.jsx @@ -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]); +} diff --git a/src/hooks/useSortedPaginated.jsx b/src/hooks/useSortedPaginated.jsx new file mode 100644 index 0000000..9e5453a --- /dev/null +++ b/src/hooks/useSortedPaginated.jsx @@ -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 }; +} \ No newline at end of file diff --git a/src/hooks/useSorting.jsx b/src/hooks/useSorting.jsx new file mode 100644 index 0000000..5cff5bc --- /dev/null +++ b/src/hooks/useSorting.jsx @@ -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 }; +} \ No newline at end of file diff --git a/src/hooks/useSpecialist.jsx b/src/hooks/useSpecialist.jsx new file mode 100644 index 0000000..78f7d38 --- /dev/null +++ b/src/hooks/useSpecialist.jsx @@ -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, + }; +} \ No newline at end of file diff --git a/src/hooks/useSpecialistFilter.jsx b/src/hooks/useSpecialistFilter.jsx new file mode 100644 index 0000000..f788398 --- /dev/null +++ b/src/hooks/useSpecialistFilter.jsx @@ -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]); +} diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..046f08a --- /dev/null +++ b/src/main.jsx @@ -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( + + + +); \ No newline at end of file diff --git a/src/pages/AddSpecialistPage.jsx b/src/pages/AddSpecialistPage.jsx new file mode 100644 index 0000000..34a8acf --- /dev/null +++ b/src/pages/AddSpecialistPage.jsx @@ -0,0 +1,1469 @@ +import React, {useEffect, useState, useMemo, useRef} from 'react' +import {useParams, useNavigate} from 'react-router-dom' +import DatePicker from "react-datepicker"; + +import { useUpdateSpecialistMutation, useDeleteSpecialistMutation, useUploadSpecialistPictureMutation, useCreateSpecialistMutation } from '../api/apiSpecialist' +import { useCreateLocationMutation, useUpdateLocationMutation, useDeleteLocationMutation } from '/src/api/apiLocation.js' +import { + useCreateCertificateMutation, + useUpdateCertificateMutation, + useUploadSertificatePictureMutation, + useDeleteCertificateMutation, +} from '/src/api/apiCertificate.js' +import { useGetFilialsQuery } from '../api/apiFilial'; +import { useGetDepartmentsQuery } from '../api/apiDepartment'; +import { useAddSpecialistMutation, useRemoveSpecialistMutation } from '../api/apiStock' +import { useSpecialist } from '../hooks/useSpecialist'; +// +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 { TagInput } from '../components/Input/Taginput'; +import { TagStaticInput } from '../components/Input/TagStaticInput'; +import { TagKodoperStaticInput } from '../components/Input/TagKodoperStatic'; +import { TextEditor } from '../components/Editors/TextEditor' +import Modal from '../components/Modals/Modal'; +import DcodeModal from '../components/Modals/DcodeModal'; +import KodoperModal from '../components/Modals/KodoperModal'; +import { CertificatesForm } from '../components/Docs/Certificates'; +import { PortfolioForm } from '../components/Docs/Portfolio'; +import { StocksForm } from '../components/Docs/Stocks' +import { StockModal } from '../components/Modals/StockModal' +import PHOTO_PLACEHOLDER from '../assets/photo-placeholder.png' + + + const regionOptions = { + 91: 'Саратов' , + 92: 'Волгоград', + 93: 'Воронеж', + 94: 'Краснодар', + }; + + const sTypeOptions = { + 0: 'Взрослый врач', + 1: 'Детский врач', + 2: 'Администрация', + 3: 'Стоматология', + 4: 'Мед. сестра', + }; + + const getYear = (date) => { + const dateObject = new Date(date); + if (isNaN(dateObject.getFullYear())) return null + return String(dateObject.getFullYear()) + } + +const formatCategory = (rawCategory) => { + if (!rawCategory) return 'Не указано' + const categoryLowerCase = rawCategory.toLowerCase(); + switch (categoryLowerCase) { + case 'высшая': + case 'ведущий специалист': + return 'Высшая'; + case 'первая': + case 'главный специалист': + return 'Первая'; + case 'вторая': + return 'Вторая'; + default: + return 'Не указано' + } +} + +export const AddSpecialistPage = () => { + + const navigate = useNavigate(); + const navigateBack = () => navigate( `/specialist` ); + + + const dateInputRef = useRef(null); + const patientAgeInputRef = useRef(null); + + const [ locations, setLocations ] = useState([]); + const [ initialLocations, setInitialLocations ] = useState([]); + + const setFromRawLocations = (rawLocations) => { + const selectedDcodes = [...new Set(rawLocations.map(({dcode}) => dcode))]; + const arr = selectedDcodes.filter(dcode => !dcodes.includes(dcode)).map(dc => String(dc)); + setDcodes(arr) + setLocations(rawLocations) + + } + + const [ stocks, setStocks ] = useState([]); + const [ stocksFromChild, setStocksFromChild ] = useState([]); + + const [ anons, setAnons ] = useState(''); + const [ content, setContent ] = useState(''); + + const [displayKodoper, setDisplayKodoper] = useState([]); + + const [errors, setErrors] = useState({ + lastName: '', + firstName: '', + middleName: '', + videoUrl: '', + videoCardUrl: '', + patientAge: '', + prodoctorLink: '', + }); + + const categoryList = [ + 'Не указано', + 'Вторая', + 'Первая', + 'Высшая', + ]; + const categoryParcer = { + 'Ведущий специалист': 'Высшая', + 'Главный специалист': 'Первая', + 'Вторая': 'Вторая', + } + + const [isModalKodopers, setModalKodopers] = useState(false); + const [kodopers, setKodopers] = useState([]); + const [sertificates, setSertificates] = useState([]); + const [certificates, setCertificates] = useState([]); + const [portfolio, setPortfolio] = useState([]); + + const [ form, setForm ] = useState({ + nameString: '', + lastName: '', + firstName: '', + middleName: '', + active: false, // boolean + displaySchedule: null, // boolean + // + hideSchedule: false, // booleal + // + alias: '', // string + post: '', // string + experience: undefined, // number + sType: null, // number + regionId: 91, // number + anons: '', // string + add redactor + content: '', // string + add redactor + tags: [], // json + highlightedTags: [], // json + videoUrl: '', // string + degree: '', + videoCardUrl: '', // string + scheduleText: '', + //isCastom: false, // has autoupdate - boolean + category: '', // +++++++ + previewPicture: PHOTO_PLACEHOLDER, // string + filialId: null, // from locations.locations[0].filial + kodoper: null, // <<<---??? + prodoctor: false, + prodoctorLink: '', + prodoctorText: '', + onlyOnlineMode: false, + // + isChildrenDoctor: undefined, // from sType + patientAge: undefined, // integer + initStocks: [], + //isCastomChecker: undefined, + // + isLeadingSpecialist: false, + isChiefSpecialist: false, + education: '', + academicDegree: '', + professionalCompetencies:'', + advancedTraining: '', + certificates: '', + certificatesGallery: [], + adultsReception: '', + cost: '', + hasPromotion: false, + onlineConsultationLink: '', + onlyOnline: false, + hasProDoctorsAward: false, + proDoctorsAwardText: '', + proDoctorsAwardLink: '', + postTags: [], + operationPhotoUrl: '', + }); + + const updateField = ( key, value ) => { + setForm(prev => ({ ...prev, [key]: value })); + }; + + const [isModalStocks, setModalStocks] = useState(false); + const [displayedStocks, setDisplayStocks] = useState([]); + + const regexCyrillic = /^[А-ЯЁ][а-яё]+$/; + const regexRutube = /^https:\/\/rutube\.ru\/.*$/; + const regexProdoctor = /^https:\/\/prodoctorov\.ru\/.*$/; + + const validateField = (field, value) => { + let error = ''; + const currentYear = new Date().getFullYear(); + switch (field) { + case 'lastName': + case 'firstName': + case 'middleName': + if (!value) error = 'Обязательное поле'; + else if (!regexCyrillic.test(value)) error = 'Только кириллица, с заглавной буквы'; + break; + case 'experience': + if (!value) { + error = 'Обязательное поле'; + } else if (Number(getYear(value)) > Number(currentYear)) { + error = 'Год должен быть не больше текущего'; + } + break; + case 'videoUrl': + case 'videoCardUrl': + if (value && !regexRutube.test(value)) error = 'Должно быть ссылкой с rutube'; + break; + case 'prodoctorLink': + if (form.prodoctor) { + if (!value) { + error = 'Укажите ссылку'; + } else { + if (value && !regexProdoctor.test(value)) error = 'Должно быть ссылкой с prodoctorov.ru'; + } + } else { + error = '' + } + break; + case 'patientAge': + if (form.isChildrenDoctor) { + if ( !value ) { + error = 'Укажите возраст'; + } else { + const num = Number(value); + if (!Number.isInteger(num) || num < 0 || num > 18) error = 'Возраст не может быть меньше 0 и больше 18'; + } + } else { + error = '' + } + break; + } + console.log(field, error) + setErrors(prev => ({ ...prev, [field]: error })); + }; + + const handleBlur = e => { + const { name, value } = e.target; + validateField(name, value); + }; + + const isFormValid = () => { + ['lastName', 'firstName', 'middleName', 'videoUrl', 'videoCardUrl', 'patientAge', 'prodoctorLink', 'experience'].forEach(f => validateField(f, form[f])); + + const a = form.prodoctor ? ( form.prodoctorLink.length > 0 ) : true; + const b = form.isChildrenDoctor ? ( form.patientAge ) : true; + + return Object.entries(errors).every( + ([key, err]) => { + return !err; + } + ) && form.lastName && form.firstName && form.middleName && a && b && form.experience; + }; + + + const [ tags, setTags ] = useState([]); + const [ highlightedTags, setHighlightedTags ] = useState([]); + const [ postTags, setPostTags ] = useState([]); + const [ dcodes, setDcodes ] = useState([]); + + const photoInputRef = useRef(null) + const photoOperationInputRef = useRef(null) + const certificatesGalleryInputRef = useRef(null) + + const [createSpecialist, { isLoading: isUpdating, isError: updateError }] = + useCreateSpecialistMutation(); + const [uploadPicture, { isLoadingPicture, isErrorOicture }] = useUploadSpecialistPictureMutation(); + const [deleteSpecialist, { isLoading: isDeleting, isError: deleteError }] = + useDeleteSpecialistMutation(); + + const [createLocation] = useCreateLocationMutation(); + const [updateLocation]= useUpdateLocationMutation(); + const [deleteLocation] = useDeleteLocationMutation(); + + const [addSpecialistToStock] = useAddSpecialistMutation(); + const [removeSpecialistToStock] = useRemoveSpecialistMutation(); + + const [createCertifcate] = useCreateCertificateMutation(); + const [updateCertifcate] = useUpdateCertificateMutation(); + const [uploadCertifcatePicture] = useUploadSertificatePictureMutation(); + const [deleteCertifcate] = useDeleteCertificateMutation(); + + const filialsQuery = useGetFilialsQuery(); + const filials = filialsQuery.data ?? []; + + const departmentsQuery = useGetDepartmentsQuery(); + const departments = departmentsQuery.data ?? []; + + const filialsList = useMemo(() => { + const arr = filials?.data ?? []; + return arr.map(({ fid, regionId, shortName }) => ({ id: fid, regionId, shortName })); + }, [filials?.data]); + + const departmentsList = useMemo(() => { + const arr = departments?.data ?? []; + return arr.map(({ did, name }) => ({ id: did, name })); + }, [departments?.data]); + + const [previewFile, setPreviewile] = useState(null) + + const isValidImage = (file) => { + // Проверяем MIME‑тип + 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('previewPicture', window.URL.createObjectURL(file)); + setPreviewile(file); + } + + const handlePhotoOperationUpload = () => { + const file = photoOperationInputRef.current.files[0] + if (!file) return window.alert('Файл не выбран') + updateField('operationPhotoUrl', { file, url: window.URL.createObjectURL(file) }); + } + + const [images, setImages] = useState([]); + + const handleCertificatesGalleryUpload = (e) => { + // const file = certificatesGalleryInputRef.current.files[0] + const files = Array.from(e.target.files); + if (!files) return window.alert('Файл не выбран') + const previews = files.map(file => { + return { + file, + url: window.URL.createObjectURL(file), + }; + }); + setImages(prew => [...prew, ...previews]); + + + // if (!file) return window.alert('Файл не выбран') + } + + const removeImage = (index) => { + setImages(prev => { + const img = prev[index]; + if (img.url && window.URL.revokeObjectURL) { + window.URL.revokeObjectURL(img.url); + } + return prev.filter((_, i) => i !== index); + }); + }; + + const removeOperationPhoto = () => { + updateField('operationPhotoUrl', {}); + }; + + const handleWrapper = () => { + if (!isFormValid()) { + window.alert('Пожалуйста, исправьте ошибки в форме'); + return; + } + + handleSave(); + } + + const [certsFromChild, setCertsFromChild] = useState([]); + // setPortfolioFromChild + const [portfolioFromChild, setPortfolioFromChild] = useState([]); + + const handleSave = async () => { + if (!isFormValid()) { + window.alert('Пожалуйста, исправьте ошибки в форме'); + return; + } + + const getDataCategory = () => { + const a = Object.keys(categoryParcer).find(categoryKey => categoryParcer[categoryKey] === form.category) + return a ?? null; + } + + + + const arr = []; + + dcodes.map(dcode => { + if (arr.includes(String(dcode))) return + arr.push(String(dcode)) + }) + locations.map(location => { + if (arr.includes(String(location.dcode))) return + arr.push(String(location.dcode)) + }) + + console.log(form.regionId) + + + const data = { + nameString: `${form.lastName} ${form.firstName} ${form.middleName}`, + fullName: { + lastName: form.firstName, + firstName: form.lastName, + middleName: form.middleName + }, + active: form.active ?? false, + displaySchedule: !form.hideSchedule, + alias: form.alias ?? '', + degree: form.degree ?? '', + post: postTags.join(', ') ?? '', + experience: getYear(form.experience) ?? '', + sType: Number(form.sType), + regionId: Number(form.regionId), + anons: anons ?? '', + content: content ?? '', + tags: tags ?? [], + highlightedTags: highlightedTags ?? [], + video: form.videoUrl ?? '', + videoVertical: form.videoCardUrl ?? '', + scheduleText: form.scheduleText ?? '', + dcodes: arr.map((dcode) => String(dcode).trim()).join(',') ?? '', + category: form.category === 'Не указано' ? '' : form.category, + kodoper: kodopers ?? [], + patientAge: form.isChildrenDoctor ? (!isNaN(Number(form.patientAge)) ? Number(form.patientAge) : null) : null, + onlyOnlineMode: form.onlyOnlineMode ?? false, + prodoctor: form.prodoctor, + prodoctorLink: form.prodoctor ? (form.prodoctorLink.length > 0 ? form.prodoctorLink : '') : '', + prodoctorText: form.prodoctor ? (form.prodoctorText.length > 0 ? form.prodoctorText : '') : '', + }; + + const formattedLocations = locations.map(location => ({ + active: location?.active ? location.active : false, + dcode: location.dcode, + department: location.department, + filial: location.filial, + id: location?.id, + onlineMode: location.onlineMode, + nearestDate: location.nearestDate, + })); + + // console.log(data) + +/**///////////////////////////////////////////////////// */ + + + function isEqualExceptId(a, b) { + for (const key in a) { + if (key === 'id') continue; + if (a[key] !== b[key]) return false; + } + for (const key in b) { + if (key === 'id') continue; + if (a[key] !== b[key]) return false; + } + return true; +} + +function diffArrays(initial, current) { + const currentMap = new Map(current.map(item => [item.id, item])); + const initialMap = new Map(initial.map(item => [item.id, item])); + + const deletedLocations = []; + const changedLocations = []; + const addedLocations = []; + + for (const item of initial) { + const curr = currentMap.get(item.id); + if (!curr) { + deletedLocations.push(item); + } else if (!isEqualExceptId(item, curr)) { + changedLocations.push({ ...curr }); + } + } + + for (const item of current) { + if (!initialMap.has(item.id)) { + addedLocations.push(item); + } + } + + return { deletedLocations, changedLocations, addedLocations }; +} + + const { deletedLocations, changedLocations, addedLocations } = diffArrays(initialLocations, formattedLocations); + + + console.log(data) + + try { + const {id} = await createSpecialist({ + data: data, + }).unwrap(); + console.log('success specialist update') + + for (let i = 0; i < deletedLocations.length; i += 1) { + await deleteLocation(deletedLocations[i].id).unwrap() + } + console.log('success delete locations') + + for (let i = 0; i < addedLocations.length; i += 1) { + const [day, month, year] = addedLocations[i].nearestDate.split('.'); + const isoDate = `${year}-${month}-${day}`; + await createLocation({ specialistId: id, data: { + active: addedLocations[i].active, + dcode: addedLocations[i].dcode, + department: addedLocations[i].department, + filial: addedLocations[i].filial, + nearestDate: isoDate, + onlineMode: addedLocations[i].onlineMode, + } }).unwrap() + } + console.log('success create locations') + + for (let i = 0; i < changedLocations.length; i += 1) { + await updateLocation({ + specialistId: id, + locationId: changedLocations[i].id, + data: { + active: changedLocations[i].active, + }, + }).unwrap() + } + console.log('success update locations') + + + //-------------------------------------------------- + const initMap = new Map([...certificates.map(c => [c.id, c]), ...portfolio.map(c => [c.id, c])]); + const currMap = new Map([...certsFromChild.filter(c => c.id).map(c => [c.id, c]), ...portfolioFromChild.filter(c => c.id).map(c => [c.id, c])]); + + const created = [...certsFromChild.filter(c => c.id == null), ...portfolioFromChild.filter(c => c.id == null)]; + + const deleted = [...certificates.filter(c => !currMap.has(c.id)), ...portfolio.filter(c => !currMap.has(c.id))]; + + const modified = [ + ...certsFromChild + .filter(c => c.id != null && initMap.has(c.id)) + .filter(c => { + const orig = initMap.get(c.id); + return ( + c.name !== orig.name || + c.description !== orig.description || + c.picture !== orig.picture || + c.active !== orig.active + ); + }), + ...portfolioFromChild + .filter(c => c.id != null && initMap.has(c.id)) + .filter(c => { + const orig = initMap.get(c.id); + return ( + c.name !== orig.name || + c.description !== orig.description || + c.picture !== orig.picture || + c.active !== orig.active + ); + }) + ]; + + for (let i = 0; i < created.length; i += 1) { + if (created[i].name.trim().length === 0 && created[i].description.trim().length === 0) continue + const response = await createCertifcate({ + specialistId: id, + data: { + name: created[i].name, + description: String(created[i].description), + active: created[i].active, + type: created[i].type, + }, + }).unwrap() + if (created[i].picture) await uploadCertifcatePicture({ + id: response.id, + file: created[i]._file, + }).unwrap() + } + console.log('success create cert') + + for (let i = 0; i < modified.length; i += 1) { + if (modified[i].name.trim().length === 0 && modified[i].description.trim().length === 0) continue + await updateCertifcate({ + specialistId: id, + id: modified[i].id, + data: { + active: modified[i].active, + description: modified[i].description, + name: modified[i].name, + type: modified[i].type, + }, + }).unwrap() + console.log(modified[i]) + if (modified[i]?._file) { + await uploadCertifcatePicture({ + id: modified[i].id, + file: modified[i]._file, + }).unwrap() + } + } + console.log('success update cert') + + for (let i = 0; i < deleted.length; i += 1) { + await deleteCertifcate({ + id: deleted[i].id, + }).unwrap() + } + console.log('success delete cert') + + //-------------------------------------------------- + + if (previewFile) { + await uploadPicture({ id, file: previewFile }).unwrap(); + console.log('success photo update') + } + + const initIds = new Set(form.initStocks.map(s => s.id)); + const childIds = new Set(stocksFromChild.map(s => s.id)); + + const deletedStocs = form.initStocks.filter(s => !childIds.has(s.id)); + const addedStocks = stocksFromChild.filter(s => !initIds.has(s.id)); + console.log('============================') + console.log(deletedStocs) + console.log(addedStocks) + + for (let i = 0; i < addedStocks.length; i += 1) { + await addSpecialistToStock({ + stockId: addedStocks[i].id, + specialistId: id, + }).unwrap() + } + console.log('success added promotions') + + for (let i = 0; i < deletedStocs.length; i += 1) { + await removeSpecialistToStock({ + stockId: deletedStocs[i].id, + specialistId: id, + }).unwrap() + } + console.log('success delete promotions') + + + + setModalSuccess(true) + window.setTimeout(() => { + navigate( `/specialist/edit/${id}` ); + // window.location.reload() + }, 2000); + } catch (err) { + console.error('Ошибка при обновлении специалиста:', err) + } + + } + +/* + useEffect(() => { + if (patientAgeInputRef.current) { + patientAgeInputRef.current.focus(); + } + }, [ form.isChildrenDoctor ]); +*/ + const [isModalCustomOpen, setModalCustomOpen] = useState(false); + const [isModalDeleteOpen, setModalDeleteOpen] = useState(false); + const [isModalSuccess, setModalSuccess] = useState(false); + const [isModalDcodes, setModalDcodes] = useState(false); + + + return ( + setModalDeleteOpen(true) } + // addSpecialist={ navigateAddSpecialist } + isAddSpecialist = {true} + > + +
    +
    + +
    +
    + Фото врача e.currentTarget.src = PHOTO_PLACEHOLDER ?? '/assets/photo-placeholder.png' } + /> + + +
    +
    +
    +
    +
    + + updateField('lastName', e.target.value) } + onBlur={handleBlur} + /> + {errors.lastName && {errors.lastName}} +
    +
    + + updateField('firstName', e.target.value) } + onBlur={handleBlur} + /> + {errors.firstName && {errors.firstName}} +
    +
    + + updateField('middleName', e.target.value) } + pattern="[А-ЯЁ][а-яё]+" + title="Только буквы кириллицы, первая буква заглавная" + onBlur={handleBlur} + /> + {errors.middleName && {errors.middleName}} +
    +
    +
    + + updateField('alias', e.target.value) } + /> +
    + +
    +
    + + +
    +
    + +
    +
    + + +
    +
    + +
    + + {form.isChildrenDoctor && +
    +
    + + { + if (patientAgeInputRef.current) patientAgeInputRef.current.focus() + }} + style={{ backgroundColor: 'white', borderTopRightRadius: 0, borderBottomRightRadius: 0, borderRight: 'none', height: 'calc(1.5em + .75rem + 2px)' }} + >Прием детей с + + + { + updateField('patientAge', e.target.value) + validateField('patientAge', form.patientAge); + }} + onBlur={handleBlur} + /> + + { + if (patientAgeInputRef.current) patientAgeInputRef.current.focus() + }} + style={{ backgroundColor: 'white', borderTopLeftRadius: 0, borderBottomLeftRadius: 0, borderLeft: 'none', height: 'calc(1.5em + .75rem + 2px)' }} + >лет + +
    + {errors.patientAge && {errors.patientAge}} +
    + } +
    + +
    +
    +
    + +
    +
    +
    + + +
    +
    + + updateField('degree', e.target.value) } + /> +
    +
    +
    + + +
    + +
    + + + + +
    + { + updateField('experience', e); + validateField('experience', e); + }} + className="form-control" + calendarClassName="react-datepicker-bootstrap" + popperClassName="react-datepicker-popper" + showYearPicker + dateFormat="yyyy" + yearItemNumber={10} + /> +
    + {errors.experience && {errors.experience}} +
    + + +
    +
    + updateField('active', e.target.checked)} + /> + +
    +
    +
    + + +
    +
    + + +
    + + updateField('scheduleText', e.target.value)} + /> +
    + +
    +
    + updateField('hideSchedule', e.target.checked)} + /> + +
    + +
    + updateField('onlyOnlineMode', e.target.checked)} + /> + +
    +
    + + + +
    +
    + Расписание из Инфоклиники +
    + + +
    + + + {/* + Boolean(dcodes.length) && + { + setDcodes(prev => + prev.filter(dc => + dc !== String(dcode) + )) + setLocations(prev => + prev.filter(l => + String(l.dcode) !== dcode + )) + }} + />*/ + } + + {locations.length > 0 && +
    +
    + + + + + + + + + + + + + + {locations.map((location, index) => ( + + + + + + + + + + ) + )} + +
    dcodeФилиалОтделениеБлижайшая дата приемаОнлайн приемОтображать на сайте
    { location.dcode }{ filialsList.find(filial => filial.id === location.filial)?.shortName }{ departmentsList.find(department => Number(department.id) === Number(location.department))?.name }{ location.checkedDate ? location.nearestDate : '' }{ location.onlineMode ? 'да' : 'нет' } + { + const checked = e.target.checked; + setLocations(prev => + prev.map(l => + l.key === location.key + ? { ...l, active: checked } + : l + )); + } + //updateField('active', e.target.checked) + } + />
    +
    + + + +
    + } + +
    +
    + + + +
    +
    +
    +
    + Услуги врача +
    + + +
    + + + {Boolean(kodopers) && kodopers.length > 0 && + + } + + {displayKodoper.length > 0 && +
    +
    + + + + + + + + + + + + + { displayKodoper.map((item, index) => + + + + + + + + + )} + +
    Мед. кодУслугаОтделениеФилиалСтоимость
    { item.kodoper }{ item.schname }{ item.specname }{ item.fname }{ `${item.priceInfo.price} ₽` } + +
    +
    +
    +}
    +
    + +
    +
    +
    +
    + Акции +
    + + +
    + + { stocksFromChild.length > 0 && +
    + + + + + + + + + + + { + stocksFromChild.map((stock, index) => + + + + + + + ) + } + +
    НазваниеНачало акцииОкончание акции
    { stock.name }{ stock.startDate }{ stock.endDate } + +
    +
    + } + + { /* + */ + } + +
    +
    + +
    +
    + +
    + + +
    + +
    + + +
    + + +
    +
    + + + +
    +
    + +
    + +
    + + { + + } + +
    +
    + + +
    +
    + +
    + +
    + + { + + } + +
    +
    + + +
    +
    +
    + + +
    + +
    + + +
    + +
    +
    + +
    +
    +
    + + updateField('videoUrl', e.target.value) } + onBlur={handleBlur} + /> + {errors.videoUrl && {errors.videoUrl}} +
    +
    + + updateField('videoCardUrl', e.target.value) } + onBlur={handleBlur} + /> + {errors.videoCardUrl && {errors.videoCardUrl}} +
    +
    +
    + +
    +
    + +
    + { + updateField('prodoctor', e.target.checked) + if (!e.target.checked) { + updateField('prodoctorText', '') + updateField('prodoctorLink', '') + setErrors(prev => ({ ...prev, prodoctorLink: '' })); + } + }} + /> + +
    + + { + form.prodoctor && <> +
    + + updateField('prodoctorText', e.target.value)} + /> +
    + +
    + + { + updateField('prodoctorLink', e.target.value) + validateField('prodoctorLink', form.prodoctorLink) + }} + onBlur={handleBlur} + /> + {errors.prodoctorLink && {errors.prodoctorLink}} +
    + + } + +
    +
    + + +

    Изменения успешно внесены.

    +
    + + setModalDcodes(false)} + departments={departmentsList} + filials={filialsList} + /> + + { + setKodopers(selectedCodopers) + setDisplayKodoper(displayedCodopers) + }} + onCancel={() => setModalKodopers(false)} + /> + + { + // setStocks(selectedStocks) + setStocksFromChild(selectedStocks) + }} + onCancel={() => setModalStocks(false)} + /> + +
    + ); +} diff --git a/src/pages/AddStockPage.jsx b/src/pages/AddStockPage.jsx new file mode 100644 index 0000000..c5580cb --- /dev/null +++ b/src/pages/AddStockPage.jsx @@ -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 ( + {} } + isAddSpecialist = {true} + > +
    + + +
    + +
    +
    +
    + + { + if (startDateInputRef.current?.showPicker) { + startDateInputRef.current.showPicker(); + } + }} + onChange={handleChange('startDate')} + /> +
    +
    + + { + if (endDateInputRef.current?.showPicker) { + endDateInputRef.current.showPicker(); + } + }} + onChange={handleChange('endDate')} + /> +
    +
    + +
    + +
    +
    + + +
    + +
    + +
    + {form.picture && ( + + )} + + +
    +
    +
    + +
    + + +
    + + + + {/* + + + */} + +

    Изменения успешно внесены.

    +
    +
    + ) +} \ No newline at end of file diff --git a/src/pages/DepartmentsListPage.jsx b/src/pages/DepartmentsListPage.jsx new file mode 100644 index 0000000..cb52193 --- /dev/null +++ b/src/pages/DepartmentsListPage.jsx @@ -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 + + return ( +
    +

    + Отделения +

    + + { isLoading && + + } + + { !isLoading && + <> +
    + + + +
    +
    + + + } + +
    + ) +} diff --git a/src/pages/EditDepartmentPage.jsx b/src/pages/EditDepartmentPage.jsx new file mode 100644 index 0000000..bb69f40 --- /dev/null +++ b/src/pages/EditDepartmentPage.jsx @@ -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 + if ( error ) return + if ( !department ) return ( + + ) + + return ( + + +
    + + updateField( 'middleName', e.target.value )} + /> +
    + +
    + + updateField( 'alias', e.target.value )} + /> +
    + + + +
    + ) +} diff --git a/src/pages/EditFilialPage.jsx b/src/pages/EditFilialPage.jsx new file mode 100644 index 0000000..5fc135b --- /dev/null +++ b/src/pages/EditFilialPage.jsx @@ -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 + if ( loadingError ) return + if ( !filial ) return ( + + ) + + return ( + + +
    + +
    +
    + Фото врача e.currentTarget.src = PHOTO_PLACEHOLDER } + /> + + +
    +
    + +
    + +
    + +
    +
    + + +
    +
    + + updateField( 'fid', e.target.value )} + /> +
    + +
    + + updateField( 'siteId', e.target.value )} + /> +
    + +
    +
    + +
    + +
    + + updateField( 'name', e.target.value )} + /> +
    + +
    + + updateField( 'company', e.target.value )} + /> +
    +
    +
    + +
    + + + +
    + + updateField( 'address', e.target.value )} + /> +
    + + + +
    + + updateField('phone', val)} + /> +
    + +
    + + updateField( 'email', e.target.value )} + /> +
    + +
    + + updateField( 'origin', e.target.value )} + /> +
    + +
    + + updateField( 'policy', e.target.value )} + /> +
    + + + +
    + ) +} diff --git a/src/pages/EditSpecialistPage.jsx b/src/pages/EditSpecialistPage.jsx new file mode 100644 index 0000000..651d939 --- /dev/null +++ b/src/pages/EditSpecialistPage.jsx @@ -0,0 +1,1609 @@ +import {useEffect, useState, useMemo, useRef} from 'react' +import {useParams, useNavigate, useLocation } from 'react-router-dom' +import DatePicker from "react-datepicker"; + +import { apiUrl } from '@/config/api'; + +import { useUpdateSpecialistMutation, useDeleteSpecialistMutation, useUploadSpecialistPictureMutation } from '../api/apiSpecialist' +import { useCreateLocationMutation, useUpdateLocationMutation, useDeleteLocationMutation } from '/src/api/apiLocation.js' +import { + useCreateCertificateMutation, + useUpdateCertificateMutation, + useUploadSertificatePictureMutation, + useDeleteCertificateMutation, +} from '/src/api/apiCertificate.js' +import { useAddSpecialistMutation, useRemoveSpecialistMutation } from '../api/apiStock' +import { useSpecialist } from '../hooks/useSpecialist'; +// +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 { TagInput } from '../components/Input/Taginput'; +import { TagStaticInput } from '../components/Input/TagStaticInput'; +import { TagKodoperStaticInput } from '../components/Input/TagKodoperStatic'; +import { TextEditor } from '../components/Editors/TextEditor' +import Modal from '../components/Modals/Modal'; +import DcodeModal from '../components/Modals/DcodeModal'; +import KodoperModal from '../components/Modals/KodoperModal'; +import { CertificatesForm } from '../components/Docs/Certificates'; +import { PortfolioForm } from '../components/Docs/Portfolio'; +import { StocksForm } from '../components/Docs/Stocks' +import { StockModal } from '../components/Modals/StockModal' +import PHOTO_PLACEHOLDER from '../assets/photo-placeholder.png' + +const formatStockDate = (date) => { + const dateStart = new Date(date); + + const year = dateStart.getFullYear(); + const month = String(dateStart.getMonth() + 1).padStart(2, '0'); + const day = String(dateStart.getDate()).padStart(2, '0'); + const hours = String(dateStart.getHours()).padStart(2, '0'); + const minutes = String(dateStart.getMinutes()).padStart(2, '0'); + + return `${hours}:${minutes}, ${day}.${month}.${year}`; +} + + const regionOptions = { + 91: 'Саратов' , + 92: 'Волгоград', + 93: 'Воронеж', + 94: 'Краснодар', + }; + + const sTypeOptions = { + 0: 'Взрослый врач', + 1: 'Детский врач', + 2: 'Администрация', + 3: 'Стоматология', + 4: 'Мед. сестра', + }; + + const getYear = (date) => { + const dateObject = new Date(date); + return String(dateObject.getFullYear()) + } + +const formatCategory = (rawCategory) => { + if (!rawCategory) return 'Не указано' + const categoryLowerCase = rawCategory.toLowerCase(); + switch (categoryLowerCase) { + case 'высшая': + case 'ведущий специалист': + return 'Высшая'; + case 'первая': + case 'главный специалист': + return 'Первая'; + case 'вторая': + return 'Вторая'; + default: + return 'Не указано' + } +} + + +export const EditSpecialistPage = () => { + const { id } = useParams(); + const { state } = useLocation(); + + const queryParams = { + search: state?.search ?? '', + region: state?.region ?? '', + page: state?.page ?? null, + } + + const navigate = useNavigate(); + const navigateBack = () => { + let query = ''; + + Object.keys(queryParams).map( param => { + if (queryParams[param]) query += `&${param}=${queryParams[param]}`; + }); + + if (query) query = `?${query.slice(1)}`; + + navigate( `/specialist${query}` ); + } + + const dateInputRef = useRef(null); + const patientAgeInputRef = useRef(null); + + const [ locations, setLocations ] = useState([]); + const [ initialLocations, setInitialLocations ] = useState([]); + + const setFromRawLocations = (rawLocations) => { + const selectedDcodes = [...new Set(rawLocations.map(({dcode}) => dcode))]; + const arr = selectedDcodes.filter(dcode => !dcodes.includes(dcode)).map(dc => String(dc)); + setDcodes(arr) + setLocations(rawLocations) + } + + const [ stocks, setStocks ] = useState([]); + const [ stocksFromChild, setStocksFromChild ] = useState([]); + + const [ anons, setAnons ] = useState(''); + const [ content, setContent ] = useState(''); + + const [displayKodoper, setDisplayKodoper] = useState([]); + + const [errors, setErrors] = useState({ + lastName: '', + firstName: '', + middleName: '', + videoUrl: '', + videoCardUrl: '', + patientAge: '', + prodoctorLink: '', + }); + + const categoryList = [ + 'Не указано', + 'Вторая', + 'Первая', + 'Высшая', + ]; + + const [isModalKodopers, setModalKodopers] = useState(false); + const [kodopers, setKodopers] = useState([]); + const [certificates, setCertificates] = useState([]); + const [portfolio, setPortfolio] = useState([]); + + const [ form, setForm ] = useState({ + nameString: '', + lastName: '', + firstName: '', + middleName: '', + active: false, // boolean + displaySchedule: null, // boolean + // + hideSchedule: false, // booleal + // + alias: undefined, // string + post: '', // string + experience: undefined, // number + sType: null, // number + regionId: null, // number + anons: '', // string + add redactor + content: '', // string + add redactor + tags: [], // json + highlightedTags: [], // json + videoUrl: '', // string + videoCardUrl: '', // string + scheduleText: '', + //isCastom: false, // has autoupdate - boolean + category: '', // +++++++ + previewPicture: undefined, // string + filialId: null, // from locations.locations[0].filial + kodoper: null, // <<<---??? + prodoctor: '', + prodoctorLink: '', + prodoctorText: '', + onlyOnlineMode: '', + // + isChildrenDoctor: undefined, // from sType + patientAge: undefined, // integer + initStocks: [], + //isCastomChecker: undefined, + // + degree: '', + isLeadingSpecialist: false, + isChiefSpecialist: false, + education: '', + academicDegree: '', + professionalCompetencies:'', + advancedTraining: '', + certificates: '', + certificatesGallery: [], + adultsReception: '', + cost: '', + hasPromotion: false, + onlineConsultationLink: '', + onlyOnline: false, + hasProDoctorsAward: false, + proDoctorsAwardText: '', + proDoctorsAwardLink: '', + postTags: [], + operationPhotoUrl: '', + }); + + const updateField = ( key, value ) => { + setForm(prev => ({ ...prev, [key]: value })); + }; + + const [isModalStocks, setModalStocks] = useState(false); + + const regexCyrillic = /^[А-ЯЁ][а-яё]+$/; + const regexRutube = /^https:\/\/rutube\.ru\/.*$/; + const regexProdoctor = /^https:\/\/prodoctorov\.ru\/.*$/; + + const validateField = (field, value) => { + let error = ''; + const currentYear = new Date().getFullYear(); + switch (field) { + case 'lastName': + case 'firstName': + case 'middleName': + if (!value) error = 'Обязательное поле'; + else if (!regexCyrillic.test(value)) error = 'Только кириллица, с заглавной буквы'; + break; + case 'experience': + if (!value) { + error = 'Обязательное поле'; + } else if (Number(getYear(value)) > Number(currentYear)) { + error = 'Год должен быть не больше текущего'; + } + break; + case 'videoUrl': + case 'videoCardUrl': + if (value && !regexRutube.test(value)) error = 'Должно быть ссылкой с rutube'; + break; + case 'prodoctorLink': + if (form.prodoctor) { + if (!value) { + error = 'Укажите ссылку'; + } else { + if (value && !regexProdoctor.test(value)) error = 'Должно быть ссылкой с prodoctorov.ru'; + } + } else { + error = '' + } + break; + case 'patientAge': + if (form.isChildrenDoctor) { + if ( !value ) { + error = 'Укажите возраст'; + } else { + const num = Number(value); + if (!Number.isInteger(num) || num < 0 || num > 18) error = 'Возраст не может быть меньше 0 и больше 18'; + } + } else { + error = '' + } + break; + } + setErrors(prev => ({ ...prev, [field]: error })); + }; + + const handleBlur = e => { + const { name, value } = e.target; + validateField(name, value); + }; + + const isFormValid = () => { + ['lastName', 'firstName', 'middleName', 'videoUrl', 'videoCardUrl', 'patientAge', 'prodoctorLink', 'experience'].forEach(f => validateField(f, form[f])); + const a = form.prodoctor ? ( form.prodoctorLink.length > 0 ) : true; + const b = form.isChildrenDoctor ? ( form.patientAge ) : true; + + return Object.entries(errors).every( + ([key, err]) => { + return !err; + } + ) && form.lastName && form.firstName && form.middleName && a && b && form.experience; + }; + + + const [ tags, setTags ] = useState([]); + const [ highlightedTags, setHighlightedTags ] = useState([]); + const [ postTags, setPostTags ] = useState([]); + const [ dcodes, setDcodes ] = useState([]); + + const photoInputRef = useRef(null) + const photoOperationInputRef = useRef(null) + const certificatesGalleryInputRef = useRef(null) + + const { isLoading, error, specialist, filials, regions, departments, kodoperDetails } = useSpecialist(id); + + const [updateSpecialist, { isLoading: isUpdating, isError: updateError }] = + useUpdateSpecialistMutation(); + const [uploadPicture, { isLoadingPicture, isErrorOicture }] = useUploadSpecialistPictureMutation(); + const [deleteSpecialist, { isLoading: isDeleting, isError: deleteError }] = + useDeleteSpecialistMutation(); + + const [createLocation] = useCreateLocationMutation(); + const [updateLocation]= useUpdateLocationMutation(); + const [deleteLocation] = useDeleteLocationMutation(); + + const [addSpecialistToStock] = useAddSpecialistMutation(); + const [removeSpecialistToStock] = useRemoveSpecialistMutation(); + + const [createCertifcate] = useCreateCertificateMutation(); + const [updateCertifcate] = useUpdateCertificateMutation(); + const [uploadCertifcatePicture] = useUploadSertificatePictureMutation(); + const [deleteCertifcate] = useDeleteCertificateMutation(); + + const filialsList = useMemo(() => { + const arr = filials?.data ?? []; + return arr.map(({ fid, regionId, shortName }) => ({ id: fid, regionId, shortName })); + }, [filials?.data]); + + const departmentsList = useMemo(() => { + const arr = departments?.data ?? []; + return arr.map(({ did, name }) => ({ id: did, name })); + }, [departments?.data]); + + useEffect( () => { + if ( specialist ) { + const initial = specialist.post + ? specialist.post.split(',').map(s => s.trim()) + : []; + const reorderInit = []; + initial.map((element, index) => { + if (element.length < 4) { + if (index > 0) { + reorderInit.pop(); + reorderInit.push(`${ initial[index-1]}, ${element}`); + return + } + reorderInit.push(element); + return + } + reorderInit.push(element); + return + }); + setPostTags(reorderInit); + + if (specialist.locations) { + setInitialLocations(specialist.locations); + const rawLocations = specialist.locations.map(loc => { + return { ...loc, name: specialist.nameString, } + }); + setLocations(rawLocations.map((location) => { + const parsedDate = new Date(location.nearestDate); + const now = new Date(); + if (parsedDate > now) { + return { ...location, checkedDate: true, key: `${location.dcode}${location.department}${location.filial}` } + } + return { ...location, checkedDate: false, key: `${location.dcode}${location.department}${location.filial}` } + })); + } + + updateField('lastName', specialist.fullName?.lastName); + updateField('firstName', specialist.fullName?.firstName); + updateField('middleName', specialist.fullName?.middleName); + updateField('alias', specialist.alias); + updateField('active', specialist.active); + setAnons(specialist.anons); + setContent(specialist.content); + updateField('regionId', specialist.regionId); + updateField('sType', specialist.sType); + updateField('scheduleText', specialist.scheduleText); + updateField('videoUrl', specialist.video); + updateField('videoCardUrl', specialist.videoVertical); + updateField('kodoper', specialist.kodoper); + setKodopers(specialist.kodoper); + updateField('onlyOnlineMode', Boolean(specialist.onlyOnlineMode)); + updateField('prodoctor', specialist.prodoctor); + updateField('degree', specialist.degree); + updateField('prodoctorLink', specialist.prodoctorLink ?? ''); + updateField('prodoctorText', specialist.prodoctorText ?? ''); + setDisplayKodoper([...kodoperDetails]) + if (specialist?.dcodes) setDcodes([...specialist.dcodes.split(',')]) + if ( specialist.tags ) setTags([...specialist.tags]) + if ( specialist.highlightedTags ) setHighlightedTags([...specialist.highlightedTags]) + + if ( specialist.stocks.length > 0 ) { + setStocks([...specialist.stocks]); + setStocksFromChild([...specialist.stocks]) + updateField('initStocks', specialist.stocks); + } + updateField('category', formatCategory(specialist.category)); + + updateField('previewPicture', apiUrl(specialist.pictureLink)); + + const formattedDate = specialist.experience + ? `${specialist.experience}-01-01` + : ''; + updateField('experience', formattedDate); + updateField('hideSchedule', !specialist.displaySchedule); + updateField('patientAge', specialist.patientAge); + updateField('isChildrenDoctor', specialist.sType === 1 ? true : false); + if (specialist.specialistDocs) { + specialist.specialistDocs.forEach(doc => { + if (doc.type === 'certificate') { + setCertificates(prev => { + if (prev.some(item => item.id === doc.id)) { + return prev; + } + return [...prev, doc]; + }); + } + if (doc.type === 'portfolio') { + setPortfolio(prev => { + if (prev.some(item => item.id === doc.id)) { + return prev; + } + return [...prev, doc]; + }); + } + }); + } + } + }, [ specialist, kodoperDetails ] ); + + const [previewFile, setPreviewFile] = useState(null) + + 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('previewPicture', window.URL.createObjectURL(file)); + setPreviewFile(file); + } + + const handlePhotoOperationUpload = () => { + const file = photoOperationInputRef.current.files[0] + if (!file) return window.alert('Файл не выбран') + updateField('operationPhotoUrl', { file, url: window.URL.createObjectURL(file) }); + } + + const handleWrapper = () => { + if (!isFormValid()) { + window.alert('Пожалуйста, исправьте ошибки в форме'); + return; + } + handleSave(); + } + + const [certsFromChild, setCertsFromChild] = useState([]); + const [portfolioFromChild, setPortfolioFromChild] = useState([]); + + const handleSave = async () => { + if (!isFormValid()) { + window.alert('Пожалуйста, исправьте ошибки в форме'); + return; + } + + + + const arr = []; + + dcodes.map(dcode => { + if (arr.includes(String(dcode))) return + arr.push(String(dcode)) + }) + locations.map(location => { + if (arr.includes(String(location.dcode))) return + arr.push(String(location.dcode)) + }) + + const data = { + nameString: `${form.lastName} ${form.firstName} ${form.middleName}`, + fullName: { + lastName: form.firstName, + firstName: form.lastName, + middleName: form.middleName + }, + active: form.active ?? false, + displaySchedule: !form.hideSchedule, + alias: form.alias ?? '', + degree: form.degree ?? '', + post: postTags.join(', ') ?? '', + experience: getYear(form.experience) ?? '', + sType: Number(form.sType), + regionId: Number(form.regionId), + anons: anons ?? '', + content: content ?? '', + tags: tags ?? [], + highlightedTags: highlightedTags ?? [], + video: form.videoUrl ?? '', + videoVertical: form.videoCardUrl ?? '', + scheduleText: form.scheduleText ?? '', + dcodes: arr.map((dcode) => String(dcode).trim()).join(',') ?? '',// dcodes.map((dcode) => dcode.trim()).join(','), + category: form.category === 'Не указано' ? '' : form.category, + kodoper: kodopers ?? [], + patientAge: form.isChildrenDoctor ? (!isNaN(Number(form.patientAge)) ? Number(form.patientAge) : null) : null, + onlyOnlineMode: form.onlyOnlineMode ?? false, + prodoctor: form.prodoctor, + prodoctorLink: form.prodoctor ? (form.prodoctorLink.length > 0 ? form.prodoctorLink : '') : '', + prodoctorText: form.prodoctor ? (form.prodoctorText.length > 0 ? form.prodoctorText : '') : '', + }; + + const formattedLocations = locations.map(location => ({ + active: location?.active ? location.active : false, + dcode: location.dcode, + department: location.department, + filial: location.filial, + id: location?.id, + onlineMode: location.onlineMode, + nearestDate: location.nearestDate, + })); + + + + function isEqualExceptId(a, b) { + for (const key in a) { + if (key === 'id') continue; + if (a[key] !== b[key]) return false; + } + for (const key in b) { + if (key === 'id') continue; + if (a[key] !== b[key]) return false; + } + return true; +} + +function diffArrays(initial, current) { + const currentMap = new Map(current.map(item => [item.id, item])); + const initialMap = new Map(initial.map(item => [item.id, item])); + + const deletedLocations = []; + const changedLocations = []; + const addedLocations = []; + + for (const item of initial) { + const curr = currentMap.get(item.id); + if (!curr) { + deletedLocations.push(item); + } else if (!isEqualExceptId(item, curr)) { + changedLocations.push({ ...curr }); + } + } + + for (const item of current) { + if (!initialMap.has(item.id)) { + addedLocations.push(item); + } + } + + return { deletedLocations, changedLocations, addedLocations }; +} + + const { deletedLocations, changedLocations, addedLocations } = diffArrays(initialLocations, formattedLocations); + + + try { + await updateSpecialist({ + specialistId: id, + data: data, + }).unwrap(); + console.log('success specialist update') + + for (let i = 0; i < deletedLocations.length; i += 1) { + await deleteLocation(deletedLocations[i].id).unwrap() + } + console.log('success delete locations') + + for (let i = 0; i < addedLocations.length; i += 1) { + const [day, month, year] = addedLocations[i].nearestDate.split('.'); + const isoDate = `${year}-${month}-${day}`; + await createLocation({ specialistId: id, data: { + active: addedLocations[i].active, + dcode: addedLocations[i].dcode, + department: addedLocations[i].department, + filial: addedLocations[i].filial, + nearestDate: isoDate, + onlineMode: addedLocations[i].onlineMode, + } }).unwrap() + } + console.log('success create locations') + + for (let i = 0; i < changedLocations.length; i += 1) { + await updateLocation({ + specialistId: id, + locationId: changedLocations[i].id, + data: { + active: changedLocations[i].active, + }, + }).unwrap() + } + console.log('success update locations') + + + //-------------------------------------------------- + const initMap = new Map([...certificates.map(c => [c.id, c]), ...portfolio.map(c => [c.id, c])]); + const currMap = new Map([...certsFromChild.filter(c => c.id).map(c => [c.id, c]), ...portfolioFromChild.filter(c => c.id).map(c => [c.id, c])]); + + const created = [...certsFromChild.filter(c => c.id == null), ...portfolioFromChild.filter(c => c.id == null)]; + + const deleted = [...certificates.filter(c => !currMap.has(c.id)), ...portfolio.filter(c => !currMap.has(c.id))]; + + const modified = [ + ...certsFromChild + .filter(c => c.id != null && initMap.has(c.id)) + .filter(c => { + const orig = initMap.get(c.id); + return ( + c.name !== orig.name || + c.description !== orig.description || + c.picture !== orig.picture || + c.active !== orig.active + ); + }), + ...portfolioFromChild + .filter(c => c.id != null && initMap.has(c.id)) + .filter(c => { + const orig = initMap.get(c.id); + return ( + c.name !== orig.name || + c.description !== orig.description || + c.picture !== orig.picture || + c.active !== orig.active + ); + }) + ]; + + for (let i = 0; i < created.length; i += 1) { + if (created[i].name.trim().length === 0 && created[i].description.trim().length === 0) continue + const response = await createCertifcate({ + specialistId: id, + data: { + name: created[i].name, + description: String(created[i].description), + active: created[i].active, + type: created[i].type, + }, + }).unwrap() + if (created[i].picture) await uploadCertifcatePicture({ + id: response.id, + file: created[i]._file, + }).unwrap() + } + console.log('success create cert') + + for (let i = 0; i < modified.length; i += 1) { + if (modified[i].name.trim().length === 0 && modified[i].description.trim().length === 0) continue + await updateCertifcate({ + specialistId: id, + id: modified[i].id, + data: { + active: modified[i].active, + description: modified[i].description, + name: modified[i].name, + type: modified[i].type, + }, + }).unwrap() + if (modified[i]?._file) { + await uploadCertifcatePicture({ + id: modified[i].id, + file: modified[i]._file, + }).unwrap() + } + } + console.log('success update cert') + + for (let i = 0; i < deleted.length; i += 1) { + await deleteCertifcate({ + id: deleted[i].id, + }).unwrap() + } + console.log('success delete cert') + + if (previewFile) { + await uploadPicture({ id, file: previewFile }).unwrap(); + console.log('success photo update') + } + + const initIds = new Set(form.initStocks.map(s => s.id)); + const childIds = new Set(stocksFromChild.map(s => s.id)); + + const deletedStocs = form.initStocks.filter(s => !childIds.has(s.id)); + const addedStocks = stocksFromChild.filter(s => !initIds.has(s.id)); + + for (let i = 0; i < addedStocks.length; i += 1) { + await addSpecialistToStock({ + stockId: addedStocks[i].id, + specialistId: id, + }).unwrap() + } + console.log('success added promotions') + + for (let i = 0; i < deletedStocs.length; i += 1) { + await removeSpecialistToStock({ + stockId: deletedStocs[i].id, + specialistId: id, + }).unwrap() + } + console.log('success delete promotions') + + + + setModalSuccess(true) + + window.setTimeout(() => { + window.location.reload() + }, 2000); + + } catch (err) { + console.error('Ошибка при обновлении специалиста:', err) + } + + + } + + const handleDelete = async () => { + + try { + await deleteSpecialist(id).unwrap() + console.log('success delete') + setModalSuccess(true) + window.setTimeout(() => { + navigateBack() + }, 2000); + } catch (err) { + console.error('Ошибка при удалении специалиста:', err) + } + + + + } +/* + useEffect(() => { + if (patientAgeInputRef.current) { + patientAgeInputRef.current.focus(); + } + }, [ form.isChildrenDoctor ]); +*/ + const [isModalCustomOpen, setModalCustomOpen] = useState(false); + const [isModalDeleteOpen, setModalDeleteOpen] = useState(false); + const [isModalSuccess, setModalSuccess] = useState(false); + const [isModalDcodes, setModalDcodes] = useState(false); + + if ( isLoading ) return + if ( error ) return + if ( !specialist ) return + + return ( + setModalDeleteOpen(true) } + // addSpecialist={ navigateAddSpecialist } + > + + +
    +
    + +
    +
    + Фото врача e.currentTarget.src = PHOTO_PLACEHOLDER ?? '/assets/photo-placeholder.png' } + /> + + +
    +
    +
    +
    +
    + + updateField('lastName', e.target.value) } + onBlur={handleBlur} + /> + {errors.lastName && {errors.lastName}} +
    +
    + + updateField('firstName', e.target.value) } + onBlur={handleBlur} + /> + {errors.firstName && {errors.firstName}} +
    +
    + + updateField('middleName', e.target.value) } + pattern="[А-ЯЁ][а-яё]+" + title="Только буквы кириллицы, первая буква заглавная" + onBlur={handleBlur} + /> + {errors.middleName && {errors.middleName}} +
    +
    +
    + + updateField('alias', e.target.value) } + /> +
    + +
    +
    + + +
    +
    + +
    +
    + + +
    +
    + +
    + + {form.isChildrenDoctor && +
    +
    + + { + if (patientAgeInputRef.current) patientAgeInputRef.current.focus() + }} + style={{ backgroundColor: 'white', borderTopRightRadius: 0, borderBottomRightRadius: 0, borderRight: 'none', height: 'calc(1.5em + .75rem + 2px)' }} + >Прием детей с + + + { + updateField('patientAge', e.target.value) + validateField('patientAge', form.patientAge); + }} + onBlur={handleBlur} + /> + + { + if (patientAgeInputRef.current) patientAgeInputRef.current.focus() + }} + style={{ backgroundColor: 'white', borderTopLeftRadius: 0, borderBottomLeftRadius: 0, borderLeft: 'none', height: 'calc(1.5em + .75rem + 2px)' }} + >лет + +
    + {errors.patientAge && {errors.patientAge}} +
    + } +
    + +
    +
    +
    + +
    +
    +
    + + +
    +
    + + updateField('degree', e.target.value) } + /> +
    +
    +
    + + +
    + +{/* + +
    + + { + if (dateInputRef.current?.showPicker) { + dateInputRef.current.showPicker(); + } + }} + onChange={e => { + updateField('experience', e.target.value) + }} + /> +
    + +*/} + +
    + +
    + { + updateField('experience', e); + validateField('experience', e); + }} + className="form-control" + calendarClassName="react-datepicker-bootstrap" + popperClassName="react-datepicker-popper" + showYearPicker + dateFormat="yyyy" + yearItemNumber={10} + /> +
    + {errors.experience && {errors.experience}} +
    + + + + + + + +
    +
    + updateField('active', e.target.checked)} + /> + +
    +
    +
    + + +
    +
    + + +
    + + updateField('scheduleText', e.target.value)} + /> +
    + +
    +
    + updateField('hideSchedule', e.target.checked)} + /> + +
    + +
    + updateField('onlyOnlineMode', e.target.checked)} + /> + +
    +
    + + + +
    +
    + Расписание из Инфоклиники +
    + + +
    + + {locations.length > 0 && +
    + {/* +
    + Филиалы: +
    */ + } +
    + + + + + + + + + + + + + + {locations.map((location, index) => { + return ( + + + + + + + + + + ) + } + )} + +
    dcodeФилиалОтделениеБлижайшая дата приемаОнлайн приемОтображать на сайте
    { location.dcode }{ filialsList.find(filial => filial.id === location.filial)?.shortName }{ departmentsList.find(department => Number(department.id) === Number(location.department))?.name }{ location.nearestDate ? location.nearestDate : '' }{ location.onlineMode ? 'да' : 'нет' } + { + const checked = e.target.checked; + setLocations(prev => + prev.map(l => + l.key === location.key + ? { ...l, active: checked } + : l + )); + } + //updateField('active', e.target.checked) + } + />
    +
    + + + +
    + } + + + +
    +
    + + + +
    +
    +
    +
    + Услуги врача +
    + + +
    + + + {Boolean(kodopers) && kodopers.length > 0 && + + } + + {displayKodoper.length > 0 && +
    +
    + Добавленные услуги: +
    +
    + + + + + + + + + + + + + { displayKodoper.map((item, index) => + + + + + + + + + )} + +
    Мед. кодУслугаОтделениеФилиалСтоимость
    { item.kodoper }{ item.schname }{ item.specname }{ item.fname }{ `${item.priceInfo.price} ₽` } + +
    +
    +
    +} + +
    +
    + + +
    +
    +
    +
    + Акции +
    + + +
    + + { stocksFromChild.length > 0 && +
    + + + + + + + + + + + { + stocksFromChild.map((stock, index) => + + + + + + + ) + } + +
    НазваниеНачало акцииОкончание акции
    { stock.name }{ formatStockDate(stock.startDate) }{ formatStockDate(stock.endDate) } + +
    +
    + } + + + +
    +
    + + + + +
    +
    + +
    + + { + /* + updateField('anons', e.target.value)} + /> + */ + } + +
    + +
    + + { + /* + updateField('content', e.target.value)} + /> + */ + } + +
    + + +
    +
    + + + +
    +
    + +
    + +
    + + { + + } + +
    +
    + + +
    +
    + +
    + +
    + + { + + } + +
    +
    + + +
    +
    +
    + + +
    + +
    + + +
    + +
    +
    + +
    +
    +
    + + updateField('videoUrl', e.target.value) } + onBlur={handleBlur} + /> + {errors.videoUrl && {errors.videoUrl}} +
    +
    + + updateField('videoCardUrl', e.target.value) } + onBlur={handleBlur} + /> + {errors.videoCardUrl && {errors.videoCardUrl}} +
    +
    +
    + +
    +
    + +
    + { + updateField('prodoctor', e.target.checked) + if (!e.target.checked) { + updateField('prodoctorText', '') + updateField('prodoctorLink', '') + setErrors(prev => ({ ...prev, prodoctorLink: '' })); + } + }} + /> + +
    + + { + form.prodoctor && <> +
    + + updateField('prodoctorText', e.target.value)} + /> +
    + +
    + + { + updateField('prodoctorLink', e.target.value) + validateField('prodoctorLink', form.prodoctorLink) + }} + onBlur={handleBlur} + /> + {errors.prodoctorLink && {errors.prodoctorLink}} +
    + + } + +
    +
    + + setModalDeleteOpen(false)} + onConfirm={() => { + setModalDeleteOpen(false) + handleDelete() + }} + title="Вы уверены?" + hasButtons={true} + confirmText={'Удалить врача'} + > +

    Этим действием, вы удалите врача.

    +

    Вы уверены?

    +
    + + +

    Изменения успешно внесены.

    +
    + + setModalDcodes(false)} + departments={departmentsList} + filials={filialsList} + /> + + { + setKodopers(selectedCodopers) + setDisplayKodoper(displayedCodopers) + }} + onCancel={() => setModalKodopers(false)} + /> + + { + setStocks(selectedStocks) + }} + onCancel={() => setModalStocks(false)} + /> + +
    + ); +} diff --git a/src/pages/EditStockPage.jsx b/src/pages/EditStockPage.jsx new file mode 100644 index 0000000..89443b4 --- /dev/null +++ b/src/pages/EditStockPage.jsx @@ -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 + if ( error ) return + if ( !stock ) return ( + + ) + + return ( + +
    + + + {errors.name && {errors.name}} +
    + +
    +
    +
    + + { + if (startDateInputRef.current?.showPicker) { + startDateInputRef.current.showPicker(); + } + }} + onChange={handleChange('startDate')} + /> +
    +
    + + { + if (endDateInputRef.current?.showPicker) { + endDateInputRef.current.showPicker(); + } + }} + onChange={handleChange('endDate')} + /> +
    +
    + {errors.dateRange && ( +
    + {errors.dateRange} +
    + )} + +
    + +
    +
    + + +
    + +
    + +
    + {form.picture && ( + + )} + + +
    +
    +
    + +
    + + +
    + + +

    Изменения успешно внесены.

    +
    +
    + ) +} \ No newline at end of file diff --git a/src/pages/FilialsListPage.jsx b/src/pages/FilialsListPage.jsx new file mode 100644 index 0000000..f2e62db --- /dev/null +++ b/src/pages/FilialsListPage.jsx @@ -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 + + return ( +
    +

    + Филиалы +

    + + { isLoading && + + } + + { !isLoading && + <> +
    + + + +
    +
    + + + } + +
    + ) +} \ No newline at end of file diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx new file mode 100644 index 0000000..63861ca --- /dev/null +++ b/src/pages/HomePage.jsx @@ -0,0 +1,7 @@ +export const HomePage = () => ( +
    +

    + Главная +

    +
    +) diff --git a/src/pages/InfoclinicListPage.jsx b/src/pages/InfoclinicListPage.jsx new file mode 100644 index 0000000..6b6a2a0 --- /dev/null +++ b/src/pages/InfoclinicListPage.jsx @@ -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( +
  • + +
  • + ); + } + elems.push( +
  • + +
  • + ); + last = page; + }); + + return ( + + ); + }; + + return ( +
    +

    Расписание ИК

    + +
    +
    + + { + setSearchValue(e.target.value); + setCurrentPage(1); + }} + /> +
    +
    + + {isFetching ? ( +

    Загрузка...

    + ) : error ? ( +

    Ошибка при загрузке: {error.message || String(error)}

    + ) : ( +
    +
    + + + + + + + + + + + + + + + + + + + {items.map((specialist, index) => { + return ( + + + + + + + + + + + ); + })} + +
    dcodeФИОФилиалОтделениеБлижайшая дата приемаОнлайн запись
    IDНазваниеIDАдрес
    {specialist.dcode}{specialist.name}{specialist.filial}{ + filials.length > 0 ? filials.find(filial => String(filial.fid) === String(specialist.filial))?.shortName : null + }{specialist.department}{ + departments.length > 0 ? departments.find(department => String(department.did) === String(specialist.department))?.name : null + }{specialist.nearestDate}{specialist.onlineMode ? 'да' : 'нет'}
    +
    + + {renderPagination()} +
    + )} +
    + ); +}; \ No newline at end of file diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx new file mode 100644 index 0000000..8a6471c --- /dev/null +++ b/src/pages/LoginPage.jsx @@ -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 + } + + const handleSubmit = async (e) => { + e.preventDefault(); + setValidationError(''); + + if (!user.trim() || !pass.trim()) { + setValidationError('Пожалуйста, заполните оба поля.'); + return + } + + try { + await login({ + username: user, + password: pass, + }).unwrap(); + } catch { + // + } + }; + + return ( +
    +
    +

    + Вход +

    +
    +
    + +
    +
    + +
    + {!validationError && ( + error && ( +
    + { error.status === 500 || error.status === 400 + ? 'Неверный логин или пароль' : null } + { error.status === 'FETCH_ERROR' ? 'Ошибка сети' : null } +
    + ) + ) + } + { validationError && ( +
    + { validationError } +
    + ) + } +
    + +
    +
    +
    +
    + ) +}; diff --git a/src/pages/LostDoctorsPage.jsx b/src/pages/LostDoctorsPage.jsx new file mode 100644 index 0000000..3a86cc7 --- /dev/null +++ b/src/pages/LostDoctorsPage.jsx @@ -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 ( +
    +

    Врачи-потеряшки

    + +
    +
    + + { + setSearchValue(e.target.value); + }} + /> +
    +
    + setSearchValue('')} + /> +
    +
    + + {isLoading || lostDoctors.length === 0 ? ( +

    Загрузка...

    + ) : error ? ( +

    Ошибка при загрузке: {error.message || String(error)}

    + ) : ( + <> +
    +
    + + + + + + + + + + + + + + + + + + + {paged.length === 0 ? ( + + + + ) : ( + paged.map((lostDoctor, index) => + + + + + + + + + + + ) + )} + +
    dcodeФИОФилиалОтделениеБлижайшая дата приемаОнлайн запись
    IDНазваниеIDАдрес
    Ничего не найдено.
    {lostDoctor.doctor.dcode} + {lostDoctor.doctor.name} + + {lostDoctor.emptyLocation.filial} + + { filials.length > 0 ? filials.find(filial => String(filial.fid) === String(lostDoctor.emptyLocation.filial))?.shortName : null } + + {lostDoctor.emptyLocation.department} + + { departments.length > 0 ? departments.find(department => String(department.did) === String(lostDoctor.emptyLocation.department))?.name : null } + + {getFormatDate(lostDoctor.emptyLocation.nearestDate)} + + {lostDoctor.emptyLocation.onlineMode ? 'да' : 'нет'} +
    +
    +
    + + + + + )} +
    + ); +}; diff --git a/src/pages/MainPage.jsx b/src/pages/MainPage.jsx new file mode 100644 index 0000000..d2821ec --- /dev/null +++ b/src/pages/MainPage.jsx @@ -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 ( +
    + +
    +
    + +
    + +
    +
    +
    +
    + ) +} diff --git a/src/pages/NotFoundPage.jsx b/src/pages/NotFoundPage.jsx new file mode 100644 index 0000000..e084b75 --- /dev/null +++ b/src/pages/NotFoundPage.jsx @@ -0,0 +1,14 @@ +export const NotFoundPage = () => { + return ( +
    +
    +

    + 404 +

    +

    + Страница не найдена +

    +
    +
    + ) +} diff --git a/src/pages/PricesListPage.jsx b/src/pages/PricesListPage.jsx new file mode 100644 index 0000000..cf5d9b2 --- /dev/null +++ b/src/pages/PricesListPage.jsx @@ -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( +
  • + +
  • + ); + } + items.push( +
  • + +
  • + ); + last = page; + }); + + return ( + + ) + }; + + return ( + <> +
    +

    + Цены и услуги +

    +
    +
    +
    +
    + + { + setSearchValue( e.target.value ); + setCurrentPage( 1 ); + }} + /> +
    +
    +
    + + +
    +
    + +
    + { isFetching + ? ( + + ) + : queryError + ? ( + + ) + : ( + <> +
    + + + + + + + + + + + + + { prices.length > 0 ? + prices.map( ( item, idx ) => { + const updateDate = getDateInfo(item.dateUpdate); + return ( + + + + + + + + + ) + }) : ( + + + + ) + } + +
    Мед.кодУслугаДата обновленияВремя обновленияФилиалСтоимость
    { item.kodoper }{ item.schname }{ updateDate.date }{ updateDate.time }{ item.fname }{ item.priceInfo.price } ₽
    + По данному запросу ничего не найдено +
    +
    + { renderPagination() } + + ) + } +
    +
    +
    + + ) +} diff --git a/src/pages/SpecialistListPage.jsx b/src/pages/SpecialistListPage.jsx new file mode 100644 index 0000000..e573c0b --- /dev/null +++ b/src/pages/SpecialistListPage.jsx @@ -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( +
  • + +
  • + ); + } + elems.push( +
  • + +
  • + ); + last = page; + }); + + return ( + + ); + }; + + return ( +
    +

    Врачи

    + +
    +
    + navigate('/specialist/add')} + /> +
    + +
    + + +
    + +
    + + { + setSearchValue(e.target.value); + setCurrentPage(1); + }} + /> +
    +
    + + {isFetching ? ( +

    Загрузка...

    + ) : error ? ( +

    Ошибка при загрузке: {error.message || String(error)}

    + ) : ( + <> +
    + + + + + + + + + + + + + + + + {items.map(specialist => { + return ( + + { + setExpandedId(expandedId === specialist.id ? '' : specialist.id); + }} + > + + + + + + + + + + + + {expandedId === specialist.id && ( + + + + )} + + ); + })} + +
    IDФИОДолжностьГородКатегорияНаличие услугНаличие расписанияОтображать на сайтеОтображать расписание
    {specialist.id}{specialist.nameString}{specialist.post}{regionOptions.find(region => Number(region.id) === specialist.regionId)?.label || 'не указан'}{specialist?.sType || specialist?.sType === 0 ? sTypeOptions[Number(specialist.sType)] : 'не указан'}{Array.isArray(specialist.kodoper) ? ( specialist.kodoper.length > 0 ? 'есть' : 'нет' ) : 'нет'}{Array.isArray(specialist.locations) ? ( specialist.locations.length > 0 ? 'есть' : 'нет' ) : 'нет'}{specialist.active ? 'да' : 'нет'}{specialist.displaySchedule ? 'да' : 'нет'}
    + { + e.stopPropagation(); + navigate(`/specialist/edit/${specialist.id}`, { + state: { search: searchValue, region: regionId, page: currentPage } + }); + }} + /> +
    +
    + + {renderPagination()} + + )} +
    + ); +}; \ No newline at end of file diff --git a/src/pages/SpecialistTable.jsx b/src/pages/SpecialistTable.jsx new file mode 100644 index 0000000..7e51918 --- /dev/null +++ b/src/pages/SpecialistTable.jsx @@ -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

    Загрузка...

    ; + if (error) return

    Ошибка: {String(error)}

    ; + + 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) + ? [
  • + +
  • ] + : []; + const btn = ( +
  • + +
  • + ); + last = p; + return [...dots, btn]; + }); + + return ( + + ); + }; + + return ( +
    +
    + + + + + + + + {items.map(d => ( + + setExpandedId(expandedId===d.id?null:d.id)} + > + + + + + + + {expandedId===d.id && ( + + + + )} + + ))} + +
    IDФИОДолжностьОпытАктивен
    {d.id}{d.nameString}{d.post}{formatExp(d.experience)}{d.active?'✓':'—'}
    + +
    +
    + {renderPagination()} +
    + ); +}); diff --git a/src/pages/StoksListPage.jsx b/src/pages/StoksListPage.jsx new file mode 100644 index 0000000..a7df256 --- /dev/null +++ b/src/pages/StoksListPage.jsx @@ -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( +
  • + +
  • + ); + } + elements.push( +
  • + +
  • + ); + last = page; + }); + + return ( + + ) + }; + + return ( +
    +

    + Акции +

    +
    +
    + { + e.stopPropagation(); + navigate(`/promotions/create`); + }} + /> +
    +
    + + { + setSearchValue( e.target.value ); + setCurrentPage( 1 ); + }} + /> +
    +
    + + { isFetching + ? ( + + ) + : queryError + ? ( + + ) + : ( + <> +
    + + + + + + + + + + + + {stocks.map( stock => ( + <> + { + if ( expandedId === stock.id ) { + setExpandedId( null ) + return + } + setExpandedId( stock.id ) + }} + > + + + + + + + { expandedId === stock.id && + { + if ( expandedId === stock.id ) { + setExpandedId( null ) + return + } + setExpandedId( stock.id ) + }} + > + + + } + + + ))} + + +
    IDНазваниеНачало акцииОкончание акции
    { stock.id }{ stock.name } + + + +
    + { e.stopPropagation(); navigate(`/promotions/edit/${stock.id}`)}} + /> +
    +
    + + { renderPagination() } + + + ) + } +
    + ) +}; diff --git a/src/pages/UserPage.jsx b/src/pages/UserPage.jsx new file mode 100644 index 0000000..d459c43 --- /dev/null +++ b/src/pages/UserPage.jsx @@ -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 ( +
    +

    + Профиль пользователя +

    +

    + ID: {userUID} +

    +

    + Регион: {regions[userRegionId]} +

    +
    + ); +}; diff --git a/src/pages/__test__/LoginPage.test.jsx b/src/pages/__test__/LoginPage.test.jsx new file mode 100644 index 0000000..7578a89 --- /dev/null +++ b/src/pages/__test__/LoginPage.test.jsx @@ -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( + + + + + + ); + + fireEvent.click(screen.getByRole('button', { name: /войти/i })); + expect(screen.getByText(/пожалуйста, заполните оба поля/i)).toBeInTheDocument(); +}); diff --git a/src/pages/content/ContentEditPage.jsx b/src/pages/content/ContentEditPage.jsx new file mode 100644 index 0000000..76e3888 --- /dev/null +++ b/src/pages/content/ContentEditPage.jsx @@ -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 ? {message} : 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 ( +
    + updateField(field.key, e.target.checked)} + /> + + +
    + ) + } + + if (field.type === 'region') { + const selectValue = value === '' || value == null ? '' : String(value) + return ( +
    + + + +
    + ) + } + + if (field.type === 'html') { + return ( +
    + + updateField(field.key, html)} /> + +
    + ) + } + + if (field.type === 'json') { + return ( +
    + +