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