Initial commit MKD fixes

This commit is contained in:
Arsen
2026-02-04 00:17:04 +05:00
commit de94ad707b
312 changed files with 138754 additions and 0 deletions

245
components/HRModule.tsx Executable file
View 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>
);
};