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

414 lines
22 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, 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>
);
};