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