Files
mkd/components/ProfileSettingsModal.tsx
2026-02-04 00:17:04 +05:00

534 lines
24 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};