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

246 lines
11 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, 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>
);
};