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">