Темный режим

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>&laquo;</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>&raquo;</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;