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

358 lines
13 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 } from 'react';
import {
Users,
ChevronDown,
ChevronRight,
User,
Phone,
Loader2,
AlertCircle,
ZoomIn,
ZoomOut,
Maximize2
} from 'lucide-react';
import { apiClient } from '../../services/apiClient';
interface OrgEmployee {
id: string;
name: string;
position: string;
phone: string;
status: 'active' | 'vacation' | 'inactive';
photoUrl?: string;
managerId?: string;
subordinates: OrgEmployee[];
subordinateCount?: number;
}
export const OrganizationalStructure: React.FC = () => {
const [structure, setStructure] = useState<OrgEmployee[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [zoom, setZoom] = useState(100);
const [selectedEmployee, setSelectedEmployee] = useState<OrgEmployee | null>(null);
useEffect(() => {
loadStructure();
}, []);
const loadStructure = async () => {
try {
setLoading(true);
setError(null);
const data = await apiClient.get<OrgEmployee[]>('/employees/organizational-structure');
setStructure(data);
// По умолчанию разворачиваем все узлы
const allIds = new Set<string>();
const collectIds = (employees: OrgEmployee[]) => {
employees.forEach(emp => {
allIds.add(emp.id);
if (emp.subordinates.length > 0) {
collectIds(emp.subordinates);
}
});
};
collectIds(data);
setExpandedNodes(allIds);
} catch (err: any) {
console.error('Error loading organizational structure:', err);
setError(err.message || 'Не удалось загрузить организационную структуру');
} finally {
setLoading(false);
}
};
const toggleNode = (id: string) => {
const newExpanded = new Set(expandedNodes);
if (newExpanded.has(id)) {
newExpanded.delete(id);
} else {
newExpanded.add(id);
}
setExpandedNodes(newExpanded);
};
const expandAll = () => {
const allIds = new Set<string>();
const collectIds = (employees: OrgEmployee[]) => {
employees.forEach(emp => {
allIds.add(emp.id);
if (emp.subordinates.length > 0) {
collectIds(emp.subordinates);
}
});
};
collectIds(structure);
setExpandedNodes(allIds);
};
const collapseAll = () => {
setExpandedNodes(new Set());
};
const renderEmployeeCard = (employee: OrgEmployee, level: number = 0) => {
const isExpanded = expandedNodes.has(employee.id);
const hasSubordinates = employee.subordinates.length > 0;
const indent = level * 60;
return (
<div key={employee.id} className="relative">
<div
className="flex items-start gap-4 mb-4 group"
style={{ marginLeft: `${indent}px` }}
>
{/* Вертикальная линия для связи с родителем */}
{level > 0 && (
<div className="absolute left-0 top-0 bottom-0 w-px bg-slate-300" style={{ left: `${indent - 20}px` }} />
)}
{/* Кнопка разворачивания */}
{hasSubordinates && (
<button
onClick={() => toggleNode(employee.id)}
className="mt-6 p-1 hover:bg-slate-100 rounded-lg transition-colors flex-shrink-0"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-slate-500" />
) : (
<ChevronRight className="w-4 h-4 text-slate-500" />
)}
</button>
)}
{!hasSubordinates && <div className="w-6" />}
{/* Карточка сотрудника */}
<div
className={`flex-1 bg-white rounded-2xl border-2 transition-all cursor-pointer hover:shadow-lg ${
selectedEmployee?.id === employee.id
? 'border-primary-500 shadow-lg'
: 'border-slate-200 hover:border-primary-300'
}`}
onClick={() => setSelectedEmployee(employee)}
>
<div className="p-4 flex items-start gap-4">
{/* Фото */}
<div className="flex-shrink-0">
{employee.photoUrl ? (
<img
src={employee.photoUrl.startsWith('http') ? employee.photoUrl : `/uploads/${employee.photoUrl}`}
alt={employee.name}
className="w-16 h-16 rounded-xl object-cover border-2 border-slate-200"
onError={(e) => {
(e.target as HTMLImageElement).src = `https://ui-avatars.com/api/?name=${encodeURIComponent(employee.name)}&background=6366f1&color=fff&size=128`;
}}
/>
) : (
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center border-2 border-slate-200">
<span className="text-white font-black text-lg">
{employee.name.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase()}
</span>
</div>
)}
</div>
{/* Информация */}
<div className="flex-1 min-w-0">
<h3 className="font-black text-sm text-slate-800 mb-1 truncate">
{employee.name}
</h3>
<p className="text-xs text-slate-600 font-bold mb-2 truncate">
{employee.position}
</p>
<div className="flex items-center gap-2 text-[10px] text-slate-500">
<Phone className="w-3 h-3" />
<span>{employee.phone}</span>
</div>
{hasSubordinates && (
<div className="mt-2 flex items-center gap-1 text-[10px] text-primary-600 font-bold">
<Users className="w-3 h-3" />
<span>{employee.subordinateCount || employee.subordinates.length} подчиненных</span>
</div>
)}
</div>
{/* Статус */}
<div className="flex-shrink-0">
<span className={`px-2 py-1 rounded-full text-[9px] font-black uppercase ${
employee.status === 'active'
? 'bg-emerald-100 text-emerald-700'
: employee.status === 'vacation'
? 'bg-amber-100 text-amber-700'
: 'bg-slate-100 text-slate-600'
}`}>
{employee.status === 'active' ? 'Активен' : employee.status === 'vacation' ? 'Отпуск' : 'Неактивен'}
</span>
</div>
</div>
</div>
</div>
{/* Горизонтальная линия для связи с подчиненными */}
{hasSubordinates && isExpanded && level >= 0 && (
<div
className="absolute left-0 top-16 w-px h-4 bg-slate-300"
style={{ left: `${indent + 20}px` }}
/>
)}
{/* Подчиненные */}
{hasSubordinates && isExpanded && (
<div className="relative">
{employee.subordinates.map((sub, idx) => (
<div key={sub.id}>
{renderEmployeeCard(sub, level + 1)}
</div>
))}
</div>
)}
</div>
);
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[500px]">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin text-primary-500 mx-auto mb-4" />
<p className="text-sm text-slate-600 font-bold">Загрузка организационной структуры...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center min-h-[500px]">
<div className="text-center max-w-md">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<p className="text-sm text-red-600 font-bold mb-4">{error}</p>
<button
onClick={loadStructure}
className="px-4 py-2 bg-primary-500 text-white rounded-xl text-sm font-bold hover:bg-primary-600 transition-colors"
>
Попробовать снова
</button>
</div>
</div>
);
}
if (structure.length === 0) {
return (
<div className="flex items-center justify-center min-h-[500px]">
<div className="text-center">
<Users className="w-12 h-12 text-slate-400 mx-auto mb-4" />
<p className="text-sm text-slate-600 font-bold">Организационная структура пуста</p>
<p className="text-xs text-slate-500 mt-2">Добавьте сотрудников и укажите их руководителей</p>
</div>
</div>
);
}
return (
<div className="space-y-6 animate-fade-in">
{/* Панель управления */}
<div className="bg-white rounded-3xl border border-slate-200 shadow-sm p-4">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-2">
<h2 className="font-black text-sm text-slate-800">Организационная структура</h2>
<span className="text-xs text-slate-500 font-bold">
({structure.reduce((acc, emp) => {
const count = (e: OrgEmployee) => 1 + e.subordinates.reduce((sum, s) => sum + count(s), 0);
return acc + count(emp);
}, 0)} сотрудников)
</span>
</div>
<div className="flex items-center gap-2">
{/* Управление масштабом */}
<div className="flex items-center gap-2 bg-slate-100 rounded-xl p-1">
<button
onClick={() => setZoom(Math.max(50, zoom - 10))}
className="p-1.5 hover:bg-white rounded-lg transition-colors"
title="Уменьшить"
>
<ZoomOut className="w-4 h-4 text-slate-600" />
</button>
<span className="text-xs font-bold text-slate-700 min-w-[3rem] text-center">{zoom}%</span>
<button
onClick={() => setZoom(Math.min(150, zoom + 10))}
className="p-1.5 hover:bg-white rounded-lg transition-colors"
title="Увеличить"
>
<ZoomIn className="w-4 h-4 text-slate-600" />
</button>
</div>
{/* Управление разворачиванием */}
<button
onClick={expandAll}
className="px-3 py-1.5 bg-slate-100 hover:bg-slate-200 rounded-xl text-xs font-bold text-slate-700 transition-colors"
>
Развернуть все
</button>
<button
onClick={collapseAll}
className="px-3 py-1.5 bg-slate-100 hover:bg-slate-200 rounded-xl text-xs font-bold text-slate-700 transition-colors"
>
Свернуть все
</button>
</div>
</div>
</div>
{/* Структура */}
<div
className="bg-slate-50 rounded-3xl border border-slate-200 shadow-sm p-8 overflow-auto"
style={{
transform: `scale(${zoom / 100})`,
transformOrigin: 'top left',
minHeight: `${500 * (zoom / 100)}px`
}}
>
{structure.map(employee => (
<div key={employee.id}>
{renderEmployeeCard(employee, 0)}
</div>
))}
</div>
{/* Детали выбранного сотрудника */}
{selectedEmployee && (
<div className="bg-white rounded-3xl border border-slate-200 shadow-sm p-6">
<div className="flex items-start justify-between mb-4">
<h3 className="font-black text-sm text-slate-800">Информация о сотруднике</h3>
<button
onClick={() => setSelectedEmployee(null)}
className="text-slate-400 hover:text-slate-600 transition-colors"
>
</button>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-[10px] text-slate-500 font-bold uppercase mb-1">ФИО</p>
<p className="text-sm font-bold text-slate-800">{selectedEmployee.name}</p>
</div>
<div>
<p className="text-[10px] text-slate-500 font-bold uppercase mb-1">Должность</p>
<p className="text-sm font-bold text-slate-800">{selectedEmployee.position}</p>
</div>
<div>
<p className="text-[10px] text-slate-500 font-bold uppercase mb-1">Телефон</p>
<p className="text-sm font-bold text-slate-800">{selectedEmployee.phone}</p>
</div>
<div>
<p className="text-[10px] text-slate-500 font-bold uppercase mb-1">Подчиненные</p>
<p className="text-sm font-bold text-slate-800">
{selectedEmployee.subordinateCount || selectedEmployee.subordinates.length} человек
</p>
</div>
</div>
</div>
)}
</div>
);
};