5. Компоненты UI
HRoom предоставляет обширную библиотеку компонентов пользовательского интерфейса для создания единообразного, интуитивно понятного и отзывчивого пользовательского опыта. Эти компоненты разработаны с учетом лучших практик UI/UX, включая доступность, масштабируемость и простоту использования.
Компоненты UI в HRoom созданы с использованием React и TypeScript, и стилизованы с помощью Tailwind CSS. Это обеспечивает единообразный внешний вид и поведение по всему приложению, а также упрощает разработку новых функций.
5.1. Общие компоненты
Общие компоненты HRoom представляют собой базовые элементы интерфейса, которые используются многократно по всему приложению. Эти компоненты обеспечивают единообразный внешний вид и поведение, позволяя создавать согласованный пользовательский интерфейс.
Расположение компонентов
Большинство общих компонентов расположены в директории src/components/ui. Эта директория содержит базовые компоненты пользовательского интерфейса, которые можно использовать для создания более сложных компонентов и страниц.
Список основных общих компонентов
Layout (Макет)
Компонент Layout определяет основную структуру страницы, включая боковую панель, верхнюю панель и основную область содержимого.
Пример кода компонента Layout
// src/components/Layout.tsx
import React, { ReactNode } from 'react';
import Sidebar from './Sidebar';
interface LayoutProps {
children: ReactNode;
}
const Layout: React.FC<LayoutProps> = ({ children }) => {
return (
<div className="flex h-screen bg-gray-100 dark:bg-gray-900">
<Sidebar />
<div className="flex flex-col flex-1 overflow-hidden">
<main className="flex-1 overflow-y-auto p-4">
{children}
</main>
</div>
</div>
);
};
export default Layout;
Использование компонента Layout:
Пример использования компонента Layout
import Layout from '../components/Layout';
const DashboardPage = () => {
return (
<Layout>
<h1>Dashboard Content</h1>
{/* Содержимое страницы */}
</Layout>
);
};
Sidebar (Боковая панель)
Компонент Sidebar отображает основную навигацию приложения, включая ссылки на различные модули и функции.
Пример кода компонента Sidebar
// src/components/Sidebar.tsx
import React from 'react';
import { NavLink } from 'react-router-dom';
const Sidebar: React.FC = () => {
// Структура навигационных элементов
const navItems = [
{ path: '/dashboard', label: 'Дашборд', icon: 'dashboard' },
{ path: '/analytics', label: 'Аналитика', icon: 'analytics' },
// Другие пункты меню
];
return (
<div className="w-64 bg-hroom-gray-dark text-white h-full flex flex-col">
<div className="p-4 border-b border-gray-700">
<h2 className="text-xl font-semibold">HRoom</h2>
</div>
<nav className="mt-6 flex-1 overflow-y-auto">
<ul>
{navItems.map((item) => (
<li key={item.path} className="px-4 py-2">
<NavLink
to={item.path}
className={({ isActive }) =>
`flex items-center p-2 rounded-md ${
isActive
? 'bg-primary text-white'
: 'text-gray-300 hover:bg-gray-700'
}`
}
>
<span className="material-icons mr-3">{item.icon}</span>
{item.label}
</NavLink>
</li>
))}
</ul>
</nav>
</div>
);
};
export default Sidebar;
Button (Кнопка)
Компонент Button представляет собой кастомизированную кнопку, которая используется по всему приложению для обеспечения единообразного внешнего вида и поведения.
Пример кода компонента Button
// src/components/ui/Button.tsx
import React, { ButtonHTMLAttributes } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'success' | 'outline';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
icon?: React.ReactNode;
}
const Button: React.FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'md',
isLoading = false,
icon,
className,
disabled,
...props
}) => {
// Определение классов на основе пропсов
const baseClasses = "inline-flex items-center justify-center font-medium rounded-md focus:outline-none transition-colors";
const variantClasses = {
primary: "bg-primary hover:bg-primary-light text-white",
secondary: "bg-gray-200 hover:bg-gray-300 text-gray-800",
danger: "bg-red-600 hover:bg-red-700 text-white",
success: "bg-green-500 hover:bg-green-600 text-white",
outline: "border border-gray-300 text-gray-700 hover:bg-gray-50"
};
const sizeClasses = {
sm: "text-xs px-2 py-1",
md: "text-sm px-4 py-2",
lg: "text-base px-6 py-3"
};
const disabledClasses = disabled || isLoading
? "opacity-50 cursor-not-allowed"
: "cursor-pointer";
const buttonClasses = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${disabledClasses} ${className || ''}`;
return (
<button
className={buttonClasses}
disabled={disabled || isLoading}
{...props}
>
{isLoading && (
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
)}
{icon && !isLoading && (
<span className="mr-2">{icon}</span>
)}
{children}
</button>
);
};
export default Button;
Использование компонента Button:
Пример использования компонента Button
import Button from '../components/ui/Button';
// Простая кнопка с дефолтным стилем
<Button onClick={handleClick}>
Нажми меня
</Button>
// Кнопка с иконкой
<Button
variant="success"
icon={<span className="material-icons">check</span>}
>
Подтвердить
</Button>
// Кнопка загрузки
<Button isLoading={isSubmitting} disabled={isSubmitting}>
Сохранить
</Button>
Card (Карточка)
Компонент Card представляет собой контейнер с белым фоном и тенью, который используется для группировки связанного содержимого.
Пример кода компонента Card
// src/components/ui/Card.tsx
import React, { ReactNode } from 'react';
interface CardProps {
children: ReactNode;
title?: string;
footer?: ReactNode;
className?: string;
}
const Card: React.FC<CardProps> = ({
children,
title,
footer,
className = ""
}) => {
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow ${className}`}>
{title && (
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">{title}</h3>
</div>
)}
<div className="p-4">
{children}
</div>
{footer && (
<div className="px-4 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700 rounded-b-lg">
{footer}
</div>
)}
</div>
);
};
export default Card;
Использование компонента Card:
Пример использования компонента Card
import Card from '../components/ui/Card';
import Button from '../components/ui/Button';
<Card
title="Статистика опроса"
footer={
<div className="flex justify-end">
<Button size="sm">Просмотреть детали</Button>
</div>
}
>
<p>Количество ответов: 45</p>
<p>Средняя оценка: 4.2 / 5</p>
</Card>
Tabs (Вкладки)
Компонент Tabs позволяет организовать содержимое в отдельные вкладки, которые можно переключать. Этот компонент полезен, когда нужно отобразить разные категории информации в одной области.
Пример кода компонента Tabs
// src/components/ui/Tabs.tsx
import React, { useState } from 'react';
interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
interface TabsProps {
tabs: Tab[];
defaultTabId?: string;
className?: string;
}
const Tabs: React.FC<TabsProps> = ({
tabs,
defaultTabId,
className = ""
}) => {
const [activeTabId, setActiveTabId] = useState(defaultTabId || tabs[0]?.id);
return (
<div className={className}>
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-4 overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTabId(tab.id)}
className={`
py-3 px-4 font-medium text-sm inline-flex items-center
${activeTabId === tab.id
? 'border-b-2 border-primary text-primary'
: 'text-gray-500 hover:text-gray-700 hover:border-gray-300 border-b-2 border-transparent'
}
`}
>
{tab.label}
</button>
))}
</nav>
</div>
<div className="mt-4">
{tabs.find(tab => tab.id === activeTabId)?.content}
</div>
</div>
);
};
export default Tabs;
Использование компонента Tabs:
Пример использования компонента Tabs
import Tabs from '../components/ui/Tabs';
const tabsData = [
{
id: 'overview',
label: 'Обзор',
content: <div>Содержимое вкладки "Обзор"...</div>
},
{
id: 'details',
label: 'Детали',
content: <div>Содержимое вкладки "Детали"...</div>
},
{
id: 'history',
label: 'История',
content: <div>Содержимое вкладки "История"...</div>
}
];
<Tabs tabs={tabsData} defaultTabId="overview" />
Badge (Бейдж)
Компонент Badge используется для отображения статусов, счетчиков или других небольших элементов информации.
Пример кода компонента Badge
// src/components/ui/Badge.tsx
import React from 'react';
interface BadgeProps {
children: React.ReactNode;
variant?: 'primary' | 'success' | 'warning' | 'danger' | 'default';
size?: 'sm' | 'md';
className?: string;
}
const Badge: React.FC<BadgeProps> = ({
children,
variant = 'default',
size = 'md',
className = '',
}) => {
const variantClasses = {
primary: 'bg-primary-100 text-primary',
success: 'bg-green-100 text-green-800',
warning: 'bg-yellow-100 text-yellow-800',
danger: 'bg-red-100 text-red-800',
default: 'bg-gray-100 text-gray-800',
};
const sizeClasses = {
sm: 'text-xs px-2 py-0.5',
md: 'text-sm px-2.5 py-0.5',
};
return (
<span
className={`
inline-flex items-center rounded-full font-medium
${variantClasses[variant]}
${sizeClasses[size]}
${className}
`}
>
{children}
</span>
);
};
export default Badge;
Использование компонента Badge:
Пример использования компонента Badge
import Badge from '../components/ui/Badge';
// Стандартный бейдж
<Badge>Новый</Badge>
// Бейдж с вариантом
<Badge variant="success">Завершено</Badge>
<Badge variant="warning">В процессе</Badge>
<Badge variant="danger">Ошибка</Badge>
// Бейдж-счетчик
<Badge variant="primary" size="sm">5</Badge>
Avatar (Аватар)
Компонент Avatar отображает круглое изображение пользователя или текстовые инициалы, если изображение недоступно.
Пример кода компонента Avatar
// src/components/ui/Avatar.tsx
import React from 'react';
interface AvatarProps {
src?: string;
alt?: string;
name?: string;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
className?: string;
}
const Avatar: React.FC<AvatarProps> = ({
src,
alt = '',
name = '',
size = 'md',
className = '',
}) => {
// Размеры аватара
const sizeClasses = {
xs: 'w-6 h-6 text-xs',
sm: 'w-8 h-8 text-sm',
md: 'w-10 h-10 text-base',
lg: 'w-12 h-12 text-lg',
xl: 'w-16 h-16 text-xl',
};
// Получение инициалов из имени
const getInitials = (name: string) => {
return name
.split(' ')
.map(part => part[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
return (
<div
className={`relative rounded-full overflow-hidden flex items-center justify-center bg-primary-100 text-primary ${sizeClasses[size]} ${className}`}
>
{src ? (
<img
src={src}
alt={alt || name}
className="w-full h-full object-cover"
/>
) : (
<span>{getInitials(name)}</span>
)}
</div>
);
};
export default Avatar;
Использование компонента Avatar:
Пример использования компонента Avatar
import Avatar from '../components/ui/Avatar';
// Аватар с изображением
<Avatar
src="https://example.com/avatar.jpg"
alt="John Doe"
size="md"
/>
// Аватар с инициалами (если изображение не предоставлено)
<Avatar
name="John Doe"
size="lg"
/>
Pagination (Пагинация)
Компонент Pagination используется для навигации по многостраничным данным, позволяя пользователю переключаться между страницами.
Пример кода компонента Pagination
// src/components/ui/Pagination.tsx
import React from 'react';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
pageSize?: number;
totalItems?: number;
}
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange,
pageSize,
totalItems,
}) => {
const pageNumbers = [];
// Создаем массив номеров страниц для отображения
if (totalPages <= 7) {
// Если меньше 7 страниц, показываем все
for (let i = 1; i <= totalPages; i++) {
pageNumbers.push(i);
}
} else {
// Иначе, показываем первую, последнюю и несколько вокруг текущей
if (currentPage <= 3) {
// Начало: 1, 2, 3, 4, 5, ..., totalPages
for (let i = 1; i <= 5; i++) {
pageNumbers.push(i);
}
pageNumbers.push('...');
pageNumbers.push(totalPages);
} else if (currentPage >= totalPages - 2) {
// Конец: 1, ..., totalPages-4, totalPages-3, totalPages-2, totalPages-1, totalPages
pageNumbers.push(1);
pageNumbers.push('...');
for (let i = totalPages - 4; i <= totalPages; i++) {
pageNumbers.push(i);
}
} else {
// Середина: 1, ..., currentPage-1, currentPage, currentPage+1, ..., totalPages
pageNumbers.push(1);
pageNumbers.push('...');
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
pageNumbers.push(i);
}
pageNumbers.push('...');
pageNumbers.push(totalPages);
}
}
return (
<div className="flex items-center justify-between py-3">
{(pageSize && totalItems) && (
<div className="text-sm text-gray-500">
Показаны {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, totalItems)} из {totalItems} записей
</div>
)}
<div className="flex space-x-1">
{/* Кнопка "Назад" */}
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className={`
px-3 py-1 rounded-md text-sm font-medium
${currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white text-gray-700 hover:bg-gray-50'}
`}
>
<span className="sr-only">Предыдущая страница</span>
<span>«</span>
</button>
{/* Номера страниц */}
{pageNumbers.map((page, index) => (
page === '...' ? (
<span key={`ellipsis-${index}`} className="px-3 py-1">...</span>
) : (
<button
key={`page-${page}`}
onClick={() => onPageChange(page as number)}
className={`
px-3 py-1 rounded-md text-sm font-medium
${currentPage === page
? 'bg-primary text-white'
: 'bg-white text-gray-700 hover:bg-gray-50'}
`}
>
{page}
</button>
)
))}
{/* Кнопка "Вперед" */}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className={`
px-3 py-1 rounded-md text-sm font-medium
${currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white text-gray-700 hover:bg-gray-50'}
`}
>
<span className="sr-only">Следующая страница</span>
<span>»</span>
</button>
</div>
</div>
);
};
export default Pagination;
Использование компонента Pagination:
Пример использования компонента Pagination
import Pagination from '../components/ui/Pagination';
import { useState } from 'react';
const ExampleList = () => {
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10;
const totalItems = 87;
const totalPages = Math.ceil(totalItems / pageSize);
const handlePageChange = (page) => {
setCurrentPage(page);
// Загружаем данные для выбранной страницы
// fetchData(page, pageSize);
};
return (
<div>
{/* Содержимое списка */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
pageSize={pageSize}
totalItems={totalItems}
/>
</div>
);
};
5.2. Формы
Компоненты форм в HRoom используются для сбора и валидации пользовательского ввода. Они обеспечивают единообразный внешний вид и поведение для всех форм в приложении, а также упрощают создание новых форм.
Основные компоненты форм
Input (Текстовое поле)
Компонент Input представляет собой стилизованное текстовое поле ввода с поддержкой различных состояний, включая ошибки и подсказки.
Пример кода компонента Input
// src/components/ui/Input.tsx
import React, { InputHTMLAttributes } from 'react';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helpText?: string;
}
const Input: React.FC<InputProps> = ({
label,
error,
helpText,
id,
className = '',
...props
}) => {
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
return (
<div className="mb-4">
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
{label}
</label>
)}
<input
id={inputId}
className={`
block w-full px-3 py-2 border rounded-md shadow-sm placeholder-gray-400
focus:outline-none focus:ring-primary focus:border-primary
${error
? 'border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white'}
${className}
`}
{...props}
/>
{helpText && !error && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{helpText}</p>
)}
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-500">{error}</p>
)}
</div>
);
};
export default Input;
Использование компонента Input:
Пример использования компонента Input
import Input from '../components/ui/Input';
import { useState } from 'react';
const EmployeeForm = () => {
const [formData, setFormData] = useState({
firstName: '',
email: ''
});
const [errors, setErrors] = useState({});
const handleChange = (e) => {
const { name, value } = e.target;
setFormData({
...formData,
[name]: value
});
};
return (
<form>
<Input
label="Имя"
name="firstName"
value={formData.firstName}
onChange={handleChange}
placeholder="Введите имя"
error={errors.firstName}
required
/>
<Input
label="Email"
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="example@company.com"
error={errors.email}
helpText="Рабочий email сотрудника"
required
/>
{/* Другие поля формы */}
</form>
);
};
Select (Выпадающий список)
Компонент Select представляет собой стилизованный выпадающий список для выбора одного варианта из нескольких.
Пример кода компонента Select
// src/components/ui/Select.tsx
import React, { SelectHTMLAttributes } from 'react';
interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
options: SelectOption[];
error?: string;
helpText?: string;
}
const Select: React.FC<SelectProps> = ({
label,
options,
error,
helpText,
id,
className = '',
...props
}) => {
const selectId = id || `select-${Math.random().toString(36).substr(2, 9)}`;
return (
<div className="mb-4">
{label && (
<label
htmlFor={selectId}
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
{label}
</label>
)}
<select
id={selectId}
className={`
block w-full px-3 py-2 border rounded-md shadow-sm
focus:outline-none focus:ring-primary focus:border-primary
${error
? 'border-red-300 text-red-900 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white'}
${className}
`}
{...props}
>
{options.map((option) => (
<option
key={option.value}
value={option.value}
disabled={option.disabled}
>
{option.label}
</option>
))}
</select>
{helpText && !error && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{helpText}</p>
)}
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-500">{error}</p>
)}
</div>
);
};
export default Select;
Использование компонента Select:
Пример использования компонента Select
import Select from '../components/ui/Select';
const departmentOptions = [
{ value: '', label: 'Выберите отдел', disabled: true },
{ value: 'engineering', label: 'Инженерный' },
{ value: 'marketing', label: 'Маркетинг' },
{ value: 'sales', label: 'Продажи' },
{ value: 'hr', label: 'HR' }
];
const EmployeeForm = () => {
const [department, setDepartment] = useState('');
return (
<form>
<Select
label="Отдел"
name="department"
value={department}
onChange={(e) => setDepartment(e.target.value)}
options={departmentOptions}
helpText="Выберите отдел, к которому относится сотрудник"
required
/>
{/* Другие поля формы */}
</form>
);
};
Checkbox (Флажок)
Компонент Checkbox представляет собой стилизованный флажок для выбора опций.
Пример кода компонента Checkbox
// src/components/ui/Checkbox.tsx
import React, { InputHTMLAttributes } from 'react';
interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
label?: string;
error?: string;
}
const Checkbox: React.FC<CheckboxProps> = ({
label,
error,
id,
className = '',
...props
}) => {
const checkboxId = id || `checkbox-${Math.random().toString(36).substr(2, 9)}`;
return (
<div className="mb-4">
<div className="flex items-start">
<div className="flex items-center h-5">
<input
id={checkboxId}
type="checkbox"
className={`
h-4 w-4 border-gray-300 text-primary focus:ring-primary
${error ? 'border-red-300' : ''}
${className}
`}
{...props}
/>
</div>
{label && (
<div className="ml-2 text-sm">
<label
htmlFor={checkboxId}
className="font-medium text-gray-700 dark:text-gray-300"
>
{label}
</label>
</div>
)}
</div>
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-500">{error}</p>
)}
</div>
);
};
export default Checkbox;
Использование компонента Checkbox:
Пример использования компонента Checkbox
import Checkbox from '../components/ui/Checkbox';
import { useState } from 'react';
const PrivacySettings = () => {
const [settings, setSettings] = useState({
receiveEmails: false,
shareData: false
});
const handleChange = (e) => {
const { name, checked } = e.target;
setSettings({
...settings,
[name]: checked
});
};
return (
<div>
<h2 className="text-lg font-medium mb-4">Настройки конфиденциальности</h2>
<Checkbox
name="receiveEmails"
checked={settings.receiveEmails}
onChange={handleChange}
label="Получать уведомления по электронной почте"
/>
<Checkbox
name="shareData"
checked={settings.shareData}
onChange={handleChange}
label="Разрешить использование анонимных данных для улучшения сервиса"
/>
</div>
);
};
Radio (Переключатель)
Компонент Radio представляет собой стилизованный переключатель для выбора одного варианта из нескольких.
Пример кода компонента Radio
// src/components/ui/Radio.tsx
import React, { InputHTMLAttributes } from 'react';
interface RadioProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
label?: string;
}
const Radio: React.FC<RadioProps> = ({
label,
id,
className = '',
...props
}) => {
const radioId = id || `radio-${Math.random().toString(36).substr(2, 9)}`;
return (
<div className="flex items-center mb-2">
<input
id={radioId}
type="radio"
className={`
h-4 w-4 border-gray-300 text-primary focus:ring-primary
${className}
`}
{...props}
/>
{label && (
<label
htmlFor={radioId}
className="ml-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{label}
</label>
)}
</div>
);
};
export default Radio;
Использование компонента Radio:
Пример использования компонента Radio
import Radio from '../components/ui/Radio';
import { useState } from 'react';
const SurveyTypeSelector = () => {
const [surveyType, setSurveyType] = useState('regular');
const handleChange = (e) => {
setSurveyType(e.target.value);
};
return (
<div>
<h3 className="text-lg font-medium mb-3">Тип опроса</h3>
<div className="space-y-2">
<Radio
name="surveyType"
value="regular"
checked={surveyType === 'regular'}
onChange={handleChange}
label="Обычный опрос"
/>
<Radio
name="surveyType"
value="pulse"
checked={surveyType === 'pulse'}
onChange={handleChange}
label="Пульс-опрос"
/>
<Radio
name="surveyType"
value="onboarding"
checked={surveyType === 'onboarding'}
onChange={handleChange}
label="Адаптационный опрос"
/>
<Radio
name="surveyType"
value="exit"
checked={surveyType === 'exit'}
onChange={handleChange}
label="Опрос при увольнении"
/>
</div>
</div>
);
};
Textarea (Многострочное текстовое поле)
Компонент Textarea представляет собой стилизованное многострочное текстовое поле для ввода длинного текста.
Пример кода компонента Textarea
// src/components/ui/Textarea.tsx
import React, { TextareaHTMLAttributes } from 'react';
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
helpText?: string;
}
const Textarea: React.FC<TextareaProps> = ({
label,
error,
helpText,
id,
className = '',
rows = 4,
...props
}) => {
const textareaId = id || `textarea-${Math.random().toString(36).substr(2, 9)}`;
return (
<div className="mb-4">
{label && (
<label
htmlFor={textareaId}
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
{label}
</label>
)}
<textarea
id={textareaId}
rows={rows}
className={`
block w-full px-3 py-2 border rounded-md shadow-sm placeholder-gray-400
focus:outline-none focus:ring-primary focus:border-primary
${error
? 'border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white'}
${className}
`}
{...props}
/>
{helpText && !error && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{helpText}</p>
)}
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-500">{error}</p>
)}
</div>
);
};
export default Textarea;
Использование компонента Textarea:
Пример использования компонента Textarea
import Textarea from '../components/ui/Textarea';
import { useState } from 'react';
const FeedbackForm = () => {
const [comment, setComment] = useState('');
return (
<form>
<Textarea
label="Комментарий"
name="comment"
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Оставьте свой комментарий или отзыв..."
rows={6}
helpText="Максимальная длина - 500 символов"
maxLength={500}
/>
{/* Другие поля формы */}
</form>
);
};
Примеры форм в HRoom
HRoom содержит множество специализированных форм для различных задач. Рассмотрим некоторые примеры реализации форм в проекте.
Форма создания опроса
Форма создания опроса позволяет HR-менеджерам создавать новые опросы для сотрудников.
Код формы создания опроса
// src/components/surveys/SurveyForm.tsx (сокращенная версия)
import React, { useState } from 'react';
import Button from '../ui/Button';
import Input from '../ui/Input';
import Textarea from '../ui/Textarea';
import Select from '../ui/Select';
import SurveyTypeSelect from './SurveyTypeSelect';
import { Survey, SurveyType } from '../../types/survey';
interface SurveyFormProps {
initialData?: Partial<Survey>;
onSubmit: (data: Partial<Survey>) => void;
onCancel: () => void;
isSubmitting?: boolean;
}
const SurveyForm: React.FC<SurveyFormProps> = ({
initialData = {},
onSubmit,
onCancel,
isSubmitting = false
}) => {
const [formData, setFormData] = useState({
title: initialData.title || '',
description: initialData.description || '',
type: initialData.type || 'REGULAR' as SurveyType,
startDate: initialData.startDate || '',
endDate: initialData.endDate || '',
questions: initialData.questions || []
});
const [errors, setErrors] = useState({});
const handleChange = (e) => {
const { name, value } = e.target;
setFormData({
...formData,
[name]: value
});
};
const handleTypeChange = (type: SurveyType) => {
setFormData({
...formData,
type
});
};
const validateForm = () => {
const newErrors = {};
if (!formData.title.trim()) {
newErrors.title = 'Название опроса обязательно';
}
if (!formData.startDate) {
newErrors.startDate = 'Дата начала обязательна';
}
if (formData.questions.length === 0) {
newErrors.questions = 'Добавьте хотя бы один вопрос';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e) => {
e.preventDefault();
if (validateForm()) {
onSubmit(formData);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<Input
label="Название опроса"
name="title"
value={formData.title}
onChange={handleChange}
placeholder="Введите название опроса"
error={errors.title}
required
/>
<Textarea
label="Описание"
name="description"
value={formData.description}
onChange={handleChange}
placeholder="Введите описание опроса..."
/>
<SurveyTypeSelect
selectedType={formData.type}
onChange={handleTypeChange}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Дата начала"
type="date"
name="startDate"
value={formData.startDate}
onChange={handleChange}
error={errors.startDate}
required
/>
<Input
label="Дата окончания"
type="date"
name="endDate"
value={formData.endDate}
onChange={handleChange}
helpText="Необязательно"
/>
</div>
{/* Здесь компонент для добавления вопросов */}
{errors.questions && (
<p className="text-sm text-red-600">{errors.questions}</p>
)}
<div className="flex justify-end space-x-3">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
Отмена
</Button>
<Button
type="submit"
isLoading={isSubmitting}
disabled={isSubmitting}
>
{initialData.id ? 'Сохранить' : 'Создать опрос'}
</Button>
</div>
</form>
);
};
export default SurveyForm;
Форма добавления сотрудника
Форма добавления сотрудника используется для добавления новых сотрудников в систему.
Код формы добавления сотрудника
// src/pages/Teams/components/Employees/EmployeeForm.tsx (сокращенная версия)
import React, { useState, useEffect } from 'react';
import Input from '../../../../components/ui/Input';
import Select from '../../../../components/ui/Select';
import Button from '../../../../components/ui/Button';
import { useTeamsService } from '../../hooks/useTeamsService';
interface EmployeeFormProps {
initialData?: any;
onSubmit: (data: any) => void;
onCancel: () => void;
isSubmitting?: boolean;
}
const EmployeeForm: React.FC<EmployeeFormProps> = ({
initialData = {},
onSubmit,
onCancel,
isSubmitting = false
}) => {
const { getDepartments } = useTeamsService();
const [departments, setDepartments] = useState([]);
const [formData, setFormData] = useState({
firstName: initialData.firstName || '',
lastName: initialData.lastName || '',
email: initialData.email || '',
position: initialData.position || '',
departmentId: initialData.departmentId || '',
phone: initialData.phone || ''
});
const [errors, setErrors] = useState({});
useEffect(() => {
const fetchDepartments = async () => {
try {
const data = await getDepartments();
// Форматируем данные для селекта
const options = [
{ value: '', label: 'Выберите отдел', disabled: true },
...data.map(dep => ({
value: dep.id,
label: dep.name
}))
];
setDepartments(options);
} catch (error) {
console.error('Error fetching departments:', error);
}
};
fetchDepartments();
}, []);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData({
...formData,
[name]: value
});
};
const validateForm = () => {
const newErrors = {};
if (!formData.firstName.trim()) {
newErrors.firstName = 'Имя обязательно';
}
if (!formData.lastName.trim()) {
newErrors.lastName = 'Фамилия обязательна';
}
if (!formData.email.trim()) {
newErrors.email = 'Email обязателен';
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = 'Некорректный email';
}
if (!formData.departmentId) {
newErrors.departmentId = 'Отдел обязателен';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e) => {
e.preventDefault();
if (validateForm()) {
onSubmit(formData);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Имя"
name="firstName"
value={formData.firstName}
onChange={handleChange}
error={errors.firstName}
required
/>
<Input
label="Фамилия"
name="lastName"
value={formData.lastName}
onChange={handleChange}
error={errors.lastName}
required
/>
</div>
<Input
label="Email"
type="email"
name="email"
value={formData.email}
onChange={handleChange}
error={errors.email}
required
/>
<Input
label="Должность"
name="position"
value={formData.position}
onChange={handleChange}
/>
<Select
label="Отдел"
name="departmentId"
value={formData.departmentId}
onChange={handleChange}
options={departments}
error={errors.departmentId}
required
/>
<Input
label="Телефон"
type="tel"
name="phone"
value={formData.phone}
onChange={handleChange}
placeholder="+7 (123) 456-7890"
/>
<div className="flex justify-end space-x-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
Отмена
</Button>
<Button
type="submit"
isLoading={isSubmitting}
disabled={isSubmitting}
>
{initialData.id ? 'Сохранить' : 'Добавить сотрудника'}
</Button>
</div>
</form>
);
};
export default EmployeeForm;
Валидация форм
HRoom использует обычную валидацию форм на стороне клиента для обеспечения корректности вводимых данных. Валидация выполняется перед отправкой данных на сервер.
Основные типы валидации, используемые в HRoom:
- Обязательные поля - проверка на заполнение обязательных полей
- Форматы данных - проверка соответствия данных определенному формату (email, телефон и т.д.)
- Минимальная/максимальная длина - проверка длины введенных данных
- Специфичные для предметной области проверки - проверки, связанные с бизнес-логикой приложения
5.3. Таблицы
Компоненты таблиц в HRoom используются для отображения и управления структурированными данными. Они предоставляют функциональность сортировки, фильтрации и пагинации для улучшения пользовательского опыта при работе с большими объемами данных.
Основной компонент таблицы
Базовый компонент Table предоставляет основную структуру для отображения табличных данных.
Код компонента таблицы
// src/components/ui/Table.tsx
import React, { ReactNode } from 'react';
interface Column<T> {
key: string;
header: ReactNode;
render: (item: T, index: number) => ReactNode;
sortable?: boolean;
width?: string;
}
interface TableProps<T> {
data: T[];
columns: Column<T>[];
keyExtractor: (item: T) => string;
onRowClick?: (item: T) => void;
sortColumn?: string;
sortDirection?: 'asc' | 'desc';
onSort?: (column: string) => void;
emptyState?: ReactNode;
className?: string;
}
function Table<T>({
data,
columns,
keyExtractor,
onRowClick,
sortColumn,
sortDirection,
onSort,
emptyState,
className = ''
}: TableProps<T>) {
return (
<div className={`overflow-x-auto ${className}`}>
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{columns.map((column) => (
<th
key={column.key}
scope="col"
className={`px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${
column.sortable ? 'cursor-pointer select-none' : ''
}`}
onClick={() => {
if (column.sortable && onSort) {
onSort(column.key);
}
}}
style={{ width: column.width }}
>
<div className="flex items-center space-x-1">
<span>{column.header}</span>
{column.sortable && sortColumn === column.key && (
<span className="ml-1">
{sortDirection === 'asc' ? '▲' : '▼'}
</span>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-800">
{data.length > 0 ? (
data.map((item, index) => (
<tr
key={keyExtractor(item)}
className={`${
onRowClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800' : ''
}`}
onClick={() => onRowClick && onRowClick(item)}
>
{columns.map((column) => (
<td key={column.key} className="px-6 py-4 whitespace-nowrap">
{column.render(item, index)}
</td>
))}
</tr>
))
) : (
<tr>
<td colSpan={columns.length} className="px-6 py-8 text-center">
{emptyState || (
<div className="text-gray-500 dark:text-gray-400">
Нет данных для отображения
</div>
)}
</td>
</tr>
)}
</tbody>
</table>
</div>
);
};
export default Table;
Использование компонента Table:
Пример использования компонента Table
import Table from '../components/ui/Table';
import Badge from '../components/ui/Badge';
import { useState } from 'react';
const EmployeesList = ({ employees }) => {
const [sortColumn, setSortColumn] = useState('lastName');
const [sortDirection, setSortDirection] = useState('asc');
const handleSort = (column) => {
if (sortColumn === column) {
// Если колонка уже выбрана, меняем направление сортировки
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
// Если выбрана новая колонка, устанавливаем сортировку по возрастанию
setSortColumn(column);
setSortDirection('asc');
}
};
// Сортировка данных
const sortedEmployees = [...employees].sort((a, b) => {
const aValue = a[sortColumn];
const bValue = b[sortColumn];
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1;
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
const columns = [
{
key: 'lastName',
header: 'Фамилия',
render: (employee) => `${employee.lastName}`,
sortable: true
},
{
key: 'firstName',
header: 'Имя',
render: (employee) => `${employee.firstName}`,
sortable: true
},
{
key: 'position',
header: 'Должность',
render: (employee) => employee.position || '-',
sortable: true
},
{
key: 'department',
header: 'Отдел',
render: (employee) => employee.department?.name || '-',
sortable: true
},
{
key: 'status',
header: 'Статус',
render: (employee) => (
<Badge
variant={employee.status === 'ACTIVE' ? 'success' : 'default'}
>
{employee.status === 'ACTIVE' ? 'Активен' : 'Неактивен'}
</Badge>
)
},
{
key: 'actions',
header: 'Действия',
render: (employee) => (
<div className="flex space-x-2">
<button
onClick={(e) => {
e.stopPropagation();
handleEdit(employee);
}}
className="text-primary hover:text-primary-light"
>
Редактировать
</button>
</div>
),
width: '100px'
}
];
const handleRowClick = (employee) => {
// Обработка клика по строке (например, переход к профилю сотрудника)
console.log('Selected employee:', employee);
};
const handleEdit = (employee) => {
// Обработка нажатия кнопки "Редактировать"
console.log('Edit employee:', employee);
};
return (
<div className="bg-white shadow rounded-lg overflow-hidden">
<Table
data={sortedEmployees}
columns={columns}
keyExtractor={(employee) => employee.id}
onRowClick={handleRowClick}
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
emptyState={
<div className="py-8 text-center">
<p className="text-gray-500 mb-2">Нет сотрудников</p>
<button className="text-primary hover:underline">
Добавить сотрудника
</button>
</div>
}
/>
</div>
);
};
Таблица сотрудников
Таблица сотрудников (EmployeeTable) - специализированный компонент для отображения списка сотрудников с возможностью сортировки, фильтрации и выполнения действий.
Код таблицы сотрудников
// src/pages/Teams/components/Employees/EmployeeTable.tsx (сокращенная версия)
import React, { useState } from 'react';
import Table from '../../../../components/ui/Table';
import Pagination from '../../../../components/ui/Pagination';
import Badge from '../../../../components/ui/Badge';
import Button from '../../../../components/ui/Button';
import EmployeeFilter from '../../../../components/teams/EmployeeFilter';
interface EmployeeTableProps {
employees: any[];
totalItems: number;
currentPage: number;
pageSize: number;
onPageChange: (page: number) => void;
onEdit: (employee: any) => void;
onStatusChange: (employee: any, status: string) => void;
onDelete: (employee: any) => void;
onBulkAction: (ids: string[], action: string) => void;
isLoading?: boolean;
}
const EmployeeTable: React.FC<EmployeeTableProps> = ({
employees,
totalItems,
currentPage,
pageSize,
onPageChange,
onEdit,
onStatusChange,
onDelete,
onBulkAction,
isLoading = false
}) => {
const [selectedEmployees, setSelectedEmployees] = useState([]);
const [sortColumn, setSortColumn] = useState('lastName');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [filters, setFilters] = useState({
status: 'all',
department: '',
search: ''
});
const totalPages = Math.ceil(totalItems / pageSize);
const handleSort = (column: string) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('asc');
}
// Здесь может быть запрос на сервер для сортировки данных
};
const handleFilterChange = (newFilters) => {
setFilters(newFilters);
// Сброс страницы при изменении фильтров
onPageChange(1);
// Здесь может быть запрос на сервер для фильтрации данных
};
const handleSelectAll = (e) => {
if (e.target.checked) {
setSelectedEmployees(employees.map(emp => emp.id));
} else {
setSelectedEmployees([]);
}
};
const handleSelectEmployee = (employeeId: string) => {
setSelectedEmployees(prev =>
prev.includes(employeeId)
? prev.filter(id => id !== employeeId)
: [...prev, employeeId]
);
};
const columns = [
{
key: 'select',
header: (
<input
type="checkbox"
checked={employees.length > 0 && selectedEmployees.length === employees.length}
onChange={handleSelectAll}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
),
render: (employee) => (
<input
type="checkbox"
checked={selectedEmployees.includes(employee.id)}
onChange={() => handleSelectEmployee(employee.id)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
),
width: '40px'
},
{
key: 'lastName',
header: 'Фамилия',
render: (employee) => employee.lastName,
sortable: true
},
{
key: 'firstName',
header: 'Имя',
render: (employee) => employee.firstName,
sortable: true
},
{
key: 'email',
header: 'Email',
render: (employee) => employee.email,
sortable: true
},
{
key: 'position',
header: 'Должность',
render: (employee) => employee.position || '-',
sortable: true
},
{
key: 'department',
header: 'Отдел',
render: (employee) => employee.department?.name || '-',
sortable: true
},
{
key: 'status',
header: 'Статус',
render: (employee) => (
<Badge
variant={employee.status === 'ACTIVE' ? 'success' : 'default'}
>
{employee.status === 'ACTIVE' ? 'Активен' : 'Неактивен'}
</Badge>
),
sortable: true
},
{
key: 'actions',
header: 'Действия',
render: (employee) => (
<div className="flex space-x-2">
<button
onClick={(e) => {
e.stopPropagation();
onEdit(employee);
}}
className="text-primary hover:text-primary-light"
>
Редактировать
</button>
<button
onClick={(e) => {
e.stopPropagation();
onStatusChange(
employee,
employee.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
);
}}
className="text-gray-600 hover:text-gray-900"
>
{employee.status === 'ACTIVE' ? 'Деактивировать' : 'Активировать'}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(employee);
}}
className="text-red-600 hover:text-red-800"
>
Удалить
</button>
</div>
),
width: '200px'
}
];
return (
<div className="space-y-4">
<EmployeeFilter
filters={filters}
onChange={handleFilterChange}
/>
{selectedEmployees.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-lg flex items-center justify-between">
<span className="text-sm text-gray-600 dark:text-gray-300">
Выбрано {selectedEmployees.length} сотрудников
</span>
<div className="flex space-x-2">
<Button
size="sm"
variant="outline"
onClick={() => onBulkAction(selectedEmployees, 'activate')}
>
Активировать
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onBulkAction(selectedEmployees, 'deactivate')}
>
Деактивировать
</Button>
<Button
size="sm"
variant="danger"
onClick={() => onBulkAction(selectedEmployees, 'delete')}
>
Удалить
</Button>
</div>
</div>
)}
{isLoading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
</div>
) : (
<Table
data={employees}
columns={columns}
keyExtractor={(employee) => employee.id}
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
emptyState={
<div className="py-8 text-center">
<p className="text-gray-500 mb-2">Сотрудники не найдены</p>
<p className="text-sm text-gray-400">
Попробуйте изменить параметры фильтрации или добавьте новых сотрудников
</p>
</div>
}
/>
)}
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
pageSize={pageSize}
totalItems={totalItems}
/>
)}
</div>
);
};
export default EmployeeTable;
Таблица групп вопросов
Таблица групп вопросов (QuestionGroupTable) используется для отображения и управления группами вопросов в административном модуле.
Код таблицы групп вопросов
// src/pages/Admin/QuestionGroups/QuestionGroupTable.tsx (сокращенная версия)
import React, { useState } from 'react';
import Table from '../../../components/ui/Table';
import Pagination from '../../../components/ui/Pagination';
import Button from '../../../components/ui/Button';
import Badge from '../../../components/ui/Badge';
import { QuestionGroup } from '../../../types/questionGroup';
interface QuestionGroupTableProps {
groups: QuestionGroup[];
totalItems: number;
currentPage: number;
pageSize: number;
onPageChange: (page: number) => void;
onEdit: (group: QuestionGroup) => void;
onDelete: (group: QuestionGroup) => void;
isLoading?: boolean;
}
const QuestionGroupTable: React.FC<QuestionGroupTableProps> = ({
groups,
totalItems,
currentPage,
pageSize,
onPageChange,
onEdit,
onDelete,
isLoading = false
}) => {
const [sortColumn, setSortColumn] = useState('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const totalPages = Math.ceil(totalItems / pageSize);
const handleSort = (column: string) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('asc');
}
};
const getMetricBadge = (metricType) => {
const metricColors = {
ENGAGEMENT: 'primary',
COMMUNICATION: 'success',
TEAMWORK: 'warning',
LEADERSHIP: 'danger'
};
const metricLabels = {
ENGAGEMENT: 'Вовлеченность',
COMMUNICATION: 'Коммуникация',
TEAMWORK: 'Командная работа',
LEADERSHIP: 'Лидерство'
};
return (
<Badge
variant={metricColors[metricType] || 'default'}
>
{metricLabels[metricType] || metricType}
</Badge>
);
};
const columns = [
{
key: 'name',
header: 'Название',
render: (group) => group.name,
sortable: true
},
{
key: 'metric',
header: 'Метрика',
render: (group) => getMetricBadge(group.metricType),
sortable: true
},
{
key: 'questionsCount',
header: 'Количество вопросов',
render: (group) => group.questionsCount || 0,
sortable: true
},
{
key: 'updatedAt',
header: 'Последнее обновление',
render: (group) => new Date(group.updatedAt).toLocaleDateString(),
sortable: true
},
{
key: 'actions',
header: 'Действия',
render: (group) => (
<div className="flex space-x-2">
<button
onClick={(e) => {
e.stopPropagation();
onEdit(group);
}}
className="text-primary hover:text-primary-light"
>
Редактировать
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(group);
}}
className="text-red-600 hover:text-red-800"
>
Удалить
</button>
</div>
),
width: '150px'
}
];
return (
<div className="space-y-4">
{isLoading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
</div>
) : (
<Table
data={groups}
columns={columns}
keyExtractor={(group) => group.id}
onRowClick={onEdit}
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
emptyState={
<div className="py-8 text-center">
<p className="text-gray-500 mb-2">Группы вопросов не найдены</p>
<Button
size="sm"
onClick={() => onEdit(null)}
>
Создать группу
</Button>
</div>
}
/>
)}
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
pageSize={pageSize}
totalItems={totalItems}
/>
)}
</div>
);
};
export default QuestionGroupTable;
Фильтры для таблиц
Фильтры для таблиц позволяют пользователям легко находить нужную информацию в больших наборах данных. В проекте HRoom используются различные компоненты фильтров, адаптированные под конкретные нужды.
Фильтр для сотрудников
Код фильтра для сотрудников
// src/components/teams/EmployeeFilter.tsx
import React from 'react';
import Input from '../ui/Input';
import Select from '../ui/Select';
import { useTeamsService } from '../../services/teams/teamsService';
import { useState, useEffect } from 'react';
interface EmployeeFilterProps {
filters: {
status: string;
department: string;
search: string;
};
onChange: (filters: any) => void;
}
const EmployeeFilter: React.FC<EmployeeFilterProps> = ({
filters,
onChange
}) => {
const { getDepartments } = useTeamsService();
const [departments, setDepartments] = useState([]);
const statusOptions = [
{ value: 'all', label: 'Все статусы' },
{ value: 'ACTIVE', label: 'Активные' },
{ value: 'INACTIVE', label: 'Неактивные' }
];
useEffect(() => {
const fetchDepartments = async () => {
try {
const data = await getDepartments();
// Форматируем данные для селекта
const options = [
{ value: '', label: 'Все отделы' },
...data.map(dep => ({
value: dep.id,
label: dep.name
}))
];
setDepartments(options);
} catch (error) {
console.error('Error fetching departments:', error);
}
};
fetchDepartments();
}, []);
const handleFilterChange = (name, value) => {
onChange({
...filters,
[name]: value
});
};
return (
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow mb-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Input
placeholder="Поиск по имени или email..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
/>
<Select
options={statusOptions}
value={filters.status}
onChange={(e) => handleFilterChange('status', e.target.value)}
/>
<Select
options={departments}
value={filters.department}
onChange={(e) => handleFilterChange('department', e.target.value)}
/>
</div>
</div>
);
};
export default EmployeeFilter;
5.4. Графики и диаграммы
Графики и диаграммы являются важной частью интерфейса HRoom, позволяя визуализировать данные для более удобного анализа. HRoom использует библиотеку Recharts для создания интерактивных и отзывчивых визуализаций данных.
Основные типы графиков
LineChart (Линейный график)
Линейный график используется для отображения тенденций во времени, например, изменения метрик вовлеченности по месяцам.
Код компонента линейного графика
// src/pages/Dashboard/MetricsModule/components/MetricsChart.tsx (сокращенная версия)
import React from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer
} from 'recharts';
import CustomTooltip from './CustomTooltip';
interface MetricsChartProps {
data: any[];
metrics: string[];
colors: Record<string, string>;
labels: Record<string, string>;
period: string;
}
const MetricsChart: React.FC<MetricsChartProps> = ({
data,
metrics,
colors,
labels,
period
}) => {
// Форматирование даты на оси X в зависимости от периода
const formatXAxis = (value) => {
if (!value) return '';
const date = new Date(value);
if (period === 'month') {
return date.toLocaleDateString('ru', { day: '2-digit', month: 'short' });
} else if (period === 'quarter') {
return date.toLocaleDateString('ru', { month: 'short' });
} else if (period === 'year') {
return date.toLocaleDateString('ru', { month: 'short', year: '2-digit' });
}
return value;
};
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 h-80">
} />
{metrics.map((metric) => (
))}
</div>
);
};
export default MetricsChart;
Компонент кастомного тултипа для графика:
Код компонента кастомного тултипа
// src/pages/Dashboard/MetricsModule/components/CustomTooltip.tsx
import React from 'react';
const CustomTooltip = ({ active, payload, labels, label }) => {
if (!active || !payload || !payload.length) {
return null;
}
return (
<div className="bg-white shadow-lg rounded-lg p-3 border border-gray-200">
<p className="font-medium text-gray-700 mb-2">
{new Date(label).toLocaleDateString('ru', {
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</p>
{payload.map((entry, index) => (
<div key={index} className="flex items-center mb-1">
<div
className="w-3 h-3 rounded-full mr-2"
style={{ backgroundColor: entry.color }}
/>
<span className="text-gray-700">
{labels[entry.dataKey]}: <b>{entry.value.toFixed(1)}</b>
</span>
</div>
))}
</div>
);
};
export default CustomTooltip;
Использование компонента линейного графика:
Пример использования линейного графика
import MetricsChart from './components/MetricsChart';
import { useState, useEffect } from 'react';
import { useMetricsData } from './hooks/useMetricsData';
const MetricsModule = () => {
const [period, setPeriod] = useState('month');
const [selectedMetrics, setSelectedMetrics] = useState(['engagement', 'communication']);
const { data, isLoading, error } = useMetricsData(period);
const metricColors = {
engagement: '#6366f1',
communication: '#10b981',
teamwork: '#f59e0b',
leadership: '#ef4444'
};
const metricLabels = {
engagement: 'Вовлеченность',
communication: 'Коммуникация',
teamwork: 'Командная работа',
leadership: 'Лидерство'
};
if (isLoading) {
return <div>Загрузка данных...</div>;
}
if (error) {
return <div>Ошибка загрузки данных</div>;
}
return (
<div>
<div className="mb-4 flex flex-wrap gap-2">
{/* Селектор периода и метрик */}
</div>
<MetricsChart
data={data}
metrics={selectedMetrics}
colors={metricColors}
labels={metricLabels}
period={period}
/>
</div>
);
};
BarChart (Столбчатая диаграмма)
Столбчатая диаграмма используется для сравнения различных категорий данных, например, средних значений метрик по отделам.
Код компонента столбчатой диаграммы
// Компонент столбчатой диаграммы
import React from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer
} from 'recharts';
interface MetricsBarChartProps {
data: any[];
metrics: string[];
colors: Record<string, string>;
labels: Record<string, string>;
}
const MetricsBarChart: React.FC<MetricsBarChartProps> = ({
data,
metrics,
colors,
labels
}) => {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 h-80">
{metrics.map((metric) => (
))}
</div>
);
};
export default MetricsBarChart;
PieChart (Круговая диаграмма)
Круговая диаграмма используется для отображения пропорциональных частей целого, например, распределения ответов на опрос.
Код компонента круговой диаграммы
// src/components/ui/ResponsePieChart.tsx
import React from 'react';
import {
PieChart,
Pie,
Cell,
Tooltip,
Legend,
ResponsiveContainer
} from 'recharts';
interface ResponsePieChartProps {
data: any[];
colors: string[];
dataKey: string;
nameKey: string;
}
const ResponsePieChart: React.FC<ResponsePieChartProps> = ({
data,
colors,
dataKey,
nameKey
}) => {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4 h-80">
`${name}: ${(percent * 100).toFixed(0)}%`}
>
{data.map((entry, index) => (
|
))}
[`${value}`, 'Количество']} />
</div>
);
};
export default ResponsePieChart;
Gauge (Датчик)
Компонент датчика используется для отображения одного значения в контексте диапазона, например, общего уровня вовлеченности от 0 до 5.
Код компонента датчика
// src/pages/Dashboard/EngagementGauge/index.tsx (сокращенная версия)
import React from 'react';
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';
import CustomTooltip from './components/CustomTooltip';
import { useEngagementData } from './hooks/useEngagementData';
import { COLORS, RADIAN } from './constants';
const EngagementGauge: React.FC = () => {
const { data, isLoading, error } = useEngagementData();
if (isLoading) {
return (
<div className="flex justify-center items-center h-full">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
</div>
);
}
if (error) {
return (
<div className="text-center text-red-500">
Ошибка загрузки данных
</div>
);
}
const score = data?.score || 0;
const maxScore = 5;
const fillPercentage = (score / maxScore) * 100;
// Данные для заполненной части датчика
const gaugeData = [
{ name: 'score', value: fillPercentage },
{ name: 'empty', value: 100 - fillPercentage }
];
// Определение цвета в зависимости от значения
const getScoreColor = (score) => {
if (score <= 2) return COLORS.LOW;
if (score <= 3.5) return COLORS.MEDIUM;
return COLORS.HIGH;
};
const scoreColor = getScoreColor(score);
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 h-80">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
Средний уровень вовлеченности
</h3>
<div className="relative h-64">
{/* Фон датчика */}
<div className="absolute top-0 left-0 w-full h-full">
<svg width="100%" height="100%" viewBox="0 0 100 100">
<path
d="M 50, 0
A 50,50 0 1,1 99.99,49.99
L 50,99
A 50,50 0 1,0 0.01,49.99
L 50,0"
fill="#F7F7F7"
/>
</svg>
</div>
{/* Заполнение датчика */}
<div className="absolute top-0 left-0 w-full h-full">
|
|
</div>
{/* Показатель посередине */}
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 text-center">
<div className="text-4xl font-bold" style={{ color: scoreColor }}>
{score.toFixed(1)}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">из 5</div>
</div>
{/* Подсказка */}
<CustomTooltip score={score} data={data} />
</div>
</div>
);
};
export default EngagementGauge;
Heatmap (Тепловая карта)
Тепловая карта используется для визуализации данных в виде цветовой матрицы, например, для отображения метрик по отделам.
Код компонента тепловой карты
// src/pages/Dashboard/DepartmentsHeatmap/index.tsx (сокращенная версия)
import React, { useState } from 'react';
import { useHeatmapData } from './hooks/useHeatmapData';
import HeatmapCell from './components/HeatmapCell';
import MetricHeader from './components/MetricHeader';
import DepartmentIcon from './components/DepartmentIcon';
import Tooltip from './components/Tooltip';
import PeriodSelector from './components/PeriodSelector';
import LoadingState from './components/LoadingState';
import ErrorState from './components/ErrorState';
import EmptyState from './components/EmptyState';
import { METRICS, PERIODS } from './constants';
const DepartmentsHeatmap: React.FC = () => {
const [selectedPeriod, setSelectedPeriod] = useState(PERIODS[0].value);
const [hoveredCell, setHoveredCell] = useState(null);
const { data, isLoading, error } = useHeatmapData(selectedPeriod);
if (isLoading) {
return <LoadingState />;
}
if (error) {
return <ErrorState />;
}
if (!data || data.departments.length === 0) {
return <EmptyState />;
}
const { departments, metrics } = data;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
Метрики по отделам
</h3>
<PeriodSelector
periods={PERIODS}
selected={selectedPeriod}
onChange={setSelectedPeriod}
/>
</div>
<div className="relative overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left">
<th className="p-2 w-48">Отдел</th>
{METRICS.map((metric) => (
<th key={metric.id} className="p-2 text-center">
<MetricHeader metric={metric} />
</th>
))}
</tr>
</thead>
<tbody>
{departments.map((department) => (
<tr key={department.id}>
<td className="p-2">
<div className="flex items-center">
<DepartmentIcon type={department.type} />
<span className="ml-2">{department.name}</span>
</div>
</td>
{METRICS.map((metric) => {
const value = metrics.find(
m => m.departmentId === department.id && m.metricId === metric.id
)?.value || 0;
return (
<td key={`${department.id}-${metric.id}`} className="p-2">
<div
className="relative"
onMouseEnter={() => setHoveredCell({
department,
metric,
value
})}
onMouseLeave={() => setHoveredCell(null)}
>
<HeatmapCell value={value} />
</div>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{hoveredCell && (
<Tooltip cell={hoveredCell} />
)}
</div>
);
};
export default DepartmentsHeatmap;
Компонент ячейки тепловой карты:
Код компонента ячейки тепловой карты
// src/pages/Dashboard/DepartmentsHeatmap/components/HeatmapCell.tsx
import React from 'react';
import { getColorForValue } from '../utils';
interface HeatmapCellProps {
value: number;
}
const HeatmapCell: React.FC<HeatmapCellProps> = ({ value }) => {
const backgroundColor = getColorForValue(value);
const textColor = value > 3 ? '#ffffff' : '#000000';
return (
<div
className="w-14 h-14 rounded-md flex items-center justify-center cursor-pointer transition-transform hover:scale-105"
style={{ backgroundColor }}
>
<span
className="font-medium text-lg"
style={{ color: textColor }}
>
{value.toFixed(1)}
</span>
</div>
);
};
export default HeatmapCell;
Создание собственных визуализаций данных
HRoom также содержит компоненты пользовательских визуализаций данных, созданных с помощью SVG и CSS. Например, компонент StatBar используется для отображения прогресса по метрикам.
Код компонента StatBar
// src/pages/Dashboard/EngagementGauge/components/StatBar.tsx
import React from 'react';
interface StatBarProps {
value: number;
maxValue: number;
color?: string;
height?: number;
label?: string;
showValue?: boolean;
}
const StatBar: React.FC<StatBarProps> = ({
value,
maxValue,
color = '#7d4cf1',
height = 8,
label,
showValue = true
}) => {
const percentage = Math.min(Math.max((value / maxValue) * 100, 0), 100);
return (
<div className="mb-2">
{(label || showValue) && (
<div className="flex justify-between mb-1 text-sm text-gray-600 dark:text-gray-400">
{label && <span>{label}</span>}
{showValue && <span>{value.toFixed(1)} из {maxValue}</span>}
</div>
)}
<div
className="w-full bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden"
style={{ height: `${height}px` }}
>
<div
className="h-full rounded-full transition-all duration-500 ease-out"
style={{
width: `${percentage}%`,
backgroundColor: color
}}
/>
</div>
</div>
);
};
export default StatBar;
5.5. Модальные окна
Модальные окна в HRoom используются для отображения дополнительного содержимого, форм или предупреждений без необходимости перехода на новую страницу. Они помогают сосредоточить внимание пользователя на конкретной задаче и упрощают интерфейс.
Базовый компонент Modal
Базовый компонент Modal предоставляет основную структуру и функциональность для всех модальных окон в приложении.
Код базового компонента Modal
// src/components/Modal.tsx
import React, { ReactNode, useEffect } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
footer?: ReactNode;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
closeOnOutsideClick?: boolean;
}
const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
footer,
maxWidth = 'md',
closeOnOutsideClick = true
}) => {
// Предотвращение прокрутки основного содержимого при открытом модальном окне
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
// Обработка нажатия клавиши Escape для закрытия модального окна
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
window.addEventListener('keydown', handleEscape);
return () => {
window.removeEventListener('keydown', handleEscape);
};
}, [isOpen, onClose]);
if (!isOpen) {
return null;
}
// Определение максимальной ширины модального окна
const maxWidthClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
full: 'max-w-full'
};
// Обработка клика вне модального окна
const handleOutsideClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget && closeOnOutsideClick) {
onClose();
}
};
return (
<div
className="fixed inset-0 z-50 overflow-y-auto bg-gray-900 bg-opacity-50 flex items-center justify-center p-4"
onClick={handleOutsideClick}
>
<div
className={`bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full ${maxWidthClasses[maxWidth]} transform transition-all`}
role="dialog"
aria-modal="true"
>
{title && (
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
{title}
</h3>
<button
type="button"
className="text-gray-400 hover:text-gray-500 focus:outline-none"
onClick={onClose}
aria-label="Close"
>
<span className="sr-only">Закрыть</span>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
<div className="px-6 py-4">
{children}
</div>
{footer && (
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700 rounded-b-lg">
{footer}
</div>
)}
</div>
</div>
);
};
export default Modal;
Использование компонента Modal:
Пример использования компонента Modal
import Modal from '../components/Modal';
import Button from '../components/ui/Button';
import { useState } from 'react';
const ExamplePage = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const openModal = () => setIsModalOpen(true);
const closeModal = () => setIsModalOpen(false);
return (
<div>
<Button onClick={openModal}>
Открыть модальное окно
</Button>
<Modal
isOpen={isModalOpen}
onClose={closeModal}
title="Пример модального окна"
footer={
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={closeModal} disabled={false}>
Отмена
</Button>
<Button onClick={() => {
// Обработка действия
closeModal();
}}>
Подтвердить
</Button>
</div>
}
>
<p>Это пример содержимого модального окна.</p>
<p className="mt-2">Здесь может быть любой контент, включая формы, таблицы или другие элементы.</p>
</Modal>
</div>
);
};
Модальное окно создания опроса
Модальное окно создания опроса (CreateSurveyModal) - специализированный компонент для создания и редактирования опросов.
Код модального окна создания опроса
// src/components/surveys/CreateSurveyModal.tsx (сокращенная версия)
import React, { useState } from 'react';
import Modal from '../Modal';
import Button from '../ui/Button';
import SurveyForm from './SurveyForm';
import { Survey } from '../../types/survey';
import { useCreateSurvey, useUpdateSurvey } from '../../hooks/useSurveys';
interface CreateSurveyModalProps {
isOpen: boolean;
onClose: () => void;
initialData?: Partial<Survey>;
onSuccess?: () => void;
}
const CreateSurveyModal: React.FC<CreateSurveyModalProps> = ({
isOpen,
onClose,
initialData,
onSuccess
}) => {
const [isSubmitting, setIsSubmitting] = useState(false);
const { createSurvey } = useCreateSurvey();
const { updateSurvey } = useUpdateSurvey();
const handleSubmit = async (data: Partial<Survey>) => {
setIsSubmitting(true);
try {
if (initialData?.id) {
// Обновление существующего опроса
await updateSurvey(initialData.id, data);
} else {
// Создание нового опроса
await createSurvey(data);
}
onSuccess?.();
onClose();
} catch (error) {
console.error('Error saving survey:', error);
// Здесь можно добавить отображение ошибки
} finally {
setIsSubmitting(false);
}
};
const modalTitle = initialData?.id ? 'Редактирование опроса' : 'Создание нового опроса';
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={modalTitle}
maxWidth="lg"
closeOnOutsideClick={false}
>
<SurveyForm
initialData={initialData}
onSubmit={handleSubmit}
onCancel={onClose}
isSubmitting={isSubmitting}
/>
</Modal>
);
};
export default CreateSurveyModal;
Модальное окно с деталями AI-рекомендации
Модальное окно с деталями AI-рекомендации (AIDetailModal) отображает подробную информацию о рекомендации, сгенерированной искусственным интеллектом.
Код модального окна с деталями AI-рекомендации
// src/components/AIDetailModal.tsx (сокращенная версия)
import React, { useEffect, useState } from 'react';
import Modal from './Modal';
import { getAIDetail } from '../services/aiAdvice';
interface AIDetailModalProps {
isOpen: boolean;
onClose: () => void;
adviceId: string | null;
}
const AIDetailModal: React.FC<AIDetailModalProps> = ({
isOpen,
onClose,
adviceId
}) => {
const [detailData, setDetailData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchDetails = async () => {
if (!adviceId || !isOpen) return;
setIsLoading(true);
setError(null);
try {
const data = await getAIDetail(adviceId);
setDetailData(data);
} catch (err) {
console.error('Error fetching AI detail:', err);
setError('Не удалось загрузить данные. Пожалуйста, попробуйте снова позже.');
} finally {
setIsLoading(false);
}
};
fetchDetails();
}, [adviceId, isOpen]);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Рекомендация AI"
maxWidth="lg"
>
{isLoading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
</div>
) : error ? (
<div className="text-center text-red-500 py-4">{error}</div>
) : detailData ? (
<div className="space-y-6">
<div>
<h3 className="text-xl font-medium text-gray-900 dark:text-white mb-2">
{detailData.advice.title}
</h3>
<div className="flex space-x-2 mb-4">
<span className={`
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
${detailData.advice.severity === 'HIGH' ? 'bg-red-100 text-red-800' :
detailData.advice.severity === 'MEDIUM' ? 'bg-yellow-100 text-yellow-800' :
'bg-blue-100 text-blue-800'}
`}>
{detailData.advice.severity === 'HIGH' ? 'Высокий приоритет' :
detailData.advice.severity === 'MEDIUM' ? 'Средний приоритет' :
'Низкий приоритет'}
</span>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
{detailData.advice.type === 'PERFORMANCE_IMPROVEMENT' ? 'Улучшение производительности' :
detailData.advice.type === 'ENGAGEMENT_BOOST' ? 'Повышение вовлеченности' :
'Рекомендация'}
</span>
</div>
</div>
<div>
<h4 className="text-lg font-medium text-gray-800 dark:text-gray-200 mb-2">
Описание
</h4>
<p className="text-gray-600 dark:text-gray-400">
{detailData.details.description}
</p>
</div>
<div>
<h4 className="text-lg font-medium text-gray-800 dark:text-gray-200 mb-2">
Возможное влияние
</h4>
<p className="text-gray-600 dark:text-gray-400">
{detailData.details.impact}
</p>
</div>
<div>
<h4 className="text-lg font-medium text-gray-800 dark:text-gray-200 mb-2">
Рекомендуемые шаги
</h4>
<ul className="list-disc pl-5 text-gray-600 dark:text-gray-400 space-y-1">
{detailData.details.steps.map((step, index) => (
<li key={index}>{step}</li>
))}
</ul>
</div>
<div>
<h4 className="text-lg font-medium text-gray-800 dark:text-gray-200 mb-2">
Связанные метрики
</h4>
<div className="flex flex-wrap gap-2">
{detailData.details.relatedMetrics.map((metric, index) => (
<span key={index} className="px-3 py-1 bg-gray-100 dark:bg-gray-700 rounded-full text-sm">
{metric}
</span>
))}
</div>
</div>
</div>
) : (
<div className="text-center text-gray-500 py-4">Нет данных</div>
)}
</Modal>
);
};
export default AIDetailModal;
Модальное окно импорта CSV
Модальное окно импорта CSV (CSVImportModal) позволяет пользователям загружать данные из CSV-файлов.
Код модального окна импорта CSV
// src/pages/Admin/Questions/CSVImportModal.tsx (сокращенная версия)
import React, { useState, useRef } from 'react';
import Modal from '../../../components/Modal';
import Button from '../../../components/ui/Button';
interface CSVImportModalProps {
isOpen: boolean;
onClose: () => void;
onImport: (data: any[]) => void;
templates?: { name: string; url: string }[];
sampleData?: string;
}
const CSVImportModal: React.FC<CSVImportModalProps> = ({
isOpen,
onClose,
onImport,
templates = [],
sampleData
}) => {
const [file, setFile] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [parsedData, setParsedData] = useState(null);
const fileInputRef = useRef(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (!selectedFile) return;
setFile(selectedFile);
setError(null);
// Проверка расширения файла
if (!selectedFile.name.endsWith('.csv')) {
setError('Пожалуйста, загрузите файл в формате CSV');
return;
}
// Чтение файла
const reader = new FileReader();
reader.onload = (event) => {
try {
const csvText = event.target?.result as string;
// Здесь можно использовать любую библиотеку для парсинга CSV
// Простой пример парсинга (для демонстрации)
const lines = csvText.split('\n');
const headers = lines[0].split(',').map(header => header.trim());
const data = [];
for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue;
const values = lines[i].split(',').map(value => value.trim());
const item = {};
headers.forEach((header, index) => {
item[header] = values[index];
});
data.push(item);
}
setParsedData(data);
} catch (err) {
console.error('Error parsing CSV:', err);
setError('Ошибка при обработке файла. Пожалуйста, проверьте формат CSV.');
}
};
reader.onerror = () => {
setError('Ошибка при чтении файла.');
};
reader.readAsText(selectedFile);
};
const handleImport = async () => {
if (!parsedData) return;
setIsLoading(true);
setError(null);
try {
await onImport(parsedData);
onClose();
} catch (err) {
console.error('Error importing data:', err);
setError('Произошла ошибка при импорте данных.');
} finally {
setIsLoading(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Импорт данных из CSV"
maxWidth="lg"
footer={
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={onClose} disabled={isLoading}>
Отмена
</Button>
<Button
onClick={handleImport}
disabled={!parsedData || isLoading}
isLoading={isLoading}
>
Импортировать
</Button>
</div>
}
>
<div className="space-y-6">
<div>
<p className="mb-2 text-gray-600 dark:text-gray-400">
Загрузите CSV-файл с данными для импорта. Файл должен содержать заголовки столбцов и соответствовать ожидаемой структуре.
</p>
{templates.length > 0 && (
<div className="mt-2">
<p className="text-sm text-gray-600 dark:text-gray-400">
Вы можете скачать шаблоны для заполнения:
</p>
<div className="mt-1 space-y-1">
{templates.map((template, index) => (
<div key={index}>
<a
href={template.url}
download
className="text-primary hover:text-primary-light text-sm"
>
{template.name}
</a>
</div>
))}
</div>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Выберите файл CSV
</label>
<div className="mt-1 flex items-center">
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
Выбрать файл
</Button>
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">
{file ? file.name : 'Файл не выбран'}
</span>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".csv"
className="hidden"
/>
</div>
{error && (
<p className="mt-2 text-sm text-red-600 dark:text-red-500">{error}</p>
)}
</div>
{parsedData && (
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Предварительный просмотр данных
</h4>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{Object.keys(parsedData[0]).map((header, index) => (
<th
key={index}
className="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
{header}
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-800">
{parsedData.slice(0, 5).map((row, rowIndex) => (
<tr key={rowIndex}>
{Object.values(row).map((cell: any, cellIndex) => (
<td
key={cellIndex}
className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400"
>
{cell}
</td>
))}
</tr>
))}
{parsedData.length > 5 && (
<tr>
<td
colSpan={Object.keys(parsedData[0]).length}
className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 text-center"
>
...и еще {parsedData.length - 5} строк
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
{sampleData && !parsedData && (
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Пример ожидаемого формата
</h4>
<pre className="bg-gray-50 dark:bg-gray-800 p-3 rounded-md text-xs overflow-x-auto">
{sampleData}
</pre>
</div>
)}
</div>
</Modal>
);
};
export default CSVImportModal;
Модальное окно выбора типа опроса
Модальное окно выбора типа опроса (SurveyTypePopup) позволяет пользователю выбрать тип опроса при его создании.
Код модального окна выбора типа опроса
// src/components/surveys/SurveyTypePopup.tsx (сокращенная версия)
import React from 'react';
import Modal from '../Modal';
import { SurveyType } from '../../types/survey';
interface SurveyTypeOption {
type: SurveyType;
title: string;
description: string;
icon: React.ReactNode;
}
interface SurveyTypePopupProps {
isOpen: boolean;
onClose: () => void;
onSelect: (type: SurveyType) => void;
}
const SurveyTypePopup: React.FC<SurveyTypePopupProps> = ({
isOpen,
onClose,
onSelect
}) => {
const surveyTypes: SurveyTypeOption[] = [
{
type: 'REGULAR',
title: 'Обычный опрос',
description: 'Стандартный опрос для сбора мнений о вовлеченности и других метриках.',
icon: (
<svg className="w-8 h-8 text-primary" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3a1 1 0 00.293.707l2.5 2.5a1 1 0 001.414-1.414L11 9.586V7z" clipRule="evenodd" />
</svg>
)
},
{
type: 'PULSE',
title: 'Пульс-опрос',
description: 'Короткий и частый опрос для отслеживания изменений в настроении команды.',
icon: (
<svg className="w-8 h-8 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clipRule="evenodd" />
</svg>
)
},
{
type: 'ONBOARDING',
title: 'Адаптационный опрос',
description: 'Опрос для новых сотрудников, проходящих период адаптации.',
icon: (
<svg className="w-8 h-8 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M8 9a3 3 0 100-6 3 3 0 000 6zM8 11a6 6 0 016 6H2a6 6 0 016-6z" />
<path d="M16 7a1 1 0 10-2 0v1h-1a1 1 0 100 2h1v1a1 1 0 102 0v-1h1a1 1 0 100-2h-1V7z" />
</svg>
)
},
{
type: 'EXIT',
title: 'Опрос при увольнении',
description: 'Опрос для сотрудников, покидающих компанию, для выявления причин ухода.',
icon: (
<svg className="w-8 h-8 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
)
}
];
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Выберите тип опроса"
maxWidth="lg"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{surveyTypes.map((option) => (
<div
key={option.type}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer transition-colors"
onClick={() => {
onSelect(option.type);
onClose();
}}
>
<div className="flex items-start">
<div className="flex-shrink-0">
{option.icon}
</div>
<div className="ml-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
{option.title}
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{option.description}
</p>
</div>
</div>
</div>
))}
</div>
</Modal>
);
};
export default SurveyTypePopup;