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