Темный режим

3.4. Комментарии

Модуль «Комментарии» представляет собой систему для сбора, отображения и анализа текстовых комментариев, полученных от сотрудников в рамках опросов. Этот модуль позволяет HR-менеджерам и руководителям команд получать качественную обратную связь, выявлять проблемы и предложения, которые не могут быть в полной мере отражены в количественных метриках.

Основные компоненты модуля

  • Список комментариев - отображение всех комментариев с возможностью фильтрации и поиска
  • Карточка комментария - детальное отображение одного комментария с контекстной информацией
  • Фильтры и сортировка - инструменты для работы с комментариями
  • Состояния интерфейса - обработка различных состояний пользовательского интерфейса

Архитектура модуля комментариев

Модуль комментариев организован по следующей структуре:


src/pages/Comments/
├── components/            # Компоненты пользовательского интерфейса
│   ├── Comments.tsx       # Основной компонент с логикой
│   ├── CommentsList.tsx   # Список комментариев
│   ├── CommentCard.tsx    # Карточка отдельного комментария
│   ├── EmptyState.tsx     # Компонент пустого состояния
│   ├── LoadingState.tsx   # Компонент состояния загрузки
│   └── ErrorState.tsx     # Компонент состояния ошибки
├── hooks/                 # Пользовательские хуки
│   └── useComments.ts     # Хук для работы с данными комментариев
├── types/                 # Типы TypeScript
│   └── index.ts           # Определение типов для комментариев
└── index.tsx              # Точка входа в модуль
                

Получение и обработка комментариев

Модуль комментариев использует хук useComments для получения данных от API и управления состоянием.

Хук useComments

Хук useComments отвечает за загрузку комментариев, обработку состояний загрузки и ошибок, а также за применение фильтров:

// Пример хука useComments из src/pages/Comments/hooks/useComments.ts
export const useComments = (
  initialFilters = {}
) => {
  const [comments, setComments] = useState([]);
  const [filteredComments, setFilteredComments] = useState([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);
  const [filters, setFilters] = useState(initialFilters);
  
  // Загрузка комментариев при монтировании компонента
  useEffect(() => {
    const fetchComments = async () => {
      try {
        setIsLoading(true);
        
        // Выполнение запроса к API для получения комментариев
        const response = await fetch('https://api.hroom.ai/comments', {
          headers: {
            'Authorization': `Bearer ${localStorage.getItem('authToken')}`
          }
        });
        
        if (!response.ok) {
          throw new Error('Ошибка при загрузке комментариев');
        }
        
        const data = await response.json();
        setComments(data);
        setFilteredComments(data);
        setError(null);
      } catch (err) {
        console.error('Error fetching comments:', err);
        setError(err.message || 'Произошла ошибка при загрузке комментариев');
      } finally {
        setIsLoading(false);
      }
    };
    
    fetchComments();
  }, []);
  
  // Применение фильтров при их изменении
  useEffect(() => {
    // Фильтрация комментариев на основе текущих фильтров
    const filtered = comments.filter(comment => {
      // Фильтр по опросу
      if (filters.surveyId && comment.surveyId !== filters.surveyId) {
        return false;
      }
      
      // Фильтр по отделу
      if (filters.departmentId && comment.departmentId !== filters.departmentId) {
        return false;
      }
      
      // Фильтр по команде
      if (filters.teamId && comment.teamId !== filters.teamId) {
        return false;
      }
      
      // Фильтр по дате
      if (filters.dateRange) {
        const commentDate = new Date(comment.createdAt);
        if (filters.dateRange.start && commentDate < filters.dateRange.start) {
          return false;
        }
        if (filters.dateRange.end && commentDate > filters.dateRange.end) {
          return false;
        }
      }
      
      // Фильтр по тексту
      if (filters.searchText && filters.searchText.trim() !== '') {
        const searchLower = filters.searchText.toLowerCase().trim();
        const textMatches = comment.text.toLowerCase().includes(searchLower);
        const questionMatches = comment.questionText.toLowerCase().includes(searchLower);
        
        if (!textMatches && !questionMatches) {
          return false;
        }
      }
      
      // Фильтр по сентименту
      if (filters.sentiment && filters.sentiment.length > 0) {
        if (!filters.sentiment.includes(comment.sentiment)) {
          return false;
        }
      }
      
      return true;
    });
    
    // Сортировка отфильтрованных комментариев
    const sorted = [...filtered].sort((a, b) => {
      if (filters.sortBy === 'date') {
        return filters.sortDirection === 'desc'
          ? new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
          : new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
      }
      
      if (filters.sortBy === 'sentiment') {
        const sentimentValues = { positive: 3, neutral: 2, negative: 1 };
        return filters.sortDirection === 'desc'
          ? sentimentValues[b.sentiment] - sentimentValues[a.sentiment]
          : sentimentValues[a.sentiment] - sentimentValues[b.sentiment];
      }
      
      return 0;
    });
    
    setFilteredComments(sorted);
  }, [comments, filters]);
  
  // Функция обновления фильтров
  const updateFilter = (filterName, value) => {
    setFilters(prev => ({
      ...prev,
      [filterName]: value
    }));
  };
  
  // Функция сброса всех фильтров
  const resetFilters = () => {
    setFilters(initialFilters);
  };
  
  return {
    comments: filteredComments,
    isLoading,
    error,
    filters,
    updateFilter,
    resetFilters,
    totalCount: comments.length,
    filteredCount: filteredComments.length
  };
};

Типы данных для комментариев

Для работы с комментариями определены следующие типы данных:

// Пример типов из src/pages/Comments/types/index.ts
export type CommentSentiment = 'positive' | 'neutral' | 'negative';

export interface Comment {
  id: string;
  text: string;
  surveyId: string;
  surveyName: string;
  questionId: string;
  questionText: string;
  userId: string;
  userName: string;
  departmentId: string;
  departmentName: string;
  teamId: string;
  teamName: string;
  createdAt: string;
  sentiment: CommentSentiment;
  tags?: string[];
  metricId?: string;
  metricName?: string;
}

export interface CommentFilters {
  surveyId?: string;
  departmentId?: string;
  teamId?: string;
  dateRange?: {
    start?: Date;
    end?: Date;
  };
  searchText?: string;
  sentiment?: CommentSentiment[];
  sortBy?: 'date' | 'sentiment';
  sortDirection?: 'asc' | 'desc';
}

Компоненты пользовательского интерфейса

Модуль комментариев включает несколько ключевых компонентов для отображения и взаимодействия с данными.

Основной компонент Comments

Компонент Comments является основным контейнером, который объединяет все остальные компоненты и управляет состоянием пользовательского интерфейса:

// Пример компонента Comments из src/pages/Comments/components/Comments.tsx
export const Comments = () => {
  // Использование хука для получения комментариев и управления фильтрами
  const {
    comments,
    isLoading,
    error,
    filters,
    updateFilter,
    resetFilters,
    totalCount,
    filteredCount
  } = useComments({
    sortBy: 'date',
    sortDirection: 'desc'
  });
  
  // Обработчики для фильтров
  const handleSearchChange = (e) => {
    updateFilter('searchText', e.target.value);
  };
  
  const handleSurveyChange = (surveyId) => {
    updateFilter('surveyId', surveyId);
  };
  
  const handleDepartmentChange = (departmentId) => {
    updateFilter('departmentId', departmentId);
  };
  
  const handleTeamChange = (teamId) => {
    updateFilter('teamId', teamId);
  };
  
  const handleDateRangeChange = (range) => {
    updateFilter('dateRange', range);
  };
  
  const handleSentimentChange = (sentiments) => {
    updateFilter('sentiment', sentiments);
  };
  
  const handleSortChange = (sortField) => {
    updateFilter('sortBy', sortField);
    updateFilter('sortDirection', 
      filters.sortBy === sortField && filters.sortDirection === 'desc' ? 'asc' : 'desc'
    );
  };
  
  // Отображение состояний интерфейса
  if (isLoading) {
    return <LoadingState />;
  }
  
  if (error) {
    return <ErrorState error={error} />;
  }
  
  if (totalCount === 0) {
    return <EmptyState />;
  }
  
  return (
    <div className="comments-container">
      <div className="comments-header">
        <h1>Комментарии</h1>
        <p>
          Показано {filteredCount} из {totalCount} комментариев
        </p>
      </div>
      
      <div className="comments-filters">
        <div className="search-filter">
          <input
            type="text"
            placeholder="Поиск в комментариях..."
            value={filters.searchText || ''}
            onChange={handleSearchChange}
          />
        </div>
        
        <div className="filter-controls">
          <div className="filter-group">
            <label>Опрос:</label>
            <select 
              value={filters.surveyId || ''}
              onChange={(e) => handleSurveyChange(e.target.value)}
            >
              <option value="">Все опросы</option>
              {/* Опции опросов */}
            </select>
          </div>
          
          <div className="filter-group">
            <label>Отдел:</label>
            <select 
              value={filters.departmentId || ''}
              onChange={(e) => handleDepartmentChange(e.target.value)}
            >
              <option value="">Все отделы</option>
              {/* Опции отделов */}
            </select>
          </div>
          
          <div className="filter-group">
            <label>Команда:</label>
            <select 
              value={filters.teamId || ''}
              onChange={(e) => handleTeamChange(e.target.value)}
            >
              <option value="">Все команды</option>
              {/* Опции команд */}
            </select>
          </div>
          
          <div className="filter-group">
            <label>Настроение:</label>
            <div className="sentiment-filter">
              <label className="sentiment-option">
                <input 
                  type="checkbox" 
                  checked={filters.sentiment?.includes('positive')}
                  onChange={(e) => {
                    const sentiments = filters.sentiment || [];
                    if (e.target.checked) {
                      handleSentimentChange([...sentiments, 'positive']);
                    } else {
                      handleSentimentChange(sentiments.filter(s => s !== 'positive'));
                    }
                  }}
                />
                <span className="positive-sentiment">Позитивные</span>
              </label>
              
              <label className="sentiment-option">
                <input 
                  type="checkbox" 
                  checked={filters.sentiment?.includes('neutral')}
                  onChange={(e) => {
                    const sentiments = filters.sentiment || [];
                    if (e.target.checked) {
                      handleSentimentChange([...sentiments, 'neutral']);
                    } else {
                      handleSentimentChange(sentiments.filter(s => s !== 'neutral'));
                    }
                  }}
                />
                <span className="neutral-sentiment">Нейтральные</span>
              </label>
              
              <label className="sentiment-option">
                <input 
                  type="checkbox" 
                  checked={filters.sentiment?.includes('negative')}
                  onChange={(e) => {
                    const sentiments = filters.sentiment || [];
                    if (e.target.checked) {
                      handleSentimentChange([...sentiments, 'negative']);
                    } else {
                      handleSentimentChange(sentiments.filter(s => s !== 'negative'));
                    }
                  }}
                />
                <span className="negative-sentiment">Негативные</span>
              </label>
            </div>
          </div>
          
          <button 
            className="reset-filters-button"
            onClick={resetFilters}
          >
            Сбросить фильтры
          </button>
        </div>
        
        <div className="sort-controls">
          <button 
            className={`sort-button ${filters.sortBy === 'date' ? 'active' : ''}`}
            onClick={() => handleSortChange('date')}
          >
            По дате
            {filters.sortBy === 'date' && (
              <span className="sort-direction">
                {filters.sortDirection === 'desc' ? '▼' : '▲'}
              </span>
            )}
          </button>
          
          <button 
            className={`sort-button ${filters.sortBy === 'sentiment' ? 'active' : ''}`}
            onClick={() => handleSortChange('sentiment')}
          >
            По настроению
            {filters.sortBy === 'sentiment' && (
              <span className="sort-direction">
                {filters.sortDirection === 'desc' ? '▼' : '▲'}
              </span>
            )}
          </button>
        </div>
      </div>
      
      {filteredCount === 0 ? (
        <div className="no-comments-found">
          <p>Комментарии не найдены. Попробуйте изменить параметры фильтрации.</p>
          <button 
            className="reset-filters-button"
            onClick={resetFilters}
          >
            Сбросить все фильтры
          </button>
        </div>
      ) : (
        <CommentsList comments={comments} />
      )}
    </div>
  );
};

Компонент списка комментариев

Компонент CommentsList отображает список комментариев в виде карточек:

// Пример компонента CommentsList из src/pages/Comments/components/CommentsList.tsx
export const CommentsList = ({ comments }) => {
  return (
    <div className="comments-list">
      {comments.map(comment => (
        <CommentCard 
          key={comment.id} 
          comment={comment} 
        />
      ))}
    </div>
  );
};

Компонент карточки комментария

Компонент CommentCard отображает информацию об отдельном комментарии в виде карточки с контекстной информацией:

// Пример компонента CommentCard из src/pages/Comments/components/CommentCard.tsx
export const CommentCard = ({ comment }) => {
  // Форматирование даты
  const formatDate = (dateString) => {
    const date = new Date(dateString);
    return new Intl.DateTimeFormat('ru-RU', { 
      day: 'numeric', 
      month: 'short', 
      year: 'numeric',
      hour: '2-digit',
      minute: '2-digit'
    }).format(date);
  };
  
  // Определение цвета индикатора настроения
  const getSentimentColor = (sentiment) => {
    switch (sentiment) {
      case 'positive': return '#10b981'; // Зеленый для позитивных
      case 'neutral': return '#6366f1'; // Фиолетовый для нейтральных
      case 'negative': return '#ef4444'; // Красный для негативных
      default: return '#9da3ae'; // Серый по умолчанию
    }
  };
  
  return (
    <div className="comment-card">
      <div 
        className="sentiment-indicator"
        style={{ backgroundColor: getSentimentColor(comment.sentiment) }}
      >
        {/* Иконка настроения */}
      </div>
      
      <div className="comment-content">
        <div className="comment-text">
          {comment.text}
        </div>
        
        <div className="comment-context">
          <div className="question-context">
            Вопрос: {comment.questionText}
          </div>
          
          <div className="survey-context">
            Опрос: {comment.surveyName}
          </div>
        </div>
        
        <div className="comment-meta">
          <span className="comment-author">
            {comment.userName || 'Анонимно'}
          </span>
          
          <span className="comment-team">
            {comment.teamName && `Команда: ${comment.teamName}`}
          </span>
          
          <span className="comment-department">
            {comment.departmentName && `Отдел: ${comment.departmentName}`}
          </span>
          
          <span className="comment-date">
            {formatDate(comment.createdAt)}
          </span>
        </div>
        
        {comment.tags && comment.tags.length > 0 && (
          <div className="comment-tags">
            {comment.tags.map(tag => (
              <span key={tag} className="comment-tag">
                {tag}
              </span>
            ))}
          </div>
        )}
        
        {comment.metricName && (
          <div className="comment-metric">
            Метрика: {comment.metricName}
          </div>
        )}
      </div>
    </div>
  );
};

Компоненты состояний интерфейса

Модуль комментариев включает компоненты для отображения различных состояний пользовательского интерфейса:

Компонент пустого состояния

Отображается, когда в системе нет комментариев:

// Пример компонента EmptyState из src/pages/Comments/components/EmptyState.tsx
export const EmptyState = () => {
  return (
    <div className="empty-state">
      <div className="empty-illustration">
        {/* Иллюстрация пустого состояния */}
      </div>
      
      <h2>Комментарии отсутствуют</h2>
      
      <p>
        В системе пока нет комментариев от сотрудников.
        Комментарии появятся после прохождения опросов с открытыми вопросами.
      </p>
      
      <div className="empty-actions">
        <a href="/surveys" className="create-survey-link">
          Создать новый опрос
        </a>
      </div>
    </div>
  );
};

Компонент состояния загрузки

Отображается во время загрузки данных:

// Пример компонента LoadingState из src/pages/Comments/components/LoadingState.tsx
export const LoadingState = () => {
  return (
    <div className="loading-state">
      <div className="loading-spinner">
        <div className="spinner">
</div> <p>Загрузка комментариев...</p> </div> ); };

Компонент состояния ошибки

Отображается при возникновении ошибки при загрузке данных:

// Пример компонента ErrorState из src/pages/Comments/components/ErrorState.tsx
export const ErrorState = ({ error }) => {
  return (
    <div className="error-state">
      <div className="error-icon">
        {/* Иконка ошибки */}
      </div>
      
      <h2>Ошибка при загрузке комментариев</h2>
      
      <p>
        {error || 'Произошла ошибка при загрузке комментариев. Пожалуйста, попробуйте позже.'}
      </p>
      
      <button 
        className="retry-button"
        onClick={() => window.location.reload()}
      >
        Повторить попытку
      </button>
    </div>
  );
};

Анализ настроения в комментариях

Модуль комментариев включает автоматический анализ настроения (sentiment analysis) для классификации комментариев на позитивные, нейтральные и негативные. Это помогает HR-менеджерам быстро выявлять проблемные зоны и положительные аспекты в отзывах сотрудников.

Принцип работы анализа настроения

Анализ настроения в комментариях работает следующим образом:

  1. Предварительная обработка - API выполняет предварительную обработку текста комментария (удаление стоп-слов, нормализация текста и т.д.)
  2. Анализ с помощью NLP - обработанный текст анализируется с использованием алгоритмов обработки естественного языка
  3. Классификация - на основе анализа комментарий классифицируется по одной из трех категорий:
  4. Визуализация - результаты анализа отображаются с помощью цветовых индикаторов и фильтров

Преимущества анализа настроения

Автоматический анализ настроения в комментариях предоставляет следующие преимущества:

Интеграция с API

Модуль комментариев интегрируется с API для получения данных и выполнения операций с комментариями:

// Пример интеграции с API в src/pages/Comments/hooks/useComments.ts
const fetchComments = async () => {
  try {
    setIsLoading(true);
    
    // Получение токена из локального хранилища
    const token = localStorage.getItem('authToken');
    
    // Выполнение запроса к API для получения комментариев
    const response = await fetch('https://api.hroom.ai/comments', {
      headers: {
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json'
      }
    });
    
    if (!response.ok) {
      throw new Error(`Ошибка API: ${response.status}`);
    }
    
    const data = await response.json();
    setComments(data);
    setFilteredComments(data);
    setError(null);
  } catch (err) {
    console.error('Error fetching comments:', err);
    setError(err.message || 'Произошла ошибка при загрузке комментариев');
  } finally {
    setIsLoading(false);
  }
};

Доступные эндпоинты API

API предоставляет следующие эндпоинты для работы с комментариями:

Адаптивный дизайн

Модуль комментариев полностью адаптирован для работы на различных устройствах:

Выводы

Модуль комментариев в HRoom предоставляет мощный инструмент для работы с качественной обратной связью от сотрудников. Он позволяет HR-менеджерам и руководителям команд анализировать текстовые комментарии, выявлять проблемы и идеи, которые не могут быть в полной мере отражены в количественных метриках.

Ключевые преимущества модуля комментариев:

Модуль комментариев - это важный инструмент для качественного анализа мнений сотрудников, который дополняет количественные метрики и помогает получить полную картину о настроениях внутри компании, обеспечивая основу для принятия обоснованных управленческих решений.