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