Files
mkd/components/hr/EmployeeRegistry.tsx

414 lines
22 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { MOCK_DISTRICTS } from '../../constants';
import { Employee, User, District } from '../../types';
import { Search, Filter, Phone, MessageCircle, MoreVertical, MapPin, Briefcase, Mail, User as UserIcon, Calendar, FileText, Eye, X, Plus } from 'lucide-react';
import { EmployeeCardModal } from './EmployeeCardModal';
import { backendApi, authFetch } from '../../services/apiClient';
import { readCache, saveCache } from '../../hooks/useCachedFetch';
import { REFRESH_EVENTS } from '../../constants/refreshEvents';
interface EmployeeRegistryProps {
onEmployeeCreated?: () => void;
refreshTrigger?: string;
currentUser?: User; // Текущий пользователь для проверки прав доступа
onOpenCreateModal?: () => void; // Функция для открытия модального окна создания сотрудника
}
export const EmployeeRegistry: React.FC<EmployeeRegistryProps> = ({ onEmployeeCreated, refreshTrigger, currentUser, onOpenCreateModal }) => {
const CACHE_EMP = 'mkd_hr_employee_registry';
const CACHE_DIST = 'mkd_hr_districts';
const cachedEmp = readCache<Employee[]>(CACHE_EMP, []);
const cachedDist = readCache<District[]>(CACHE_DIST, []);
const [search, setSearch] = useState('');
const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(null);
const [employees, setEmployees] = useState<Employee[]>(cachedEmp);
const [districts, setDistricts] = useState<District[]>(cachedDist);
const [loading, setLoading] = useState(cachedEmp.length === 0);
// Фильтры
const [showFilters, setShowFilters] = useState(false);
const [statusFilter, setStatusFilter] = useState<('active' | 'vacation' | 'inactive')[]>(['active', 'vacation']); // По умолчанию скрываем неактивных
const [districtFilter, setDistrictFilter] = useState<string | null>(null);
const filtersRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetchEmployees();
fetchDistricts();
}, [refreshTrigger]);
useEffect(() => {
const onRefresh = () => { fetchEmployees(); fetchDistricts(); };
window.addEventListener(REFRESH_EVENTS.employees, onRefresh);
return () => window.removeEventListener(REFRESH_EVENTS.employees, onRefresh);
}, []);
useEffect(() => {
const interval = setInterval(() => { fetchEmployees(false); fetchDistricts(); }, 10 * 1000);
return () => clearInterval(interval);
}, []);
// Закрытие панели фильтров при клике вне её
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (filtersRef.current && !filtersRef.current.contains(event.target as Node)) {
setShowFilters(false);
}
};
if (showFilters) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showFilters]);
const fetchEmployees = async (showSpinner = true) => {
try {
if (showSpinner && cachedEmp.length === 0) setLoading(true);
const response = await authFetch('/api/employees');
if (response.ok) {
const data = await response.json();
setEmployees(data);
saveCache(CACHE_EMP, data);
} else setEmployees([]);
} catch (error) {
console.error('Error fetching employees:', error);
setEmployees([]);
} finally {
setLoading(false);
}
};
const fetchDistricts = async () => {
try {
const data = await backendApi.getDistricts();
setDistricts(data);
saveCache(CACHE_DIST, data);
} catch (error) {
console.warn('Failed to fetch districts from API, using MOCK_DISTRICTS:', error);
setDistricts(MOCK_DISTRICTS);
}
};
const handleEmployeeUpdate = async (updatedEmployee: Employee) => {
try {
const response = await authFetch(`/api/employees/${updatedEmployee.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedEmployee)
});
if (response.ok) {
window.dispatchEvent(new CustomEvent('mkd-employees-changed'));
setEmployees(employees.map(emp =>
emp.id === updatedEmployee.id ? updatedEmployee : emp
));
setSelectedEmployee(updatedEmployee);
} else {
alert('Ошибка при обновлении сотрудника');
}
} catch (error) {
console.error('Error updating employee:', error);
alert('Ошибка при обновлении сотрудника');
}
};
const filtered = employees.filter(e => {
// Поиск по имени и должности
const matchesSearch = e.name.toLowerCase().includes(search.toLowerCase()) ||
e.position.toLowerCase().includes(search.toLowerCase());
// Фильтр по статусу
const matchesStatus = statusFilter.includes(e.status);
// Фильтр по участку
const matchesDistrict = !districtFilter || e.assignedDistrictId === districtFilter;
return matchesSearch && matchesStatus && matchesDistrict;
});
const getDistrictName = (districtId: string) => {
const district = districts.find(d => d.id === districtId) || MOCK_DISTRICTS.find(d => d.id === districtId);
return district?.name || 'Не указан';
};
const toggleStatusFilter = (status: 'active' | 'vacation' | 'inactive') => {
setStatusFilter(prev =>
prev.includes(status)
? prev.filter(s => s !== status)
: [...prev, status]
);
};
const getActiveFiltersCount = () => {
let count = 0;
if (statusFilter.length !== 3) count++; // Если не все статусы выбраны
if (districtFilter) count++;
return count;
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="text-slate-400">Загрузка сотрудников...</div>
</div>
);
}
return (
<div className="space-y-4 animate-fade-in">
{selectedEmployee && (
<EmployeeCardModal
employee={selectedEmployee}
onClose={() => setSelectedEmployee(null)}
onUpdate={handleEmployeeUpdate}
currentUser={currentUser}
/>
)}
<div className="flex gap-4 items-center">
<div className="relative flex-1">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Поиск по ФИО, должности, участку..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500 shadow-sm"
/>
</div>
{onOpenCreateModal && (
<button
className="bg-primary-600 text-white p-2.5 rounded-xl shadow-lg shadow-primary-500/30 flex items-center gap-2 px-4 text-xs font-black uppercase tracking-wider active:scale-95 transition-all whitespace-nowrap"
onClick={onOpenCreateModal}
>
<Plus className="w-4 h-4" /> Добавить сотрудника
</button>
)}
<div className="relative" ref={filtersRef}>
<button
onClick={() => setShowFilters(!showFilters)}
className={`p-3 bg-white border border-slate-200 rounded-2xl text-slate-500 hover:bg-slate-50 transition-all shadow-sm relative ${showFilters ? 'bg-primary-50 border-primary-300 text-primary-600' : ''}`}
>
<Filter className="w-5 h-5"/>
{getActiveFiltersCount() > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-primary-600 text-white text-[10px] font-black rounded-full flex items-center justify-center">
{getActiveFiltersCount()}
</span>
)}
</button>
{showFilters && (
<div className="absolute right-0 top-full mt-2 w-80 bg-white border border-slate-200 rounded-2xl shadow-lg z-50 p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-black text-slate-800 text-sm">Фильтры</h3>
<button
onClick={() => setShowFilters(false)}
className="p-1 text-slate-400 hover:text-slate-600"
>
<X className="w-4 h-4"/>
</button>
</div>
{/* Фильтр по статусу */}
<div className="mb-4">
<label className="text-[10px] font-black text-slate-500 uppercase tracking-wider mb-2 block">
Статус
</label>
<div className="space-y-2">
{[
{ value: 'active' as const, label: 'В строю', color: 'emerald' },
{ value: 'vacation' as const, label: 'В отпуске', color: 'amber' },
{ value: 'inactive' as const, label: 'Неактивен (уволен)', color: 'red' }
].map(status => (
<label
key={status.value}
className="flex items-center gap-2 cursor-pointer p-2 rounded-xl hover:bg-slate-50"
>
<input
type="checkbox"
checked={statusFilter.includes(status.value)}
onChange={() => toggleStatusFilter(status.value)}
className="w-4 h-4 rounded border-slate-300 text-primary-600 focus:ring-primary-500"
/>
<span className={`text-xs font-bold ${statusFilter.includes(status.value) ? 'text-slate-800' : 'text-slate-400'}`}>
{status.label}
</span>
</label>
))}
</div>
</div>
{/* Фильтр по участку */}
<div>
<label className="text-[10px] font-black text-slate-500 uppercase tracking-wider mb-2 block">
Участок
</label>
<select
value={districtFilter || ''}
onChange={(e) => setDistrictFilter(e.target.value || null)}
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-300"
>
<option value="">Все участки</option>
{districts.map(district => (
<option key={district.id} value={district.id}>
{district.name}
</option>
))}
</select>
</div>
{/* Кнопка сброса фильтров */}
{(statusFilter.length !== 3 || districtFilter) && (
<button
onClick={() => {
setStatusFilter(['active', 'vacation']);
setDistrictFilter(null);
}}
className="mt-4 w-full py-2 text-xs font-black text-slate-500 hover:text-slate-700 uppercase tracking-wider"
>
Сбросить фильтры
</button>
)}
</div>
)}
</div>
</div>
{/* Активные фильтры (чипсы) */}
{(statusFilter.length !== 3 || districtFilter) && (
<div className="flex flex-wrap gap-2">
{statusFilter.length !== 3 && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-primary-50 text-primary-700 rounded-full text-[10px] font-black uppercase">
<span>
{statusFilter.length === 1
? statusFilter[0] === 'active' ? 'В строю'
: statusFilter[0] === 'vacation' ? 'В отпуске'
: 'Неактивен'
: `${statusFilter.length} статуса`
}
</span>
<button
onClick={() => setStatusFilter(['active', 'vacation', 'inactive'])}
className="hover:text-primary-900"
>
<X className="w-3 h-3"/>
</button>
</div>
)}
{districtFilter && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-primary-50 text-primary-700 rounded-full text-[10px] font-black uppercase">
<span>{getDistrictName(districtFilter)}</span>
<button
onClick={() => setDistrictFilter(null)}
className="hover:text-primary-900"
>
<X className="w-3 h-3"/>
</button>
</div>
)}
</div>
)}
{filtered.length === 0 ? (
<div className="text-center py-20 text-slate-400">
<p>Сотрудники не найдены</p>
{search && <p className="text-sm mt-2">Попробуйте изменить поисковый запрос</p>}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{filtered.map(emp => (
<div
key={emp.id}
className="bg-white p-5 rounded-[2rem] border border-slate-200 shadow-sm hover:shadow-md hover:border-primary-300 transition-all group cursor-pointer"
onClick={() => setSelectedEmployee(emp)}
>
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-4">
{emp.photoUrl ? (
<img
src={emp.photoUrl.startsWith('http')
? emp.photoUrl
: `${import.meta.env.VITE_API_BASE_URL?.replace('/api', '') || 'http://localhost:4000'}${emp.photoUrl}`}
alt={emp.name}
className="w-14 h-14 rounded-2xl object-cover border-2 border-slate-200"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const parent = target.parentElement;
if (parent) {
const fallback = document.createElement('div');
fallback.className = 'w-14 h-14 rounded-2xl bg-slate-100 flex items-center justify-center text-xl font-black text-slate-400 group-hover:bg-primary-50 group-hover:text-primary-600 transition-colors';
fallback.textContent = emp.name.split(' ').map(n => n[0]).join('');
parent.appendChild(fallback);
}
}}
/>
) : (
<div className="w-14 h-14 rounded-2xl bg-slate-100 flex items-center justify-center text-xl font-black text-slate-400 group-hover:bg-primary-50 group-hover:text-primary-600 transition-colors">
{emp.name.split(' ').map(n => n[0]).join('')}
</div>
)}
<div>
<h4 className="font-black text-slate-800 text-base leading-tight group-hover:text-primary-600 transition-colors">{emp.name}</h4>
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-wider mt-1">{emp.position}</p>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<span className={`text-[9px] font-black px-2 py-1 rounded-full uppercase tracking-tighter ${emp.status === 'active' ? 'bg-emerald-50 text-emerald-600' : emp.status === 'vacation' ? 'bg-amber-50 text-amber-600' : 'bg-red-50 text-red-600'}`}>
{emp.status === 'active' ? 'В строю' : emp.status === 'vacation' ? 'В отпуске' : 'Неактивен'}
</span>
<button
onClick={(e) => {
e.stopPropagation();
setSelectedEmployee(emp);
}}
className="p-1 text-slate-300 hover:text-slate-600"
>
<Eye className="w-4 h-4"/>
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 mb-4">
<div className="bg-slate-50 p-3 rounded-xl border border-slate-100 flex items-center gap-2">
<MapPin className="w-3.5 h-3.5 text-slate-400"/>
<span className="text-[10px] font-bold text-slate-600 uppercase truncate">{getDistrictName(emp.assignedDistrictId)}</span>
</div>
<div className="bg-slate-50 p-3 rounded-xl border border-slate-100 flex items-center gap-2">
<Briefcase className="w-3.5 h-3.5 text-slate-400"/>
<span className="text-[10px] font-bold text-slate-600 uppercase">Оклад: {emp.salary.toLocaleString()}</span>
</div>
</div>
<div className="flex gap-2 pt-4 border-t border-slate-100">
<a
href={`tel:${emp.phone}`}
onClick={(e) => e.stopPropagation()}
className="flex-1 py-2.5 bg-slate-50 text-slate-700 rounded-xl text-[10px] font-black uppercase flex items-center justify-center gap-2 hover:bg-slate-100 transition-all"
>
<Phone className="w-3.5 h-3.5"/> Позвонить
</a>
{emp.messengerLogins && emp.messengerLogins.length > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
// Здесь можно добавить логику открытия мессенджера
}}
className="flex-1 py-2.5 bg-primary-600 text-white rounded-xl text-[10px] font-black uppercase flex items-center justify-center gap-2 shadow-lg shadow-primary-500/20 active:scale-95 transition-all"
>
<MessageCircle className="w-3.5 h-3.5"/> Чат
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
);
};