3.6. Управление командами
Модуль "Управление командами" в системе HRoom предоставляет инструменты для организации и управления структурой компании, отделами, командами и сотрудниками. Этот модуль является фундаментальной частью системы, так как он определяет иерархию и взаимосвязи между различными группами сотрудников, необходимые для корректной работы аналитических и опросных функций платформы.
Основные компоненты модуля
- Управление отделами - создание и редактирование структурных подразделений компании
- Управление сотрудниками - добавление, редактирование и архивирование данных о сотрудниках
- Импорт данных - инструменты для массового импорта информации о сотрудниках из CSV
- Фильтрация и поиск - инструменты для быстрого доступа к нужной информации
- Структура организации - управление иерархией и взаимосвязями между отделами
Архитектура модуля управления командами
Модуль управления командами построен на основе следующей структуры файлов и компонентов:
src/pages/Teams/
├── components/ # Компоненты пользовательского интерфейса
│ ├── Employees/ # Компоненты для работы с сотрудниками
│ │ ├── EmployeeTable.tsx # Таблица сотрудников
│ │ ├── EmployeeForm.tsx # Форма создания/редактирования сотрудника
│ │ └── BulkActions.tsx # Массовые действия с сотрудниками
│ └── EmptyStates/ # Компоненты для отображения пустых состояний
│ ├── DepartmentsEmpty.tsx # Пустое состояние для отделов
│ └── EmployeesEmpty.tsx # Пустое состояние для сотрудников
├── DepartmentForm.tsx # Форма создания/редактирования отдела
├── DepartmentList.tsx # Список отделов
├── EmployeeList.tsx # Страница списка сотрудников
├── CSVImportModal.tsx # Модальное окно импорта данных из CSV
└── index.tsx # Главный компонент страницы Teams
src/components/teams/ # Общие компоненты для работы с командами
├── EmployeeFilter.tsx # Компонент фильтрации сотрудников
└── Pagination.tsx # Компонент пагинации для списков
src/services/teamsApi.ts # API-сервис для работы с данными команд и сотрудников
src/services/teams/ # Дополнительные сервисы
│ ├── types.ts # Типы данных
│ ├── utils.ts # Утилиты
│ └── departmentService.ts # Сервис для работы с отделами
Управление отделами
Компонент DepartmentList предоставляет интерфейс для управления отделами компании. Он позволяет просматривать существующие отделы, создавать новые, редактировать и удалять их.
Функциональность управления отделами
- Просмотр списка всех отделов компании
- Создание новых отделов
- Редактирование существующих отделов
- Удаление отделов (с проверкой наличия связанных сотрудников)
- Просмотр статистики по отделам (количество сотрудников, команд)
- Навигация к списку сотрудников конкретного отдела
Компонент списка отделов
Компонент DepartmentList отображает список отделов в виде карточек с ключевой информацией и действиями:
// Пример компонента DepartmentList из src/pages/Teams/DepartmentList.tsx
export const DepartmentList = () => {
const [departments, setDepartments] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [isCreateFormVisible, setIsCreateFormVisible] = useState(false);
const [editingDepartment, setEditingDepartment] = useState(null);
// Загрузка списка отделов
useEffect(() => {
const fetchDepartments = async () => {
try {
setIsLoading(true);
const data = await departmentService.getDepartments();
setDepartments(data);
setError(null);
} catch (err) {
console.error('Error fetching departments:', err);
setError('Произошла ошибка при загрузке списка отделов');
} finally {
setIsLoading(false);
}
};
fetchDepartments();
}, []);
// Обработчик создания нового отдела
const handleCreateDepartment = async (departmentData) => {
try {
const newDepartment = await departmentService.createDepartment(departmentData);
setDepartments(prev => [...prev, newDepartment]);
setIsCreateFormVisible(false);
} catch (err) {
console.error('Error creating department:', err);
// Обработка ошибки
}
};
// Обработчик обновления отдела
const handleUpdateDepartment = async (id, departmentData) => {
try {
const updatedDepartment = await departmentService.updateDepartment(id, departmentData);
setDepartments(prev =>
prev.map(dept => dept.id === id ? updatedDepartment : dept)
);
setEditingDepartment(null);
} catch (err) {
console.error('Error updating department:', err);
// Обработка ошибки
}
};
// Обработчик удаления отдела
const handleDeleteDepartment = async (id) => {
try {
await departmentService.deleteDepartment(id);
setDepartments(prev => prev.filter(dept => dept.id !== id));
} catch (err) {
console.error('Error deleting department:', 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>
);
}
if (departments.length === 0 && !isCreateFormVisible) {
return <DepartmentsEmpty onCreateClick={() => setIsCreateFormVisible(true)} />;
}
return (
<div className="departments-container">
<div className="departments-header">
<h1>Отделы компании</h1>
<button
className="create-department-button"
onClick={() => setIsCreateFormVisible(true)}
>
Создать отдел
</button>
</div>
{isCreateFormVisible && (
<DepartmentForm
onSubmit={handleCreateDepartment}
onCancel={() => setIsCreateFormVisible(false)}
/>
)}
{editingDepartment && (
<DepartmentForm
department={editingDepartment}
isEditing={true}
onSubmit={(data) => handleUpdateDepartment(editingDepartment.id, data)}
onCancel={() => setEditingDepartment(null)}
/>
)}
<div className="departments-grid">
{departments.map(department => (
<div key={department.id} className="department-card">
<div className="department-header">
<h3>{department.name}</h3>
<div className="department-actions">
<button
className="edit-button"
onClick={() => setEditingDepartment(department)}
>
Редактировать
</button>
<button
className="delete-button"
onClick={() => handleDeleteDepartment(department.id)}
>
Удалить
</button>
</div>
</div>
<div className="department-info">
<p>{department.description || 'Нет описания'}</p>
</div>
<div className="department-stats">
<div className="stat-item">
<span className="stat-label">Сотрудников:</span>
<span className="stat-value">{department.employeesCount || 0}</span>
</div>
<div className="stat-item">
<span className="stat-label">Команд:</span>
<span className="stat-value">{department.teamsCount || 0}</span>
</div>
</div>
<div className="department-footer">
<a
href={`/teams/employees?departmentId=${department.id}`}
className="view-employees-link"
>
Просмотреть сотрудников
</a>
</div>
</div>
))}
</div>
</div>
);
};
Форма отдела
Компонент DepartmentForm предоставляет интерфейс для создания и редактирования отделов:
// Пример компонента DepartmentForm из src/pages/Teams/DepartmentForm.tsx
export const DepartmentForm = ({
department,
isEditing = false,
onSubmit,
onCancel
}) => {
const [formData, setFormData] = useState({
name: department?.name || '',
description: department?.description || '',
parentDepartmentId: department?.parentDepartmentId || null
});
const [departments, setDepartments] = useState([]);
const [errors, setErrors] = useState({});
const [isLoading, setIsLoading] = useState(false);
// Загрузка списка отделов для выбора родительского отдела
useEffect(() => {
const fetchDepartments = async () => {
try {
setIsLoading(true);
const data = await departmentService.getDepartments();
// Фильтруем текущий отдел из списка возможных родителей
const filteredDepartments = department
? data.filter(dept => dept.id !== department.id)
: data;
setDepartments(filteredDepartments);
} catch (err) {
console.error('Error fetching departments:', err);
} finally {
setIsLoading(false);
}
};
fetchDepartments();
}, [department]);
// Обработчик изменения полей формы
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 (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
// Вызов функции обработки отправки
onSubmit(formData);
};
return (
<div className="department-form-container">
<h2>{isEditing ? 'Редактирование отдела' : 'Создание нового отдела'}</h2>
<form onSubmit={handleSubmit} className="department-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="description">Описание</label>
<textarea
id="description"
value={formData.description}
onChange={(e) => handleChange('description', e.target.value)}
rows={4}
/>
</div>
<div className="form-group">
<label htmlFor="parentDepartmentId">Родительский отдел</label>
<select
id="parentDepartmentId"
value={formData.parentDepartmentId || ''}
onChange={(e) => handleChange('parentDepartmentId', e.target.value || null)}
>
<option value="">Нет (корневой отдел)</option>
{departments.map(dept => (
<option key={dept.id} value={dept.id}>
{dept.name}
</option>
))}
</select>
</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>
);
};
Управление сотрудниками
Компонент EmployeeList предоставляет интерфейс для управления сотрудниками. Он позволяет просматривать список сотрудников, добавлять новых, редактировать и архивировать существующих, а также выполнять массовые операции.
Функциональность управления сотрудниками
- Просмотр списка сотрудников с возможностью фильтрации и сортировки
- Добавление новых сотрудников через форму
- Редактирование данных существующих сотрудников
- Архивирование/деактивация сотрудников
- Массовый импорт сотрудников из CSV-файла
- Массовые операции с выбранными сотрудниками
- Поиск сотрудников по различным параметрам
Компонент списка сотрудников
Компонент EmployeeList отображает список сотрудников и предоставляет инструменты для управления ими:
// Пример компонента EmployeeList из src/pages/Teams/EmployeeList.tsx
import React, { useState, useEffect } from 'react';
import { Employee } from '../../types';
import { EmployeeFilter } from '../components/EmployeeFilter';
import { BulkActions } from '../components/Employees/BulkActions';
import { EmployeeTable } from '../components/Employees/EmployeeTable';
import { Pagination } from '../components/Pagination';
import { CSVImportModal } from '../components/CSVImportModal';
export const EmployeeList = () => {
const [employees, setEmployees] = useState([]);
const [filteredEmployees, setFilteredEmployees] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [filters, setFilters] = useState({
departmentId: '',
status: 'active',
search: ''
});
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [isFormVisible, setIsFormVisible] = useState(false);
const [editingEmployee, setEditingEmployee] = useState(null);
const [selectedEmployees, setSelectedEmployees] = useState([]);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const pageSize = 10; // Количество сотрудников на странице
// Загрузка списка сотрудников с учетом фильтров и пагинации
useEffect(() => {
const fetchEmployees = async () => {
try {
setIsLoading(true);
const queryParams = {
page,
pageSize,
...filters
};
const response = await teamsApi.getEmployees(queryParams);
setEmployees(response.employees);
setFilteredEmployees(response.employees);
setTotalPages(Math.ceil(response.total / pageSize));
setError(null);
} catch (err) {
console.error('Error fetching employees:', err);
setError('Произошла ошибка при загрузке списка сотрудников');
} finally {
setIsLoading(false);
}
};
fetchEmployees();
}, [page, filters]);
// Обработчик изменения фильтров
const handleFilterChange = (filterName, value) => {
setFilters(prev => ({
...prev,
[filterName]: value
}));
setPage(1); // Сброс на первую страницу при изменении фильтров
};
// Обработчик создания нового сотрудника
const handleCreateEmployee = async (employeeData) => {
try {
const newEmployee = await teamsApi.createEmployee(employeeData);
// Обновление списка сотрудников только если новый сотрудник соответствует текущим фильтрам
if (
(!filters.departmentId || employeeData.departmentId === filters.departmentId) &&
(!filters.status || employeeData.status === filters.status)
) {
setEmployees(prev => [...prev, newEmployee]);
setFilteredEmployees(prev => [...prev, newEmployee]);
}
setIsFormVisible(false);
} catch (err) {
console.error('Error creating employee:', err);
// Обработка ошибки
}
};
// Обработчик обновления сотрудника
const handleUpdateEmployee = async (id, employeeData) => {
try {
const updatedEmployee = await teamsApi.updateEmployee(id, employeeData);
// Обновление списка сотрудников
setEmployees(prev =>
prev.map(emp => emp.id === id ? updatedEmployee : emp)
);
setFilteredEmployees(prev =>
prev.map(emp => emp.id === id ? updatedEmployee : emp)
);
setEditingEmployee(null);
} catch (err) {
console.error('Error updating employee:', err);
// Обработка ошибки
}
};
// Обработчик изменения статуса сотрудника (активный/архивный)
const handleStatusChange = async (id, newStatus) => {
try {
await teamsApi.updateEmployeeStatus(id, newStatus);
// Обновление списка сотрудников
if (filters.status === 'all' || filters.status === newStatus) {
setEmployees(prev =>
prev.map(emp => emp.id === id ? { ...emp, status: newStatus } : emp)
);
setFilteredEmployees(prev =>
prev.map(emp => emp.id === id ? { ...emp, status: newStatus } : emp)
);
} else {
// Если текущий фильтр не включает новый статус, удаляем сотрудника из списка
setEmployees(prev => prev.filter(emp => emp.id !== id));
setFilteredEmployees(prev => prev.filter(emp => emp.id !== id));
}
} catch (err) {
console.error('Error updating employee status:', err);
// Обработка ошибки
}
};
// Обработчик массового импорта сотрудников
const handleBulkImport = async (importedEmployees) => {
try {
const results = await teamsApi.bulkImportEmployees(importedEmployees);
// Обновление списка сотрудников, если они соответствуют текущим фильтрам
if (results.success) {
// Перезагрузка списка сотрудников для обновления данных
const queryParams = {
page,
pageSize,
...filters
};
const response = await teamsApi.getEmployees(queryParams);
setEmployees(response.employees);
setFilteredEmployees(response.employees);
setTotalPages(Math.ceil(response.total / pageSize));
}
setIsImportModalOpen(false);
} catch (err) {
console.error('Error importing employees:', err);
// Обработка ошибки
}
};
// Обработчик выбора/отмены выбора сотрудника
const handleEmployeeSelection = (employeeId) => {
setSelectedEmployees(prev => {
if (prev.includes(employeeId)) {
return prev.filter(id => id !== employeeId);
} else {
return [...prev, employeeId];
}
});
};
// Обработчик выбора/отмены выбора всех сотрудников на странице
const handleSelectAllEmployees = () => {
if (selectedEmployees.length === filteredEmployees.length) {
// Если все выбраны, отменяем выбор
setSelectedEmployees([]);
} else {
// Иначе выбираем всех
setSelectedEmployees(filteredEmployees.map(emp => emp.id));
}
};
// Обработчик массовой операции с выбранными сотрудниками
const handleBulkAction = async (action) => {
if (selectedEmployees.length === 0) return;
try {
switch (action) {
case 'archive':
await teamsApi.bulkUpdateEmployeeStatus(selectedEmployees, 'archived');
break;
case 'activate':
await teamsApi.bulkUpdateEmployeeStatus(selectedEmployees, 'active');
break;
case 'changeDepartment':
// Логика изменения отдела для выбранных сотрудников
break;
default:
return;
}
// Перезагрузка списка сотрудников для обновления данных
const queryParams = {
page,
pageSize,
...filters
};
const response = await teamsApi.getEmployees(queryParams);
setEmployees(response.employees);
setFilteredEmployees(response.employees);
// Сброс выбранных сотрудников
setSelectedEmployees([]);
} catch (err) {
console.error('Error performing bulk action:', err);
// Обработка ошибки
}
};
if (isLoading && employees.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>
);
}
if (employees.length === 0 && !isFormVisible && filters.departmentId === '' && filters.status === 'active' && filters.search === '') {
return <EmployeesEmpty onCreateClick={() => setIsFormVisible(true)} onImportClick={() => setIsImportModalOpen(true)} />;
}
return (
<div className="employees-container">
<div className="employees-header">
<h1>Сотрудники</h1>
<div className="header-actions">
<button
className="create-employee-button"
onClick={() => setIsFormVisible(true)}
>
Добавить сотрудника
</button>
<button
className="import-button"
onClick={() => setIsImportModalOpen(true)}
>
Импорт из CSV
</button>
</div>
</div>
<EmployeeFilter
filters={filters}
onFilterChange={handleFilterChange}
/>
{selectedEmployees.length > 0 && (
<BulkActions
selectedCount={selectedEmployees.length}
onBulkAction={handleBulkAction}
/>
)}
<EmployeeTable
employees={filteredEmployees}
selectedEmployees={selectedEmployees}
onSelectEmployee={handleEmployeeSelection}
onSelectAll={handleSelectAllEmployees}
onEdit={setEditingEmployee}
onStatusChange={handleStatusChange}
/>
{totalPages > 1 && (
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
/>
)}
{isFormVisible && (
<EmployeeForm
onSubmit={handleCreateEmployee}
onCancel={() => setIsFormVisible(false)}
/>
)}
{editingEmployee && (
<EmployeeForm
employee={editingEmployee}
isEditing={true}
onSubmit={(data) => handleUpdateEmployee(editingEmployee.id, data)}
onCancel={() => setEditingEmployee(null)}
/>
)}
{isImportModalOpen && (
<CSVImportModal
onImport={handleBulkImport}
onClose={() => setIsImportModalOpen(false)}
/>
)}
</div>
);
};
Таблица сотрудников
Компонент EmployeeTable отображает данные сотрудников в табличном виде с возможностью выбора и выполнения действий:
// Пример компонента EmployeeTable из src/pages/Teams/components/Employees/EmployeeTable.tsx
import React from 'react';
import { Employee } from '../../types';
export const EmployeeTable = ({
employees,
selectedEmployees,
onSelectEmployee,
onSelectAll,
onEdit,
onStatusChange
}) => {
// Проверка, выбраны ли все сотрудники
const allSelected = employees.length > 0 && selectedEmployees.length === employees.length;
// Форматирование даты
const formatDate = (dateString) => {
if (!dateString) return 'Н/Д';
const date = new Date(dateString);
return new Intl.DateTimeFormat('ru-RU').format(date);
};
// Получение статуса сотрудника в читаемом виде
const getStatusLabel = (status) => {
switch (status) {
case 'active': return 'Активный';
case 'archived': return 'Архивный';
default: return status;
}
};
return (
<div className="employee-table-container">
{employees.length === 0 ? (
<div className="no-employees-found">
<p>Сотрудники не найдены. Попробуйте изменить параметры фильтрации.</p>
</div>
) : (
<table className="employee-table">
<thead>
<tr>
<th className="checkbox-column">
<input
type="checkbox"
checked={allSelected}
onChange={onSelectAll}
/>
</th>
<th>ФИО</th>
<th>Email</th>
<th>Должность</th>
<th>Отдел</th>
<th>Статус</th>
<th>Дата найма</th>
<th className="actions-column">Действия</th>
</tr>
</thead>
<tbody>
{employees.map(employee => (
<tr key={employee.id}>
<td>
<input
type="checkbox"
checked={selectedEmployees.includes(employee.id)}
onChange={() => onSelectEmployee(employee.id)}
/>
</td>
<td>
<div className="employee-name">
{employee.firstName} {employee.lastName}
</div>
</td>
<td>{employee.email}</td>
<td>{employee.position || 'Не указана'}</td>
<td>{employee.departmentName || 'Не указан'}</td>
<td>
<span className={`status-badge ${employee.status}`}>
{getStatusLabel(employee.status)}
</span>
</td>
<td>{formatDate(employee.hireDate)}</td>
<td>
<div className="employee-actions">
<button
className="edit-button"
onClick={() => onEdit(employee)}
>
Редактировать
</button>
{employee.status === 'active' ? (
<button
className="archive-button"
onClick={() => onStatusChange(employee.id, 'archived')}
>
Архивировать
</button>
) : (
<button
className="activate-button"
onClick={() => onStatusChange(employee.id, 'active')}
>
Активировать
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};
Форма сотрудника
Компонент EmployeeForm предоставляет интерфейс для создания и редактирования данных сотрудника:
// Пример компонента EmployeeForm из src/pages/Teams/components/Employees/EmployeeForm.tsx
export const EmployeeForm = ({
employee,
isEditing = false,
onSubmit,
onCancel
}) => {
const [formData, setFormData] = useState({
firstName: employee?.firstName || '',
lastName: employee?.lastName || '',
email: employee?.email || '',
position: employee?.position || '',
departmentId: employee?.departmentId || '',
hireDate: employee?.hireDate || '',
phone: employee?.phone || '',
status: employee?.status || 'active'
});
const [departments, setDepartments] = useState([]);
const [errors, setErrors] = useState({});
const [isLoading, setIsLoading] = useState(false);
// Загрузка списка отделов для выбора
useEffect(() => {
const fetchDepartments = async () => {
try {
setIsLoading(true);
const data = await departmentService.getDepartments();
setDepartments(data);
} catch (err) {
console.error('Error fetching departments:', err);
} finally {
setIsLoading(false);
}
};
fetchDepartments();
}, []);
// Обработчик изменения полей формы
const handleChange = (field, value) => {
setFormData(prev => ({
...prev,
[field]: value
}));
// Сброс ошибки для измененного поля
if (errors[field]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
// Валидация email
const validateEmail = (email) => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(String(email).toLowerCase());
};
// Обработчик отправки формы
const handleSubmit = (e) => {
e.preventDefault();
// Валидация данных
const validationErrors = {};
if (!formData.firstName.trim()) {
validationErrors.firstName = 'Имя обязательно';
}
if (!formData.lastName.trim()) {
validationErrors.lastName = 'Фамилия обязательна';
}
if (!formData.email.trim()) {
validationErrors.email = 'Email обязателен';
} else if (!validateEmail(formData.email)) {
validationErrors.email = 'Некорректный формат email';
}
if (!formData.departmentId) {
validationErrors.departmentId = 'Отдел обязателен';
}
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
// Вызов функции обработки отправки
onSubmit(formData);
};
if (isLoading && departments.length === 0) {
return <div className="loading-state">Загрузка данных...</div>;
}
return (
<div className="employee-form-container">
<div className="form-header">
<h2>{isEditing ? 'Редактирование сотрудника' : 'Добавление нового сотрудника'}</h2>
<button className="close-button" onClick={onCancel}>×</button>
</div>
<form onSubmit={handleSubmit} className="employee-form">
<div className="form-row">
<div className="form-group">
<label htmlFor="firstName">Имя *</label>
<input
type="text"
id="firstName"
value={formData.firstName}
onChange={(e) => handleChange('firstName', e.target.value)}
className={errors.firstName ? 'error' : ''}
/>
{errors.firstName && <div className="error-message">{errors.firstName}</div>}
</div>
<div className="form-group">
<label htmlFor="lastName">Фамилия *</label>
<input
type="text"
id="lastName"
value={formData.lastName}
onChange={(e) => handleChange('lastName', e.target.value)}
className={errors.lastName ? 'error' : ''}
/>
{errors.lastName && <div className="error-message">{errors.lastName}</div>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="email">Email *</label>
<input
type="email"
id="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
className={errors.email ? 'error' : ''}
/>
{errors.email && <div className="error-message">{errors.email}</div>}
</div>
<div className="form-group">
<label htmlFor="phone">Телефон</label>
<input
type="tel"
id="phone"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="position">Должность</label>
<input
type="text"
id="position"
value={formData.position}
onChange={(e) => handleChange('position', e.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="departmentId">Отдел *</label>
<select
id="departmentId"
value={formData.departmentId}
onChange={(e) => handleChange('departmentId', e.target.value)}
className={errors.departmentId ? 'error' : ''}
>
<option value="">Выберите отдел</option>
{departments.map(dept => (
<option key={dept.id} value={dept.id}>
{dept.name}
</option>
))}
</select>
{errors.departmentId && <div className="error-message">{errors.departmentId}</div>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="hireDate">Дата найма</label>
<input
type="date"
id="hireDate"
value={formData.hireDate}
onChange={(e) => handleChange('hireDate', e.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="status">Статус</label>
<select
id="status"
value={formData.status}
onChange={(e) => handleChange('status', e.target.value)}
>
<option value="active">Активный</option>
<option value="archived">Архивный</option>
</select>
</div>
</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>
);
};
Массовый импорт данных
Компонент CSVImportModal предоставляет интерфейс для массового импорта сотрудников из CSV-файла:
// Пример компонента CSVImportModal из src/pages/Teams/CSVImportModal.tsx
export const CSVImportModal = ({ onImport, onClose }) => {
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 = ['firstName', 'lastName', 'email', 'departmentId'];
// Доступные поля для маппинга
const availableFields = [
{ id: 'firstName', name: 'Имя' },
{ id: 'lastName', name: 'Фамилия' },
{ id: 'email', name: 'Email' },
{ id: 'departmentId', name: 'ID отдела' },
{ id: 'position', name: 'Должность' },
{ id: 'phone', name: 'Телефон' },
{ id: 'hireDate', 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 employees = [];
// Пропускаем заголовки (первую строку)
for (let i = 1; i < previewData.length; i++) {
const row = previewData[i];
const employee = {};
// Маппинг данных из CSV в объект сотрудника
Object.entries(mappings).forEach(([columnIndex, fieldId]) => {
if (row[columnIndex] !== undefined) {
employee[fieldId] = row[columnIndex];
}
});
employees.push(employee);
}
// Вызываем функцию импорта
await onImport(employees);
} catch (err) {
console.error('Error importing employees:', 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-файл должен содержать заголовки в первой строке и данные сотрудников в последующих строках.
Обязательные поля: Имя, Фамилия, Email, ID отдела.
</p>
<p>Пример CSV-файла:</p>
<pre>
Имя,Фамилия,Email,ID отдела,Должность,Телефон,Дата найма
Иван,Иванов,ivan@example.com,dept-1,Инженер,+7 900 123-45-67,2023-01-15
Мария,Петрова,maria@example.com,dept-2,Дизайнер,+7 900 765-43-21,2023-02-01
</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>
<button
className="import-button"
onClick={handleImport}
disabled={isLoading}
>
{isLoading ? 'Импорт...' : 'Импортировать'}
</button>
</div>
)}
</div>
<div className="modal-footer">
<button
className="cancel-button"
onClick={onClose}
disabled={isLoading}
>
Отмена
</button>
<button
className="confirm-button"
onClick={handleImport}
disabled={isLoading}
>
Подтвердить
</button>
</div>
</div>
</div>
);
};
Фильтрация сотрудников
Компонент EmployeeFilter предоставляет интерфейс для фильтрации списка сотрудников по различным параметрам:
// Пример компонента EmployeeFilter из src/components/teams/EmployeeFilter.tsx
export const EmployeeFilter = ({ filters, onFilterChange }) => {
const [departments, setDepartments] = useState([]);
const [isLoading, setIsLoading] = useState(false);
// Загрузка списка отделов для фильтрации
useEffect(() => {
if (isLoading) {
const fetchDepartments = async () => {
try {
setIsLoading(true);
const data = await departmentService.getDepartments();
setDepartments(data);
} catch (err) {
console.error('Error fetching departments:', err);
} finally {
setIsLoading(false);
}
};
fetchDepartments();
}
}, []);
return (
<div className="employee-filters">
<div className="filter-row">
<div className="filter-group">
<label htmlFor="search">Поиск:</label>
<input
type="text"
id="search"
placeholder="Имя, фамилия, email..."
value={filters.search}
onChange={(e) => onFilterChange('search', e.target.value)}
/>
</div>
<div className="filter-group">
<label htmlFor="departmentId">Отдел:</label>
<select
id="departmentId"
value={filters.departmentId}
onChange={(e) => onFilterChange('departmentId', e.target.value)}
>
<option value="">Все отделы</option>
{departments.map(dept => (
<option key={dept.id} value={dept.id}>
{dept.name}
</option>
))}
</select>
</div>
<div className="filter-group">
<label htmlFor="status">Статус:</label>
<select
id="status"
value={filters.status}
onChange={(e) => onFilterChange('status', e.target.value)}
>
<option value="active">Активные</option>
<option value="archived">Архивные</option>
<option value="all">Все</option>
</select>
</div>
<button
className="reset-filters-button"
onClick={() => {
onFilterChange('departmentId', '');
onFilterChange('status', 'active');
onFilterChange('search', '');
}}
>
Сбросить фильтры
</button>
</div>
</div>
);
};
Массовые действия с сотрудниками
Компонент BulkActions предоставляет интерфейс для выполнения массовых операций с выбранными сотрудниками:
// Пример компонента BulkActions из src/pages/Teams/components/Employees/BulkActions.tsx
export const BulkActions = ({ selectedCount, onBulkAction }) => {
const [isDepartmentModalOpen, setIsDepartmentModalOpen] = useState(false);
const [selectedDepartmentId, setSelectedDepartmentId] = useState('');
const [departments, setDepartments] = useState([]);
// Загрузка списка отделов для выбора при массовом изменении отдела
useEffect(() => {
if (isDepartmentModalOpen) {
const fetchDepartments = async () => {
try {
const data = await departmentService.getDepartments();
setDepartments(data);
} catch (err) {
console.error('Error fetching departments:', err);
}
};
fetchDepartments();
}
}, [isDepartmentModalOpen]);
// Обработчик подтверждения изменения отдела
const handleConfirmDepartmentChange = () => {
if (selectedDepartmentId) {
onBulkAction('changeDepartment', { departmentId: selectedDepartmentId });
setIsDepartmentModalOpen(false);
setSelectedDepartmentId('');
}
};
return (
<div className="bulk-actions-panel">
<div className="selected-count">
Выбрано сотрудников: {selectedCount}
</div>
<div className="bulk-actions">
<button
className="action-button"
onClick={() => onBulkAction('archive')}
>
Архивировать выбранных
</button>
<button
className="action-button"
onClick={() => onBulkAction('activate')}
>
Активировать выбранных
</button>
<button
className="action-button"
onClick={() => setIsDepartmentModalOpen(true)}
>
Изменить отдел
</button>
</div>
{isDepartmentModalOpen && (
<div className="modal-overlay">
<div className="change-department-modal">
<div className="modal-header">
<h3>Изменение отдела для выбранных сотрудников</h3>
<button
className="close-button"
onClick={() => setIsDepartmentModalOpen(false)}
>
×
</button>
</div>
<div className="modal-body">
<p>
Выберите новый отдел для {selectedCount} выбранных сотрудников:
</p>
<select
value={selectedDepartmentId}
onChange={(e) => setSelectedDepartmentId(e.target.value)}
>
<option value="">Выберите отдел</option>
{departments.map(dept => (
<option key={dept.id} value={dept.id}>
{dept.name}
</option>
))}
</select>
</div>
<div className="modal-footer">
<button
className="cancel-button"
onClick={() => setIsDepartmentModalOpen(false)}
>
Отмена
</button>
<button
className="confirm-button"
onClick={handleConfirmDepartmentChange}
disabled={!selectedDepartmentId}
>
Подтвердить
</button>
</div>
</div>
</div>
)}
</div>
);
};
Пустые состояния
Модуль управления командами включает компоненты для отображения пустых состояний при отсутствии данных:
Пустое состояние для отделов
// Пример компонента DepartmentsEmpty из src/pages/Teams/components/EmptyStates/DepartmentsEmpty.tsx
export const DepartmentsEmpty = ({ onCreateClick }) => {
return (
<div className="empty-state-container">
<div className="empty-state-icon">
{/* Иконка или иллюстрация */}
</div>
<h2>У вас пока нет отделов</h2>
<p>
Отделы позволяют организовать структуру вашей компании и группировать сотрудников.
Создайте первый отдел, чтобы начать работу с системой.
</p>
<button
className="create-department-button"
onClick={onCreateClick}
>
Создать первый отдел
</button>
</div>
);
};
Пустое состояние для сотрудников
// Пример компонента EmployeesEmpty из src/pages/Teams/components/EmptyStates/EmployeesEmpty.tsx
export const EmployeesEmpty = ({ onCreateClick, onImportClick }) => {
return (
<div className="empty-state-container">
<div className="empty-state-icon">
{/* Иконка или иллюстрация */}
</div>
<h2>У вас пока нет сотрудников</h2>
<p>
Добавьте сотрудников в систему для проведения опросов и анализа вовлеченности.
Вы можете добавить сотрудников по одному или импортировать список из CSV-файла.
</p>
<div className="empty-state-actions">
<button
className="create-employee-button"
onClick={onCreateClick}
>
Добавить сотрудника
</button>
<button
className="import-button"
onClick={onImportClick}
>
Импорт из CSV
</button>
</div>
</div>
);
};
Интеграция с API
Модуль управления командами интегрируется с API-сервисами для получения и сохранения данных о командах и сотрудниках.
API-сервис для работы с командами
Сервис teamsApi предоставляет функции для взаимодействия с API-эндпоинтами, связанными с командами и сотрудниками:
// Пример API-сервиса из src/services/api/teamsApi.ts
import { Team, TeamMember, Employee, Department } from '../../types';
const API_URL = 'https://api.hroom.ai';
export const teamsApi = {
// Получение списка сотрудников
async getEmployees(params = {}) {
const queryParams = new URLSearchParams();
// Добавление параметров запроса
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
queryParams.append(key, value.toString());
}
});
const response = await fetch(`${API_URL}/employees?${queryParams.toString()}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
}
});
if (!response.ok) {
throw new Error('Failed to fetch employees');
}
return await response.json();
},
// Получение сотрудника по ID
async getEmployeeById(id) {
const response = await fetch(`${API_URL}/employees/${id}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
}
});
if (!response.ok) {
throw new Error(`Failed to fetch employee with ID ${id}`);
}
return await response.json();
},
// Создание нового сотрудника
async createEmployee(employeeData) {
const response = await fetch(`${API_URL}/employees`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
},
body: JSON.stringify(employeeData)
});
if (!response.ok) {
throw new Error('Failed to create employee');
}
return await response.json();
},
// Обновление данных сотрудника
async updateEmployee(id, employeeData) {
const response = await fetch(`${API_URL}/employees/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
},
body: JSON.stringify(employeeData)
});
if (!response.ok) {
throw new Error(`Failed to update employee with ID ${id}`);
}
return await response.json();
},
// Обновление статуса сотрудника
async updateEmployeeStatus(id, status) {
const response = await fetch(`${API_URL}/employees/${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 employee with ID ${id}`);
}
return await response.json();
},
// Массовый импорт сотрудников
async bulkImportEmployees(employees) {
const response = await fetch(`${API_URL}/employees/bulk-import`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
},
body: JSON.stringify({ employees })
});
if (!response.ok) {
throw new Error('Failed to import employees');
}
return await response.json();
},
// Массовое обновление статуса сотрудников
async bulkUpdateEmployeeStatus(employeeIds, status) {
const response = await fetch(`${API_URL}/employees/bulk-status-update`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
},
body: JSON.stringify({ employeeIds, status })
});
if (!response.ok) {
throw new Error('Failed to update employee statuses');
}
return await response.json();
}
};
Сервис для работы с отделами
Сервис departmentService предоставляет функции для управления отделами:
// Пример сервиса из src/services/teams/departmentService.ts
import { Department } from '../types';
const API_URL = 'https://api.hroom.ai';
export const departmentService = {
// Получение списка отделов
async getDepartments() {
const response = await fetch(`${API_URL}/departments`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
}
});
if (!response.ok) {
throw new Error('Failed to fetch departments');
}
return await response.json();
},
// Получение отдела по ID
async getDepartmentById(id) {
const response = await fetch(`${API_URL}/departments/${id}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
}
});
if (!response.ok) {
throw new Error(`Failed to fetch department with ID ${id}`);
}
return await response.json();
},
// Создание нового отдела
async createDepartment(departmentData) {
const response = await fetch(`${API_URL}/departments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
},
body: JSON.stringify(departmentData)
});
if (!response.ok) {
throw new Error('Failed to create department');
}
return await response.json();
},
// Обновление отдела
async updateDepartment(id, departmentData) {
const response = await fetch(`${API_URL}/departments/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
},
body: JSON.stringify(departmentData)
});
if (!response.ok) {
throw new Error(`Failed to update department with ID ${id}`);
}
return await response.json();
},
// Удаление отдела
async deleteDepartment(id) {
const response = await fetch(`${API_URL}/departments/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
}
});
if (!response.ok) {
throw new Error(`Failed to delete department with ID ${id}`);
}
return true;
},
// Получение сотрудников отдела
async getDepartmentEmployees(departmentId, params = {}) {
const queryParams = new URLSearchParams();
// Добавление параметров запроса
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
queryParams.append(key, value.toString());
}
});
const response = await fetch(`${API_URL}/departments/${departmentId}/employees?${queryParams.toString()}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
}
});
if (!response.ok) {
throw new Error(`Failed to fetch employees for department ${departmentId}`);
}
return await response.json();
}
};
Пагинация
Компонент Pagination предоставляет интерфейс для навигации по страницам списка сотрудников:
// Пример компонента Pagination из src/components/teams/Pagination.tsx
export const Pagination = ({ currentPage, totalPages, onPageChange }) => {
// Функция для генерации массива номеров страниц для отображения
const getPageNumbers = () => {
const pageNumbers = [];
// Ограничиваем количество отображаемых страниц
if (totalPages <= 7) {
// Если страниц мало, показываем все
for (let i = 1; i <= totalPages; i++) {
pageNumbers.push(i);
}
} else {
// Если страниц много, показываем текущую, несколько соседних и границы
if (currentPage <= 3) {
// Начало списка
for (let i = 1; i <= 5; i++) {
pageNumbers.push(i);
}
pageNumbers.push('...');
pageNumbers.push(totalPages);
} else if (currentPage >= totalPages - 2) {
// Конец списка
pageNumbers.push(1);
pageNumbers.push('...');
for (let i = totalPages - 4; i <= totalPages; i++) {
pageNumbers.push(i);
}
} else {
// Середина списка
pageNumbers.push(1);
pageNumbers.push('...');
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
pageNumbers.push(i);
}
pageNumbers.push('...');
pageNumbers.push(totalPages);
}
}
return pageNumbers;
};
// Переход на предыдущую страницу
const goToPrevPage = () => {
if (currentPage > 1) {
onPageChange(currentPage - 1);
}
};
// Переход на следующую страницу
const goToNextPage = () => {
if (currentPage < totalPages) {
onPageChange(currentPage + 1);
}
};
// Переход на конкретную страницу
const goToPage = (page) => {
if (page !== '...' && page !== currentPage) {
onPageChange(page);
}
};
return (
<div className="pagination">
<button
className="pagination-button prev"
onClick={goToPrevPage}
disabled={currentPage === 1}
>
< Назад
</button>
<div className="pagination-pages">
{getPageNumbers().map((page, index) => (
<button
key={index}
className={`pagination-page ${page === currentPage ? 'active' : ''} ${page === '...' ? 'ellipsis' : ''}`}
onClick={() => goToPage(page)}
disabled={page === '...'}
>
{page}
</button>
))}
</div>
<button
className="pagination-button next"
onClick={goToNextPage}
disabled={currentPage === totalPages}
>
Вперед >
</button>
</div>
);
};
Выводы
Модуль "Управление командами" в системе HRoom предоставляет комплексный набор инструментов для организации и управления структурой компании, отделами, командами и сотрудниками. Этот модуль является фундаментальной частью платформы, так как он определяет организационную структуру, необходимую для правильного функционирования опросов, аналитики и других компонентов системы.
Основные преимущества модуля управления командами:
- Гибкость структуры - возможность создания иерархической структуры отделов, соответствующей организации компании
- Удобное управление сотрудниками - интуитивно понятный интерфейс для добавления, редактирования и архивирования сотрудников
- Массовые операции - инструменты для массового импорта и обновления данных о сотрудниках
- Эффективный поиск и фильтрация - возможность быстро находить нужных сотрудников и отделы
- Интеграция с другими модулями - тесная связь с модулями опросов, аналитики и других компонентов системы
Модуль управления командами обеспечивает создание прочной основы для управления персоналом и проведения опросов, что является ключевым фактором для успешного анализа вовлеченности сотрудников и повышения эффективности работы команд.