246 lines
11 KiB
TypeScript
Executable File
246 lines
11 KiB
TypeScript
Executable File
|
||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||
import {
|
||
UsersRound,
|
||
UserPlus,
|
||
ShieldCheck,
|
||
Printer,
|
||
Briefcase,
|
||
Calendar,
|
||
ChevronDown,
|
||
FileText,
|
||
Upload,
|
||
Loader2
|
||
} from 'lucide-react';
|
||
import { Employee, User } from '../types';
|
||
import { backendApi, authFetch } from '../services/apiClient';
|
||
|
||
// Modular Imports
|
||
import { HRSummary } from './hr/HRSummary';
|
||
import { EmployeeRegistry } from './hr/EmployeeRegistry';
|
||
import { HiringPipeline } from './hr/HiringPipeline';
|
||
import { VacanciesRegistry } from './hr/VacanciesRegistry';
|
||
import { CandidatesRegistry } from './hr/CandidatesRegistry';
|
||
import { TrainingModule } from './hr/TrainingModule';
|
||
import { EmployeeFormModal } from './hr/EmployeeFormModal';
|
||
import { WorkCalendarView } from './hr/WorkCalendarView';
|
||
import { allowedSubsForSection } from '../constants/permissions';
|
||
|
||
type Tab = 'summary' | 'employees' | 'vacancies' | 'hiring' | 'safety' | 'calendar';
|
||
|
||
const HR_TABS: Tab[] = ['summary', 'employees', 'calendar', 'vacancies', 'hiring', 'safety'];
|
||
const SUBTAB_KEY = 'mkd_subTab_hr';
|
||
|
||
interface HRModuleProps {
|
||
currentUser?: User;
|
||
allowedPermissions?: string[] | null;
|
||
}
|
||
|
||
export const HRModule: React.FC<HRModuleProps> = ({ currentUser, allowedPermissions }) => {
|
||
const visibleTabs = useMemo(() => {
|
||
const allowed = allowedSubsForSection(allowedPermissions ?? [], 'hr');
|
||
if (allowed === 'all') return HR_TABS;
|
||
return HR_TABS.filter((t) => allowed.includes(t));
|
||
}, [allowedPermissions]);
|
||
|
||
const [activeTab, setActiveTab] = useState<Tab>(() => {
|
||
const s = localStorage.getItem(SUBTAB_KEY);
|
||
return (s && HR_TABS.includes(s as Tab)) ? s as Tab : 'summary';
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (visibleTabs.length > 0 && !visibleTabs.includes(activeTab)) {
|
||
setActiveTab(visibleTabs[0]);
|
||
}
|
||
}, [visibleTabs, activeTab]);
|
||
|
||
useEffect(() => {
|
||
if (visibleTabs.includes(activeTab)) localStorage.setItem(SUBTAB_KEY, activeTab);
|
||
}, [activeTab, visibleTabs]);
|
||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||
const [refreshKey, setRefreshKey] = useState(0);
|
||
const [printerDropdownOpen, setPrinterDropdownOpen] = useState(false);
|
||
const [templateDocs, setTemplateDocs] = useState<{ id: number; name: string; filePath: string; originalFilename: string | null; createdAt: string }[]>([]);
|
||
const [loadingTemplates, setLoadingTemplates] = useState(false);
|
||
const [uploadingTemplate, setUploadingTemplate] = useState(false);
|
||
const printerDropdownRef = useRef<HTMLDivElement>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (printerDropdownOpen) {
|
||
setLoadingTemplates(true);
|
||
backendApi.getHrTemplateDocuments()
|
||
.then(setTemplateDocs)
|
||
.catch(() => setTemplateDocs([]))
|
||
.finally(() => setLoadingTemplates(false));
|
||
}
|
||
}, [printerDropdownOpen]);
|
||
|
||
useEffect(() => {
|
||
const handleClickOutside = (e: MouseEvent) => {
|
||
if (printerDropdownRef.current && !printerDropdownRef.current.contains(e.target as Node)) {
|
||
setPrinterDropdownOpen(false);
|
||
}
|
||
};
|
||
if (printerDropdownOpen) {
|
||
document.addEventListener('mousedown', handleClickOutside);
|
||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||
}
|
||
}, [printerDropdownOpen]);
|
||
|
||
const handleDownloadTemplate = async (doc: { id: number; name: string; originalFilename: string | null }) => {
|
||
const url = backendApi.getHrTemplateDocumentDownloadUrl(doc.id);
|
||
const res = await authFetch(url);
|
||
const blob = await res.blob();
|
||
const u = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = u;
|
||
a.download = doc.originalFilename || doc.name || 'document';
|
||
a.click();
|
||
URL.revokeObjectURL(u);
|
||
};
|
||
|
||
const handleUploadTemplate = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
setUploadingTemplate(true);
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
formData.append('name', file.name);
|
||
backendApi.uploadHrTemplateDocument(formData)
|
||
.then(() => backendApi.getHrTemplateDocuments().then(setTemplateDocs))
|
||
.catch((err) => console.error(err))
|
||
.finally(() => {
|
||
setUploadingTemplate(false);
|
||
e.target.value = '';
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="animate-fade-in pb-20">
|
||
<div className="flex justify-between items-center mb-6 px-1">
|
||
<div>
|
||
<h2 className="text-xl font-bold text-slate-800 leading-none">Управление персоналом</h2>
|
||
<p className="text-[10px] text-slate-400 font-black uppercase tracking-widest mt-1">Кадровая политика и штатное расписание</p>
|
||
</div>
|
||
<div className="flex gap-2 relative" ref={printerDropdownRef}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPrinterDropdownOpen((v) => !v)}
|
||
className="p-2.5 bg-white border border-slate-200 text-slate-400 rounded-xl hover:text-primary-600 transition-all shadow-sm flex items-center gap-1"
|
||
title="Типовые документы"
|
||
>
|
||
<Printer className="w-5 h-5" />
|
||
<ChevronDown className={`w-4 h-4 transition-transform ${printerDropdownOpen ? 'rotate-180' : ''}`} />
|
||
</button>
|
||
{printerDropdownOpen && (
|
||
<div className="absolute right-0 top-full mt-1 w-72 bg-white border border-slate-200 rounded-2xl shadow-lg z-50 overflow-hidden">
|
||
<div className="p-2 border-b border-slate-100 bg-slate-50/50">
|
||
<p className="text-[10px] font-black text-slate-500 uppercase tracking-widest flex items-center gap-2">
|
||
<FileText className="w-3.5 h-3.5" /> Типовые документы
|
||
</p>
|
||
</div>
|
||
<div className="max-h-64 overflow-y-auto p-2">
|
||
{loadingTemplates ? (
|
||
<div className="flex items-center justify-center py-6">
|
||
<Loader2 className="w-6 h-6 animate-spin text-primary-500" />
|
||
</div>
|
||
) : templateDocs.length === 0 ? (
|
||
<p className="text-xs text-slate-500 py-4 text-center">Нет типовых документов</p>
|
||
) : (
|
||
<ul className="space-y-0.5">
|
||
{templateDocs.map((doc) => (
|
||
<li key={doc.id}>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleDownloadTemplate(doc)}
|
||
className="w-full text-left px-3 py-2 rounded-xl text-sm font-medium text-slate-700 hover:bg-primary-50 hover:text-primary-700 transition-colors flex items-center gap-2"
|
||
>
|
||
<FileText className="w-4 h-4 flex-shrink-0 text-slate-400" />
|
||
<span className="truncate">{doc.name}</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
<div className="p-2 border-t border-slate-100">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.odt"
|
||
className="hidden"
|
||
onChange={handleUploadTemplate}
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={uploadingTemplate}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-xs font-bold text-primary-600 bg-primary-50 hover:bg-primary-100 transition-colors disabled:opacity-50"
|
||
>
|
||
{uploadingTemplate ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||
Загрузить типовой документ
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sheet Selector */}
|
||
<div className="flex p-1 bg-slate-200/50 rounded-2xl mb-6 overflow-x-auto no-scrollbar gap-1">
|
||
{[
|
||
{ id: 'summary', label: 'Сводка', icon: UsersRound },
|
||
{ id: 'employees', label: 'Штат', icon: UsersRound },
|
||
{ id: 'calendar', label: 'Рабочий календарь', icon: Calendar },
|
||
{ id: 'vacancies', label: 'Вакансии', icon: Briefcase },
|
||
{ id: 'hiring', label: 'Кандидаты', icon: UserPlus },
|
||
{ id: 'safety', label: 'Обучение', icon: ShieldCheck },
|
||
].filter((tab) => visibleTabs.includes(tab.id as Tab)).map((tab) => (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setActiveTab(tab.id as Tab)}
|
||
className={`flex-shrink-0 min-w-[7rem] flex items-center justify-center gap-2 py-2.5 px-4 text-[10px] font-black uppercase tracking-wider whitespace-nowrap rounded-xl transition-all ${activeTab === tab.id ? 'bg-white text-primary-600 shadow-sm border border-white' : 'text-slate-500 hover:text-slate-700'}`}
|
||
>
|
||
<tab.icon className="w-3.5 h-3.5"/> {tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Dynamic Sheet Content */}
|
||
<div className="min-h-[280px] sm:min-h-[360px] md:min-h-[500px]">
|
||
{activeTab === 'summary' && <HRSummary onNavigate={setActiveTab} />}
|
||
{activeTab === 'employees' && (
|
||
<EmployeeRegistry
|
||
key={refreshKey}
|
||
refreshTrigger={refreshKey.toString()}
|
||
onEmployeeCreated={() => {
|
||
setIsCreateModalOpen(false);
|
||
}}
|
||
currentUser={currentUser}
|
||
onOpenCreateModal={() => setIsCreateModalOpen(true)}
|
||
/>
|
||
)}
|
||
{activeTab === 'calendar' && <WorkCalendarView currentUser={currentUser} />}
|
||
{activeTab === 'vacancies' && <VacanciesRegistry />}
|
||
{activeTab === 'hiring' && <CandidatesRegistry />}
|
||
{activeTab === 'safety' && <TrainingModule />}
|
||
</div>
|
||
|
||
{/* Форма создания сотрудника */}
|
||
{isCreateModalOpen && (
|
||
<EmployeeFormModal
|
||
employee={null}
|
||
onClose={() => setIsCreateModalOpen(false)}
|
||
onSave={async (employee) => {
|
||
// EmployeeFormModal уже создает сотрудника через API,
|
||
// поэтому здесь просто закрываем модальное окно и обновляем список
|
||
setIsCreateModalOpen(false);
|
||
setRefreshKey(prev => prev + 1); // Обновляем список сотрудников
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|