Темный режим

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>