Темный режим

3.8. Профиль пользователя

Модуль "Профиль пользователя" в системе HRoom предоставляет HR-менеджерам возможность управлять своими персональными данными, настройками аккаунта, информацией о компании, а также настраивать платежные данные. Этот модуль является важной частью системы, обеспечивающей персонализацию взаимодействия пользователя с платформой и управление административными данными.

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

  • Личная информация - управление персональными данными HR-менеджера
  • Информация о компании - просмотр и редактирование данных компании
  • Платежная информация - управление платежными данными компании
  • История платежей - просмотр истории финансовых операций
  • Настройки - конфигурация рабочего пространства и системных параметров
  • Загрузка аватара - обновление фотографии профиля

Архитектура модуля профиля пользователя

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


src/pages/Profile/
├── CompanyInfo.tsx       # Компонент информации о компании
├── PaymentHistory.tsx    # Компонент истории платежей
├── PaymentInfo.tsx       # Компонент платежной информации
├── PersonalInfo.tsx      # Компонент личной информации
├── Settings.tsx          # Компонент настроек пользователя
└── index.tsx             # Главный компонент страницы профиля

src/components/
├── UserProfile.tsx       # Компонент для отображения краткой информации о пользователе
└── AvatarUpload.tsx      # Компонент для загрузки и обновления аватара
                

Основной компонент профиля (Profile/index.tsx)

Главный компонент модуля профиля объединяет все подкомпоненты и обеспечивает навигацию между различными разделами профиля. Он реализован как страница с табами (вкладками), где каждая вкладка представляет собой отдельный раздел профиля.

Код основного компонента профиля
import React, { useState } from 'react';
import { Tabs } from '../../components/ui/Tabs';
import PersonalInfo from './PersonalInfo';
import CompanyInfo from './CompanyInfo';
import PaymentInfo from './PaymentInfo';
import PaymentHistory from './PaymentHistory';
import Settings from './Settings';
import Layout from '../../components/Layout';

const Profile: React.FC = () => {
  const [activeTab, setActiveTab] = useState('personal');

  const tabs = [
    { id: 'personal', label: 'Личная информация' },
    { id: 'company', label: 'Информация о компании' },
    { id: 'payment', label: 'Платежная информация' },
    { id: 'history', label: 'История платежей' },
    { id: 'settings', label: 'Настройки' },
  ];

  return (
    <Layout>
      <div className="container mx-auto py-6">
        <h1 className="text-2xl font-semibold mb-6">Профиль пользователя</h1>
        
        <Tabs 
          tabs={tabs} 
          activeTab={activeTab} 
          onChange={setActiveTab} 
          className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden"
        />
        
        <div className="mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
          {activeTab === 'personal' && <PersonalInfo />}
          {activeTab === 'company' && <CompanyInfo />}
          {activeTab === 'payment' && <PaymentInfo />}
          {activeTab === 'history' && <PaymentHistory />}
          {activeTab === 'settings' && <Settings />}
        </div>
      </div>
    </Layout>
  );
};

export default Profile;

Основной компонент профиля выполняет следующие функции:

  • Организует интерфейс профиля пользователя в виде вкладок для удобной навигации
  • Управляет состоянием активной вкладки
  • Отображает соответствующий компонент в зависимости от выбранной вкладки
  • Интегрируется с общим Layout приложения для обеспечения единообразного интерфейса

Личная информация (PersonalInfo.tsx)

Раздел "Личная информация" позволяет HR-менеджеру просматривать и редактировать свои персональные данные, включая ФИО, контактную информацию и аватар профиля.

Функциональность компонента PersonalInfo

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

  • Загрузка текущих данных профиля пользователя из API
  • Предоставление интерфейса для редактирования личных данных
  • Валидация вводимых пользователем данных
  • Сохранение обновленных данных через API
  • Интеграция с компонентом загрузки аватара
  • Отображение сообщений об успешном сохранении или ошибках
Код компонента личной информации
import React, { useState, useEffect } from 'react';
import AvatarUpload from '../../components/AvatarUpload';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';

const PersonalInfo: React.FC = () => {
  const [loading, setLoading] = useState(true);
  const [saving, setSaving] = useState(false);
  const [success, setSuccess] = useState(false);
  const [error, setError] = useState<string | null>(null);
  
  const [profileData, setProfileData] = useState({
    firstName: '',
    lastName: '',
    middleName: '',
    email: '',
    phone: '',
    photoUrl: '',
    managerId: ''
  });
  
  // Загрузка данных профиля
  useEffect(() => {
    const fetchProfileData = async () => {
      setLoading(true);
      try {
        const response = await fetch('https://api.hroom.ai/webhook/profile/personal/get', {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${localStorage.getItem('manager_token')}`,
            'Content-Type': 'application/json'
          }
        });
        
        const data = await response.json();
        
        if (data && data[0]) {
          setProfileData({
            firstName: data[0].firstName || '',
            lastName: data[0].lastName || '',
            middleName: data[0].middleName || '',
            email: data[0].email || '',
            phone: data[0].phone || '',
            photoUrl: data[0].photoUrl || '',
            managerId: data[0].managerId || ''
          });
        }
      } catch (err) {
        setError('Ошибка при загрузке данных профиля');
        console.error('Error fetching profile data:', err);
      } finally {
        setLoading(false);
      }
    };
    
    fetchProfileData();
  }, []);
  
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setProfileData(prev => ({
      ...prev,
      [name]: value
    }));
  };
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setSaving(true);
    setError(null);
    setSuccess(false);
    
    try {
      const response = await fetch('https://api.hroom.ai/webhook/profile/personal/save', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${localStorage.getItem('manager_token')}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          managerId: profileData.managerId,
          firstName: profileData.firstName,
          lastName: profileData.lastName,
          middleName: profileData.middleName,
          phone: profileData.phone
        })
      });
      
      const data = await response.json();
      
      if (data && data[0] && data[0].success) {
        setSuccess(true);
      } else {
        setError('Не удалось сохранить изменения');
      }
    } catch (err) {
      setError('Ошибка при сохранении данных');
      console.error('Error saving profile data:', err);
    } finally {
      setSaving(false);
      
      // Скрываем сообщение об успехе через 3 секунды
      if (success) {
        setTimeout(() => {
          setSuccess(false);
        }, 3000);
      }
    }
  };
  
  const handleAvatarUpload = (url: string) => {
    setProfileData(prev => ({
      ...prev,
      photoUrl: url
    }));
  };
  
  if (loading) {
    return (
      <div className="flex justify-center items-center p-8">
        <div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
      </div>
    );
  }
  
  return (
    <div>
      <h2 className="text-xl font-medium mb-6">Личная информация</h2>
      
      <div className="flex flex-col md:flex-row gap-8">
        <div className="w-full md:w-1/3">
          <AvatarUpload 
            currentUrl={profileData.photoUrl} 
            onUploadSuccess={handleAvatarUpload} 
          />
        </div>
        
        <div className="w-full md:w-2/3">
          <form onSubmit={handleSubmit} className="space-y-4">
            <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
              <Input
                label="Имя"
                name="firstName"
                value={profileData.firstName}
                onChange={handleChange}
                required
              />
              
              <Input
                label="Фамилия"
                name="lastName"
                value={profileData.lastName}
                onChange={handleChange}
                required
              />
            </div>
            
            <Input
              label="Отчество"
              name="middleName"
              value={profileData.middleName}
              onChange={handleChange}
            />
            
            <Input
              label="Email"
              type="email"
              name="email"
              value={profileData.email}
              disabled
              helpText="Email нельзя изменить"
            />
            
            <Input
              label="Телефон"
              type="tel"
              name="phone"
              value={profileData.phone}
              onChange={handleChange}
              placeholder="+7 (XXX) XXX-XXXX"
            />
            
            {error && (
              <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
                {error}
              </div>
            )}
            
            {success && (
              <div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded">
                Данные успешно сохранены
              </div>
            )}
            
            <div className="flex justify-end">
              <Button 
                type="submit" 
                isLoading={saving}
                disabled={saving}
              >
                Сохранить изменения
              </Button>
            </div>
          </form>
        </div>
      </div>
    </div>
  );
};

API для работы с личной информацией

Для работы с личной информацией пользователя используются следующие API-эндпоинты:

  • /profile/personal/get - получение текущих данных профиля
  • /profile/personal/save - сохранение обновленных данных профиля
  • /profile/file/upload - загрузка аватара пользователя

Все запросы к этим эндпоинтам проходят аутентификацию с использованием manager_token, который хранится в localStorage.

Информация о компании (CompanyInfo.tsx)

Раздел "Информация о компании" позволяет просматривать и редактировать данные компании HR-менеджера, включая название компании, веб-сайт и другие реквизиты.

Функциональность компонента CompanyInfo

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

  • Загрузка текущих данных компании из API
  • Предоставление интерфейса для редактирования информации о компании
  • Валидация вводимых данных
  • Сохранение обновленных данных через API
  • Отображение статуса подписки и срока действия аккаунта
Код компонента информации о компании
import React, { useState, useEffect } from 'react';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';

const CompanyInfo: React.FC = () => {
  const [loading, setLoading] = useState(true);
  const [saving, setSaving] = useState(false);
  const [success, setSuccess] = useState(false);
  const [error, setError] = useState<string | null>(null);
  
  const [companyData, setCompanyData] = useState({
    companyId: '',
    name: '',
    website: '',
    accountType: '',
    expirationDate: '',
  });
  
  // Загрузка данных компании
  useEffect(() => {
    const fetchCompanyData = async () => {
      setLoading(true);
      try {
        const response = await fetch('https://api.hroom.ai/webhook/profile/company/get', {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${localStorage.getItem('company_token')}`,
            'Content-Type': 'application/json'
          }
        });
        
        const data = await response.json();
        
        if (data && data[0]) {
          setCompanyData({
            companyId: data[0].companyId || '',
            name: data[0].name || '',
            website: data[0].website || '',
            accountType: data[0].accountType || '',
            expirationDate: data[0].expirationDate || '',
          });
        }
      } catch (err) {
        setError('Ошибка при загрузке данных компании');
        console.error('Error fetching company data:', err);
      } finally {
        setLoading(false);
      }
    };
    
    fetchCompanyData();
  }, []);
  
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setCompanyData(prev => ({
      ...prev,
      [name]: value
    }));
  };
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setSaving(true);
    setError(null);
    setSuccess(false);
    
    try {
      const response = await fetch('https://api.hroom.ai/webhook/profile/company/save', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${localStorage.getItem('manager_token')}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          companyId: companyData.companyId,
          name: companyData.name,
          website: companyData.website
        })
      });
      
      const data = await response.json();
      
      if (data && data[0] && data[0].success) {
        setSuccess(true);
      } else {
        setError('Не удалось сохранить изменения');
      }
    } catch (err) {
      setError('Ошибка при сохранении данных');
      console.error('Error saving company data:', err);
    } finally {
      setSaving(false);
      
      // Скрываем сообщение об успехе через 3 секунды
      if (success) {
        setTimeout(() => {
          setSuccess(false);
        }, 3000);
      }
    }
  };
  
  // Форматирование даты истечения подписки
  const formattedExpirationDate = companyData.expirationDate 
    ? new Date(companyData.expirationDate).toLocaleDateString('ru-RU', {
        day: 'numeric',
        month: 'long',
        year: 'numeric'
      })
    : 'Не указано';
  
  // Определение текста и цвета для статуса аккаунта
  const getAccountTypeInfo = () => {
    switch (companyData.accountType) {
      case 'free':
        return { text: 'Бесплатный', color: 'text-yellow-500' };
      case 'paid':
        return { text: 'Оплаченный', color: 'text-green-500' };
      case 'trial':
        return { text: 'Пробный период', color: 'text-blue-500' };
      default:
        return { text: 'Не определен', color: 'text-gray-500' };
    }
  };
  
  const accountTypeInfo = getAccountTypeInfo();
  
  if (loading) {
    return (
      <div className="flex justify-center items-center p-8">
        <div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
      </div>
    );
  }
  
  return (
    <div>
      <h2 className="text-xl font-medium mb-6">Информация о компании</h2>
      
      <div className="mb-8 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
        <h3 className="text-lg font-medium mb-2">Статус аккаунта</h3>
        <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
          <div>
            <p className="text-sm text-gray-500 dark:text-gray-400">Тип аккаунта</p>
            <p className={`font-medium ${accountTypeInfo.color}`}>{accountTypeInfo.text}</p>
          </div>
          <div>
            <p className="text-sm text-gray-500 dark:text-gray-400">Действует до</p>
            <p className="font-medium">{formattedExpirationDate}</p>
          </div>
        </div>
      </div>
      
      <form onSubmit={handleSubmit} className="space-y-4">
        <Input
          label="Название компании"
          name="name"
          value={companyData.name}
          onChange={handleChange}
          required
        />
        
        <Input
          label="Веб-сайт"
          name="website"
          value={companyData.website}
          onChange={handleChange}
          placeholder="https://example.com"
        />
        
        {error && (
          <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
            {error}
          </div>
        )}
        
        {success && (
          <div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded">
            Данные успешно сохранены
          </div>
        )}
        
        <div className="flex justify-end">
          <Button 
            type="submit" 
            isLoading={saving}
            disabled={saving}
          >
            Сохранить изменения
          </Button>
        </div>
      </form>
    </div>
  );
};

API для работы с информацией о компании

Для работы с информацией о компании используются следующие API-эндпоинты:

  • /profile/company/get - получение текущих данных компании (требуется company_token)
  • /profile/company/save - сохранение обновленных данных компании (требуется manager_token)

Платежная информация (PaymentInfo.tsx)

Раздел "Платежная информация" позволяет HR-менеджеру управлять платежными данными компании, такими как юридический адрес, банковские реквизиты и данные для выставления счетов.

Функциональность компонента PaymentInfo

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

  • Загрузка текущих платежных данных компании из API
  • Предоставление формы для заполнения/редактирования платежной информации
  • Валидация вводимых платежных данных
  • Сохранение обновленных платежных данных через API
Код компонента платежной информации
import React, { useState, useEffect } from 'react';
import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input';
import Textarea from '../../components/ui/Textarea';

const PaymentInfo: React.FC = () => {
  const [loading, setLoading] = useState(true);
  const [saving, setSaving] = useState(false);
  const [success, setSuccess] = useState(false);
  const [error, setError] = useState<string | null>(null);
  
  const [billingInfo, setBillingInfo] = useState({
    companyId: '',
    legalAddress: '',
    actualAddress: '',
    inn: '',
    kpp: '',
    ogrn: '',
    bankAccount: '',
    corrAccount: '',
    bik: '',
    bankName: ''
  });
  
  // Загрузка платежных данных
  useEffect(() => {
    const fetchBillingInfo = async () => {
      setLoading(true);
      try {
        const response = await fetch('https://api.hroom.ai/webhook/profile/company/billing/get', {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${localStorage.getItem('company_token')}`,
            'Content-Type': 'application/json'
          }
        });
        
        const data = await response.json();
        
        if (data && data[0]) {
          const billing = data[0].billing_info || {};
          
          setBillingInfo({
            companyId: data[0].companyId || '',
            legalAddress: billing.legalAddress || '',
            actualAddress: billing.actualAddress || '',
            inn: billing.inn || '',
            kpp: billing.kpp || '',
            ogrn: billing.ogrn || '',
            bankAccount: billing.bankAccount || '',
            corrAccount: billing.corrAccount || '',
            bik: billing.bik || '',
            bankName: billing.bankName || ''
          });
        }
      } catch (err) {
        setError('Ошибка при загрузке платежных данных');
        console.error('Error fetching billing info:', err);
      } finally {
        setLoading(false);
      }
    };
    
    fetchBillingInfo();
  }, []);
  
  const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
    const { name, value } = e.target;
    setBillingInfo(prev => ({
      ...prev,
      [name]: value
    }));
  };
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setSaving(true);
    setError(null);
    setSuccess(false);
    
    try {
      const response = await fetch('https://api.hroom.ai/webhook/profile/company/billing/save', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${localStorage.getItem('company_token')}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          billingInfo: {
            legalAddress: billingInfo.legalAddress,
            actualAddress: billingInfo.actualAddress,
            inn: billingInfo.inn,
            kpp: billingInfo.kpp,
            ogrn: billingInfo.ogrn,
            bankAccount: billingInfo.bankAccount,
            corrAccount: billingInfo.corrAccount,
            bik: billingInfo.bik,
            bankName: billingInfo.bankName
          }
        })
      });
      
      const data = await response.json();
      
      if (data && data[0] && data[0].success) {
        setSuccess(true);
      } else {
        setError('Не удалось сохранить изменения');
      }
    } catch (err) {
      setError('Ошибка при сохранении данных');
      console.error('Error saving billing info:', err);
    } finally {
      setSaving(false);
      
      // Скрываем сообщение об успехе через 3 секунды
      if (success) {
        setTimeout(() => {
          setSuccess(false);
        }, 3000);
      }
    }
  };
  
  if (loading) {
    return (
      <div className="flex justify-center items-center p-8">
        <div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
      </div>
    );
  }
  
  return (
    <div>
      <h2 className="text-xl font-medium mb-6">Платежная информация</h2>
      
      <form onSubmit={handleSubmit} className="space-y-6">
        <div>
          <h3 className="text-lg font-medium mb-4">Юридические данные</h3>
          <div className="space-y-4">
            <Textarea
              label="Юридический адрес"
              name="legalAddress"
              value={billingInfo.legalAddress}
              onChange={handleChange}
              rows={2}
            />
            
            <Textarea
              label="Фактический адрес"
              name="actualAddress"
              value={billingInfo.actualAddress}
              onChange={handleChange}
              rows={2}
            />
            
            <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
              <Input
                label="ИНН"
                name="inn"
                value={billingInfo.inn}
                onChange={handleChange}
              />
              
              <Input
                label="КПП"
                name="kpp"
                value={billingInfo.kpp}
                onChange={handleChange}
              />
              
              <Input
                label="ОГРН"
                name="ogrn"
                value={billingInfo.ogrn}
                onChange={handleChange}
              />
            </div>
          </div>
        </div>
        
        <div>
          <h3 className="text-lg font-medium mb-4">Банковские реквизиты</h3>
          <div className="space-y-4">
            <Input
              label="Наименование банка"
              name="bankName"
              value={billingInfo.bankName}
              onChange={handleChange}
            />
            
            <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
              <Input
                label="Расчетный счет"
                name="bankAccount"
                value={billingInfo.bankAccount}
                onChange={handleChange}
              />
              
              <Input
                label="Корреспондентский счет"
                name="corrAccount"
                value={billingInfo.corrAccount}
                onChange={handleChange}
              />
            </div>
            
            <Input
              label="БИК"
              name="bik"
              value={billingInfo.bik}
              onChange={handleChange}
            />
          </div>
        </div>
        
        {error && (
          <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
            {error}
          </div>
        )}
        
        {success && (
          <div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded">
            Платежная информация успешно сохранена
          </div>
        )}
        
        <div className="flex justify-end">
          <Button 
            type="submit" 
            isLoading={saving}
            disabled={saving}
          >
            Сохранить платежную информацию
          </Button>
        </div>
      </form>
    </div>
  );
};

API для работы с платежной информацией

Для работы с платежной информацией используются следующие API-эндпоинты:

  • /profile/company/billing/get - получение текущих платежных данных компании
  • /profile/company/billing/save - сохранение обновленных платежных данных

Оба запроса требуют аутентификации с использованием company_token.

История платежей (PaymentHistory.tsx)

Раздел "История платежей" предоставляет информацию о выставленных счетах, совершенных платежах и истории транзакций по аккаунту компании.

Функциональность компонента PaymentHistory

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

  • Загрузка истории платежей из API
  • Отображение списка платежей с информацией о дате, сумме, статусе и назначении
  • Фильтрация платежей по периоду или статусу
  • Просмотр деталей платежа
  • Скачивание документов (счетов, актов)
Код компонента истории платежей
import React, { useState, useEffect } from 'react';
import Button from '../../components/ui/Button';
import { Select } from '../../components/ui/Select';
import Pagination from '../../components/ui/Pagination';

// Пример данных для истории платежей
const mockPaymentHistory = [
  {
    id: '1',
    invoiceNumber: 'INV-2024-001',
    date: '2024-03-15T10:00:00.000Z',
    amount: 12000,
    status: 'paid',
    description: 'Оплата подписки на 3 месяца',
    documents: [
      { type: 'invoice', url: '#', name: 'Счет INV-2024-001.pdf' },
      { type: 'act', url: '#', name: 'Акт от 15.03.2024.pdf' },
    ]
  },
  {
    id: '2',
    invoiceNumber: 'INV-2023-012',
    date: '2023-12-20T14:30:00.000Z',
    amount: 24000,
    status: 'paid',
    description: 'Оплата подписки на 6 месяцев',
    documents: [
      { type: 'invoice', url: '#', name: 'Счет INV-2023-012.pdf' },
      { type: 'act', url: '#', name: 'Акт от 20.12.2023.pdf' },
    ]
  },
  {
    id: '3',
    invoiceNumber: 'INV-2024-002',
    date: '2024-03-25T09:15:00.000Z',
    amount: 5000,
    status: 'pending',
    description: 'Дополнительные услуги',
    documents: [
      { type: 'invoice', url: '#', name: 'Счет INV-2024-002.pdf' },
    ]
  }
];

const PaymentHistory: React.FC = () => {
  const [loading, setLoading] = useState(true);
  const [payments, setPayments] = useState([]);
  const [filteredPayments, setFilteredPayments] = useState([]);
  const [currentPage, setCurrentPage] = useState(1);
  const [statusFilter, setStatusFilter] = useState('all');
  const [periodFilter, setPeriodFilter] = useState('all');
  
  const itemsPerPage = 10;
  
  // Загрузка истории платежей
  useEffect(() => {
    // Имитация загрузки данных из API
    const fetchPaymentHistory = async () => {
      setLoading(true);
      try {
        // В реальном приложении здесь был бы запрос к API
        // const response = await fetch('https://api.hroom.ai/webhook/payments/history', {
        //   method: 'GET',
        //   headers: {
        //     'Authorization': `Bearer ${localStorage.getItem('company_token')}`,
        //     'Content-Type': 'application/json'
        //   }
        // });
        // const data = await response.json();
        
        // Используем mock-данные для демонстрации
        setTimeout(() => {
          setPayments(mockPaymentHistory);
          setFilteredPayments(mockPaymentHistory);
          setLoading(false);
        }, 1000);
        
      } catch (err) {
        console.error('Error fetching payment history:', err);
        setLoading(false);
      }
    };
    
    fetchPaymentHistory();
  }, []);
  
  // Применение фильтров
  useEffect(() => {
    let filtered = [...payments];
    
    // Фильтр по статусу
    if (statusFilter !== 'all') {
      filtered = filtered.filter(payment => payment.status === statusFilter);
    }
    
    // Фильтр по периоду
    const now = new Date();
    if (periodFilter === '30days') {
      const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
      filtered = filtered.filter(payment => new Date(payment.date) >= thirtyDaysAgo);
    } else if (periodFilter === '90days') {
      const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
      filtered = filtered.filter(payment => new Date(payment.date) >= ninetyDaysAgo);
    } else if (periodFilter === 'year') {
      const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
      filtered = filtered.filter(payment => new Date(payment.date) >= oneYearAgo);
    }
    
    setFilteredPayments(filtered);
    setCurrentPage(1); // Сбрасываем на первую страницу при изменении фильтров
  }, [statusFilter, periodFilter, payments]);
  
  // Пагинация
  const totalPages = Math.ceil(filteredPayments.length / itemsPerPage);
  const currentPayments = filteredPayments.slice(
    (currentPage - 1) * itemsPerPage,
    currentPage * itemsPerPage
  );
  
  // Форматирование даты
  const formatDate = (dateString) => {
    return new Date(dateString).toLocaleDateString('ru-RU', {
      day: 'numeric',
      month: 'long',
      year: 'numeric'
    });
  };
  
  // Форматирование суммы
  const formatAmount = (amount) => {
    return new Intl.NumberFormat('ru-RU', {
      style: 'currency',
      currency: 'RUB',
      minimumFractionDigits: 0
    }).format(amount);
  };
  
  // Получение статуса платежа
  const getPaymentStatus = (status) => {
    switch (status) {
      case 'paid':
        return { text: 'Оплачен', color: 'bg-green-100 text-green-800' };
      case 'pending':
        return { text: 'Ожидает оплаты', color: 'bg-yellow-100 text-yellow-800' };
      case 'canceled':
        return { text: 'Отменен', color: 'bg-red-100 text-red-800' };
      default:
        return { text: 'Неизвестно', color: 'bg-gray-100 text-gray-800' };
    }
  };
  
  if (loading) {
    return (
      <div className="flex justify-center items-center p-8">
        <div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
      </div>
    );
  }
  
  return (
    <div>
      <h2 className="text-xl font-medium mb-6">История платежей</h2>
      
      <div className="flex flex-col md:flex-row gap-4 mb-6">
        <div className="w-full md:w-1/2">
          <Select
            label="Статус платежа"
            value={statusFilter}
            onChange={(e) => setStatusFilter(e.target.value)}
            options={[
              { value: 'all', label: 'Все статусы' },
              { value: 'paid', label: 'Оплачены' },
              { value: 'pending', label: 'Ожидают оплаты' },
              { value: 'canceled', label: 'Отменены' }
            ]}
          />
        </div>
        
        <div className="w-full md:w-1/2">
          <Select
            label="Период"
            value={periodFilter}
            onChange={(e) => setPeriodFilter(e.target.value)}
            options={[
              { value: 'all', label: 'За все время' },
              { value: '30days', label: 'За последние 30 дней' },
              { value: '90days', label: 'За последние 90 дней' },
              { value: 'year', label: 'За последний год' }
            ]}
          />
        </div>
      </div>
      
      {filteredPayments.length === 0 ? (
        <div className="text-center py-8 bg-gray-50 dark:bg-gray-800 rounded-lg">
          <p className="text-gray-500 dark:text-gray-400">Платежи не найдены</p>
        </div>
      ) : (
        <>
          <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>
                  <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
                    № счета
                  </th>
                  <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
                    Дата
                  </th>
                  <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
                    Описание
                  </th>
                  <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
                    Сумма
                  </th>
                  <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
                    Статус
                  </th>
                  <th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
                    Документы
                  </th>
                </tr>
              </thead>
              <tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-800">
                {currentPayments.map((payment) => {
                  const status = getPaymentStatus(payment.status);
                  
                  return (
                    <tr key={payment.id}>
                      <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
                        {payment.invoiceNumber}
                      </td>
                      <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
                        {formatDate(payment.date)}
                      </td>
                      <td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
                        {payment.description}
                      </td>
                      <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
                        {formatAmount(payment.amount)}
                      </td>
                      <td className="px-6 py-4 whitespace-nowrap">
                        <span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${status.color}`}>
                          {status.text}
                        </span>
                      </td>
                      <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
                        <div className="flex justify-end space-x-2">
                          {payment.documents.map((doc, index) => (
                            <a
                              key={index}
                              href={doc.url}
                              className="text-primary hover:text-primary-light"
                              download={doc.name}
                            >
                              {doc.type === 'invoice' ? 'Счет' : 'Акт'}
                            </a>
                          ))}
                        </div>
                      </td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          </div>
          
          {totalPages > 1 && (
            <div className="mt-4">
              <Pagination
                currentPage={currentPage}
                totalPages={totalPages}
                onPageChange={setCurrentPage}
              />
            </div>
          )}
        </>
      )}
    </div>
  );
};

В текущей реализации компонент PaymentHistory использует мокированные данные для демонстрации функциональности, поскольку в доступных API-эндпоинтах не предусмотрена работа с историей платежей. В реальном приложении этот компонент будет подключен к соответствующему API для получения реальных данных о платежах.

Настройки (Settings.tsx)

Раздел "Настройки" позволяет пользователю настраивать различные параметры приложения, включая уведомления, язык интерфейса и другие пользовательские предпочтения.

Функциональность компонента Settings

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

  • Загрузка текущих настроек пользователя
  • Настройка параметров уведомлений (email, push-уведомления)
  • Выбор языка интерфейса
  • Управление предпочтениями отображения
  • Настройки конфиденциальности
  • Сохранение обновленных настроек
Код компонента настроек
import React, { useState } from 'react';
import Button from '../../components/ui/Button';
import { Select } from '../../components/ui/Select';
import Checkbox from '../../components/ui/Checkbox';

const Settings: React.FC = () => {
  const [saving, setSaving] = useState(false);
  const [success, setSuccess] = useState(false);
  const [error, setError] = useState<string | null>(null);
  
  // Настройки уведомлений
  const [notificationSettings, setNotificationSettings] = useState({
    emailNotifications: true,
    surveyCreated: true,
    surveyCompleted: true,
    newComments: true,
    weeklyReports: true,
    marketingEmails: false
  });
  
  // Настройки языка и отображения
  const [displaySettings, setDisplaySettings] = useState({
    language: 'ru',
    darkMode: false,
    dashboardLayout: 'standard'
  });
  
  const handleNotificationChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, checked } = e.target;
    setNotificationSettings(prev => ({
      ...prev,
      [name]: checked
    }));
  };
  
  const handleDisplayChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
    const { name, value } = e.target;
    setDisplaySettings(prev => ({
      ...prev,
      [name]: value
    }));
  };
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setSaving(true);
    setError(null);
    setSuccess(false);
    
    try {
      // В реальном приложении здесь был бы запрос к API
      // const response = await fetch('https://api.hroom.ai/webhook/settings/save', {
      //   method: 'POST',
      //   headers: {
      //     'Authorization': `Bearer ${localStorage.getItem('manager_token')}`,
      //     'Content-Type': 'application/json'
      //   },
      //   body: JSON.stringify({
      //     settings: {
      //       notifications: notificationSettings,
      //       display: displaySettings
      //     }
      //   })
      // });
      // const data = await response.json();
      
      // Имитируем успешное сохранение
      await new Promise(resolve => setTimeout(resolve, 1000));
      setSuccess(true);
      
      // Если настройка темного режима изменилась, применяем её
      const htmlElement = document.documentElement;
      if (displaySettings.darkMode) {
        htmlElement.setAttribute('data-theme', 'dark');
        htmlElement.classList.add('dark');
      } else {
        htmlElement.setAttribute('data-theme', 'light');
        htmlElement.classList.remove('dark');
      }
      
      // Сохраняем настройку темы в localStorage
      localStorage.setItem('theme', displaySettings.darkMode ? 'dark' : 'light');
      
    } catch (err) {
      setError('Ошибка при сохранении настроек');
      console.error('Error saving settings:', err);
    } finally {
      setSaving(false);
      
      // Скрываем сообщение об успехе через 3 секунды
      if (success) {
        setTimeout(() => {
          setSuccess(false);
        }, 3000);
      }
    }
  };
  
  // Опции для выпадающих списков
  const languageOptions = [
    { value: 'ru', label: 'Русский' },
    { value: 'en', label: 'English' }
  ];
  
  const layoutOptions = [
    { value: 'standard', label: 'Стандартный' },
    { value: 'compact', label: 'Компактный' },
    { value: 'expanded', label: 'Расширенный' }
  ];
  
  return (
    <div>
      <h2 className="text-xl font-medium mb-6">Настройки</h2>
      
      <form onSubmit={handleSubmit} className="space-y-8">
        <div>
          <h3 className="text-lg font-medium mb-4">Уведомления</h3>
          <div className="space-y-4">
            <Checkbox
              label="Получать уведомления по email"
              name="emailNotifications"
              checked={notificationSettings.emailNotifications}
              onChange={handleNotificationChange}
            />
            
            <div className="ml-6 space-y-3">
              <Checkbox
                label="Уведомления о создании опроса"
                name="surveyCreated"
                checked={notificationSettings.surveyCreated}
                onChange={handleNotificationChange}
                disabled={!notificationSettings.emailNotifications}
              />
              
              <Checkbox
                label="Уведомления о завершении опроса"
                name="surveyCompleted"
                checked={notificationSettings.surveyCompleted}
                onChange={handleNotificationChange}
                disabled={!notificationSettings.emailNotifications}
              />
              
              <Checkbox
                label="Уведомления о новых комментариях"
                name="newComments"
                checked={notificationSettings.newComments}
                onChange={handleNotificationChange}
                disabled={!notificationSettings.emailNotifications}
              />
              
              <Checkbox
                label="Еженедельные отчеты"
                name="weeklyReports"
                checked={notificationSettings.weeklyReports}
                onChange={handleNotificationChange}
                disabled={!notificationSettings.emailNotifications}
              />
            </div>
            
            <Checkbox
              label="Получать маркетинговые рассылки"
              name="marketingEmails"
              checked={notificationSettings.marketingEmails}
              onChange={handleNotificationChange}
            />
          </div>
        </div>
        
        <div>
          <h3 className="text-lg font-medium mb-4">Язык и отображение</h3>
          <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
            <Select
              label="Язык интерфейса"
              name="language"
              value={displaySettings.language}
              onChange={handleDisplayChange}
              options={languageOptions}
            />
            
            <Select
              label="Макет дашборда"
              name="dashboardLayout"
              value={displaySettings.dashboardLayout}
              onChange={handleDisplayChange}
              options={layoutOptions}
            />
          </div>
          
          <div className="mt-4">
            <Checkbox
              label="Использовать темную тему"
              name="darkMode"
              checked={displaySettings.darkMode}
              onChange={(e) => setDisplaySettings(prev => ({
                ...prev,
                darkMode: e.target.checked
              }))}
            />
          </div>
        </div>
        
        {error && (
          <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
            {error}
          </div>
        )}
        
        {success && (
          <div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded">
            Настройки успешно сохранены
          </div>
        )}
        
        <div className="flex justify-end">
          <Button 
            type="submit" 
            isLoading={saving}
            disabled={saving}
          >
            Сохранить настройки
          </Button>
        </div>
      </form>
    </div>
  );
};

Компонент Settings реализует пользовательские настройки, которые сохраняются в localStorage браузера. В реальном приложении эти настройки также могут сохраняться на сервере через соответствующий API-эндпоинт.

Компонент загрузки аватара (AvatarUpload)

Компонент AvatarUpload предоставляет возможность загрузки и обновления аватара пользователя. Он используется в разделе личной информации для визуальной персонализации профиля.

Функциональность компонента AvatarUpload

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

  • Отображение текущего аватара пользователя
  • Загрузка нового изображения с устройства пользователя
  • Валидация типа и размера файла изображения
  • Отправка изображения на сервер с помощью API
  • Обработка успешной загрузки или ошибок
  • Обновление отображаемого аватара после загрузки
Код компонента загрузки аватара
import React, { useState, useRef } from 'react';
import Button from './ui/Button';

interface AvatarUploadProps {
  currentUrl?: string;
  onUploadSuccess: (url: string) => void;
}

const AvatarUpload: React.FC<AvatarUploadProps> = ({
  currentUrl,
  onUploadSuccess
}) => {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);
  
  // Обработчик клика по кнопке загрузки
  const handleUploadClick = () => {
    if (fileInputRef.current) {
      fileInputRef.current.click();
    }
  };
  
  // Обработчик загрузки файла
  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;
    
    // Валидация типа файла
    const validTypes = ['image/jpeg', 'image/png', 'image/gif'];
    if (!validTypes.includes(file.type)) {
      setError('Пожалуйста, загрузите изображение в формате JPEG, PNG или GIF');
      return;
    }
    
    // Валидация размера файла (не более 5 МБ)
    if (file.size > 5 * 1024 * 1024) {
      setError('Размер файла не должен превышать 5 МБ');
      return;
    }
    
    setLoading(true);
    setError(null);
    
    try {
      // Создаем FormData для отправки файла
      const formData = new FormData();
      formData.append('file', file);
      
      // Отправляем файл на сервер
      const response = await fetch('https://api.hroom.ai/webhook/profile/file/upload', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${localStorage.getItem('manager_token')}`
        },
        body: formData
      });
      
      const data = await response.json();
      
      if (data && data.success && data.url) {
        onUploadSuccess(data.url);
      } else {
        setError('Не удалось загрузить изображение');
      }
    } catch (err) {
      setError('Ошибка при загрузке изображения');
      console.error('Error uploading avatar:', err);
    } finally {
      setLoading(false);
      
      // Сбрасываем input, чтобы можно было загрузить тот же файл еще раз
      if (fileInputRef.current) {
        fileInputRef.current.value = '';
      }
    }
  };
  
  // Базовый аватар, если URL не предоставлен
  const getInitials = () => {
    return 'HR';
  };
  
  return (
    <div className="flex flex-col items-center">
      <div className="mb-4 relative">
        {currentUrl ? (
          <img 
            src={currentUrl} 
            alt="Avatar" 
            className="w-32 h-32 rounded-full object-cover border-2 border-primary"
          />
        ) : (
          <div className="w-32 h-32 rounded-full bg-primary-light flex items-center justify-center text-white text-2xl font-semibold">
            {getInitials()}
          </div>
        )}
        
        {loading && (
          <div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 rounded-full">
            <div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-white"></div>
          </div>
        )}
      </div>
      
      <input
        type="file"
        ref={fileInputRef}
        onChange={handleFileChange}
        accept="image/jpeg,image/png,image/gif"
        className="hidden"
      />
      
      <Button 
        onClick={handleUploadClick}
        disabled={loading}
        variant="outline"
        size="sm"
      >
        {currentUrl ? 'Изменить фото' : 'Загрузить фото'}
      </Button>
      
      {error && (
        <p className="mt-2 text-sm text-red-600">{error}</p>
      )}
      
      <p className="mt-2 text-xs text-gray-500 dark:text-gray-400 text-center">
        Загружайте изображения в формате JPG, PNG или GIF размером не более 5 МБ
      </p>
    </div>
  );
};

export default AvatarUpload;

API для загрузки аватара

Для загрузки аватара используется API-эндпоинт /profile/file/upload, который принимает файл изображения в формате multipart/form-data и возвращает URL загруженного аватара. Запрос требует аутентификации с использованием manager_token.

Интеграция модуля профиля с другими компонентами

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

Интеграция с компонентом UserProfile

Компонент UserProfile используется в различных частях приложения для отображения краткой информации о текущем пользователе, включая его имя, фото и роль. Данные для этого компонента берутся из тех же API-эндпоинтов, что и для модуля профиля.

Интеграция с системой аутентификации

Модуль профиля тесно интегрирован с системой аутентификации HRoom. Каждый запрос к API, используемый в модуле профиля, требует аутентификации с помощью токена (manager_token или company_token), который хранится в localStorage после успешного входа пользователя в систему.

Интеграция с модулем администрирования

Для пользователей с административными правами модуль профиля предоставляет дополнительные возможности, такие как управление платежной информацией компании и другие административные функции.

Схема API-запросов в модуле профиля

Ниже представлена схема API-запросов, используемых в модуле профиля пользователя:

Эндпоинт Метод Описание Токен Тело запроса
/profile/personal/get POST Получение личных данных manager_token -
/profile/personal/save POST Сохранение личных данных manager_token managerId, firstName, lastName, middleName, phone
/profile/company/get POST Получение данных компании company_token -
/profile/company/save POST Сохранение данных компании manager_token companyId, name, website
/profile/company/billing/get POST Получение платежных данных company_token -
/profile/company/billing/save POST Сохранение платежных данных company_token billingInfo: { legalAddress, actualAddress, inn, kpp, ogrn, bankAccount, corrAccount, bik, bankName }
/profile/file/upload POST Загрузка аватара manager_token file (formData)

Заключение

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

Основные преимущества модуля профиля:

  • Интуитивный интерфейс с разделением на тематические вкладки
  • Гибкая система управления персональными данными и настройками
  • Возможность загрузки аватара для персонализации аккаунта
  • Управление платежной информацией компании
  • Настройка уведомлений и предпочтений интерфейса

Компоненты модуля профиля реализованы с использованием React и TypeScript, что обеспечивает типобезопасность и простоту поддержки кода. Модуль взаимодействует с API с помощью Fetch API, используя токены аутентификации (manager_token и company_token) для защиты запросов.