3.7. Административный модуль
Административный модуль HRoom предоставляет полный набор инструментов для управления системой, необходимый для администраторов и суперпользователей. Этот модуль позволяет управлять компаниями, HR-менеджерами, настройками, вопросами для опросов и группами вопросов, а также другими аспектами системы, недоступными обычным пользователям.
Основные компоненты модуля
- Управление компаниями - создание и редактирование компаний в системе
- Управление HR-менеджерами - добавление и настройка пользователей с ролью HR-менеджера
- Управление вопросами - создание, редактирование и импорт вопросов для опросов
- Управление группами вопросов - организация вопросов в тематические группы
- Настройки системы - конфигурация глобальных параметров и CDN-настроек
Архитектура административного модуля
Административный модуль построен по принципу модульной организации и включает следующую структуру компонентов:
src/pages/Admin/
├── Companies/ # Управление компаниями
│ ├── CompanyCard.tsx # Карточка компании
│ ├── CompanyForm.tsx # Форма создания/редактирования компании
│ └── index.tsx # Основной компонент страницы компаний
├── HRManagers/ # Управление HR-менеджерами
│ ├── HRManagerCard.tsx # Карточка HR-менеджера
│ ├── HRManagerForm.tsx # Форма создания/редактирования HR-менеджера
│ └── index.tsx # Основной компонент страницы HR-менеджеров
├── Questions/ # Управление вопросами
│ ├── CSVImportModal.tsx # Модальное окно импорта вопросов из CSV
│ ├── QuestionForm.tsx # Форма создания/редактирования вопроса
│ └── index.tsx # Основной компонент страницы вопросов
├── QuestionGroups/ # Управление группами вопросов
│ ├── QuestionGroupForm.tsx # Форма создания/редактирования группы вопросов
│ ├── QuestionGroupTable.tsx # Таблица групп вопросов
│ └── index.tsx # Основной компонент страницы групп вопросов
├── Settings/ # Настройки системы
│ ├── CDNSettings.tsx # Настройки CDN
│ └── index.tsx # Основной компонент страницы настроек
└── index.tsx # Главный компонент административного модуля
Управление компаниями
Раздел управления компаниями позволяет администраторам создавать, редактировать и управлять компаниями в системе HRoom. Каждая компания представляет собой отдельную организацию со своими пользователями, отделами и настройками.
Функциональность управления компаниями
- Просмотр списка всех компаний в системе
- Создание новых компаний
- Редактирование данных существующих компаний
- Активация/деактивация компаний
- Просмотр статистики по компаниям (количество пользователей, отделов, опросов)
- Управление подпиской и тарифным планом компании
Компонент списка компаний
Компонент Companies отображает список всех компаний в системе и предоставляет интерфейс для их управления:
// Пример компонента Companies из src/pages/Admin/Companies/index.tsx
export const Companies = () => {
const [companies, setCompanies] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [isCreateFormVisible, setIsCreateFormVisible] = useState(false);
const [editingCompany, setEditingCompany] = useState(null);
// Загрузка списка компаний
useEffect(() => {
const fetchCompanies = async () => {
try {
setIsLoading(true);
const data = await companyService.getCompanies();
setCompanies(data);
setError(null);
} catch (err) {
console.error('Error fetching companies:', err);
setError('Произошла ошибка при загрузке списка компаний');
} finally {
setIsLoading(false);
}
};
fetchCompanies();
}, []);
// Обработчик создания новой компании
const handleCreateCompany = async (companyData) => {
try {
const newCompany = await companyService.createCompany(companyData);
setCompanies(prev => [...prev, newCompany]);
setIsCreateFormVisible(false);
} catch (err) {
console.error('Error creating company:', err);
// Обработка ошибки
}
};
// Обработчик обновления компании
const handleUpdateCompany = async (id, companyData) => {
try {
const updatedCompany = await companyService.updateCompany(id, companyData);
setCompanies(prev =>
prev.map(company => company.id === id ? updatedCompany : company)
);
setEditingCompany(null);
} catch (err) {
console.error('Error updating company:', err);
// Обработка ошибки
}
};
// Обработчик изменения статуса компании
const handleStatusChange = async (id, isActive) => {
try {
await companyService.updateCompanyStatus(id, isActive);
setCompanies(prev =>
prev.map(company =>
company.id === id ? { ...company, isActive } : company
)
);
} catch (err) {
console.error('Error updating company status:', err);
// Обработка ошибки
}
};
if (isLoading) {
return <div className="loading-state">Загрузка компаний...</div>;
}
if (error) {
return (
<div className="error-state">
<p>{error}</p>
<button onClick={() => window.location.reload()}>Повторить</button>
</div>
);
}
return (
<div className="companies-container">
<div className="companies-header">
<h1>Управление компаниями</h1>
<button
className="create-company-button"
onClick={() => setIsCreateFormVisible(true)}
>
Создать компанию
</button>
</div>
{isCreateFormVisible && (
<CompanyForm
onSubmit={handleCreateCompany}
onCancel={() => setIsCreateFormVisible(false)}
/>
)}
{editingCompany && (
<CompanyForm
company={editingCompany}
isEditing={true}
onSubmit={(data) => handleUpdateCompany(editingCompany.id, data)}
onCancel={() => setEditingCompany(null)}
/>
)}
<div className="companies-grid">
{companies.map(company => (
<CompanyCard
key={company.id}
company={company}
onEdit={() => setEditingCompany(company)}
onStatusChange={(isActive) => handleStatusChange(company.id, isActive)}
/>
))}
</div>
</div>
);
};
Форма компании
Компонент CompanyForm предоставляет интерфейс для создания и редактирования компаний:
// Пример компонента CompanyForm из src/pages/Admin/Companies/CompanyForm.tsx
export const CompanyForm = ({
company,
isEditing = false,
onSubmit,
onCancel
}) => {
const [formData, setFormData] = useState({
name: company?.name || '',
domain: company?.domain || '',
logo: company?.logo || '',
primaryColor: company?.primaryColor || '#7d4cf1',
secondaryColor: company?.secondaryColor || '#10b981',
planId: company?.planId || '',
maxUsers: company?.maxUsers || 50,
isActive: company?.isActive !== undefined ? company?.isActive : true
});
const [errors, setErrors] = useState({});
const [plans, setPlans] = useState([]);
const [isLoading, setIsLoading] = useState(false);
// Загрузка списка тарифных планов
useEffect(() => {
const fetchPlans = async () => {
try {
setIsLoading(true);
const data = await companyService.getSubscriptionPlans();
setPlans(data);
} catch (err) {
console.error('Error fetching subscription plans:', err);
} finally {
setIsLoading(false);
}
};
fetchPlans();
}, []);
// Обработчик изменения полей формы
const handleChange = (field, value) => {
setFormData(prev => ({
...prev,
[field]: value
}));
// Сброс ошибки для измененного поля
if (errors[field]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
// Обработчик отправки формы
const handleSubmit = (e) => {
e.preventDefault();
// Валидация данных
const validationErrors = {};
if (!formData.name.trim()) {
validationErrors.name = 'Название компании обязательно';
}
if (!formData.domain.trim()) {
validationErrors.domain = 'Домен компании обязателен';
}
if (!formData.planId) {
validationErrors.planId = 'Тарифный план обязателен';
}
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
// Вызов функции обработки отправки
onSubmit(formData);
};
return (
<div className="company-form-container">
<h2>{isEditing ? 'Редактирование компании' : 'Создание новой компании'}</h2>
<form onSubmit={handleSubmit} className="company-form">
<div className="form-group">
<label htmlFor="name">Название компании *</label>
<input
type="text"
id="name"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
className={errors.name ? 'error' : ''}
/>
{errors.name && <div className="error-message">{errors.name}</div>}
</div>
<div className="form-group">
<label htmlFor="domain">Домен компании *</label>
<input
type="text"
id="domain"
value={formData.domain}
onChange={(e) => handleChange('domain', e.target.value)}
className={errors.domain ? 'error' : ''}
/>
{errors.domain && <div className="error-message">{errors.domain}</div>}
</div>
<div className="form-group">
<label htmlFor="logo">URL логотипа</label>
<input
type="text"
id="logo"
value={formData.logo}
onChange={(e) => handleChange('logo', e.target.value)}
/>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="primaryColor">Основной цвет</label>
<input
type="color"
id="primaryColor"
value={formData.primaryColor}
onChange={(e) => handleChange('primaryColor', e.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="secondaryColor">Дополнительный цвет</label>
<input
type="color"
id="secondaryColor"
value={formData.secondaryColor}
onChange={(e) => handleChange('secondaryColor', e.target.value)}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="planId">Тарифный план *</label>
<select
id="planId"
value={formData.planId}
onChange={(e) => handleChange('planId', e.target.value)}
className={errors.planId ? 'error' : ''}
>
<option value="">Выберите тарифный план</option>
{plans.map(plan => (
<option key={plan.id} value={plan.id}>
{plan.name} - {plan.price} руб/мес
</option>
))}
</select>
{errors.planId && <div className="error-message">{errors.planId}</div>}
</div>
<div className="form-group">
<label htmlFor="maxUsers">Максимальное количество пользователей</label>
<input
type="number"
id="maxUsers"
min="1"
value={formData.maxUsers}
onChange={(e) => handleChange('maxUsers', parseInt(e.target.value))}
/>
</div>
</div>
<div className="form-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={formData.isActive}
onChange={(e) => handleChange('isActive', e.target.checked)}
/>
Активна
</label>
</div>
<div className="form-actions">
<button
type="button"
className="cancel-button"
onClick={onCancel}
>
Отмена
</button>
<button
type="submit"
className="submit-button"
>
{isEditing ? 'Сохранить изменения' : 'Создать компанию'}
</button>
</div>
</form>
</div>
);
};
Управление HR-менеджерами
Раздел управления HR-менеджерами позволяет администраторам создавать и управлять пользователями с ролью HR-менеджера, которые имеют расширенные права в системе для управления опросами, командами и сотрудниками.
Функциональность управления HR-менеджерами
- Просмотр списка всех HR-менеджеров в системе
- Создание новых HR-менеджеров
- Редактирование данных существующих HR-менеджеров
- Активация/деактивация учетных записей HR-менеджеров
- Привязка HR-менеджеров к компаниям
- Установка прав доступа и ролей
Компонент списка HR-менеджеров
Компонент HRManagers отображает список всех HR-менеджеров в системе и предоставляет интерфейс для их управления:
// Пример компонента HRManagers из src/pages/Admin/HRManagers/index.tsx
export const HRManagers = () => {
const [managers, setManagers] = useState([]);
const [companies, setCompanies] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedCompanyId, setSelectedCompanyId] = useState('');
const [isCreateFormVisible, setIsCreateFormVisible] = useState(false);
const [editingManager, setEditingManager] = useState(null);
// Загрузка списка HR-менеджеров и компаний
useEffect(() => {
const fetchData = async () => {
try {
setIsLoading(true);
// Параллельная загрузка HR-менеджеров и компаний
const [managersData, companiesData] = await Promise.all([
companyService.getHRManagers(selectedCompanyId || null),
companyService.getCompanies()
]);
setManagers(managersData);
setCompanies(companiesData);
setError(null);
} catch (err) {
console.error('Error fetching data:', err);
setError('Произошла ошибка при загрузке данных');
} finally {
setIsLoading(false);
}
};
fetchData();
}, [selectedCompanyId]);
// Обработчик создания нового HR-менеджера
const handleCreateManager = async (managerData) => {
try {
const newManager = await companyService.createHRManager(managerData);
if (!selectedCompanyId || newManager.companyId === selectedCompanyId) {
setManagers(prev => [...prev, newManager]);
}
setIsCreateFormVisible(false);
} catch (err) {
console.error('Error creating HR manager:', err);
// Обработка ошибки
}
};
// Обработчик обновления HR-менеджера
const handleUpdateManager = async (id, managerData) => {
try {
const updatedManager = await companyService.updateHRManager(id, managerData);
// Если изменилась компания и активен фильтр по компании,
// менеджер может исчезнуть из списка
if (!selectedCompanyId || updatedManager.companyId === selectedCompanyId) {
setManagers(prev =>
prev.map(manager => manager.id === id ? updatedManager : manager)
);
} else {
setManagers(prev => prev.filter(manager => manager.id !== id));
}
setEditingManager(null);
} catch (err) {
console.error('Error updating HR manager:', err);
// Обработка ошибки
}
};
// Обработчик изменения статуса HR-менеджера
const handleStatusChange = async (id, isActive) => {
try {
await companyService.updateHRManagerStatus(id, isActive);
setManagers(prev =>
prev.map(manager =>
manager.id === id ? { ...manager, isActive } : manager
)
);
} catch (err) {
console.error('Error updating HR manager status:', err);
// Обработка ошибки
}
};
if (isLoading && managers.length === 0) {
return <div className="loading-state">Загрузка HR-менеджеров...</div>;
}
if (error) {
return (
<div className="error-state">
<p>{error}</p>
<button onClick={() => window.location.reload()}>Повторить</button>
</div>
);
}
return (
<div className="hr-managers-container">
<div className="hr-managers-header">
<h1>Управление HR-менеджерами</h1>
<button
className="create-manager-button"
onClick={() => setIsCreateFormVisible(true)}
>
Добавить HR-менеджера
</button>
</div>
<div className="company-filter">
<label htmlFor="companyFilter">Фильтр по компании:</label>
<select
id="companyFilter"
value={selectedCompanyId}
onChange={(e) => handleCompanyFilterChange(e.target.value)}
>
<option value="">Все компании</option>
{companies.map(company => (
<option key={company.id} value={company.id}>
{company.name}
</option>
))}
</select>
</div>
{isCreateFormVisible && (
<HRManagerForm
companies={companies}
onSubmit={handleCreateManager}
onCancel={() => setIsCreateFormVisible(false)}
/>
)}
{editingManager && (
<HRManagerForm
manager={editingManager}
companies={companies}
isEditing={true}
onSubmit={(data) => handleUpdateManager(editingManager.id, data)}
onCancel={() => setEditingManager(null)}
/>
)}
<div className="managers-grid">
{managers.length === 0 ? (
<div className="no-managers-found">
{selectedCompanyId ?
'В выбранной компании нет HR-менеджеров.' :
'В системе нет HR-менеджеров.'}
</div>
) : (
managers.map(manager => (
<HRManagerCard
key={manager.id}
manager={manager}
companyName={companies.find(c => c.id === manager.companyId)?.name || 'Неизвестно'}
onEdit={() => setEditingManager(manager)}
onStatusChange={(isActive) => handleStatusChange(manager.id, isActive)}
/>
))
)}
</div>
</div>
);
};
Карточка HR-менеджера
Компонент HRManagerCard отображает информацию об HR-менеджере в виде карточки с доступными действиями:
// Пример компонента HRManagerCard из src/pages/Admin/HRManagers/HRManagerCard.tsx
export const HRManagerCard = ({
manager,
companyName,
onEdit,
onStatusChange
}) => {
// Форматирование даты
const formatDate = (dateString) => {
if (!dateString) return 'Н/Д';
const date = new Date(dateString);
return new Intl.DateTimeFormat('ru-RU').format(date);
};
return (
<div className={`manager-card ${!manager.isActive ? 'inactive' : ''}`}>
<div className="manager-header">
<div className="manager-avatar">
{manager.avatar ? (
<img src={manager.avatar} alt={`${manager.firstName} ${manager.lastName}`} />
) : (
<div className="avatar-placeholder">
{manager.firstName.charAt(0)}{manager.lastName.charAt(0)}
</div>
)}
</div>
<div className="manager-info">
<h3 className="manager-name">
{manager.firstName} {manager.lastName}
</h3>
<div className="manager-email">{manager.email}</div>
<div className="manager-company">Компания: {companyName}</div>
</div>
</div>
<div className="manager-details">
<div className="detail-item">
<span className="detail-label">Роль:</span>
<span className="detail-value">{manager.role || 'HR-менеджер'}</span>
</div>
<div className="detail-item">
<span className="detail-label">Создан:</span>
<span className="detail-value">{formatDate(manager.createdAt)}</span>
</div>
<div className="detail-item">
<span className="detail-label">Статус:</span>
<span className={`status-badge ${manager.isActive ? 'active' : 'inactive'}`}>
{manager.isActive ? 'Активен' : 'Неактивен'}
</span>
</div>
</div>
<div className="manager-actions">
<button
className="edit-button"
onClick={onEdit}
>
Редактировать
</button>
<button
className={`status-button ${manager.isActive ? 'deactivate' : 'activate'}`}
onClick={() => onStatusChange(!manager.isActive)}
>
{manager.isActive ? 'Деактивировать' : 'Активировать'}
</button>
</div>
</div>
);
};
Управление вопросами
Раздел управления вопросами позволяет администраторам создавать, редактировать и управлять вопросами, которые используются в опросах. Этот раздел также предоставляет возможность массового импорта вопросов из CSV-файла.
Функциональность управления вопросами
- Просмотр списка всех вопросов в системе
- Создание новых вопросов различных типов
- Редактирование существующих вопросов
- Привязка вопросов к метрикам и группам вопросов
- Массовый импорт вопросов из CSV-файла
- Фильтрация и поиск вопросов
Компонент списка вопросов
Компонент Questions отображает список вопросов с возможностью фильтрации, поиска и управления:
// Пример компонента Questions из src/pages/Admin/Questions/index.tsx
export const Questions = () => {
const [questions, setQuestions] = useState([]);
const [filteredQuestions, setFilteredQuestions] = useState([]);
const [questionGroups, setQuestionGroups] = useState([]);
const [metrics, setMetrics] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [filters, setFilters] = useState({
type: '',
groupId: '',
metricId: '',
search: ''
});
const [isFormVisible, setIsFormVisible] = useState(false);
const [editingQuestion, setEditingQuestion] = useState(null);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
// Загрузка списка вопросов, групп вопросов и метрик
useEffect(() => {
const fetchData = async () => {
try {
setIsLoading(true);
// Параллельная загрузка данных
const [questionsData, groupsData, metricsData] = await Promise.all([
questionsApi.getQuestions(),
questionsApi.getQuestionGroups(),
metricsApi.getMetricsList()
]);
setQuestions(questionsData);
setQuestionGroups(groupsData);
setMetrics(metricsData);
setError(null);
} catch (err) {
console.error('Error fetching data:', err);
setError('Произошла ошибка при загрузке данных');
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
// Фильтрация вопросов при изменении фильтров
useEffect(() => {
const filtered = questions.filter(question => {
// Фильтр по типу вопроса
if (filters.type && question.type !== filters.type) {
return false;
}
// Фильтр по группе вопросов
if (filters.groupId && question.groupId !== filters.groupId) {
return false;
}
// Фильтр по метрике
if (filters.metricId && question.metricId !== filters.metricId) {
return false;
}
// Поиск по тексту вопроса
if (filters.search && !question.text.toLowerCase().includes(filters.search.toLowerCase())) {
return false;
}
return true;
});
setFilteredQuestions(filtered);
}, [questions, filters]);
// Обработчик изменения фильтров
const handleFilterChange = (filterName, value) => {
setFilters(prev => ({
...prev,
[filterName]: value
}));
};
// Обработчик создания нового вопроса
const handleCreateQuestion = async (questionData) => {
try {
const newQuestion = await questionsApi.createQuestion(questionData);
setQuestions(prev => [...prev, newQuestion]);
setIsFormVisible(false);
} catch (err) {
console.error('Error creating question:', err);
// Обработка ошибки
}
};
// Обработчик обновления вопроса
const handleUpdateQuestion = async (id, questionData) => {
try {
const updatedQuestion = await questionsApi.updateQuestion(id, questionData);
setQuestions(prev =>
prev.map(question => question.id === id ? updatedQuestion : question)
);
setEditingQuestion(null);
} catch (err) {
console.error('Error updating question:', err);
// Обработка ошибки
}
};
// Обработчик удаления вопроса
const handleDeleteQuestion = async (id) => {
try {
await questionsApi.deleteQuestion(id);
setQuestions(prev => prev.filter(question => question.id !== id));
} catch (err) {
console.error('Error deleting question:', err);
// Обработка ошибки
}
};
// Обработчик импорта вопросов из CSV
const handleImportQuestions = async (questions) => {
try {
const importedQuestions = await questionsApi.bulkImportQuestions(questions);
setQuestions(prev => [...prev, ...importedQuestions]);
setIsImportModalOpen(false);
} catch (err) {
console.error('Error importing questions:', err);
// Обработка ошибки
}
};
if (isLoading && questions.length === 0) {
return <div className="loading-state">Загрузка вопросов...</div>;
}
if (error) {
return (
<div className="error-state">
<p>{error}</p>
<button onClick={() => window.location.reload()}>Повторить</button>
</div>
);
}
return (
<div className="questions-container">
<div className="questions-header">
<h1>Управление вопросами</h1>
<div className="header-actions">
<button
className="create-question-button"
onClick={() => setIsFormVisible(true)}
>
Добавить вопрос
</button>
<button
className="import-button"
onClick={() => setIsImportModalOpen(true)}
>
Импорт из CSV
</button>
</div>
</div>
<div className="questions-filters">
<div className="filter-row">
<div className="filter-group">
<label htmlFor="search">Поиск:</label>
<input
type="text"
id="search"
placeholder="Поиск по тексту вопроса..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
/>
</div>
<div className="filter-group">
<label htmlFor="type">Тип вопроса:</label>
<select
id="type"
value={filters.type}
onChange={(e) => handleFilterChange('type', e.target.value)}
>
<option value="">Все типы</option>
<option value="RATING">Оценка</option>
<option value="SINGLE_CHOICE">Единственный выбор</option>
<option value="MULTIPLE_CHOICE">Множественный выбор</option>
<option value="OPEN_TEXT">Открытый вопрос</option>
<option value="LIKERT_SCALE">Шкала Лайкерта</option>
<option value="MATRIX">Матрица</option>
</select>
</div>
<div className="filter-group">
<label htmlFor="groupId">Группа вопросов:</label>
<select
id="groupId"
value={filters.groupId}
onChange={(e) => handleFilterChange('groupId', e.target.value)}
>
<option value="">Все группы</option>
{questionGroups.map(group => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
</div>
<div className="filter-group">
<label htmlFor="metricId">Метрика:</label>
<select
id="metricId"
value={filters.metricId}
onChange={(e) => handleFilterChange('metricId', e.target.value)}
>
<option value="">Все метрики</option>
{metrics.map(metric => (
<option key={metric.id} value={metric.id}>
{metric.name}
</option>
))}
</select>
</div>
</div>
</div>
{filteredQuestions.length === 0 ? (
<div className="no-questions-found">
<p>Вопросы не найдены. Попробуйте изменить параметры фильтрации или добавьте новые вопросы.</p>
</div>
) : (
<table className="questions-table">
<thead>
<tr>
<th>Текст вопроса</th>
<th>Тип</th>
<th>Группа</th>
<th>Метрика</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{filteredQuestions.map(question => (
<tr key={question.id}>
<td>{question.text}</td>
<td>{getQuestionTypeName(question.type)}</td>
<td>
{questionGroups.find(g => g.id === question.groupId)?.name || 'Не указана'}
</td>
<td>
{metrics.find(m => m.id === question.metricId)?.name || 'Не указана'}
</td>
<td>
<div className="question-actions">
<button
className="edit-button"
onClick={() => setEditingQuestion(question)}
>
Редактировать
</button>
<button
className="delete-button"
onClick={() => handleDeleteQuestion(question.id)}
>
Удалить
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
{isFormVisible && (
<QuestionForm
questionGroups={questionGroups}
metrics={metrics}
onSubmit={handleCreateQuestion}
onCancel={() => setIsFormVisible(false)}
/>
)}
{editingQuestion && (
<QuestionForm
question={editingQuestion}
questionGroups={questionGroups}
metrics={metrics}
isEditing={true}
onSubmit={(data) => handleUpdateQuestion(editingQuestion.id, data)}
onCancel={() => setEditingQuestion(null)}
/>
)}
{isImportModalOpen && (
<CSVImportModal
onClose={() => setIsImportModalOpen(false)}
onImport={handleImportQuestions}
questionGroups={questionGroups}
metrics={metrics}
/>
)}
</div>
);
};
// Вспомогательная функция для получения названия типа вопроса
const getQuestionTypeName = (type) => {
switch (type) {
case 'RATING': return 'Оценка';
case 'SINGLE_CHOICE': return 'Единственный выбор';
case 'MULTIPLE_CHOICE': return 'Множественный выбор';
case 'OPEN_TEXT': return 'Открытый вопрос';
case 'LIKERT_SCALE': return 'Шкала Лайкерта';
case 'MATRIX': return 'Матрица';
default: return type;
}
};
Модальное окно импорта вопросов
Компонент CSVImportModal предоставляет интерфейс для массового импорта вопросов из CSV-файла:
// Пример компонента CSVImportModal из src/pages/Admin/Questions/CSVImportModal.tsx
export const CSVImportModal = ({
onClose,
onImport,
questionGroups,
metrics
}) => {
const [file, setFile] = useState(null);
const [previewData, setPreviewData] = useState(null);
const [mappings, setMappings] = useState({});
const [errors, setErrors] = useState({});
const [isLoading, setIsLoading] = useState(false);
// Обязательные поля для импорта
const requiredFields = ['text', 'type'];
// Доступные поля для маппинга
const availableFields = [
{ id: 'text', name: 'Текст вопроса' },
{ id: 'type', name: 'Тип вопроса' },
{ id: 'groupId', name: 'ID группы вопросов' },
{ id: 'metricId', name: 'ID метрики' },
{ id: 'required', name: 'Обязательный (true/false)' },
{ id: 'options', name: 'Варианты ответов (через ;)' },
{ id: 'minValue', name: 'Минимальное значение' },
{ id: 'maxValue', name: 'Максимальное значение' }
];
// Обработчик выбора файла
const handleFileChange = (e) => {
const selectedFile = e.target.files[0];
setFile(selectedFile);
if (selectedFile) {
const reader = new FileReader();
reader.onload = (event) => {
try {
// Парсинг CSV-файла
const csvData = parseCSVData(event.target.result);
if (csvData.length === 0) {
setErrors({ file: 'Файл не содержит данных' });
return;
}
// Получение заголовков из первой строки
const headers = csvData[0];
// Автоматическое сопоставление заголовков с полями
const initialMappings = {};
headers.forEach((header, index) => {
const matchedField = availableFields.find(field =>
field.name.toLowerCase() === header.toLowerCase() ||
field.id.toLowerCase() === header.toLowerCase()
);
if (matchedField) {
initialMappings[index] = matchedField.id;
}
});
setMappings(initialMappings);
setPreviewData(csvData);
} catch (err) {
setErrors({ file: 'Ошибка при чтении файла: ' + err.message });
}
};
reader.readAsText(selectedFile);
}
};
// Функция для парсинга CSV-данных
const parseCSVData = (csvText) => {
const lines = csvText.split('\n');
return lines.map(line => {
// Простой парсер CSV, учитывающий запятые внутри кавычек
const result = [];
let inQuotes = false;
let currentValue = '';
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
result.push(currentValue.trim());
currentValue = '';
} else {
currentValue += char;
}
}
result.push(currentValue.trim());
return result;
}).filter(line => line.some(cell => cell.length > 0));
};
// Обработчик изменения маппинга
const handleMappingChange = (columnIndex, fieldId) => {
setMappings(prev => ({
...prev,
[columnIndex]: fieldId
}));
};
// Валидация данных перед импортом
const validateMappings = () => {
const validationErrors = {};
// Проверяем, что все обязательные поля замаплены
requiredFields.forEach(field => {
if (!Object.values(mappings).includes(field)) {
validationErrors[field] = `Поле "${availableFields.find(f => f.id === field).name}" обязательно`;
}
});
return validationErrors;
};
// Обработчик импорта данных
const handleImport = async () => {
// Валидация маппинга
const validationErrors = validateMappings();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
try {
setIsLoading(true);
// Преобразование CSV-данных в формат для импорта
const questions = [];
// Пропускаем заголовки (первую строку)
for (let i = 1; i < previewData.length; i++) {
const row = previewData[i];
const question = {};
// Маппинг данных из CSV в объект вопроса
Object.entries(mappings).forEach(([columnIndex, fieldId]) => {
if (row[columnIndex] !== undefined) {
if (fieldId === 'options') {
// Парсинг вариантов ответов, разделенных точкой с запятой
question[fieldId] = row[columnIndex].split(';').map(o => o.trim()).filter(o => o);
} else if (fieldId === 'required') {
// Преобразование "true"/"false" в булево значение
question[fieldId] = row[columnIndex].toLowerCase() === 'true';
} else if (['minValue', 'maxValue'].includes(fieldId)) {
// Преобразование строковых значений в числа
question[fieldId] = parseFloat(row[columnIndex]);
} else {
question[fieldId] = row[columnIndex];
}
}
});
questions.push(question);
}
// Вызываем функцию импорта
await onImport(questions);
} catch (err) {
console.error('Error importing questions:', err);
setErrors({ import: 'Ошибка при импорте вопросов: ' + err.message });
} finally {
setIsLoading(false);
}
};
return (
<div className="modal-overlay">
<div className="import-modal">
<div className="modal-header">
<h2>Импорт вопросов из CSV</h2>
<button className="close-button" onClick={onClose}>×</button>
</div>
<div className="modal-body">
{!previewData ? (
<div className="file-upload-section">
<p>Выберите CSV-файл со списком вопросов для импорта.</p>
<input
type="file"
accept=".csv"
onChange={handleFileChange}
/>
{errors.file && <div className="error-message">{errors.file}</div>}
<div className="file-format-info">
<h4>Формат файла</h4>
<p>
CSV-файл должен содержать заголовки в первой строке и данные вопросов в последующих строках.
Обязательные поля: Текст вопроса, Тип вопроса.
</p>
<p>Пример CSV-файла:</p>
<pre>
Текст вопроса,Тип вопроса,ID группы вопросов,ID метрики,Обязательный,Варианты ответов,Минимальное значение,Максимальное значение
Как вы оцениваете атмосферу в команде?,RATING,group-1,metric-1,true,,1,5
Какие факторы мотивируют вас больше всего?,MULTIPLE_CHOICE,group-2,metric-2,true,"Зарплата;Интересные задачи;Коллектив;Возможность роста",,
</pre>
</div>
</div>
) : (
<div className="import-mapping-section">
<h3>Предварительный просмотр и настройка импорта</h3>
<div className="field-mapping">
<h4>Сопоставление полей</h4>
<p>
Сопоставьте столбцы CSV-файла с полями системы.
Обязательные поля отмечены звездочкой (*).
</p>
<table className="mapping-table">
<thead>
<tr>
<th>Столбец в файле</th>
<th>Поле в системе</th>
</tr>
</thead>
<tbody>
{previewData[0].map((header, index) => (
<tr key={index}>
<td>{header}</td>
<td>
<select
value={mappings[index] || ''}
onChange={(e) => handleMappingChange(index, e.target.value)}
className={errors[mappings[index]] ? 'error' : ''}
>
<option value="">Не импортировать</option>
{availableFields.map(field => (
<option key={field.id} value={field.id}>
{field.name} {requiredFields.includes(field.id) ? '*' : ''}
</option>
))}
</select>
{mappings[index] && errors[mappings[index]] && (
<div className="error-message">{errors[mappings[index]]}</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="data-preview">
<h4>Предварительный просмотр данных</h4>
<p>Показаны первые 5 строк из файла (без учета заголовков).</p>
<table className="preview-table">
<thead>
<tr>
{previewData[0].map((header, index) => (
<th key={index}>{header}</th>
))}
</tr>
</thead>
<tbody>
{previewData.slice(1, 6).map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, cellIndex) => (
<td key={cellIndex}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
<p className="total-rows">
Всего строк для импорта: {previewData.length - 1}
</p>
</div>
{errors.import && <div className="error-message">{errors.import}</div>}
</div>
)}
</div>
<div className="modal-footer">
<button
className="cancel-button"
onClick={onClose}
disabled={isLoading}
>
Отмена
</button>
{previewData && (
<button
className="import-button"
onClick={handleImport}
disabled={isLoading}
>
{isLoading ? 'Импорт...' : 'Импортировать'}
</button>
)}
</div>
</div>
</div>
);
};
Управление группами вопросов
Раздел управления группами вопросов позволяет администраторам создавать и управлять тематическими группами для категоризации вопросов. Группы вопросов используются для организации наборов связанных вопросов, которые можно легко использовать при создании опросов.
Функциональность управления группами вопросов
- Просмотр списка всех групп вопросов
- Создание новых групп вопросов
- Редактирование существующих групп вопросов
- Привязка групп к метрикам
- Добавление и удаление вопросов в группах
Компонент списка групп вопросов
Компонент QuestionGroups отображает список групп вопросов и предоставляет инструменты для их управления:
// Пример компонента QuestionGroups из src/pages/Admin/QuestionGroups/index.tsx
export const QuestionGroups = () => {
const [groups, setGroups] = useState([]);
const [metrics, setMetrics] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [isFormVisible, setIsFormVisible] = useState(false);
const [editingGroup, setEditingGroup] = useState(null);
// Загрузка списка групп вопросов и метрик
useEffect(() => {
const fetchData = async () => {
try {
setIsLoading(true);
// Параллельная загрузка данных
const [groupsData, metricsData] = await Promise.all([
questionsApi.getQuestionGroups(),
metricsApi.getMetricsList()
]);
setGroups(groupsData);
setMetrics(metricsData);
setError(null);
} catch (err) {
console.error('Error fetching data:', err);
setError('Произошла ошибка при загрузке данных');
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
// Обработчик создания новой группы вопросов
const handleCreateGroup = async (groupData) => {
try {
const newGroup = await questionsApi.createQuestionGroup(groupData);
setGroups(prev => [...prev, newGroup]);
setIsFormVisible(false);
} catch (err) {
console.error('Error creating question group:', err);
// Обработка ошибки
}
};
// Обработчик обновления группы вопросов
const handleUpdateGroup = async (id, groupData) => {
try {
const updatedGroup = await questionsApi.updateQuestionGroup(id, groupData);
setGroups(prev =>
prev.map(group => group.id === id ? updatedGroup : group)
);
setEditingGroup(null);
} catch (err) {
console.error('Error updating question group:', err);
// Обработка ошибки
}
};
// Обработчик удаления группы вопросов
const handleDeleteGroup = async (id) => {
try {
await questionsApi.deleteQuestionGroup(id);
setGroups(prev => prev.filter(group => group.id !== id));
} catch (err) {
console.error('Error deleting question group:', err);
// Обработка ошибки
}
};
if (isLoading && groups.length === 0) {
return <div className="loading-state">Загрузка групп вопросов...</div>;
}
if (error) {
return (
<div className="error-state">
<p>{error}</p>
<button onClick={() => window.location.reload()}>Повторить</button>
</div>
);
}
return (
<div className="question-groups-container">
<div className="question-groups-header">
<h1>Управление группами вопросов</h1>
<button
className="create-group-button"
onClick={() => setIsFormVisible(true)}
>
Создать группу вопросов
</button>
</div>
{isFormVisible && (
<QuestionGroupForm
metrics={metrics}
onSubmit={handleCreateGroup}
onCancel={() => setIsFormVisible(false)}
/>
)}
{editingGroup && (
<QuestionGroupForm
group={editingGroup}
metrics={metrics}
isEditing={true}
onSubmit={(data) => handleUpdateGroup(editingGroup.id, data)}
onCancel={() => setEditingGroup(null)}
/>
)}
<QuestionGroupTable
groups={groups}
metrics={metrics}
onEdit={setEditingGroup}
onDelete={handleDeleteGroup}
/>
</div>
);
};
Таблица групп вопросов
Компонент QuestionGroupTable отображает список групп вопросов в виде таблицы с возможностью редактирования и удаления:
// Пример компонента QuestionGroupTable из src/pages/Admin/QuestionGroups/QuestionGroupTable.tsx
export const QuestionGroupTable = ({
groups,
metrics,
onEdit,
onDelete
}) => {
// Получение названия метрики по ID
const getMetricName = (metricId) => {
const metric = metrics.find(m => m.id === metricId);
return metric ? metric.name : 'Не указана';
};
// Подтверждение удаления группы
const confirmDelete = (groupId, groupName) => {
if (window.confirm(`Вы уверены, что хотите удалить группу "${groupName}"?`)) {
onDelete(groupId);
}
};
return (
<div className="question-group-table-container">
{groups.length === 0 ? (
<div className="no-groups-found">
<p>Группы вопросов не найдены. Создайте новую группу, чтобы начать.</p>
</div>
) : (
<table className="question-group-table">
<thead>
<tr>
<th>Название группы</th>
<th>Описание</th>
<th>Метрика</th>
<th>Количество вопросов</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{groups.map(group => (
<tr key={group.id}>
<td>{group.name}</td>
<td>{group.description || 'Нет описания'}</td>
<td>{getMetricName(group.metricId)}</td>
<td>{group.questionsCount || 0}</td>
<td>
<div className="group-actions">
<button
className="edit-button"
onClick={() => onEdit(group)}
>
Редактировать
</button>
<button
className="delete-button"
onClick={() => confirmDelete(group.id, group.name)}
>
Удалить
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};
Настройки системы
Раздел настроек системы позволяет администраторам управлять глобальными параметрами и конфигурацией платформы HRoom.
Функциональность настроек системы
- Настройка CDN для хранения файлов
- Управление глобальными параметрами системы
- Настройка уведомлений и интеграций
- Конфигурация безопасности
Компонент настроек CDN
Компонент CDNSettings предоставляет интерфейс для управления настройками CDN (Content Delivery Network):
// Пример компонента CDNSettings из src/pages/Admin/Settings/CDNSettings.tsx
export const CDNSettings = () => {
const [formData, setFormData] = useState({
provider: 'aws',
region: '',
bucketName: '',
accessKeyId: '',
secretAccessKey: '',
publicUrl: '',
enabled: true
});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState(null);
const [successMessage, setSuccessMessage] = useState('');
// Загрузка текущих настроек CDN
useEffect(() => {
const fetchSettings = async () => {
try {
setIsLoading(true);
const settings = await settingsService.getCDNSettings();
setFormData(settings);
setError(null);
} catch (err) {
console.error('Error fetching CDN settings:', err);
setError('Произошла ошибка при загрузке настроек CDN');
} finally {
setIsLoading(false);
}
};
fetchSettings();
}, []);
// Обработчик изменения полей формы
const handleChange = (field, value) => {
setFormData(prev => ({
...prev,
[field]: value
}));
// Сброс сообщений
setSuccessMessage('');
setError(null);
};
// Обработчик отправки формы
const handleSubmit = async (e) => {
e.preventDefault();
try {
setIsSaving(true);
setError(null);
setSuccessMessage('');
await settingsService.updateCDNSettings(formData);
setSuccessMessage('Настройки CDN успешно сохранены');
} catch (err) {
console.error('Error saving CDN settings:', err);
setError('Произошла ошибка при сохранении настроек CDN');
} finally {
setIsSaving(false);
}
};
// Обработчик тестирования соединения с CDN
const handleTestConnection = async () => {
try {
setIsSaving(true);
setError(null);
setSuccessMessage('');
const result = await settingsService.testCDNConnection(formData);
if (result.success) {
setSuccessMessage('Соединение с CDN успешно установлено');
} else {
setError(`Ошибка при подключении к CDN: ${result.message}`);
}
} catch (err) {
console.error('Error testing CDN connection:', err);
setError('Произошла ошибка при тестировании соединения с CDN');
} finally {
setIsSaving(false);
}
};
if (isLoading) {
return <div className="loading-state">Загрузка настроек CDN...</div>;
}
return (
<div className="cdn-settings-container">
<h2>Настройки CDN</h2>
{error && (
<div className="error-message-box">
{error}
</div>
)}
{successMessage && (
<div className="success-message-box">
{successMessage}
</div>
)}
<form onSubmit={handleSubmit} className="cdn-settings-form">
<div className="form-group">
<label htmlFor="provider">Провайдер CDN</label>
<select
id="provider"
value={formData.provider}
onChange={(e) => handleChange('provider', e.target.value)}
>
<option value="aws">Amazon S3</option>
<option value="gcp">Google Cloud Storage</option>
<option value="azure">Azure Blob Storage</option>
<option value="custom">Пользовательский</option>
</select>
</div>
<div className="form-group">
<label htmlFor="region">Регион</label>
<input
type="text"
id="region"
value={formData.region}
onChange={(e) => handleChange('region', e.target.value)}
/>
</div>