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