chore: initial import for test contour

This commit is contained in:
sova-bootstrap
2026-05-27 19:36:33 +03:00
commit ffd4cf9031
105 changed files with 10772 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
import styles from './Button.module.scss';
export const Button = ({ children, onClick, type = 'button', className = '' }) => {
return (
<button
type={type}
onClick={onClick}
className={`${styles.button} btn btn-success ${className}`}
>
{children}
</button>
);
};
+10
View File
@@ -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;
}
+146
View File
@@ -0,0 +1,146 @@
import React, { useState, useEffect } from 'react';
import { apiUrl } from '@/config/api';
import { CertEditor } from '../Editors/CertEditor'
const isValidImage = (file) => {
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png'];
if (allowedTypes.includes(file.type)) return true;
const ext = file.name.split('.').pop().toLowerCase();
return ['jpg', 'jpeg', 'png'].includes(ext);
};
export function CertificatesForm({ initCertificates, onChange }) {
const [initialCertificates, setInitialCertificates] = useState([]);
const [certificates, setCertificates] = useState([]);
useEffect(() => {
const certificatesWithPictureUrl = initCertificates.map((init) => ({ ...init, picture: apiUrl(`/uploads/${init.picture}`)}))
setInitialCertificates([...certificatesWithPictureUrl])
setCertificates([...certificatesWithPictureUrl])
}, [initCertificates])
useEffect(() => {
if (typeof onChange === 'function') {
onChange(certificates);
}
}, [certificates, onChange]);
const updateField = (idx, field, value) => {
setCertificates(prev =>
prev.map((cert, i) =>
i === idx
? { ...cert, [field]: value }
: cert
)
);
};
const replaceImage = (e, idx) => {
const file = e.target.files[0];
if (!file) return;
if (!isValidImage(file)) {
return window.alert('Изображения должны быть только формата JPG, JPEG или PNG');
}
const url = window.URL.createObjectURL(file);
setCertificates(prev =>
prev.map((cert, i) =>
i === idx
? { ...cert, picture: url, _file: file }
: cert
)
);
};
const addCertificate = () => {
setCertificates(prev => [
...prev,
{ name: '', description: '', picture: '', type: 'certificate', active: false, }
]);
};
const deleteCertificate = idx => {
setCertificates(prev => prev.filter((_, i) => i !== idx));
};
return (
<>
{certificates.map((cert, idx) => (
<div className="card mb-3 p-3" key={idx}>
<div className="form-group">
<label>Название</label>
<input
className="form-control"
value={cert.name}
onChange={e => updateField(idx, 'name', e.target.value)}
/>
</div>
<div className='d-flex' style={{gap: '2rem'}}>
<div className="form-group flex-grow-1">
<label>Описание</label>
<CertEditor
content={cert.description}
setContent={newVal => updateField(idx, 'description', newVal)}
/>
</div>
<div>
<div className="form-check mb-3">
<input
id={`active-cert${idx}`}
type="checkbox"
className="form-check-input"
checked={ cert.active }
onChange={e => updateField(idx, 'active', e.target.checked)}
/>
<label htmlFor={`active-cert${idx}`} className="form-check-label">
Отображать на сайте
</label>
</div>
<div className="form-group d-flex flex-column align-items-center" style={{ gap: '1rem' }}>
<label
htmlFor={`file-${idx}`}
className="btn btn-outline-primary"
style={{ cursor: 'pointer', height: '2.5rem', width: '13rem' }}
>
Загрузить сертификат
</label>
<input
id={`file-${idx}`}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={e => replaceImage(e, idx)}
/>
{cert.picture && (
<img
src={cert.picture}
alt="Сертификат"
style={{ width: '10rem', height: '10rem', objectFit: 'cover', borderRadius: '4px' }}
onError={e => e.currentTarget.src = '/placeholder.png'}
/>
)}
</div>
</div>
</div>
<div className='d-flex justify-content-start'>
<button
className="btn btn-danger"
onClick={() => deleteCertificate(idx)}
>
Удалить
</button>
</div>
</div>
))}
<button
className="btn btn-outline-primary mb-3"
onClick={addCertificate}
>
Добавить сертификат
</button>
</>
);
}
+152
View File
@@ -0,0 +1,152 @@
import React, { useState, useEffect } from 'react';
import { apiUrl } from '@/config/api';
import { CertEditor } from '../Editors/CertEditor'
const isValidImage = (file) => {
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png'];
if (allowedTypes.includes(file.type)) return true;
const ext = file.name.split('.').pop().toLowerCase();
return ['jpg', 'jpeg', 'png'].includes(ext);
};
export function PortfolioForm({ initPortfolios, onChange }) {
const [initialPortfolios, setInitialPorfolios] = useState([]);
const [portfolios, setPortfolios] = useState([]);
useEffect(() => {
// console.log(initPortfolios)
const portfolioWithPictureUrl = initPortfolios.map((init) => {
if ( init.picture ) return ({ ...init, picture: apiUrl(`/uploads/${init.picture}`)})
return { ...init }
})
setInitialPorfolios([...portfolioWithPictureUrl])
setPortfolios([...portfolioWithPictureUrl])
}, [initPortfolios])
useEffect(() => {
if (typeof onChange === 'function') {
onChange(portfolios);
}
}, [portfolios, onChange]);
const updateField = (idx, field, value) => {
setPortfolios(prev =>
prev.map((cert, i) =>
i === idx
? { ...cert, [field]: value }
: cert
)
);
};
const replaceImage = (e, idx) => {
const file = e.target.files[0];
if (!file) return;
if (!isValidImage(file)) {
return window.alert('Изображения должны быть только формата JPG, JPEG или PNG');
}
const url = window.URL.createObjectURL(file);
setPortfolios(prev =>
prev.map((cert, i) =>
i === idx
? { ...cert, picture: url, _file: file }
: cert
)
);
// console.log('==================')
// console.log(file)
};
const addCertificate = () => {
setPortfolios(prev => [
...prev,
{ name: '', description: '', picture: '', type: 'portfolio', active: false }
]);
};
const deleteCertificate = idx => {
setPortfolios(prev => prev.filter((_, i) => i !== idx));
};
return (
<>
{portfolios.map((cert, idx) => (
<div className="card mb-3 p-3" key={idx}>
<div className="form-group">
<label>Название</label>
<input
className="form-control"
value={cert.name}
onChange={e => updateField(idx, 'name', e.target.value)}
/>
</div>
<div className='d-flex' style={{gap: '2rem'}}>
<div className="form-group flex-grow-1">
<label>Описание</label>
<CertEditor
content={cert.description}
setContent={newVal => updateField(idx, 'description', newVal)}
/>
</div>
<div>
<div className="form-check mb-3">
<input
id={`active-port${idx}`}
type="checkbox"
className="form-check-input"
checked={ cert.active }
onChange={e => updateField(idx, 'active', e.target.checked)}
/>
<label htmlFor={`active-port${idx}`} className="form-check-label">
Отображать на сайте
</label>
</div>
<div className="form-group d-flex flex-column align-items-center" style={{ gap: '1rem' }}>
<label
htmlFor={`file-port-${idx}`}
className="btn btn-outline-primary"
style={{ cursor: 'pointer', height: '2.5rem', width: '13rem' }}
>
Загрузить фото
</label>
<input
id={`file-port-${idx}`}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={e => replaceImage(e, idx)}
/>
{cert.picture && (
<img
src={cert.picture}
alt="Портфолио"
style={{ width: '10rem', height: '10rem', objectFit: 'cover', borderRadius: '4px' }}
onError={e => e.currentTarget.src = '/placeholder.png'}
/>
)}
</div>
</div>
</div>
<div className='d-flex justify-content-start'>
<button
className="btn btn-danger"
onClick={() => deleteCertificate(idx)}
>
Удалить
</button>
</div>
</div>
))}
<button
className="btn btn-outline-primary mb-3"
onClick={addCertificate}
>
Добавить запись
</button>
</>
);
}
+196
View File
@@ -0,0 +1,196 @@
import React, { useState, useEffect, useRef } from 'react';
import { apiUrl } from '@/config/api';
import { CertEditor } from '../Editors/CertEditor'
const isValidImage = (file) => {
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png'];
if (allowedTypes.includes(file.type)) return true;
const ext = file.name.split('.').pop().toLowerCase();
return ['jpg', 'jpeg', 'png'].includes(ext);
};
export function StocksForm({ initStocks, onChange }) {
const [ initialStocks, setInitialStocks ] = useState([]);
const [ stocks, setStocks ] = useState([]);
const startDateInputRef = useRef(null);
const endDateInputRef = useRef(null);
useEffect(() => {
// console.log(initStocks)
// console.log(initStocks[0]?.startDate)
// console.log(typeof initStocks[0]?.startDate)
const portfolioWithPictureUrl = initStocks.map((init) => {
if ( init.picture ) return ({ ...init, picture: apiUrl(`/uploads/${init.picture}`)})
return { ...init }
}).map(init => {
const dateStart = new Date(init.startDate);
const year = dateStart.getFullYear();
const month = String(dateStart.getMonth() + 1).padStart(2, '0'); // Month is 0-indexed
const day = String(dateStart.getDate()).padStart(2, '0');
const hours = String(dateStart.getHours()).padStart(2, '0');
const minutes = String(dateStart.getMinutes()).padStart(2, '0');
const datetimeLocalValue = `${year}-${month}-${day}T${hours}:${minutes}`;
const dateEnd = new Date(init.endDate);
const year1 = dateEnd.getFullYear();
const month1 = String(dateEnd.getMonth() + 1).padStart(2, '0'); // Month is 0-indexed
const day1 = String(dateEnd.getDate()).padStart(2, '0');
const hours1 = String(dateEnd.getHours()).padStart(2, '0');
const minutes1 = String(dateEnd.getMinutes()).padStart(2, '0');
const datetimeLocalValue1 = `${year1}-${month1}-${day1}T${hours1}:${minutes1}`;
return ({ ...init, startDate: datetimeLocalValue, endDate: datetimeLocalValue1 });
})
setInitialStocks([...portfolioWithPictureUrl])
setStocks([...portfolioWithPictureUrl])
}, [initStocks])
useEffect(() => {
if (typeof onChange === 'function') {
onChange(stocks);
}
}, [stocks, onChange]);
const updateField = (idx, field, value) => {
setStocks(prev =>
prev.map((cert, i) =>
i === idx
? { ...cert, [field]: value }
: cert
)
);
};
const replaceImage = (e, idx) => {
const file = e.target.files[0];
if (!file) return;
if (!isValidImage(file)) {
return window.alert('Изображения должны быть только формата JPG, JPEG или PNG');
}
const url = window.URL.createObjectURL(file);
setStocks(prev =>
prev.map((cert, i) =>
i === idx
? { ...cert, picture: url, _file: file }
: cert
)
);
// console.log('==================')
// console.log(file)
};
const addCertificate = () => {
setStocks(prev => [
...prev,
{ name: '', description: '', picture: '', type: 'portfolio', active: false }
]);
};
const deleteCertificate = idx => {
setStocks(prev => prev.filter((_, i) => i !== idx));
};
return (
<>
{stocks.map((cert, idx) => (
<div className="card mb-3 p-3" key={idx}>
<div className="form-group" style={{ pointerEvents: 'none' }}>
<label>Название</label>
<input
className="form-control"
value={cert.name}
onChange={e => updateField(idx, 'name', e.target.value)}
// readOnly={true}
/>
</div>
<div className="form-group" style={{ pointerEvents: 'none' }}>
<label>Анонс</label>
<CertEditor
content={cert.anons}
setContent={newVal => updateField(idx, 'anons', newVal)}
readOnly={true}
/>
</div>
<div className="form-group" style={{ pointerEvents: 'none' }}>
<label>Описание</label>
<CertEditor
content={cert.content}
setContent={newVal => updateField(idx, 'contnet', newVal)}
readOnly={true}
/>
</div>
<div>
<div className='d-flex justify-content-between' style={{ gap: '1rem' }}>
<div className='form-group d-flex flex-column'>
<div className="form-group" style={{ pointerEvents: 'none' }}>
<label>Начало акции:</label>
<input
type="datetime-local"
ref={startDateInputRef}
className="form-control"
value={cert.startDate}
onClick={() => {
if (startDateInputRef.current?.showPicker) {
startDateInputRef.current.showPicker();
}
}}
onChange={e => updateField(idx, 'startDate', e.target.value)}
/>
</div>
<div className="form-group" style={{ pointerEvents: 'none' }}>
<label>Окончание акции:</label>
<input
type="datetime-local"
ref={endDateInputRef}
className="form-control"
value={cert.endDate}
onClick={() => {
if (endDateInputRef.current?.showPicker) {
endDateInputRef.current.showPicker();
}
}}
onChange={e => updateField(idx, 'endDate', e.target.value)}
/>
</div>
<label>
*Время указывается по МСК
</label>
</div>
<div className="form-group d-flex flex-column align-items-center" style={{ gap: '1rem' }}>
{cert.picture && (
<img
src={cert.picture}
alt="Портфолио"
style={{ width: '10rem', height: '10rem', objectFit: 'cover', borderRadius: '4px' }}
onError={e => e.currentTarget.src = '/placeholder.png'}
/>
)}
</div>
</div>
</div>
<div className='d-flex justify-content-start'>
<button
className="btn btn-danger"
onClick={() => deleteCertificate(idx)}
>
Удалить
</button>
</div>
</div>
))}
</>
);
}
+25
View File
@@ -0,0 +1,25 @@
import React from 'react';
import JoditEditor from 'jodit-react';
export function CertEditor({ content, setContent, readOnly = false }) {
const config = {
readonly: readOnly,
height: 400,
toolbarButtonSize: 'medium',
buttons: [
'bold', 'italic', 'underline', 'strikethrough',
'ul', 'ol', 'indent', 'outdent',
'font', 'fontsize', 'brush', 'paragraph',
'align', 'link', 'undo', 'redo'
]
};
return (
<JoditEditor
value={content}
config={config}
onBlur={newContent => setContent(newContent)}
onChange={newContent => {}}
/>
);
}
+26
View File
@@ -0,0 +1,26 @@
import React from 'react';
import JoditEditor from 'jodit-react';
export function TextEditor( { content, setContent, readOnly = false } ) {
const config = {
readonly: readOnly,
height: 400,
toolbarButtonSize: 'medium',
buttons: [
'source',
'bold', 'italic', 'underline', 'strikethrough',
'ul', 'ol', 'indent', 'outdent',
'font', 'fontsize', 'brush', 'paragraph',
'align', 'link', 'undo', 'redo'
]
};
return (
<JoditEditor
value={content}
config={config}
onBlur={newContent => setContent(newContent)}
onChange={newContent => {}}
/>
);
}
+48
View File
@@ -0,0 +1,48 @@
export const EditElementForm = ({
navigateBack,
header,
handleSave,
handleDelete = null,
isAddSpecialist = false,
children
}) => (
<div className="container-fluid">
<button
className="btn btn-link mb-3"
onClick={ () => navigateBack() }
>
Назад
</button>
<div className="card shadow mb-4">
<div className="card-header py-3 d-flex justify-content-between align-items-center">
<h6 className="m-0 font-weight-bold text-primary">
{ header }
</h6>
</div>
<div className="card-body">
{ children }
<div className="d-flex justify-content-start">
<button
className="btn btn-primary mr-2"
onClick={ handleSave }
>
Сохранить
</button>
<button
className="btn btn-secondary"
onClick={ () => navigateBack() }
>
Отмена
</button>
{!isAddSpecialist &&
<button
className="btn btn-danger ml-auto"
onClick={ handleDelete }
>
Удалить
</button>}
</div>
</div>
</div>
</div>
);
+11
View File
@@ -0,0 +1,11 @@
export const Input = ({ value, onChange, placeholder, type = 'text' }) => {
return (
<input
type={type}
className="form-control"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
/>
);
};
+25
View File
@@ -0,0 +1,25 @@
import React, { useState, forwardRef } from 'react';
import { IMaskInput } from 'react-imask';
export const PhoneInput = forwardRef(function PhoneInput(props, ref) {
const { value, onChange, className = 'form-control', placeholder = '+7 (___) ___-__-__', ...rest } = props;
const [internal, setInternal] = useState(value ?? '');
return (
<IMaskInput
mask="+7 (000) 000-00-00"
value={value ?? internal}
onAccept={(val, mask) => {
if (typeof onChange === 'function') {
onChange(val);
} else {
setInternal(val);
}
}}
ref={ref}
placeholder={placeholder}
className={className}
{...rest}
/>
);
});
+58
View File
@@ -0,0 +1,58 @@
import { useState, useRef, useEffect } from 'react';
export function TagKodoperStaticInput({ initialTags = [], tags, disabled = false }) {
// const [tags, setTags] = useState(initialTags);
const [inputValue, setInputValue] = useState('');
const inputRef = useRef(null);
// useEffect(() => {
// setTags(initialTags);
// }, [initialTags]);
/*
const addTag = tag => {
const trimmed = tag.trim();
if (trimmed && !tags.includes(trimmed)) {
const newTags = [...tags, trimmed];
setTags(newTags);
onChange(newTags);
}
};
const removeTag = idx => {
const newTags = tags.filter((_, i) => i !== idx);
setTags(newTags);
onChange(newTags);
};
*/
/*
const handleKeyDown = e => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addTag(inputValue);
setInputValue('');
}
if (e.key === 'Backspace' && !inputValue) {
removeTag(tags.length - 1);
}
};
*/
return (
<div
className="form-control d-flex flex-wrap align-items-center text-muted"
style={{ minHeight: 'auto', cursor: 'text', height: 'auto' }}
// onClick={() => inputRef.current.focus()}
disabled={ disabled }
>
{tags.map((tag, idx) => (
<span
key={idx}
style={{ fontSize: '1rem' }}
className="badge bg-info me-1 mb-1 d-flex align-items-center text-white mr-1"
>
<strong>{tag}</strong>
</span>
))}
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { useState, useRef, useEffect } from 'react';
export function TagStaticInput({ initTags, disabled = false, onRemove }) {
const [tags, setTags] = useState([])
useEffect(() => {
setTags([...initTags])
}, [initTags])
const removeTag = idx => {
const removingTag = tags.find((_, i) => i === idx);
const newTags = tags.filter((_, i) => i !== idx);
// setTags(newTags);
onRemove(removingTag);
};
return (
<div
className="form-control d-flex flex-wrap align-items-center text-muted"
style={{ minHeight: 'auto', cursor: 'text', height: 'auto' }}
disabled={ disabled }
>
{tags.map((tag, idx) => (
<span
key={idx}
style={{ fontSize: '1rem' }}
className="badge bg-info me-1 mb-1 d-flex align-items-center text-white mr-1"
>
<strong>{tag}</strong>
<button
type="button"
className="ml-1 mb-1 close text-red"
aria-label="Close"
onClick={() => removeTag(idx)}
>
<span aria-hidden="true">&times;</span>
</button>
</span>
))}
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { useState, useRef, useEffect } from 'react';
export function TagInput({ initialTags = [], onChange, placeholder, disabled = false }) {
const [tags, setTags] = useState(initialTags);
const [inputValue, setInputValue] = useState('');
const inputRef = useRef(null);
useEffect(() => {
setTags(initialTags);
}, [initialTags]);
const addTag = tag => {
const trimmed = tag.trim();
if (trimmed && !tags.includes(trimmed)) {
const newTags = [...tags, trimmed];
setTags(newTags);
onChange(newTags);
}
};
const removeTag = idx => {
const newTags = tags.filter((_, i) => i !== idx);
setTags(newTags);
onChange(newTags);
};
const handleKeyDown = e => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addTag(inputValue);
setInputValue('');
}
if (e.key === 'Backspace' && !inputValue) {
removeTag(tags.length - 1);
}
};
return (
<div
className="form-control d-flex flex-wrap align-items-center text-muted"
style={{ minHeight: 'auto', cursor: 'text', height: 'auto' }}
onClick={() => inputRef.current.focus()}
disabled={ disabled }
>
{tags.map((tag, idx) => (
<span
key={idx}
style={{ fontSize: '1rem' }}
className="badge bg-info me-1 mb-1 d-flex align-items-center text-white mr-1"
>
{tag}
{ !disabled &&
<button
type="button"
className="ml-1 mb-1 close text-red"
aria-label="Close"
onClick={() => removeTag(idx)}
>
<span aria-hidden="true">&times;</span>
</button>
}
</span>
))}
{ !disabled &&
<input
ref={inputRef}
type="text"
className="border-0 flex-grow-1"
style={{ minWidth: '8ch', outline: 'none', textOverflow: 'ellipsis' }}
placeholder={placeholder}
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
/>
}
</div>
);
}
+246
View File
@@ -0,0 +1,246 @@
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import ReactDOM from 'react-dom';
import { useGetIDoctorsQuery } from '/src/api/apiIDoctor';
import { selectRegions } from '/src/store/slice/regionSlice';
function DcodeModal({ isOpen, onCancel, onConfirm, initialSelectedItems=[], departments=[], filials=[]}) {
const [searchValue, setSearchValue] = useState('');
const [searchParam, setSearchParam] = useState('search');
const [selectedItems, setSelectedItems] = useState([]);
const regions = useSelector( selectRegions );
const getAdress = (filial) => {
const curentFilial = filials.find(item => Number(item.id) === Number(filial));
if (curentFilial) return `${regions[curentFilial.regionId]}, ${curentFilial.shortName}`
}
const getDepartment = (department) => {
const currentDepartment = departments.find(item => Number(item.id) === Number(department))
if (currentDepartment) return currentDepartment.name
}
const {
data: response,
isFetching,
error,
} = useGetIDoctorsQuery(
{ type: searchParam, value: searchValue },
{ skip: searchValue.length < 3 }
);
const items = response?.data ?? [];
useEffect(() => {
if (isOpen) {
document.body.classList.add('modal-open');
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.paddingRight = `${scrollbarWidth}px`;
} else {
document.body.classList.remove('modal-open');
document.body.style.paddingRight = '';
setSearchValue('');
setSearchParam('search');
setSelectedItems([...initialSelectedItems.map(item => ({ ...item, key: `${item.dcode}${item.department}${item.filial}${item.name}` }))]);
}
return () => {
document.body.classList.remove('modal-open');
document.body.style.paddingRight = '';
};
}, [isOpen, initialSelectedItems]);
if (!isOpen) return null;
const toggleSelect = (item) => {
const key = `${item.dcode}${item.department}${item.filial}${item.name}`;
if ( selectedItems.find( item => item.key === key ) ) toggleRemove(item);
else {
toggleAdd(item);
}
};
const toggleAdd = (item) => {
setSelectedItems([ ...selectedItems, { ...item, key: `${item.dcode}${item.department}${item.filial}${item.name}`} ]);
}
const toggleRemove = (removingItem) => {
setSelectedItems( selectedItems.filter( item => item.key !== removingItem.key ));
}
const renderItem = (item) => {
const key = `${item.dcode}${item.department}${item.filial}${item.name}`;
const isActive = selectedItems.some(i => i.key === key);
return (
<div key={key}
className={`list-group-item pl-1 ${isActive ? 'active' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => {
toggleSelect(item)
}}
>
<ul style={{ listStyle: 'none' }}>
<li><strong>dcode: </strong>{item.dcode}</li>
<li><strong>ФИО: </strong>{item.name}</li>
<li>
<strong>ID отделения: </strong>{item.department}
</li>
<li>
<strong>Отделение: </strong>{departments.find(department => Number(department.id) === Number(item.department))?.name}
</li>
<li><strong>Адрес: </strong>{getAdress(item.filial)}</li>
<li>
<label className="form-check-label"
>
<strong>Отображать на сайте:</strong>
</label>
<input
type="checkbox"
className="form-check-input ml-2"
checked={ !item.onlineMode }
/>
</li>
</ul>
</div>
);
};
return ReactDOM.createPortal(
<>
<div className="modal-backdrop fade show"></div>
<div className="modal fade show" tabIndex="-1" role="dialog" style={{ display: 'block' }} aria-modal="true">
<div className="modal-dialog" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Добавить расписание из Инфоклиники</h5>
</div>
<div className="modal-body">
<div className="form-row mb-3">
<div className="mr-3">
<label>Параметр поиска</label>
<select
className="form-control"
value={searchParam}
onChange={e => {
setSearchValue('');
setSearchParam(e.target.value);
}}
>
<option value="dcode">dcode</option>
<option value="search">ФИО</option>
</select>
</div>
<div className="col">
<label>Поиск</label>
<input
className="form-control"
value={searchValue}
onChange={e => {
setSearchValue(e.target.value)
}}
placeholder="Введите не менее 3 символов..."
/>
</div>
</div>
<div className="mb-3">
<h6>Расписание из Инфоклиники:</h6>
<div className="list-group">
<div
className="d-flex flex-column align-items-start"
>
{selectedItems.map(item => (
<div className="card-body bg-primary text-white border rounded mt-2"
style={{
width: '100%',
position: 'relative'
}}
key={item.key}
>
<ul style={{ listStyle: 'none' }} className='mb-0'>
<li><strong>dcode: </strong>{item.dcode}</li>
<li><strong>ФИО: </strong>{item.name}</li>
<li><strong>Отделение: </strong>{departments.find(department => Number(department.id) === Number(item.department))?.name ?? 'не найдено'}</li>
<li><strong>Адрес: </strong>{getAdress(item.filial)}</li>
<li>
<label className="form-check-label">
<strong>Отображать на сайте:</strong>
</label>
<input
type="checkbox"
className="form-check-input ml-2"
checked={ !item.onlineMode }
/>
</li>
</ul>
<button
type="button"
className="ml-1 mb-1 close text-white"
style={{ position: 'absolute', top: '0', right: '0.4rem' }}
aria-label="Close"
onClick={() => toggleRemove(item)}
>
<span aria-hidden="true">&times;</span>
</button>
</div>
))}
</div>
</div>
</div>
<hr />
{searchValue.length < 3 ? (
<small className="text-muted">Введите не менее 3 символов для поиска</small>
) : isFetching ? (
<p>Идёт поиск</p>
) : error ? (
<p className="text-danger">Ошибка при запросе</p>
) : items.length > 0 ? (
<>
<h6>Найдено:</h6>
<ul className="list-group">
{items.map((item, inx) => renderItem(item, inx))}
</ul>
</>
) : (
<p>Не найдено</p>
)}
</div>
<div className="modal-footer">
<button
className="btn btn-success mr-auto"
onClick={() => {
onConfirm(selectedItems)
onCancel()
}}
>
Добавить
</button>
<button type="button" className="btn btn-secondary"
onClick={onCancel}
>
Отменить
</button>
</div>
</div>
</div>
</div>
</>,
document.body
);
}
export default DcodeModal;
+282
View File
@@ -0,0 +1,282 @@
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import ReactDOM from 'react-dom';
import { useGetKodopersQuery } from '/src/api/apiKodoper';
import { useGetFilialsQuery } from '/src/api/apiFilial';
import { selectRegions } from '/src/store/slice/regionSlice';
function KodoperModal({ isOpen, onCancel, onConfirm, initialSelectedItems = [] }) {
const [searchValue, setSearchValue] = useState('');
const [currentFilial, setCurrentFilial] = useState(-1);
const [currentPage, setCurrentPage] = useState(1);
const [selectedItems, setSelectedItems] = useState([]);
const [selectedObjs, setSelectedObjs] = useState([]);
const { data: filialsRaw = {}, isLoading: loadingFilials } = useGetFilialsQuery();
const filials = filialsRaw.data || [];
const filialOptions = [{ fid: -1, name: 'Все филиалы' }, ...filials];
// Query parameters include search, filial, and page
const skip = searchValue.length < 3;
const {
data: response = {},
isFetching,
error: queryError,
} = useGetKodopersQuery(
{
value: searchValue,
filialId: currentFilial,
page: currentPage
},
{ skip }
);
const items = response.data || [];
const pagination = response.pagination || {};
// Toggle selection
const toggleSelect = (item) => {
const code = item.kodoper;
const exists = selectedItems.includes(code);
if (exists) {
setSelectedItems(selectedItems.filter(c => c !== code));
setSelectedObjs(selectedObjs.filter(o => o.kodoper !== code));
} else {
setSelectedItems([...selectedItems, code]);
setSelectedObjs([...selectedObjs, item]);
}
};
useEffect(() => {
if (isOpen) {
document.body.classList.add('modal-open');
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.paddingRight = `${scrollbarWidth}px`;
} else {
document.body.classList.remove('modal-open');
document.body.style.paddingRight = '';
setSearchValue('');
if (initialSelectedItems === null) return
setSelectedItems(initialSelectedItems);
// setSelectedObjs(initialSelectedItems);
}
return () => {
document.body.classList.remove('modal-open');
document.body.style.paddingRight = '';
};
}, [isOpen, initialSelectedItems]);
if (!isOpen) return null;
// Pagination controls
const renderPagination = () => {
const total = pagination.total_pages || 1;
const current = pagination.current_page || 1;
const pages = new Set();
// 1 и последняя
pages.add(1);
pages.add(total);
// текущая, две до и две после
for (let p = current - 2; p <= current + 2; p++) {
if (p > 1 && p < total) pages.add(p);
}
// сортируем
const sorted = Array.from(pages).sort((a, b) => a - b);
const items = [];
let last = 0;
sorted.forEach(page => {
if (last && page - last > 1) {
// разрыв — вставляем «...»
items.push(
<li key={`dots-${last}`} className="page-item disabled">
<span className="page-link"></span>
</li>
);
}
items.push(
<li
key={page}
className={`page-item ${page === current ? 'active' : ''}`}
>
<button
className="page-link"
onClick={() => {
if (page !== current) setCurrentPage(page);
}}
>
{page}
</button>
</li>
);
last = page;
});
return (
<nav>
<ul className="pagination justify-content-center">
{/* Кнопка «предыдущая» */}
<li className={`page-item ${!pagination.has_previous_page ? 'disabled' : ''}`}>
<button
className="page-link"
onClick={() => pagination.has_previous_page && setCurrentPage(current - 1)}
>
&laquo;
</button>
</li>
{/* Номера страниц и «...» */}
{items}
{/* Кнопка «следующая» */}
<li className={`page-item ${!pagination.has_next_page ? 'disabled' : ''}`}>
<button
className="page-link"
onClick={() => pagination.has_next_page && setCurrentPage(current + 1)}
>
&raquo;
</button>
</li>
</ul>
</nav>
);
};
return ReactDOM.createPortal(
<>
<div className="modal-backdrop fade show"></div>
<div className="modal fade show" tabIndex="-1" role="dialog" style={{ display: 'block' }} aria-modal="true">
<div className="modal-dialog" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Добавить услугу</h5>
</div>
<div className="modal-body">
<div className="mb-3">
<label>Филиал</label>
<select
className="form-control"
value={currentFilial}
onChange={e => {
setCurrentFilial(Number(e.target.value));
setCurrentPage(1);
}}
>
{filialOptions.map(f => <option key={f.fid} value={f.fid}>{f.name}</option>)}
</select>
</div>
<div className="form-row mb-3">
<div className="col">
<label>Поиск</label>
<input
className="form-control"
value={searchValue}
onChange={e => { setSearchValue(e.target.value); setCurrentPage(1); }}
placeholder="Введите не менее 3 символов..."
/>
</div>
</div>
<div className="mb-3">
<h6>Выбранные уникальные медицинские коды:</h6>
<div className="list-group">
<div
className="d-flex flex-wrap align-items-center text-muted"
>
{selectedItems.map((code, i) => (
<span
key={i}
className="badge bg-info me-1 mb-1 d-flex align-items-center text-white mr-1"
onClick={() => toggleSelect({ kodoper: code })}
style={{ fontSize: '1rem', cursor: 'pointer' }}
>
{code}
<button
type="button"
className="ml-1 mb-1 close text-red"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
</span>
))}
</div>
</div>
</div>
<hr />
{searchValue.length < 3 ? (
<small className="text-muted">Введите не менее 3 символов для поиска</small>
) : isFetching ? (
<p>Идёт поиск</p>
) : queryError ? (
<p className="text-danger">Ошибка при запросе</p>
) : (
<>
<div className="table-responsive">
<table className="table table-hover table-sm">
<thead className="table-light">
<tr>
<th>Мед.код</th>
<th>Услуга</th>
<th>Стоимость</th>
</tr>
</thead>
<tbody>
{items.map((item, idx) => (
<tr key={idx} className={selectedItems.includes(item.kodoper) ? 'table-primary' : ''} style={{ cursor: 'pointer' }} onClick={() => toggleSelect(item)}>
<td>{item.kodoper}</td>
<td>{item.schname}</td>
<td>{item.priceInfo.price} </td>
</tr>
))}
</tbody>
</table>
</div>
{renderPagination()}
</>
)}
</div>
<div className="modal-footer">
<button
className="btn btn-success mr-auto"
onClick={() => {
onConfirm(selectedItems, selectedObjs)
onCancel()
}}
>
Добавить
</button>
<button type="button" className="btn btn-secondary"
onClick={onCancel}
// onClick={() => console.log(selectedObjs)}
>
Отменить
</button>
</div>
</div>
</div>
</div>
</>,
document.body
);
}
export default KodoperModal;
+75
View File
@@ -0,0 +1,75 @@
import { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
export const Modal = ({ isOpen, onCancel, onConfirm, hasDangerButton = true, title, hasButtons, confirmText, children }) => {
useEffect(() => {
if (isOpen) {
document.body.classList.add('modal-open');
const scrollbarWidth =
window.innerWidth - document.documentElement.clientWidth;
document.body.style.paddingRight = `${scrollbarWidth}px`;
} else {
document.body.classList.remove('modal-open');
document.body.style.paddingRight = '';
}
return () => {
document.body.classList.remove('modal-open');
document.body.style.paddingRight = '';
};
}, [isOpen]);
const closeRef = useRef(null);
useEffect(() => {
if (isOpen && hasButtons) {
closeRef.current.focus();
}
}, [isOpen, hasButtons]);
if (!isOpen) return null;
return ReactDOM.createPortal(
<>
<div className="modal-backdrop fade show"></div>
<div
className="modal fade show"
tabIndex="-1"
role="dialog"
style={{ display: 'block' }}
aria-modal="true"
>
<div className="modal-dialog" role="document">
<div className="modal-content">
<div className="modal-header">
{title && <h5 className="modal-title">{title}</h5>}
</div>
<div className="modal-body">{children}</div>
{hasButtons &&
<div className="modal-footer">
{ hasDangerButton &&
<button
className="btn btn-danger mr-auto"
onClick={onConfirm}
>
{ confirmText }
</button>
}
<button
type="button"
className="btn btn-success"
ref={closeRef}
onClick={onCancel}
>
Отменить
</button>
</div>
}
</div>
</div>
</div>
</>,
document.body
);
}
export default Modal;
+47
View File
@@ -0,0 +1,47 @@
import { Modal } from './/Modal';
import { LoadingComponent } from '../Placeholders/LoadingComponent';
import { ErrorComponent } from '../Placeholders/ErrorComponent';
export const ResponseModals = ({ modal, setModal }) => {
switch (modal) {
case 'loading':
return (
<Modal
isOpen={ true }
title="Внесение изменений"
hasButtons={ false }
>
<LoadingComponent />
</Modal>
)
case 'error':
return (
<Modal
isOpen={ true }
title="Ошибка"
hasButtons={ true }
hasDangerButton={ false }
onCancel={() => setModal( undefined )}
>
<ErrorComponent />
</Modal>
)
case 'success':
return (
<Modal
isOpen={ true }
title="Изменения внесены"
hasButtons={ false }
>
<p className="mb-1">
Изменения успешно внесены.
</p>
</Modal>
)
default:
return null
}
};
+388
View File
@@ -0,0 +1,388 @@
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import ReactDOM from 'react-dom';
import { useGetIDoctorsQuery } from '/src/api/apiIDoctor';
import { selectRegions } from '/src/store/slice/regionSlice';
import { useGetStocksQuery } from '/src/api/apiStock';
export function StockModal({ isOpen, onCancel, onConfirm, initialSelectedItems=[], departments=[], filials=[]}) {
const [searchValue, setSearchValue] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [selectedObjs, setSelectedObjs] = useState([]);
const skip = false; //searchValue.length < 3;
const {
data: response = {},
isFetching,
error: queryError,
} = useGetStocksQuery({
search: searchValue,
page: currentPage,
}, {skip});
const items = response.data ? (response.data.map(init => {
const dateStart = new Date(init.startDate);
const year = dateStart.getFullYear();
const month = String(dateStart.getMonth() + 1).padStart(2, '0'); // Month is 0-indexed
const day = String(dateStart.getDate()).padStart(2, '0');
const hours = String(dateStart.getHours()).padStart(2, '0');
const minutes = String(dateStart.getMinutes()).padStart(2, '0');
const datetimeLocalValue = `${year}-${month}-${day}T${hours}:${minutes}`;
const dateEnd = new Date(init.endDate);
const year1 = dateEnd.getFullYear();
const month1 = String(dateEnd.getMonth() + 1).padStart(2, '0'); // Month is 0-indexed
const day1 = String(dateEnd.getDate()).padStart(2, '0');
const hours1 = String(dateEnd.getHours()).padStart(2, '0');
const minutes1 = String(dateEnd.getMinutes()).padStart(2, '0');
const datetimeLocalValue1 = `${year1}-${month1}-${day1}T${hours1}:${minutes1}`;
return ({ ...init, startDate: datetimeLocalValue, endDate: datetimeLocalValue1 });
})) : [];
const pagination = response.pagination || {};
const renderPagination = () => {
const total = pagination.total_pages || 1;
const current = pagination.current_page || 1;
const pages = new Set([1, total]);
for (let p = current - 2; p <= current + 2; p++) {
if (p > 1 && p < total) pages.add(p);
}
const sorted = Array.from(pages).sort((a, b) => a - b);
const elems = [];
let last = 0;
sorted.forEach(page => {
if (last && page - last > 1) {
elems.push(
<li key={`dots-${last}`} className="page-item disabled">
<span className="page-link"></span>
</li>
);
}
elems.push(
<li key={page} className={`page-item ${page === current ? 'active' : ''}`}>
<button
className="page-link"
onClick={() => page !== current && setCurrentPage(page)}
>
{page}
</button>
</li>
);
last = page;
});
return (
<nav>
<ul className="pagination justify-content-center">
<li className={`page-item ${!pagination.has_previous_page ? 'disabled' : ''}`}>
<button
className="page-link"
onClick={() =>
pagination.has_previous_page && setCurrentPage(current - 1)
}
>
&laquo;
</button>
</li>
{elems}
<li className={`page-item ${!pagination.has_next_page ? 'disabled' : ''}`}>
<button
className="page-link"
onClick={() =>
pagination.has_next_page && setCurrentPage(current + 1)
}
>
&raquo;
</button>
</li>
</ul>
</nav>
);
};
const [searchParam, setSearchParam] = useState('search');
const [selectedItems, setSelectedItems] = useState([]);
const regions = useSelector( selectRegions );
const getAdress = (filial) => {
const curentFilial = filials.find(item => Number(item.id) === Number(filial));
if (curentFilial) return `${regions[curentFilial.regionId]}, ${curentFilial.shortName}`
}
const getDepartment = (department) => {
const currentDepartment = departments.find(item => Number(item.id) === Number(department))
if (currentDepartment) return currentDepartment.name
}
useEffect(() => {
if (isOpen) {
document.body.classList.add('modal-open');
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.paddingRight = `${scrollbarWidth}px`;
} else {
document.body.classList.remove('modal-open');
document.body.style.paddingRight = '';
//console.log(initialSelectedItems)
setSearchValue('');
setSearchParam('search');
setSelectedObjs([...initialSelectedItems])
setSelectedItems([...initialSelectedItems.map(item => ({ ...item, key: `${item.dcode}${item.department}${item.filial}` }))]);
}
return () => {
document.body.classList.remove('modal-open');
document.body.style.paddingRight = '';
};
}, [isOpen, initialSelectedItems]);
if (!isOpen) return null;
const toggleSelect = (stock) => {
/*const key = `${item.dcode}${item.department}${item.filial}`;
if ( selectedItems.find( item => item.key === key ) ) toggleRemove(item);
else {
toggleAdd(item);
console.log(2222)
}*/
//console.log(stock)
//if (selectedItems.includes(stock.id)) {
// setSelectedItems([selectedItems.filter(({id}) => id !== stock.id)])
if (selectedObjs.find(({id}) => stock.id === id)) {
setSelectedObjs([...selectedObjs.filter(({id}) => id !== stock.id)])
return
}
// setSelectedItems([...selectedItems, stock.id])
setSelectedObjs([...selectedObjs, stock])
};
const toggleAdd = (item) => {
/* console.log(item)
setSelectedItems([ ...selectedItems, { ...item, key: `${item.dcode}${item.department}${item.filial}`} ]);
*/
}
const toggleRemove = (removingItem) => {
//setSelectedItems( selectedItems.filter( item => item.key !== removingItem.key ));
}
const renderItem = (item) => {
// const isActive = selectedItems.some(i => i === item.dcode);
//const key = `${item.dcode}${item.department}${item.filial}`;
//const isActive = selectedItems.some(i => i.key === key);
const isActive = selectedObjs.find(({id}) => item.id === id)
return (
<div key={item.id}
className={`list-group-item pl-1 ${isActive ? 'active' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => {
//console.log(item)
toggleSelect(item)
}}
>
<ul style={{ listStyle: 'none' }}>
<li><strong>dcode: </strong>{item.dcode}</li>
<li><strong>ФИО: </strong>{item.name}</li>
<li><strong>Отделение: </strong>{departments.find(department => Number(department.id) === Number(item.department))?.name}</li>
<li><strong>Адрес: </strong>{getAdress(item.filial)}</li>
<li>
<label className="form-check-label"
>
<strong>Отображать на сайте:</strong>
</label>
<input
type="checkbox"
className="form-check-input ml-2"
checked={ !item.onlineMode }
/>
</li>
</ul>
</div>
);
};
return ReactDOM.createPortal(
<>
<div className="modal-backdrop fade show"></div>
<div className="modal fade show" tabIndex="-1" role="dialog" style={{ display: 'block' }} aria-modal="true">
<div className="modal-dialog modal-lg" role="document"
>
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Добавить акции</h5>
</div>
<div className="modal-body">
<div className="mb-2">
<div className="">
<label>Поиск</label>
<input
type="text"
className="form-control"
placeholder=""
value={searchValue}
onChange={e => {
setSearchValue(e.target.value);
setCurrentPage(1);
}}
/>
</div>
</div>
<div className="mb-3">
<h6>Выбранные акции:</h6>
<div className="list-group">
<div
className="d-flex flex-wrap align-items-center text-muted"
>
{selectedObjs.map((stock) => (
<span
key={stock.id}
className="badge bg-info me-1 mb-1 d-flex align-items-center text-white mr-1"
onClick={() => toggleSelect(stock)}
style={{ fontSize: '1rem', cursor: 'pointer' }}
>
{stock.name}
<button
type="button"
className="ml-1 mb-1 close text-red"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
</span>
))}
{/*selectedItems.map(item => (
<div className="card-body bg-primary text-white border rounded mt-2"
style={{
width: '100%',
position: 'relative'
}}
key={item.key}
>
<ul style={{ listStyle: 'none' }} className='mb-0'>
<li><strong>dcode: </strong>{item.dcode}</li>
<li><strong>ФИО: </strong>{item.name}</li>
<li><strong>Отделение: </strong>{departments.find(department => Number(department.id) === Number(item.department))?.name ?? 'не найдено'}</li>
<li><strong>Адрес: </strong>{getAdress(item.filial)}</li>
<li>
<label className="form-check-label">
<strong>Отображать на сайте:</strong>
</label>
<input
type="checkbox"
className="form-check-input ml-2"
checked={ !item.onlineMode }
/>
</li>
</ul>
<button
type="button"
className="ml-1 mb-1 close text-white"
style={{ position: 'absolute', top: '0', right: '0.4rem' }}
aria-label="Close"
onClick={() => toggleRemove(item)}
>
<span aria-hidden="true">&times;</span>
</button>
</div>
))*/}
</div>
</div>
</div>
<hr />
{isFetching ? (
<p>Загрузка...</p>
) : queryError ? (
<p className="text-danger">Ошибка при загрузке: {String(queryError)}</p>
) : (
<>
<div className="table-responsive">
<table className="table table-hover table-bordered">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Начало акции</th>
<th>Окончание акции</th>
</tr>
</thead>
<tbody>
{items.map(stock => (
<tr key={stock.id} className={/*selectedItems.includes(stock.id)*/ selectedObjs.find(({id}) => stock.id === id) ? 'table-primary' : ''} style={{ cursor: 'pointer'}}
onClick={() => toggleSelect(stock)}
>
<td>{stock.id}</td>
<td>{stock.name}</td>
<td>
<input
type="datetime-local"
className="form-control"
style={{ cursor: 'pointer' }}
value={stock.startDate}
onClick={() => toggleSelect(stock)}
readOnly={true}
/>
</td>
<td>
<input
type="datetime-local"
className="form-control"
style={{ cursor: 'pointer'}}
value={stock.endDate}
onClick={() => toggleSelect(stock)}
readOnly={true}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{renderPagination()}
</>
)}
</div>
<div className="modal-footer">
<button
className="btn btn-success mr-auto"
onClick={() => {
console.log(selectedObjs)
onConfirm(selectedObjs)
onCancel()
}}
>
Добавить
</button>
<button type="button" className="btn btn-secondary"
onClick={onCancel}
>
Отменить
</button>
</div>
</div>
</div>
</div>
</>,
document.body
);
}
+82
View File
@@ -0,0 +1,82 @@
import { Link } from 'react-router-dom';
import { useState, useRef, useEffect } from 'react';
import { UserBurger } from '../UserBurger/UserBurger';
import { SidebarNavItem } from '../SidebarNavItem/SidebarNavItem';
import styles from './Navbar.module.scss';
export const Navbar = () => {
const links = [
{ to: '/', icon: 'fas fa-home', label: 'Главная', end: true },
{ to: '/specialist', icon: 'fas fa-user-md', label: 'Врачи' },
{ to: '/lostDoctors', icon: 'fas fa-address-card', label: 'Врачи-потеряшки' },
{ to: '/infoclinic', icon: 'fas fa-table', label: 'Расписание ИК' },
{ to: '/prices',icon: 'fas fa-receipt', label: 'Цены и услуги' },
{ to: '/promotions',icon: 'fas fa-percent', label: 'Акции' },
{ to: '/departments',icon: 'fas fa-stethoscope', label: 'Отделения' },
{ to: '/filials',icon: 'fas fa-building', label: 'Филиалы' },
{ to: '/news', icon: 'fas fa-newspaper', label: 'Новости' },
{ to: '/site-promo', icon: 'fas fa-bullhorn', label: 'Промо (контент)' },
{ to: '/disease', icon: 'fas fa-heartbeat', label: 'Заболевания' },
{ to: '/medical-center', icon: 'fas fa-hospital', label: 'Медцентры' },
{ to: '/article', icon: 'fas fa-file-alt', label: 'Статьи' },
{ to: '/site-services', icon: 'fas fa-concierge-bell', label: 'Услуги сайта' },
];
const [open, setOpen] = useState(false);
const toggleRef = useRef(null);
const menuRef = useRef(null);
useEffect(() => {
const handleOutside = e => {
if (
menuRef.current &&
!menuRef.current.contains(e.target) &&
toggleRef.current &&
!toggleRef.current.contains(e.target)
) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleOutside)
return () => document.removeEventListener('mousedown', handleOutside)
}, []);
return (
<nav className={`navbar navbar-expand navbar-light topbar mb-4 static-top shadow ${styles['navbar-background']}`}>
<div className="d-lg-none mr-3">
<button
className="btn btn-outline-success"
type="button"
onClick={() => setOpen(o => !o)}
aria-haspopup="true"
aria-expanded={open}
ref={toggleRef}
>
<span className="navbar-toggler-icon"></span>
</button>
{open && (
<div
ref={menuRef}
className="dropdown-menu dropdown-menu-left shadow animated--grow-in show bg-gradient-primary sidebar-dark"
aria-labelledby="userMenu"
>
{links.map(({ to, icon, label, end }) => (
<SidebarNavItem
key={to}
to={to}
icon={icon}
label={label}
end={end}
onClick={() => setOpen(false)}
/>
))}
</div>
)}
</div>
<Link className="navbar-brand" to="/">
<img src="/src/assets/logo.png" alt="" style={{height: '3rem'}}/>
</Link>
<UserBurger />
</nav>
)
};
+3
View File
@@ -0,0 +1,3 @@
.navbar-background {
background-color: #e9f7ef !important;
}
+45
View File
@@ -0,0 +1,45 @@
import React from 'react';
export const PageNav = React.memo(({ currentPage, totalPages, onPageChange }) =>
totalPages === 1 ? null : (
<nav aria-label="Page navigation">
<ul className="pagination justify-content-center">
<li className={ `page-item ${ currentPage === 1 ? 'disabled' : '' }` }>
<button
className="page-link"
onClick={ () => onPageChange( cp => Math.max(cp - 1, 1) ) }
disabled={ currentPage === 1 }
>
Назад
</button>
</li>
{Array.from({ length: totalPages }, (_, index) => index + 1).map(page => (
<li
key={ page }
className={ `page-item ${ currentPage === page ? 'active' : '' }` }
>
<button
className="page-link"
onClick={ () => onPageChange( () => page ) }
>
{ page }
</button>
</li>
))}
<li className={ `page-item ${ currentPage === totalPages ? 'disabled' : '' }` }>
<button
className="page-link"
onClick={() =>
onPageChange((currentPage) => Math.min( currentPage + 1, totalPages ) )
}
disabled={ currentPage === totalPages }
>
Вперед
</button>
</li>
</ul>
</nav>
)
);
@@ -0,0 +1,7 @@
export const ErrorComponent = () => {
return (
<div className="alert alert-danger">
Ошибка загрузки
</div>
);
};
@@ -0,0 +1,11 @@
export const LoadingComponent = () => {
return (
<div className="text-center my-5">
<div className="spinner-border text-success" role="status">
<span className="sr-only">
Загрузка...
</span>
</div>
</div>
);
};
@@ -0,0 +1,10 @@
export const NotFindElement = ({ message, navigateBack }) => (
<div className="my-4">
<div className="alert alert-warning">
{message}
</div>
<button className="btn btn-secondary" onClick={() => navigateBack()}>
Назад
</button>
</div>
);
+34
View File
@@ -0,0 +1,34 @@
import { SidebarNavItem } from '../SidebarNavItem/SidebarNavItem';
export const Sidebar = () => {
const links = [
{ to: '/', icon: 'fas fa-home', label: 'Главная', end: true },
{ to: '/specialist', icon: 'fas fa-user-md', label: 'Врачи' },
{ to: '/lostDoctors', icon: 'fas fa-address-card', label: 'Врачи-потеряшки' },
{ to: '/infoclinic', icon: 'fas fa-table', label: 'Расписание ИК' },
{ to: '/prices',icon: 'fas fa-receipt', label: 'Цены и услуги' },
{ to: '/promotions',icon: 'fas fa-percent', label: 'Акции' },
{ to: '/departments',icon: 'fas fa-stethoscope', label: 'Отделения' },
{ to: '/filials',icon: 'fas fa-building', label: 'Филиалы' },
{ to: '/news', icon: 'fas fa-newspaper', label: 'Новости' },
{ to: '/site-promo', icon: 'fas fa-bullhorn', label: 'Промо (контент)' },
{ to: '/disease', icon: 'fas fa-heartbeat', label: 'Заболевания' },
{ to: '/medical-center', icon: 'fas fa-hospital', label: 'Медцентры' },
{ to: '/article', icon: 'fas fa-file-alt', label: 'Статьи' },
{ to: '/site-services', icon: 'fas fa-concierge-bell', label: 'Услуги сайта' },
];
return (
<ul className={`navbar-nav bg-gradient-primary sidebar sidebar-dark accordion d-none d-lg-block`}>
{links.map(({ to, icon, label, end }) => (
<SidebarNavItem
key={to}
to={to}
icon={icon}
label={label}
end={end}
/>
))}
</ul>
)
}
@@ -0,0 +1,21 @@
import { NavLink } from 'react-router-dom';
import styles from './SidebarNavItem.module.scss';
export const SidebarNavItem = ({ to, icon, label, end = false, onClick = null, }) => {
return (
<li className="nav-item">
<NavLink
to={to}
end={end}
className={({ isActive }) =>
`nav-link${isActive ? ' active' : ''} ${styles['sidebar-nav-link']}`
}
onClick={onClick}
>
<i className={icon}></i>
<span className="ml-2">{label}</span>
</NavLink>
</li>
)
}
@@ -0,0 +1,8 @@
.sidebar-nav-link {
color: rgba(21, 87, 36, 0.8);
}
.sidebar-nav-link.active,
.sidebar-nav-link:hover {
color: white !important;
}
+53
View File
@@ -0,0 +1,53 @@
import React from "react";
export const FilterBar = React.memo(({ regions, filials, regionId, filialId, searchValue, onChange, goAddSpecialist }) => (
<form
className="d-flex justify-content-between mb-3"
onSubmit={e => e.preventDefault()}
>
<div className="form-group align-self-end mr-3">
<input
type="button"
className="btn btn-outline-primary"
value="Создать врача"
onClick={e => { e.stopPropagation(); goAddSpecialist() }}
/>
</div>
<div className="form-group">
<label>Регион:</label>
<select
className="form-control"
style={{ width: '12vw' }}
value={regionId}
onChange={e => onChange({ regionId: e.target.value, filialId: 'all', page: 1 })}
>
{regions.map(r => (
<option key={r.id} value={r.id}>{r.label}</option>
))}
</select>
</div>
<div className="form-group ml-3">
<label>Филиал:</label>
<select
className="form-control"
style={{ width: '20vw' }}
value={filialId}
onChange={e => onChange({ filialId: e.target.value, page: 1 })}
>
{filials.map(f => <option key={f.id} value={f.id}>{f.shortName}</option>)}
</select>
</div>
<div className="form-group flex-grow-1 ml-3">
<label>Поиск:</label>
<input
className="form-control"
value={searchValue}
onChange={e => onChange({ searchValue: e.target.value, page: 1 })}
/>
</div>
</form>
));
+96
View File
@@ -0,0 +1,96 @@
import React from "react";
const maskElement = ( element ) => {
if ( typeof element === 'boolean' )
return element ? 'да' : 'нет'
if ( typeof element === 'undefined' ) return 'нет данных'
if ( element === null ) return 'нет данных'
return element
}
const ElementRow = React.memo(({ element, expandedId, setExpandedId, onEdit, onWatch, columns, pageType = '' }) => {
const isExpanded = expandedId === element.id;
const toggle = () => setExpandedId(prev => (prev === element.id ? null : element.id));
return (
<>
<tr
className={`cursor-pointer ${isExpanded ? 'table-success' : ''}`}
onClick={toggle}
>
{columns.map((column, index) => (
<td
key={column.key}
style={{
verticalAlign: 'middle',
position: index === columns.length - 1 ? 'relative' : 'initial'
}}
>
{ maskElement(element[column.key]) }
</td>
))}
</tr>
{isExpanded && pageType !== 'specialists' && (
<tr
className='table-success cursor-pointer'
onClick={ toggle }
>
<td colSpan={ columns.length }>
<input
type="button"
className="btn btn-outline-primary ml-auto"
value="Редактировать"
onClick={e => { e.stopPropagation(); onEdit(element.id); }}
/>
</td>
</tr>
)}
{( pageType === 'specialists' && isExpanded ) && (
<tr
className='table-success cursor-pointer'
onClick={ toggle }
>
<td colSpan={ 6 }>
<input
type="button"
className="btn btn-outline-primary"
value="Редактировать"
onClick={e => { e.stopPropagation(); onEdit(element.id); }}
/>
</td>
<td colSpan={ 1 }>
<input
type="button"
className="btn btn-outline-primary"
value="Открыть"
onClick={e => { e.stopPropagation(); onWatch(element.id); }}
/>
</td>
</tr>
)}
</>
);
});
export const TBody = ({ elements, columns, expandedId, setExpandedId, goEdit, goWatch, pageType }) => {
return (
<tbody>
{
elements.map(element => (
<ElementRow
key={ element.id }
element={ element }
expandedId={ expandedId }
setExpandedId={ setExpandedId }
onEdit={ goEdit }
onWatch={ goWatch }
columns={ columns }
pageType= { pageType }
/>))
}
</tbody>
);
}
+19
View File
@@ -0,0 +1,19 @@
export const THead = ({ columns, sortBy, sortDirection, handleSort }) => (
<thead>
<tr>
{columns.map(({ key, label, width }) => (
<th
key={key}
className="cursor-pointer"
style={{ width }}
onClick={() => handleSort(key)}
>
{label}
{sortBy === key && (
<span className={`column-header ${sortDirection}`} />
)}
</th>
))}
</tr>
</thead>
)
+73
View File
@@ -0,0 +1,73 @@
import { useState, useRef, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Link } from 'react-router-dom';
import { useLogoutMutation } from '/src/api/apiSlice'
export const UserBurger = () => {
const username = useSelector( s => s.auth.username );
const [ logout ] = useLogoutMutation()
const [open, setOpen] = useState(false);
const toggleRef = useRef(null);
const menuRef = useRef(null);
useEffect(() => {
const handleOutside = e => {
if (
menuRef.current &&
!menuRef.current.contains(e.target) &&
toggleRef.current &&
!toggleRef.current.contains(e.target)
) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleOutside)
return () => document.removeEventListener('mousedown', handleOutside)
}, []);
return (
<ul className="navbar-nav ml-auto">
<li className="nav-item dropdown no-arrow">
<button
ref={toggleRef}
className="nav-link dropdown-toggle btn btn-link p-0"
onClick={() => setOpen(o => !o)}
aria-haspopup="true"
aria-expanded={open}
>
<i className="fas fa-user-circle fa-fw"></i>
<span className="ml-2 d-none d-lg-inline text-gray-600 small">
{username}
</span>
</button>
{open && (
<div
ref={menuRef}
className="dropdown-menu dropdown-menu-right shadow animated--grow-in show"
aria-labelledby="userMenu"
>
<Link
className="dropdown-item"
to="/user"
onClick={() => setOpen(false)}
>
<i className="fas fa-user fa-sm fa-fw mr-2 text-gray-400"></i>
Профиль
</Link>
<div className="dropdown-divider"></div>
<button
className="dropdown-item"
onClick={() => { setOpen(false); logout() }}
>
<i className="fas fa-sign-out-alt fa-sm fa-fw mr-2 text-gray-400"></i>
Выйти
</button>
</div>
)}
</li>
</ul>
)
}