Files
mkd/components/ProfileSettingsModal.tsx

534 lines
24 KiB
TypeScript
Raw Normal View History

2026-02-04 00:17:04 +05:00
import React, { useState, useEffect, useRef } from 'react';
import { X, User, Mail, Phone, Calendar, Lock, Settings, Image as ImageIcon, Loader2, MessageCircle } from 'lucide-react';
import { User as UserType, UserRole } from '../types';
import { backendApi } from '../services/apiClient';
const ROLE_NAMES: Record<UserRole, string> = {
DIRECTOR: 'Директор',
ENGINEER: 'Гл. Инженер',
MASTER: 'Мастер',
LAWYER: 'Юрист',
FINANCIER: 'Финансист',
HR_MANAGER: 'HR-менеджер',
PR_MANAGER: 'PR-менеджер'
};
const DEFAULT_AVATAR = 'https://api.dicebear.com/7.x/avataaars/svg?seed=default';
type TabId = 'profile' | 'photo' | 'security' | 'preferences' | 'messengers';
interface ProfileSettingsModalProps {
user: UserType;
onClose: () => void;
onSave: (user: UserType) => void;
}
export const ProfileSettingsModal: React.FC<ProfileSettingsModalProps> = ({ user, onClose, onSave }) => {
const [activeTab, setActiveTab] = useState<TabId>('profile');
const [form, setForm] = useState({
name: user.name || '',
email: user.email || '',
phone: user.phone || '',
birthDate: user.birthDate || '',
language: user.language || 'ru',
theme: user.theme || 'light',
notificationEmail: user.notificationEmail !== false,
notificationPush: user.notificationPush !== false,
});
const [passwordForm, setPasswordForm] = useState({
oldPassword: '',
newPassword: '',
confirmPassword: '',
});
const [messengers, setMessengers] = useState<Array<{ messenger: 'Max' | 'Telegram'; login: string }>>(
user.messengerLogins || []
);
const [newMessenger, setNewMessenger] = useState<{ messenger: 'Max' | 'Telegram'; login: string }>({
messenger: 'Telegram',
login: ''
});
const [photoFile, setPhotoFile] = useState<File | null>(null);
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isUploadingPhoto, setIsUploadingPhoto] = useState(false);
const [isChangingPassword, setIsChangingPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const getAvatarUrl = () => {
if (photoPreview) return photoPreview;
if (!user.avatar) return DEFAULT_AVATAR;
if (user.avatar.startsWith('http')) return user.avatar;
const base = (import.meta.env.VITE_API_BASE_URL || '').replace(/\/api\/?$/, '') || window.location.origin;
return `${base}${user.avatar.startsWith('/') ? '' : '/'}${user.avatar}`;
};
const avatarUrl = getAvatarUrl();
useEffect(() => {
setForm({
name: user.name || '',
email: user.email || '',
phone: user.phone || '',
birthDate: user.birthDate || '',
language: user.language || 'ru',
theme: user.theme || 'light',
notificationEmail: user.notificationEmail !== false,
notificationPush: user.notificationPush !== false,
});
setMessengers(user.messengerLogins || []);
}, [user]);
const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [
{ id: 'profile', label: 'Основная информация', icon: <User className="w-4 h-4" /> },
{ id: 'photo', label: 'Фото профиля', icon: <ImageIcon className="w-4 h-4" /> },
{ id: 'security', label: 'Безопасность', icon: <Lock className="w-4 h-4" /> },
{ id: 'preferences', label: 'Настройки', icon: <Settings className="w-4 h-4" /> },
{ id: 'messengers', label: 'Мессенджеры', icon: <MessageCircle className="w-4 h-4" /> },
];
const handleSaveProfile = async () => {
try {
setIsSaving(true);
setError(null);
const updated = await backendApi.updateProfile({
name: form.name.trim(),
email: form.email.trim() || undefined,
phone: form.phone.trim() || undefined,
birthDate: form.birthDate || undefined,
});
onSave(updated);
setSuccess('Профиль сохранён');
setTimeout(() => setSuccess(null), 3000);
} catch (err: any) {
setError(err.message || 'Ошибка сохранения');
} finally {
setIsSaving(false);
}
};
const handleSavePreferences = async () => {
try {
setIsSaving(true);
setError(null);
await backendApi.updatePreferences({
language: form.language,
theme: form.theme,
notificationEmail: form.notificationEmail,
notificationPush: form.notificationPush,
});
const updated = await backendApi.getMe();
onSave(updated);
setSuccess('Настройки сохранены');
setTimeout(() => setSuccess(null), 3000);
} catch (err: any) {
setError(err.message || 'Ошибка сохранения');
} finally {
setIsSaving(false);
}
};
const handlePhotoSelect = (file: File | null) => {
if (!file) {
setPhotoFile(null);
setPhotoPreview(null);
return;
}
if (!file.type.startsWith('image/')) {
setError('Выберите изображение (JPG, PNG, GIF, WEBP)');
return;
}
if (file.size > 5 * 1024 * 1024) {
setError('Размер файла не должен превышать 5MB');
return;
}
setError(null);
setPhotoFile(file);
const reader = new FileReader();
reader.onloadend = () => setPhotoPreview(reader.result as string);
reader.readAsDataURL(file);
};
const handlePhotoUpload = async () => {
if (!photoFile) return;
try {
setIsUploadingPhoto(true);
setError(null);
const { photoUrl } = await backendApi.uploadProfilePhoto(photoFile);
const updated = await backendApi.getMe();
onSave({ ...updated, avatar: photoUrl });
setPhotoFile(null);
setPhotoPreview(null);
setSuccess('Фото загружено');
setTimeout(() => setSuccess(null), 3000);
} catch (err: any) {
setError(err.message || 'Ошибка загрузки фото');
} finally {
setIsUploadingPhoto(false);
}
};
const handleDeletePhoto = async () => {
try {
setIsUploadingPhoto(true);
setError(null);
await backendApi.deleteProfilePhoto();
const updated = await backendApi.getMe();
onSave(updated);
setPhotoFile(null);
setPhotoPreview(null);
setSuccess('Фото удалено');
setTimeout(() => setSuccess(null), 3000);
} catch (err: any) {
setError(err.message || 'Ошибка удаления');
} finally {
setIsUploadingPhoto(false);
}
};
const handleChangePassword = async () => {
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
setError('Пароли не совпадают');
return;
}
if (passwordForm.newPassword.length < 8) {
setError('Пароль должен содержать не менее 8 символов');
return;
}
if (!/^(?=.*[a-zA-Z])(?=.*\d)/.test(passwordForm.newPassword)) {
setError('Пароль должен содержать буквы и цифры');
return;
}
try {
setIsChangingPassword(true);
setError(null);
await backendApi.changePassword(passwordForm.oldPassword, passwordForm.newPassword);
setPasswordForm({ oldPassword: '', newPassword: '', confirmPassword: '' });
setSuccess('Пароль успешно изменён');
setTimeout(() => setSuccess(null), 3000);
} catch (err: any) {
setError(err.message || 'Ошибка смены пароля');
} finally {
setIsChangingPassword(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files?.[0];
if (file) handlePhotoSelect(file);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = () => setIsDragging(false);
const addMessenger = () => {
if (!newMessenger.login.trim()) return;
if (messengers.some(m => m.messenger === newMessenger.messenger)) {
setError('Этот мессенджер уже добавлен');
return;
}
setMessengers([...messengers, { ...newMessenger }]);
setNewMessenger({ messenger: 'Telegram', login: '' });
};
const removeMessenger = (idx: number) => {
setMessengers(messengers.filter((_, i) => i !== idx));
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/80 backdrop-blur-md animate-fade-in" onClick={onClose}>
<div className="bg-white rounded-[2.5rem] w-full max-w-2xl max-h-[90vh] overflow-hidden shadow-2xl animate-slide-up flex flex-col" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b border-slate-200 flex-shrink-0">
<div className="flex justify-between items-start">
<div>
<h3 className="text-2xl font-black text-slate-900">Профиль и настройки</h3>
<p className="text-xs text-slate-500 mt-1">Управление вашим аккаунтом</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-full">
<X className="w-6 h-6 text-slate-400" />
</button>
</div>
<div className="flex gap-1 mt-4 overflow-x-auto no-scrollbar">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-colors whitespace-nowrap ${
activeTab === tab.id ? 'bg-primary-100 text-primary-700' : 'text-slate-600 hover:bg-slate-100'
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">
{error}
</div>
)}
{success && (
<div className="p-3 bg-green-50 border border-green-200 rounded-xl text-sm text-green-700">
{success}
</div>
)}
{activeTab === 'profile' && (
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-700 mb-1">ФИО</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Иванов Иван Иванович"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-700 mb-1">Email</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm(f => ({ ...f, email: e.target.value }))}
className="w-full px-3 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="email@example.com"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-700 mb-1">Телефон</label>
<input
type="tel"
value={form.phone}
onChange={(e) => setForm(f => ({ ...f, phone: e.target.value }))}
className="w-full px-3 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="+7 (999) 123-45-67"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-700 mb-1">Дата рождения</label>
<input
type="date"
value={form.birthDate}
onChange={(e) => setForm(f => ({ ...f, birthDate: e.target.value }))}
className="w-full px-3 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<p className="text-xs text-slate-500">Роль: {ROLE_NAMES[user.role]}</p>
<button
onClick={handleSaveProfile}
disabled={isSaving}
className="w-full px-4 py-2.5 bg-primary-600 hover:bg-primary-700 disabled:bg-slate-400 text-white font-bold text-sm rounded-xl transition-colors flex items-center justify-center gap-2"
>
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
Сохранить
</button>
</div>
)}
{activeTab === 'photo' && (
<div className="space-y-4">
<div className="flex flex-col items-center gap-4">
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={`w-32 h-32 rounded-full border-2 border-dashed flex items-center justify-center overflow-hidden transition-colors ${
isDragging ? 'border-primary-500 bg-primary-50' : 'border-slate-200 bg-slate-50'
}`}
>
<img
src={avatarUrl}
alt=""
className="w-full h-full object-cover"
/>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="hidden"
onChange={(e) => handlePhotoSelect(e.target.files?.[0] || null)}
/>
<div className="flex gap-3">
<button
onClick={() => fileInputRef.current?.click()}
className="px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 font-medium text-sm rounded-xl"
>
Выбрать файл
</button>
{photoFile && (
<button
onClick={handlePhotoUpload}
disabled={isUploadingPhoto}
className="px-4 py-2 bg-primary-600 hover:bg-primary-700 disabled:bg-slate-400 text-white font-medium text-sm rounded-xl flex items-center gap-2"
>
{isUploadingPhoto ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
Загрузить
</button>
)}
{(user.avatar || photoPreview) && (
<button
onClick={handleDeletePhoto}
disabled={isUploadingPhoto}
className="px-4 py-2 bg-red-50 hover:bg-red-100 text-red-600 font-medium text-sm rounded-xl"
>
Удалить
</button>
)}
</div>
<p className="text-xs text-slate-500">JPG, PNG, GIF, WEBP до 5MB</p>
</div>
</div>
)}
{activeTab === 'security' && (
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-700 mb-1">Текущий пароль</label>
<input
type="password"
value={passwordForm.oldPassword}
onChange={(e) => setPasswordForm(f => ({ ...f, oldPassword: e.target.value }))}
className="w-full px-3 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="••••••••"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-700 mb-1">Новый пароль</label>
<input
type="password"
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm(f => ({ ...f, newPassword: e.target.value }))}
className="w-full px-3 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Минимум 8 символов, буквы и цифры"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-700 mb-1">Подтвердите новый пароль</label>
<input
type="password"
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm(f => ({ ...f, confirmPassword: e.target.value }))}
className="w-full px-3 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="••••••••"
/>
</div>
<button
onClick={handleChangePassword}
disabled={isChangingPassword || !passwordForm.oldPassword || !passwordForm.newPassword}
className="w-full px-4 py-2.5 bg-primary-600 hover:bg-primary-700 disabled:bg-slate-400 text-white font-bold text-sm rounded-xl transition-colors flex items-center justify-center gap-2"
>
{isChangingPassword ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
Сменить пароль
</button>
</div>
)}
{activeTab === 'preferences' && (
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-700 mb-1">Язык</label>
<select
value={form.language}
onChange={(e) => setForm(f => ({ ...f, language: e.target.value }))}
className="w-full px-3 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="ru">Русский</option>
<option value="en">English</option>
</select>
</div>
<div>
<label className="block text-xs font-bold text-slate-700 mb-1">Тема</label>
<select
value={form.theme}
onChange={(e) => setForm(f => ({ ...f, theme: e.target.value }))}
className="w-full px-3 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="light">Светлая</option>
<option value="dark">Тёмная</option>
<option value="system">Системная</option>
</select>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm font-medium text-slate-700">Email уведомления</span>
<button
onClick={() => setForm(f => ({ ...f, notificationEmail: !f.notificationEmail }))}
className={`w-12 h-6 rounded-full transition-colors ${form.notificationEmail ? 'bg-primary-600' : 'bg-slate-200'}`}
>
<span className={`block w-5 h-5 bg-white rounded-full shadow transition-transform ${form.notificationEmail ? 'translate-x-6' : 'translate-x-0.5'}`} />
</button>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm font-medium text-slate-700">Push уведомления</span>
<button
onClick={() => setForm(f => ({ ...f, notificationPush: !f.notificationPush }))}
className={`w-12 h-6 rounded-full transition-colors ${form.notificationPush ? 'bg-primary-600' : 'bg-slate-200'}`}
>
<span className={`block w-5 h-5 bg-white rounded-full shadow transition-transform ${form.notificationPush ? 'translate-x-6' : 'translate-x-0.5'}`} />
</button>
</div>
<button
onClick={handleSavePreferences}
disabled={isSaving}
className="w-full px-4 py-2.5 bg-primary-600 hover:bg-primary-700 disabled:bg-slate-400 text-white font-bold text-sm rounded-xl transition-colors flex items-center justify-center gap-2"
>
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
Сохранить настройки
</button>
</div>
)}
{activeTab === 'messengers' && (
<div className="space-y-4">
<p className="text-sm text-slate-600">Логины мессенджеров для связи (информационно)</p>
{messengers.map((m, idx) => (
<div key={idx} className="flex items-center gap-3 p-3 bg-slate-50 rounded-xl">
<span className="text-sm font-medium text-slate-700">{m.messenger}:</span>
<span className="flex-1 text-sm text-slate-600">{m.login}</span>
<button onClick={() => removeMessenger(idx)} className="text-red-600 hover:text-red-700 text-sm">
Удалить
</button>
</div>
))}
<div className="flex gap-2">
<select
value={newMessenger.messenger}
onChange={(e) => setNewMessenger(m => ({ ...m, messenger: e.target.value as 'Max' | 'Telegram' }))}
className="px-3 py-2 border border-slate-200 rounded-xl text-sm"
>
<option value="Telegram">Telegram</option>
<option value="Max">Max</option>
</select>
<input
type="text"
value={newMessenger.login}
onChange={(e) => setNewMessenger(m => ({ ...m, login: e.target.value }))}
placeholder="@username"
className="flex-1 px-3 py-2 border border-slate-200 rounded-xl text-sm"
/>
<button onClick={addMessenger} className="px-4 py-2 bg-primary-600 text-white font-medium text-sm rounded-xl">
Добавить
</button>
</div>
<p className="text-xs text-slate-500">Мессенджеры пока только отображаются. Сохранение в профиль будет добавлено.</p>
</div>
)}
</div>
</div>
</div>
);
};