3.5. Опросы
Модуль "Опросы" является одним из ключевых компонентов системы HRoom, предоставляющий инструменты для создания, настройки, распространения и анализа опросов среди сотрудников. Этот модуль позволяет HR-менеджерам и руководителям собирать важную обратную связь, оценивать вовлеченность персонала и получать данные для дальнейшего анализа и принятия управленческих решений.
Основные компоненты модуля
- Список опросов - управление существующими опросами
- Создание опросов - интерфейс для создания новых опросов
- Типы опросов - различные типы опросов для разных целей
- Управление вопросами - создание и редактирование вопросов
- Управление респондентами - выбор и настройка получателей опросов
- Планирование - настройка времени проведения опросов
Архитектура модуля опросов
Модуль опросов построен на основе следующей архитектуры компонентов:
src/pages/Surveys/
├── components/ # Компоненты пользовательского интерфейса
│ ├── EmptyStates/ # Компоненты для отображения пустых состояний
├── services/ # Сервисы для работы с данными
├── hooks/ # Пользовательские хуки
├── types/ # Типы и интерфейсы
└── index.tsx # Основной компонент страницы опросов
src/components/surveys/ # Общие компоненты для работы с опросами
├── CreateSurveyModal.tsx # Модальное окно создания опроса
├── SurveyForm.tsx # Форма для создания/редактирования опроса
├── SurveyList.tsx # Список опросов
├── SurveyTypeSelect.tsx # Выбор типа опроса
├── SurveyTypePopup.tsx # Всплывающее окно с типами опросов
├── SurveyCalendar.tsx # Календарь для планирования опросов
└── RespondentSearch.tsx # Поиск и выбор респондентов
Типы опросов
Система HRoom поддерживает несколько типов опросов, каждый из которых предназначен для решения специфических задач:
1. Регулярные опросы (REGULAR)
Периодические опросы для оценки общего состояния команды и сбора обратной связи по широкому спектру вопросов. Обычно проводятся ежеквартально или раз в полгода.
2. Пульс-опросы (PULSE)
Короткие, сфокусированные опросы, которые проводятся часто (еженедельно или ежемесячно) для отслеживания динамики изменений по конкретным метрикам или темам. Обычно содержат небольшое количество вопросов.
3. Онбординг-опросы (ONBOARDING)
Специализированные опросы для новых сотрудников, помогающие оценить процесс адаптации и интеграции в команду. Обычно отправляются после определенных периодов работы (1 неделя, 1 месяц, 3 месяца).
4. Выходные интервью (EXIT)
Опросы для уходящих сотрудников, направленные на выяснение причин ухода и получение обратной связи о компании. Помогают выявить потенциальные проблемы и области для улучшения.
Компоненты пользовательского интерфейса
Модуль опросов включает несколько ключевых компонентов пользовательского интерфейса.
Список опросов (SurveyList)
Компонент SurveyList отображает все созданные опросы с возможностью фильтрации, сортировки и управления их статусом. Он позволяет:
- Просматривать опросы в виде списка с ключевой информацией
- Фильтровать опросы по статусу, типу и дате
- Сортировать опросы по различным параметрам
- Выполнять действия: просмотр, редактирование, активация, архивирование, удаление
- Отслеживать прогресс прохождения опросов (статистика ответов)
// Пример компонента SurveyList из src/components/surveys/SurveyList.tsx
export const SurveyList = ({
surveys,
isLoading,
onCreateClick,
onSurveyClick
}) => {
const [filteredSurveys, setFilteredSurveys] = useState(surveys);
const [filters, setFilters] = useState({
status: 'all',
type: 'all',
dateRange: null
});
// Эффект для фильтрации опросов при изменении фильтров
useEffect(() => {
const filtered = surveys.filter(survey => {
// Фильтрация по статусу
if (filters.status !== 'all' && survey.status !== filters.status) {
return false;
}
// Фильтрация по типу
if (filters.type !== 'all' && survey.type !== filters.type) {
return false;
}
// Фильтрация по дате
if (filters.dateRange) {
const surveyDate = new Date(survey.createdAt);
if (
(filters.dateRange.start && surveyDate < filters.dateRange.start) ||
(filters.dateRange.end && surveyDate > filters.dateRange.end)
) {
return false;
}
}
return true;
});
setFilteredSurveys(filtered);
}, [surveys, filters]);
// Обработчик изменения фильтров
const handleFilterChange = (filterName, value) => {
setFilters(prev => ({
...prev,
[filterName]: value
}));
};
// Функция для отображения статуса опроса
const renderStatus = (status) => {
switch (status) {
case 'DRAFT':
return <span className="status-badge draft">Черновик</span>;
case 'ACTIVE':
return <span className="status-badge active">Активный</span>;
case 'COMPLETED':
return <span className="status-badge completed">Завершен</span>;
case 'ARCHIVED':
return <span className="status-badge archived">Архивный</span>;
default:
return null;
}
};
// Получение локализованного названия типа опроса
const getSurveyTypeLabel = (type) => {
switch (type) {
case 'REGULAR': return 'Регулярный';
case 'PULSE': return 'Пульс-опрос';
case 'ONBOARDING': return 'Онбординг';
case 'EXIT': return 'Выходное интервью';
default: return type;
}
};
if (isLoading) {
return <div className="loading-state">Загрузка опросов...</div>;
}
return (
<div className="survey-list-container">
<div className="survey-list-header">
<h2>Опросы</h2>
<button
className="create-survey-button"
onClick={onCreateClick}
>
Создать опрос
</button>
</div>
<div className="survey-filters">
<div className="filter-group">
<label>Статус:</label>
<select
value={filters.status}
onChange={(e) => handleFilterChange('status', e.target.value)}
>
<option value="all">Все статусы</option>
<option value="DRAFT">Черновики</option>
<option value="ACTIVE">Активные</option>
<option value="COMPLETED">Завершенные</option>
<option value="ARCHIVED">Архивные</option>
</select>
</div>
<div className="filter-group">
<label>Тип:</label>
<select
value={filters.type}
onChange={(e) => handleFilterChange('type', e.target.value)}
>
<option value="all">Все типы</option>
<option value="REGULAR">Регулярные</option>
<option value="PULSE">Пульс-опросы</option>
<option value="ONBOARDING">Онбординг</option>
<option value="EXIT">Выходные интервью</option>
</select>
</div>
</div>
{filteredSurveys.length === 0 ? (
<div className="empty-surveys">
<p>Опросы не найдены. Измените параметры фильтрации или создайте новый опрос.</p>
</div>
) : (
<div className="surveys-grid">
{filteredSurveys.map(survey => (
<div
key={survey.id}
className="survey-card"
onClick={() => onSurveyClick(survey.id)}
>
<div className="survey-card-header">
<h3 className="survey-title">{survey.title}</h3>
{renderStatus(survey.status)}
</div>
<div className="survey-type">
{getSurveyTypeLabel(survey.type)}
</div>
<div className="survey-description">
{survey.description || 'Описание отсутствует'}
</div>
<div className="survey-meta">
<div className="survey-dates">
<div>Создан: {new Date(survey.createdAt).toLocaleDateString()}</div>
{survey.startDate && (
<div>Старт: {new Date(survey.startDate).toLocaleDateString()}</div>
)}
{survey.endDate && (
<div>Завершение: {new Date(survey.endDate).toLocaleDateString()}</div>
)}
</div>
<div className="survey-stats">
<div className="survey-questions-count">
Вопросов: {survey.questions?.length || 0}
</div>
{survey.respondents !== undefined && (
<div className="survey-respondents">
Участников: {survey.respondents}
</div>
)}
{survey.responses !== undefined && (
<div className="survey-responses">
Ответов: {survey.responses}
{survey.respondents && (
<span className="response-rate">
({Math.round((survey.responses / survey.respondents) * 100)}%)
</span>
)}
</div>
)}
</div>
</div>
<div className="survey-actions">
<button className="view-survey-button">Просмотреть</button>
</div>
</div>
))}
</div>
)}
</div>
);
};
Создание опроса (CreateSurveyModal)
Компонент CreateSurveyModal предоставляет интерфейс для создания новых опросов. Он включает:
- Выбор типа опроса
- Заполнение основной информации (название, описание)
- Добавление и настройку вопросов
- Выбор респондентов
- Планирование даты начала и окончания опроса
// Пример компонента CreateSurveyModal из src/components/surveys/CreateSurveyModal.tsx
export const CreateSurveyModal = ({ isOpen, onClose, onSave }) => {
const [step, setStep] = useState(1);
const [surveyData, setSurveyData] = useState({
title: '',
description: '',
type: '',
questions: [],
respondents: [],
startDate: null,
endDate: null,
status: 'DRAFT'
});
// Обработчик изменения основной информации об опросе
const handleBasicInfoChange = (field, value) => {
setSurveyData(prev => ({
...prev,
[field]: value
}));
// Сброс ошибки для измененного поля
if (errors[field]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
// Обработчик добавления вопроса
const handleAddQuestion = (question) => {
setSurveyData(prev => ({
...prev,
questions: [...prev.questions, {
id: `temp-${Date.now()}`,
...question
}]
}));
};
// Обработчик удаления вопроса
const handleRemoveQuestion = (index) => {
setSurveyData(prev => ({
...prev,
questions: prev.questions.filter((q, i) => i !== index)
}));
};
// Обработчик выбора респондентов
const handleRespondentsChange = (selectedRespondents) => {
setSurveyData(prev => ({
...prev,
respondents: selectedRespondents
}));
};
// Обработчик установки дат опроса
const handleDateChange = (field, date) => {
setSurveyData(prev => ({
...prev,
[field]: date
}));
};
// Валидация данных текущего шага
const isStepValid = () => {
switch (step) {
case 1: // Выбор типа
return !!surveyData.type;
case 2: // Основная информация
return !!surveyData.title.trim();
case 3: // Вопросы
return surveyData.questions.length > 0;
case 4: // Респонденты
return surveyData.respondents.length > 0;
case 5: // Даты
return !!surveyData.startDate;
default:
return true;
}
};
// Обработчик перехода к следующему шагу
const handleNextStep = () => {
if (isStepValid()) {
setStep(prevStep => prevStep + 1);
}
};
// Обработчик возврата к предыдущему шагу
const handlePrevStep = () => {
setStep(prevStep => prevStep - 1);
};
// Обработчик сохранения опроса
const handleSave = (asDraft = true) => {
const finalData = {
...surveyData,
status: asDraft ? 'DRAFT' : 'ACTIVE',
createdAt: new Date().toISOString()
};
onSave(finalData);
onClose();
};
if (!isOpen) {
return null;
}
return (
<div className="modal-overlay">
<div className="modal-content create-survey-modal">
<div className="modal-header">
<h2>Создание нового опроса</h2>
<button className="close-button" onClick={onClose}>×</button>
</div>
<div className="modal-progress-steps">
<div className={`step ${step >= 1 ? 'active' : ''}`}>Тип</div>
<div className={`step ${step >= 2 ? 'active' : ''}`}>Информация</div>
<div className={`step ${step >= 3 ? 'active' : ''}`}>Вопросы</div>
<div className={`step ${step >= 4 ? 'active' : ''}`}>Участники</div>
<div className={`step ${step >= 5 ? 'active' : ''}`}>Даты</div>
</div>
<div className="modal-body">
{step === 1 && (
<SurveyTypeSelect onTypeSelect={handleTypeSelect} />
)}
{step === 2 && (
<div className="survey-basic-info">
<div className="form-group">
<label>Название опроса *</label>
<input
type="text"
value={surveyData.title}
onChange={(e) => handleBasicInfoChange('title', e.target.value)}
placeholder="Введите название опроса"
/>
</div>
<div className="form-group">
<label>Описание</label>
<textarea
value={surveyData.description}
onChange={(e) => handleBasicInfoChange('description', e.target.value)}
placeholder="Введите описание опроса (необязательно)"
rows={4}
/>
</div>
</div>
)}
{step === 3 && (
<div className="survey-questions-editor">
<h3>Вопросы опроса</h3>
{surveyData.questions.map((question, index) => (
<div key={index} className="question-item">
<div className="question-content">
<h4>{question.text}</h4>
<div className="question-type">{question.type}</div>
</div>
<div className="question-actions">
<button onClick={() => handleUpdateQuestion(index, question)}>
Редактировать
</button>
<button onClick={() => handleRemoveQuestion(index)}>
Удалить
</button>
</div>
</div>
))}
<button className="add-question-button" onClick={handleAddQuestion}>
Добавить вопрос
</button>
</div>
)}
{step === 4 && (
<div className="survey-respondents-selector">
<h3>Выбор участников опроса</h3>
<RespondentSearch
selectedRespondents={surveyData.respondents}
onChange={handleRespondentsChange}
/>
</div>
)}
{step === 5 && (
<div className="survey-scheduling">
<div className="form-row">
<div className="form-group">
<label>Дата начала *</label>
<SurveyCalendar
selectedDate={surveyData.startDate}
onChange={(date) => handleDateChange('startDate', date)}
/>
</div>
<div className="form-group">
<label>Дата окончания</label>
<SurveyCalendar
selectedDate={surveyData.endDate}
onChange={(date) => handleDateChange('endDate', date)}
minDate={surveyData.startDate}
/>
</div>
</div>
</div>
)}
</div>
<div className="modal-footer">
{step > 1 && (
<button
className="secondary-button"
onClick={handlePrevStep}
>
Назад
</button>
)}
{step < 5 ? (
<button
className="primary-button"
onClick={handleNextStep}
disabled={!isStepValid()}
>
Далее
</button>
) : (
<div className="final-actions">
<button
className="secondary-button"
onClick={() => handleSave(true)}
>
Сохранить как черновик
</button>
<button
className="primary-button"
onClick={() => handleSave(false)}
disabled={!isStepValid()}
>
Активировать опрос
</button>
</div>
)}
</div>
</div>
</div>
);
};
Выбор типа опроса (SurveyTypeSelect)
Компонент SurveyTypeSelect предоставляет пользователю возможность выбрать тип создаваемого опроса с описанием каждого типа и рекомендациями по использованию:
// Пример компонента SurveyTypeSelect из src/components/surveys/SurveyTypeSelect.tsx
export const SurveyTypeSelect = ({ onTypeSelect }) => {
const surveyTypes = [
{
id: 'REGULAR',
name: 'Регулярный опрос',
description: 'Комплексный опрос для глубокой оценки вовлеченности и опыта сотрудников.',
icon: 'regular-icon',
recommendedFrequency: 'Раз в квартал или полгода',
recommendedQuestions: '20-30 вопросов'
},
{
id: 'PULSE',
name: 'Пульс-опрос',
description: 'Короткий, частый опрос для мониторинга ключевых показателей и быстрого получения обратной связи.',
icon: 'pulse-icon',
recommendedFrequency: 'Еженедельно или ежемесячно',
recommendedQuestions: '5-10 вопросов'
},
{
id: 'ONBOARDING',
name: 'Онбординг-опрос',
description: 'Специализированный опрос для оценки процесса адаптации новых сотрудников.',
icon: 'onboarding-icon',
recommendedFrequency: 'После 1 недели, 1 месяца и 3 месяцев работы',
recommendedQuestions: '10-15 вопросов'
},
{
id: 'EXIT',
name: 'Выходное интервью',
description: 'Опрос для уходящих сотрудников, направленный на выявление причин ухода и сбор отзывов о компании.',
icon: 'exit-icon',
recommendedFrequency: 'При увольнении сотрудника',
recommendedQuestions: '15-20 вопросов'
}
];
return (
<div className="survey-type-select">
<h3>Выберите тип опроса</h3>
<div className="survey-types-grid">
{surveyTypes.map(type => (
<div
key={type.id}
className="survey-type-card"
onClick={() => onTypeSelect(type.id)}
>
<div className={`type-icon ${type.icon}`}></div>
<h4 className="type-name">{type.name}</h4>
<p className="type-description">{type.description}</p>
<div className="type-recommendations">
<div className="recommendation">
<span className="label">Рекомендуемая частота:</span>
<span className="value">{type.recommendedFrequency}</span>
</div>
<div className="recommendation">
<span className="label">Рекомендуемое количество вопросов:</span>
<span className="value">{type.recommendedQuestions}</span>
</div>
</div>
<button className="select-type-button">Выбрать</button>
</div>
))}
</div>
<div className="type-selection-help">
<p>
Не уверены, какой тип опроса выбрать? Регулярные опросы подходят для комплексной оценки,
пульс-опросы — для быстрой обратной связи по конкретным вопросам.
</p>
</div>
</div>
);
};
Форма опроса (SurveyForm)
Компонент SurveyForm предоставляет интерфейс для создания и редактирования опросов. Этот компонент используется как при создании новых опросов, так и при редактировании существующих:
// Пример компонента SurveyForm из src/components/surveys/SurveyForm.tsx
export const SurveyForm = ({
survey,
onSave,
onCancel,
isEditing = false
}) => {
const [formData, setFormData] = useState(
survey || {
title: '',
description: '',
type: '',
questions: [],
respondents: [],
startDate: null,
endDate: null,
status: 'DRAFT'
}
);
const [errors, setErrors] = useState({});
// Обработчик изменения полей формы
const handleChange = (field, value) => {
setFormData(prev => ({
...prev,
[field]: value
}));
// Сброс ошибки для измененного поля
if (errors[field]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
// Обработчик добавления вопроса
const handleAddQuestion = (question) => {
setFormData(prev => ({
...prev,
questions: [...prev.questions, {
id: `temp-${Date.now()}`,
...question
}]
}));
};
// Обработчик удаления вопроса
const handleRemoveQuestion = (index) => {
setFormData(prev => {
const newQuestions = [...prev.questions];
newQuestions.splice(index, 1);
return {
...prev,
questions: newQuestions
};
});
};
// Обработчик выбора респондентов
const handleRespondentsChange = (selectedRespondents) => {
setFormData(prev => ({
...prev,
respondents: selectedRespondents
}));
};
// Обработчик установки дат опроса
const handleDateChange = (field, date) => {
setFormData(prev => ({
...prev,
[field]: date
}));
};
// Валидация данных формы
const handleSubmit = () => {
// Валидация данных текущего шага
const validationErrors = {};
if (!formData.title.trim()) {
validationErrors.title = 'Название опроса обязательно';
}
if (!formData.type) {
validationErrors.type = 'Выберите тип опроса';
}
if (formData.questions.length === 0) {
validationErrors.questions = 'Добавьте хотя бы один вопрос';
}
if (formData.respondents.length === 0) {
validationErrors.respondents = 'Выберите получателей опроса';
}
if (!formData.startDate) {
validationErrors.startDate = 'Выберите дату начала опроса';
}
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
// Если валидация прошла успешно, вызываем функцию сохранения
onSave({
...formData,
updatedAt: new Date().toISOString()
});
};
return (
<div className="survey-form">
<div className="form-section">
<h3>Основная информация</h3>
<div className="form-group">
<label>Название опроса *</label>
<input
type="text"
value={formData.title}
onChange={(e) => handleChange('title', e.target.value)}
placeholder="Введите название опроса"
className={errors.title ? 'error' : ''}
/>
{errors.title && <div className="error-message">{errors.title}</div>}
</div>
<div className="form-group">
<label>Описание</label>
<textarea
value={formData.description}
onChange={(e) => handleChange('description', e.target.value)}
placeholder="Введите описание опроса (необязательно)"
rows={4}
/>
</div>
<div className="form-group">
<label>Тип опроса *</label>
<select
value={formData.type}
onChange={(e) => handleChange('type', e.target.value)}
className={errors.type ? 'error' : ''}
>
<option value="">Выберите тип опроса</option>
<option value="REGULAR">Регулярный опрос</option>
<option value="PULSE">Пульс-опрос</option>
<option value="ONBOARDING">Онбординг-опрос</option>
<option value="EXIT">Выходное интервью</option>
</select>
{errors.type && <div className="error-message">{errors.type}</div>}
</div>
</div>
<div className="form-section">
<h3>Вопросы</h3>
{formData.questions.length === 0 ? (
<div className="no-questions">
<p>Вопросы не добавлены. Используйте кнопку ниже, чтобы добавить вопросы в опрос.</p>
</div>
) : (
<div className="questions-list">
{formData.questions.map((question, index) => (
<div key={question.id} className="question-item">
<div className="question-number">{index + 1}</div>
<div className="question-content">
<div className="question-text">{question.text}</div>
<div className="question-type">{question.type}</div>
</div>
<div className="question-actions">
<button onClick={() => handleUpdateQuestion(index, question)}>
Редактировать
</button>
<button onClick={() => handleRemoveQuestion(index)}>
Удалить
</button>
</div>
</div>
))}
</div>
)}
<button
className="add-question-button"
onClick={() => {/* Открытие модального окна добавления вопроса */}}
>
Добавить вопрос
</button>
</div>
<div className="form-section">
<h3>Получатели опроса</h3>
<RespondentSearch
selectedRespondents={formData.respondents}
onChange={(respondents) => handleChange('respondents', respondents)}
/>
{errors.respondents && <div className="error-message">{errors.respondents}</div>}
</div>
<div className="form-section">
<h3>Расписание</h3>
<div className="form-row">
<div className="form-group">
<label>Дата начала *</label>
<SurveyCalendar
selectedDate={formData.startDate}
onChange={(date) => handleDateChange('startDate', date)}
/>
{errors.startDate && <div className="error-message">{errors.startDate}</div>}
</div>
<div className="form-group">
<label>Дата окончания</label>
<SurveyCalendar
selectedDate={formData.endDate}
onChange={(date) => handleDateChange('endDate', date)}
minDate={formData.startDate}
/>
</div>
</div>
</div>
<div className="form-actions">
<button
className="cancel-button"
onClick={onCancel}
>
Отмена
</button>
<div className="save-options">
<button
className="save-draft-button"
onClick={() => {
handleSubmit();
// Сохранение как черновик
}}
>
Сохранить как черновик
</button>
<button
className="activate-button"
onClick={() => {
handleSubmit();
// Активация опроса
}}
>
{isEditing ? 'Обновить и активировать' : 'Активировать опрос'}
</button>
</div>
</div>
</div>
);
};
Поиск респондентов (RespondentSearch)
Компонент RespondentSearch предоставляет интерфейс для поиска и выбора получателей опроса. Он позволяет выбирать как отдельных сотрудников, так и целые отделы или команды:
// Пример компонента RespondentSearch из src/components/surveys/RespondentSearch.tsx
export const RespondentSearch = ({ selectedRespondents = [], onChange }) => {
const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState('departments'); // 'departments', 'teams', 'employees'
const [searchResults, setSearchResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
// Эффект для выполнения поиска при изменении запроса
useEffect(() => {
const fetchResults = async () => {
if (!searchQuery.trim()) {
setSearchResults([]);
return;
}
setIsLoading(true);
try {
// API-запрос в зависимости от активной вкладки
let results;
switch (activeTab) {
case 'departments':
results = await fetch(`/api/departments?search=${searchQuery}`).then(res => res.json());
break;
case 'teams':
results = await fetch(`/api/teams?search=${searchQuery}`).then(res => res.json());
break;
case 'employees':
results = await fetch(`/api/employees?search=${searchQuery}`).then(res => res.json());
break;
default:
results = [];
}
setSearchResults(results);
} catch (error) {
console.error('Error searching respondents:', error);
setSearchResults([]);
} finally {
setIsLoading(false);
}
};
// Задержка для предотвращения слишком частых запросов
const timeoutId = setTimeout(fetchResults, 300);
return () => clearTimeout(timeoutId);
}, [searchQuery, activeTab]);
// Проверка, выбран ли элемент
const isSelected = (item) => {
return selectedRespondents.some(
respondent => respondent.id === item.id && respondent.type === activeTab
);
};
// Обработчик выбора/отмены выбора элемента
const toggleSelection = (item) => {
if (isSelected(item)) {
// Удаление из выбранных
onChange(
selectedRespondents.filter(
respondent => !(respondent.id === item.id && respondent.type === activeTab)
)
);
} else {
// Добавление к выбранным
onChange([
...selectedRespondents,
{ id: item.id, name: item.name, type: activeTab }
]);
}
};
// Обработчик удаления выбранного элемента
const handleRemoveSelected = (respondent) => {
onChange(
selectedRespondents.filter(
r => !(r.id === respondent.id && r.type === respondent.type)
)
);
};
return (
<div className="respondent-search">
<div className="search-container">
<div className="search-tabs">
<button
className={`tab-button ${activeTab === 'departments' ? 'active' : ''}`}
onClick={() => setActiveTab('departments')}
>
Отделы
</button>
<button
className={`tab-button ${activeTab === 'teams' ? 'active' : ''}`}
onClick={() => setActiveTab('teams')}
>
Команды
</button>
<button
className={`tab-button ${activeTab === 'employees' ? 'active' : ''}`}
onClick={() => setActiveTab('employees')}
>
Сотрудники
</button>
</div>
<div className="search-input-container">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={`Поиск ${
activeTab === 'departments' ? 'отделов' :
activeTab === 'teams' ? 'команд' : 'сотрудников'
}...`}
/>
{searchQuery && (
<button
className="clear-search"
onClick={() => setSearchQuery('')}
>
×
</button>
)}
</div>
<div className="search-results">
{isLoading ? (
<div className="loading-indicator">Загрузка...</div>
) : searchResults.length > 0 ? (
<ul className="results-list">
{searchResults.map(item => (
<li
key={item.id}
className={`result-item ${isSelected(item) ? 'selected' : ''}`}
onClick={() => toggleSelection(item)}
>
<span className="item-name">{item.name}</span>
{isSelected(item) ? (
<span className="selection-indicator">✓</span>
) : (
<span className="selection-add">+</span>
)}
</li>
))}
</ul>
) : searchQuery ? (
<div className="no-results">
Ничего не найдено. Попробуйте изменить запрос.
</div>
) : null}
</div>
</div>
<div className="selected-respondents">
<h4>Выбранные получатели ({selectedRespondents.length}):</h4>
{selectedRespondents.length === 0 ? (
<div className="no-selected">
Получатели не выбраны. Используйте поиск для выбора получателей опроса.
</div>
) : (
<ul className="selected-list">
{selectedRespondents.map(respondent => (
<li key={`${respondent.type}-${respondent.id}`} className="selected-item">
<span className="item-type">
{respondent.type === 'departments' ? 'Отдел' :
respondent.type === 'teams' ? 'Команда' : 'Сотрудник'}:
</span>
<span className="item-name">{respondent.name}</span>
<button
className="remove-item"
onClick={() => handleRemoveSelected(respondent)}
>
×
</button>
</li>
))}
</ul>
)}
</div>
</div>
);
};
Календарь опроса (SurveyCalendar)
Компонент SurveyCalendar предоставляет интерфейс для выбора даты начала и окончания опроса:
// Пример компонента SurveyCalendar из src/components/surveys/SurveyCalendar.tsx
export const SurveyCalendar = ({ selectedDate, onChange, minDate }) => {
const [currentMonth, setCurrentMonth] = useState(
selectedDate ? new Date(selectedDate) : new Date()
);
// Получение дней текущего месяца
const getDaysInMonth = (year, month) => {
return new Date(year, month + 1, 0).getDate();
};
// Получение дня недели для первого дня месяца
const getFirstDayOfMonth = (year, month) => {
return new Date(year, month, 1).getDay();
};
// Проверка, является ли дата выбранной
const isSelectedDate = (date) => {
if (!selectedDate) return false;
const selected = new Date(selectedDate);
return (
date.getDate() === selected.getDate() &&
date.getMonth() === selected.getMonth() &&
date.getFullYear() === selected.getFullYear()
);
};
// Проверка, доступен ли день для выбора
const isDateDisabled = (date) => {
if (minDate) {
const min = new Date(minDate);
min.setHours(0, 0, 0, 0);
return date < min;
}
// По умолчанию считаем, что прошедшие дни недоступны
const today = new Date();
today.setHours(0, 0, 0, 0);
return date < today;
};
// Обработчик выбора даты
const handleDateClick = (date) => {
if (isDateDisabled(date)) return;
onChange(date.toISOString());
};
// Обработчик переключения месяца
const handleMonthChange = (increment) => {
setCurrentMonth(prevMonth => {
const newMonth = new Date(prevMonth);
newMonth.setMonth(newMonth.getMonth() + increment);
return newMonth;
});
};
// Формирование календарной сетки
const renderCalendarGrid = () => {
const year = currentMonth.getFullYear();
const month = currentMonth.getMonth();
const daysInMonth = getDaysInMonth(year, month);
const firstDay = getFirstDayOfMonth(year, month);
const days = [];
// Заполнение пустыми ячейками до первого дня месяца
for (let i = 0; i < firstDay; i++) {
days.push(<div key={`empty-${i}`} className="calendar-day empty"></div>);
}
// Заполнение днями месяца
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month, day);
const isDisabled = isDateDisabled(date);
const isSelected = isSelectedDate(date);
days.push(
<div
key={`day-${day}`}
className={`calendar-day ${isDisabled ? 'disabled' : ''} ${isSelected ? 'selected' : ''}`}
onClick={() => !isDisabled && handleDateClick(date)}
>
{day}
</div>
);
}
return days;
};
// Форматирование названия месяца и года
const getMonthYearHeader = () => {
const options = { month: 'long', year: 'numeric' };
return currentMonth.toLocaleDateString('ru-RU', options);
};
return (
<div className="survey-calendar">
<div className="calendar-header">
<button
className="month-nav prev"
onClick={() => handleMonthChange(-1)}
>
<
</button>
<div className="current-month">
{getMonthYearHeader()}
</div>
<button
className="month-nav next"
onClick={() => handleMonthChange(1)}
>
>
</button>
</div>
<div className="weekdays">
<div>Пн</div>
<div>Вт</div>
<div>Ср</div>
<div>Чт</div>
<div>Пт</div>
<div>Сб</div>
<div>Вс</div>
</div>
<div className="calendar-grid">
{renderCalendarGrid()}
</div>
{selectedDate && (
<div className="selected-date-display">
Выбранная дата: {new Date(selectedDate).toLocaleDateString('ru-RU')}
</div>
)}
</div>
);
};
Типы вопросов
Система опросов HRoom поддерживает различные типы вопросов для сбора разнообразной обратной связи:
1. Оценка по шкале (RATING)
Позволяет респондентам оценить утверждение по шкале (обычно от 1 до 5 или от 1 до 10). Пример: "Оцените от 1 до 5, насколько вы удовлетворены рабочей атмосферой в команде".
2. Единственный выбор (SINGLE_CHOICE)
Вопрос с набором вариантов ответа, из которых респондент может выбрать только один. Пример: "Как часто вы получаете обратную связь от руководителя?" с вариантами "Ежедневно", "Еженедельно", "Ежемесячно", "Редко", "Никогда".
3. Множественный выбор (MULTIPLE_CHOICE)
Вопрос с набором вариантов ответа, из которых респондент может выбрать несколько. Пример: "Какие факторы больше всего влияют на вашу продуктивность?" с различными вариантами ответов.
4. Открытый вопрос (OPEN_TEXT)
Вопрос, на который респондент может дать произвольный текстовый ответ. Пример: "Что бы вы изменили в рабочем процессе вашей команды?"
5. Матрица оценки (MATRIX)
Группа связанных утверждений, каждое из которых оценивается по одинаковой шкале. Пример: несколько утверждений о работе руководителя, каждое из которых оценивается от 1 до 5.
6. Шкала согласия (LIKERT_SCALE)
Специализированный тип оценки, где респондент указывает степень согласия или несогласия с утверждением. Пример: "Я чувствую, что мое мнение ценят в команде" с вариантами от "Полностью не согласен" до "Полностью согласен".
7. Числовой ввод (NUMERIC)
Вопрос, требующий ввода числового значения. Пример: "Сколько часов в неделю вы обычно работаете сверхурочно?"
8. Ранжирование (RANKING)
Вопрос, требующий расположить варианты в порядке приоритета. Пример: "Расположите следующие факторы мотивации в порядке важности для вас".
Интеграция с API
Модуль опросов взаимодействует с API для получения и сохранения данных. Ниже приведен пример сервиса для работы с опросами:
// Пример сервиса для работы с опросами из src/services/surveyService.ts
import { Survey, SurveyResponse } from '../types/survey';
export const surveyService = {
// Получение списка опросов
async getSurveys(filters = {}) {
try {
const queryParams = new URLSearchParams();
// Добавление параметров фильтрации
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
queryParams.append(key, value.toString());
}
});
const response = await fetch(`/api/surveys?${queryParams.toString()}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
}
});
if (!response.ok) {
throw new Error('Failed to fetch surveys');
}
return await response.json();
} catch (error) {
console.error('Error fetching surveys:', error);
throw error;
}
},
// Получение опроса по ID
async getSurveyById(id) {
try {
const response = await fetch(`/api/surveys/${id}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
}
});
if (!response.ok) {
throw new Error(`Failed to fetch survey with ID ${id}`);
}
return await response.json();
} catch (error) {
console.error(`Error fetching survey ${id}:`, error);
throw error;
}
},
// Создание нового опроса
async createSurvey(surveyData) {
try {
const response = await fetch('/api/surveys', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
},
body: JSON.stringify(surveyData)
});
if (!response.ok) {
throw new Error('Failed to create survey');
}
return await response.json();
} catch (error) {
console.error('Error creating survey:', error);
throw error;
}
},
// Обновление существующего опроса
async updateSurvey(id, surveyData) {
try {
const response = await fetch(`/api/surveys/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
},
body: JSON.stringify(surveyData)
});
if (!response.ok) {
throw new Error(`Failed to update survey with ID ${id}`);
}
return await response.json();
} catch (error) {
console.error(`Error updating survey ${id}:`, error);
throw error;
}
},
// Изменение статуса опроса
async updateSurveyStatus(id, status) {
try {
const response = await fetch(`/api/surveys/${id}/status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
},
body: JSON.stringify({ status })
});
if (!response.ok) {
throw new Error(`Failed to update status for survey with ID ${id}`);
}
return await response.json();
} catch (error) {
console.error(`Error updating survey status ${id}:`, error);
throw error;
}
},
// Удаление опроса
async deleteSurvey(id) {
try {
const response = await fetch(`/api/surveys/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
}
});
if (!response.ok) {
throw new Error(`Failed to delete survey with ID ${id}`);
}
return true;
} catch (error) {
console.error(`Error deleting survey ${id}:`, error);
throw error;
}
},
// Получение ответов на опрос
async getSurveyResponses(surveyId) {
try {
const response = await fetch(`/api/surveys/${surveyId}/responses`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
}
});
if (!response.ok) {
throw new Error(`Failed to fetch responses for survey with ID ${surveyId}`);
}
return await response.json();
} catch (error) {
console.error(`Error fetching survey responses ${surveyId}:`, error);
throw error;
}
},
// Получение статистики по опросу
async getSurveyStatistics(surveyId) {
try {
const response = await fetch(`/api/surveys/${surveyId}/statistics`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
}
});
if (!response.ok) {
throw new Error(`Failed to fetch statistics for survey with ID ${surveyId}`);
}
return await response.json();
} catch (error) {
console.error(`Error fetching survey statistics ${surveyId}:`, error);
throw error;
}
}
};
Жизненный цикл опроса
Опросы в системе HRoom проходят через несколько этапов своего жизненного цикла:
1. Создание (DRAFT)
На этом этапе HR-менеджер создает опрос, определяет его тип, добавляет вопросы, выбирает респондентов и устанавливает даты проведения. Опрос сохраняется в статусе "Черновик" и недоступен для прохождения.
2. Активация (ACTIVE)
Когда опрос готов, HR-менеджер активирует его. Система отправляет уведомления выбранным респондентам по электронной почте с ссылкой для прохождения опроса. В этом статусе опрос открыт для ответов.
3. Завершение (COMPLETED)
Опрос автоматически переходит в статус "Завершен" по достижении даты окончания. Также HR-менеджер может вручную завершить опрос раньше запланированной даты. После завершения опрос закрывается для новых ответов.
4. Архивация (ARCHIVED)
Завершенные опросы могут быть архивированы для сохранения истории и уменьшения загромождения активного списка опросов. Архивные опросы доступны для просмотра и аналитики, но не могут быть редактированы или реактивированы.
Типы данных опросов
Модуль опросов использует следующие основные типы данных:
Survey
Базовый тип для представления опроса с его свойствами:
// Из src/types/survey.ts
export type SurveyStatus = 'DRAFT' | 'ACTIVE' | 'COMPLETED' | 'ARCHIVED';
export type SurveyType = 'REGULAR' | 'PULSE' | 'ONBOARDING' | 'EXIT';
export interface Survey {
id: string;
title: string;
description?: string;
type: SurveyType;
status: SurveyStatus;
questions: Question[];
startDate: string;
endDate?: string;
createdAt: string;
updatedAt: string;
respondents?: number;
responses?: number;
}
Question
Тип для представления вопроса в опросе:
// Из src/types/question.ts
export type QuestionType =
| 'RATING'
| 'SINGLE_CHOICE'
| 'MULTIPLE_CHOICE'
| 'OPEN_TEXT'
| 'MATRIX'
| 'LIKERT_SCALE'
| 'NUMERIC'
| 'RANKING';
export interface Question {
id: string;
text: string;
type: QuestionType;
required: boolean;
options?: string[];
minValue?: number;
maxValue?: number;
step?: number;
groupId?: string;
metricId?: string;
}
SurveyResponse
Тип для представления ответа респондента на опрос:
// Из src/types/survey.ts
export interface SurveyResponse {
id: string;
surveyId: string;
userId: string;
answers: {
questionId: string;
value: number | string | string[];
comment?: string;
}[];
submittedAt: string;
}
Основной компонент страницы опросов
Главный компонент страницы опросов объединяет все описанные выше компоненты и функциональность:
// Пример основного компонента страницы опросов из src/pages/Surveys/index.tsx
import React, { useEffect, useState } from 'react';
import { SurveyList } from '../../components/surveys/SurveyList';
import { CreateSurveyModal } from '../../components/surveys/CreateSurveyModal';
import { Survey, SurveyStatus } from '../../types/survey';
import { surveyService } from '../../services/surveyService';
import { SurveyEmpty } from './components/EmptyStates/SurveyEmpty';
export const Surveys = () => {
const [surveys, setSurveys] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [selectedSurveyId, setSelectedSurveyId] = useState(null);
// Загрузка списка опросов
const fetchSurveys = async () => {
try {
setIsLoading(true);
const surveysData = await surveyService.getSurveys();
setSurveys(surveysData);
setError(null);
} catch (err) {
console.error('Error loading surveys:', err);
setError('Произошла ошибка при загрузке опросов. Пожалуйста, попробуйте позже.');
} finally {
setIsLoading(false);
}
};
// Загрузка опросов при монтировании компонента
useEffect(() => {
fetchSurveys();
}, []);
// Открытие модального окна создания опроса
const handleCreateClick = () => {
setIsCreateModalOpen(true);
};
// Закрытие модального окна создания опроса
const handleModalClose = () => {
setIsCreateModalOpen(false);
};
// Обработчик сохранения нового опроса
const handleSaveSurvey = async (surveyData) => {
try {
const newSurvey = await surveyService.createSurvey(surveyData);
setSurveys(prevSurveys => [...prevSurveys, newSurvey]);
setIsCreateModalOpen(false);
} catch (err) {
console.error('Error creating survey:', err);
// Обработка ошибки
}
};
// Обработчик клика по опросу
const handleSurveyClick = (surveyId) => {
setSelectedSurveyId(surveyId);
// Навигация к детальной странице опроса или открытие модального окна с деталями
};
// Обработчик изменения статуса опроса
const handleStatusChange = async (surveyId, newStatus) => {
try {
await surveyService.updateSurveyStatus(surveyId, newStatus);
// Обновление состояния опроса в интерфейсе
setSurveys(prevSurveys =>
prevSurveys.map(survey =>
survey.id === surveyId ? { ...survey, status: newStatus } : survey
)
);
} catch (err) {
console.error(`Error updating survey status ${surveyId}:`, err);
// Обработка ошибки
}
};
// Обработчик удаления опроса
const handleDeleteSurvey = async (surveyId) => {
try {
await surveyService.deleteSurvey(surveyId);
// Удаление опроса из списка
setSurveys(prevSurveys =>
prevSurveys.filter(survey => survey.id !== surveyId)
);
} catch (err) {
console.error(`Error deleting survey ${surveyId}:`, err);
// Обработка ошибки
}
};
// Пустое состояние при отсутствии опросов
if (!isLoading && !error && surveys.length === 0) {
return <SurveyEmpty onCreateClick={handleCreateClick} />;
}
return (
<div className="surveys-page">
{error && (
<div className="error-message">
{error}
<button onClick={fetchSurveys}>Повторить попытку</button>
</div>
)}
<SurveyList
surveys={surveys}
isLoading={isLoading}
onCreateClick={handleCreateClick}
onSurveyClick={handleSurveyClick}
onStatusChange={handleStatusChange}
onDeleteSurvey={handleDeleteSurvey}
/>
{isCreateModalOpen && (
<CreateSurveyModal
isOpen={isCreateModalOpen}
onClose={handleModalClose}
onSave={handleSaveSurvey}
/>
)}
</div>
);
};
Пустые состояния
Для случаев, когда в системе еще нет опросов, реализован компонент SurveyEmpty, который предлагает пользователю создать первый опрос:
// Пример компонента SurveyEmpty из src/pages/Surveys/components/EmptyStates/SurveyEmpty.tsx
export const SurveyEmpty = ({ onCreateClick }) => {
return (
<div className="empty-surveys-container">
<div className="empty-surveys-icon">
{/* Иллюстрация или иконка для пустого состояния */}
</div>
<h2>У вас пока нет опросов</h2>
<p>
Создайте свой первый опрос, чтобы начать собирать ценную обратную связь от сотрудников.
Опросы помогают оценить вовлеченность и удовлетворенность команды, выявить проблемы и найти возможности для улучшения.
</p>
<div className="survey-types-preview">
<div className="survey-type-item">
<h4>Регулярные опросы</h4>
<p>Комплексная оценка вовлеченности и удовлетворенности сотрудников</p>
</div>
<div className="survey-type-item">
<h4>Пульс-опросы</h4>
<p>Короткие, частые опросы для мониторинга ключевых показателей</p>
</div>
<div className="survey-type-item">
<h4>Онбординг-опросы</h4>
<p>Оценка процесса адаптации новых сотрудников</p>
</div>
<div className="survey-type-item">
<h4>Выходные интервью</h4>
<p>Сбор обратной связи от уходящих сотрудников</p>
</div>
</div>
<button
className="create-first-survey-button"
onClick={onCreateClick}
>
Создать первый опрос
</button>
</div>
);
};
Выводы
Модуль "Опросы" в системе HRoom предоставляет комплексный набор инструментов для создания, настройки и проведения различных типов опросов. Этот модуль выполняет ключевую роль в сборе обратной связи от сотрудников, что является основой для дальнейшего анализа и принятия управленческих решений.
Основные преимущества модуля опросов:
- Гибкость - поддержка различных типов опросов для разных целей (регулярные, пульс-опросы, онбординг, выходные интервью)
- Разнообразие вопросов - поддержка разных типов вопросов для сбора как количественных, так и качественных данных
- Таргетирование - возможность точно выбирать получателей опросов (отделы, команды, отдельные сотрудники)
- Планирование - инструменты для настройки расписания проведения опросов
- Мониторинг - отслеживание статуса прохождения опросов и сбор статистики
- Интеграция - тесная связь с другими модулями системы для комплексного анализа данных
Модуль опросов является фундаментальной частью системы HRoom, обеспечивая сбор первичных данных, которые затем используются для аналитики, генерации AI-инсайтов и разработки рекомендаций по улучшению работы команд и повышению вовлеченности сотрудников.