Initial commit MKD fixes
This commit is contained in:
245
components/HRModule.tsx
Executable file
245
components/HRModule.tsx
Executable file
@@ -0,0 +1,245 @@
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user