klg-asutk-app/app/personnel-plg/page.tsx

326 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

/**
* Сертификация персонала ПЛГ
* Учёт специалистов, первичная аттестация, повышение квалификации.
*
* Правовые основания:
* ВК РФ ст. 52-54; ФАП-147; ФАП-145; ФАП-148
* EASA Part-66, Part-145.A.30/35, Part-CAMO.A.305
* ICAO Annex 1, Doc 9760 ch.6
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { PageLayout, DataTable, StatusBadge, Modal, EmptyState } from '@/components/ui';
type Tab = 'specialists' | 'programs' | 'attestations' | 'compliance';
interface Specialist { id: string; full_name: string; personnel_number: string; position: string; category: string; specializations: string[]; license_number?: string; license_expires?: string; status: string; compliance?: any; attestations?: any[]; qualifications?: any[]; }
interface Program { id: string; name: string; type: string; legal_basis: string; duration_hours: number; modules?: any[]; periodicity?: string; certificate_validity_years?: number; }
export default function PersonnelPLGPage() {
const [tab, setTab] = useState<Tab>('specialists');
const [specialists, setSpecialists] = useState<Specialist[]>([]);
const [programs, setPrograms] = useState<Program[]>([]);
const [compliance, setCompliance] = useState<any>(null);
const [selected, setSelected] = useState<Specialist | null>(null);
const [selectedProgram, setSelectedProgram] = useState<Program | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [loading, setLoading] = useState(false);
const api = useCallback(async (endpoint: string, opts?: RequestInit) => {
const res = await fetch(`/api/v1/personnel-plg/${endpoint}`, opts);
return res.json();
}, []);
useEffect(() => {
setLoading(true);
Promise.all([
api('specialists').then(d => setSpecialists(d.items || [])),
api('programs').then(d => setPrograms(d.programs || [])),
api('compliance-report').then(d => setCompliance(d)),
]).finally(() => setLoading(false));
}, [api]);
const handleAddSpecialist = async (data: any) => {
const result = await api('specialists', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
});
if (result.id) { setSpecialists(prev => [...prev, result]); setShowAddModal(false); }
};
const tabs = [
{ id: 'specialists' as Tab, label: '👤 Специалисты', icon: '👤' },
{ id: 'programs' as Tab, label: '📚 Программы подготовки', icon: '📚' },
{ id: 'attestations' as Tab, label: '📝 Аттестация / ПК', icon: '📝' },
{ id: 'compliance' as Tab, label: '✅ Соответствие', icon: '✅' },
];
const programTypeLabels: Record<string, string> = {
initial: '🎓 Первичная', recurrent: '🔄 Периодическая', type_rating: '✈️ На тип ВС',
ewis: '⚡ EWIS', fuel_tank: '⛽ FTS', ndt: '🔬 НК/NDT', human_factors: '🧠 ЧФ',
sms: '🛡️ SMS', crs_authorization: '✍️ CRS', rvsm: '📏 RVSM', etops: '🌊 ETOPS',
};
return (
<PageLayout title="🎓 Сертификация персонала ПЛГ"
subtitle="Учёт специалистов, аттестация, повышение квалификации"
actions={
<button onClick={() => setShowAddModal(true)} className="btn-primary text-sm px-4 py-2 rounded">
+ Добавить специалиста
</button>
}>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4 text-xs text-blue-700">
<strong>Правовая база:</strong> ВК РФ ст. 52-54; ФАП-147; ФАП-145 п.145.A.30/35; ФАП-148;
EASA Part-66; Part-CAMO.A.305; ICAO Annex 1; Doc 9760 ch.6
</div>
{/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-gray-200 overflow-x-auto">
{tabs.map(t => (
<button key={t.id} onClick={() => setTab(t.id)}
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap border-b-2 transition-colors
${tab === t.id ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
{t.label}
</button>
))}
</div>
{loading ? <div className="text-center py-10 text-gray-400"> Загрузка...</div> : (
<>
{/* SPECIALISTS */}
{tab === 'specialists' && (
specialists.length > 0 ? (
<DataTable
columns={[
{ key: 'personnel_number', label: 'Таб. №' },
{ key: 'full_name', label: 'ФИО' },
{ key: 'position', label: 'Должность' },
{ key: 'category', label: 'Категория', render: (v: string) => <span className="badge bg-blue-100 text-blue-700">{v}</span> },
{ key: 'specializations', label: 'Типы ВС', render: (v: string[]) => v?.join(', ') || '—' },
{ key: 'license_number', label: 'Свидетельство' },
{ key: 'status', label: 'Статус', render: (v: string) => (
<StatusBadge status={v} colorMap={{ active: 'bg-green-500', suspended: 'bg-red-500', expired: 'bg-yellow-500' }}
labelMap={{ active: 'Действует', suspended: 'Приостановлен', expired: 'Истёк' }} />
)},
]}
data={specialists}
onRowClick={(row) => {
api(`specialists/${row.id}`).then(setSelected);
}}
/>
) : <EmptyState message="Нет специалистов. Добавьте первого специалиста ПЛГ." />
)}
{/* PROGRAMS */}
{tab === 'programs' && (
<div className="space-y-3">
{programs.map(p => (
<div key={p.id} onClick={() => setSelectedProgram(p)}
className="card p-4 cursor-pointer hover:shadow-md transition-shadow">
<div className="flex justify-between items-start">
<div>
<div className="flex items-center gap-2">
<span className="text-lg">{programTypeLabels[p.type]?.split(' ')[0] || '📋'}</span>
<span className="font-medium text-sm">{p.name}</span>
</div>
<div className="text-xs text-gray-500 mt-1">{p.legal_basis}</div>
</div>
<div className="text-right shrink-0">
<div className="badge bg-primary-100 text-primary-700">{p.duration_hours} ч.</div>
{p.periodicity && <div className="text-[10px] text-gray-400 mt-1">{p.periodicity}</div>}
{p.certificate_validity_years ? (
<div className="text-[10px] text-gray-400">Срок: {p.certificate_validity_years} лет</div>
) : null}
</div>
</div>
</div>
))}
</div>
)}
{/* ATTESTATIONS */}
{tab === 'attestations' && (
<div className="space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="card p-4 text-center border-l-4 border-green-500">
<div className="text-2xl font-bold text-green-600">🎓</div>
<div className="text-sm font-medium mt-1">Первичная аттестация</div>
<div className="text-xs text-gray-400">PLG-INIT-001 · 240 ч.</div>
</div>
<div className="card p-4 text-center border-l-4 border-blue-500">
<div className="text-2xl font-bold text-blue-600">🔄</div>
<div className="text-sm font-medium mt-1">Периодическая ПК</div>
<div className="text-xs text-gray-400">PLG-REC-001 · 40 ч. · каждые 24 мес.</div>
</div>
<div className="card p-4 text-center border-l-4 border-purple-500">
<div className="text-2xl font-bold text-purple-600"></div>
<div className="text-sm font-medium mt-1">Допуск на тип ВС</div>
<div className="text-xs text-gray-400">PLG-TYPE-001 · 80 ч.</div>
</div>
</div>
<h3 className="text-sm font-bold text-gray-600 mt-6">Специальные курсы</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{['PLG-EWIS-001', 'PLG-FUEL-001', 'PLG-NDT-001', 'PLG-HF-001', 'PLG-SMS-001', 'PLG-CRS-001', 'PLG-RVSM-001', 'PLG-ETOPS-001'].map(pid => {
const p = programs.find(pp => pp.id === pid);
if (!p) return null;
return (
<div key={pid} onClick={() => setSelectedProgram(p)}
className="card p-3 cursor-pointer hover:shadow-sm transition-shadow">
<div className="text-sm font-medium">{programTypeLabels[p.type] || p.type}</div>
<div className="text-[10px] text-gray-400 mt-1">{p.duration_hours} ч.</div>
</div>
);
})}
</div>
</div>
)}
{/* COMPLIANCE */}
{tab === 'compliance' && compliance && (
<div className="space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="card p-4 text-center"><div className="text-3xl font-bold">{compliance.total_specialists}</div><div className="text-xs text-gray-500">Всего специалистов</div></div>
<div className="card p-4 text-center bg-green-50"><div className="text-3xl font-bold text-green-600">{compliance.compliant}</div><div className="text-xs text-green-600">Соответствуют</div></div>
<div className="card p-4 text-center bg-red-50"><div className="text-3xl font-bold text-red-600">{compliance.non_compliant}</div><div className="text-xs text-red-600">Нарушения</div></div>
<div className="card p-4 text-center bg-yellow-50"><div className="text-3xl font-bold text-yellow-600">{compliance.expiring_soon?.length || 0}</div><div className="text-xs text-yellow-600">Истекает &lt;90 дн.</div></div>
</div>
{compliance.overdue?.length > 0 && (
<div className="card p-4 border-l-4 border-red-500">
<h4 className="text-sm font-bold text-red-700 mb-2"> Просроченные квалификации</h4>
{compliance.overdue.map((o: any, i: number) => (
<div key={i} className="flex justify-between py-1.5 border-b border-red-100 text-sm">
<span className="font-medium">{o.specialist}</span>
<span className="text-gray-500">{o.program}</span>
<span className="text-red-600 text-xs">{new Date(o.due).toLocaleDateString('ru-RU')}</span>
</div>
))}
</div>
)}
{compliance.expiring_soon?.length > 0 && (
<div className="card p-4 border-l-4 border-yellow-500">
<h4 className="text-sm font-bold text-yellow-700 mb-2"> Истекает в течение 90 дней</h4>
{compliance.expiring_soon.map((e: any, i: number) => (
<div key={i} className="flex justify-between py-1.5 border-b border-yellow-100 text-sm">
<span className="font-medium">{e.specialist}</span>
<span className="text-gray-500">{e.program || e.item}</span>
<span className="text-yellow-600 text-xs">{new Date(e.due).toLocaleDateString('ru-RU')}</span>
</div>
))}
</div>
)}
</div>
)}
</>
)}
{/* Specialist detail modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title={selected?.full_name || ''} size="lg">
{selected && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 text-sm">
<div><span className="text-gray-500">Таб. :</span> {selected.personnel_number}</div>
<div><span className="text-gray-500">Категория:</span> <span className="badge bg-blue-100 text-blue-700">{selected.category}</span></div>
<div><span className="text-gray-500">Должность:</span> {selected.position}</div>
<div><span className="text-gray-500">Свидетельство:</span> {selected.license_number || '—'}</div>
<div><span className="text-gray-500">Типы ВС:</span> {selected.specializations?.join(', ') || '—'}</div>
<div><span className="text-gray-500">Действует до:</span> {selected.license_expires ? new Date(selected.license_expires).toLocaleDateString('ru-RU') : '—'}</div>
</div>
{selected.compliance && (
<div className={`p-3 rounded text-sm ${selected.compliance.status === 'compliant' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>
{selected.compliance.status === 'compliant' ? '✅ Все квалификации в порядке' : `⚠️ Просрочено: ${selected.compliance.overdue_items?.join(', ')}`}
</div>
)}
{selected.attestations?.length > 0 && (
<div><h4 className="text-sm font-bold mb-2">Аттестации</h4>
{selected.attestations.map((a: any) => (
<div key={a.id} className="flex justify-between py-1 border-b border-gray-50 text-xs">
<span>{a.program_name}</span><span className={a.result === 'passed' ? 'text-green-600' : 'text-red-600'}>{a.result}</span>
</div>
))}
</div>
)}
{selected.qualifications?.length > 0 && (
<div><h4 className="text-sm font-bold mb-2">Повышение квалификации</h4>
{selected.qualifications.map((q: any) => (
<div key={q.id} className="flex justify-between py-1 border-b border-gray-50 text-xs">
<span>{q.program_name}</span><span>{q.next_due ? `до ${new Date(q.next_due).toLocaleDateString('ru-RU')}` : '—'}</span>
</div>
))}
</div>
)}
</div>
)}
</Modal>
{/* Program detail modal */}
<Modal isOpen={!!selectedProgram} onClose={() => setSelectedProgram(null)} title={selectedProgram?.name || ''} size="lg">
{selectedProgram && (
<div className="space-y-3">
<div className="text-xs text-gray-500">{selectedProgram.legal_basis}</div>
<div className="flex gap-3 text-sm">
<span className="badge bg-primary-100 text-primary-700">{selectedProgram.duration_hours} часов</span>
{selectedProgram.periodicity && <span className="badge bg-yellow-100 text-yellow-700">{selectedProgram.periodicity}</span>}
{selectedProgram.certificate_validity_years ? <span className="badge bg-green-100 text-green-700">Срок: {selectedProgram.certificate_validity_years} лет</span> : null}
</div>
{selectedProgram.modules && (
<div>
<h4 className="text-sm font-bold mb-2">Модули программы</h4>
<div className="space-y-1">
{selectedProgram.modules.map((m: any, i: number) => (
<div key={i} className="flex justify-between py-1.5 border-b border-gray-50 text-xs">
<div><span className="font-mono text-gray-400 mr-2">{m.code}</span>{m.name}</div>
<div className="flex gap-2 shrink-0">
{m.hours > 0 && <span className="badge bg-gray-100">{m.hours}ч</span>}
{m.basis && <span className="text-[10px] text-gray-400 max-w-[200px] truncate">{m.basis}</span>}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</Modal>
{/* Add specialist modal */}
<Modal isOpen={showAddModal} onClose={() => setShowAddModal(false)} title="Добавить специалиста ПЛГ" size="lg">
<AddSpecialistForm onSubmit={handleAddSpecialist} onCancel={() => setShowAddModal(false)} />
</Modal>
</PageLayout>
);
}
function AddSpecialistForm({ onSubmit, onCancel }: { onSubmit: (d: any) => void; onCancel: () => void }) {
const [form, setForm] = useState({ full_name: '', personnel_number: '', position: 'Авиатехник', category: 'B1', specializations: '', license_number: '' });
return (
<div className="space-y-3">
{[
{ key: 'full_name', label: 'ФИО', placeholder: 'Иванов Иван Иванович' },
{ key: 'personnel_number', label: 'Табельный номер', placeholder: 'ТН-001' },
{ key: 'position', label: 'Должность', placeholder: 'Авиатехник' },
{ key: 'license_number', label: 'Номер свидетельства', placeholder: 'АС-12345' },
{ key: 'specializations', label: 'Типы ВС (через запятую)', placeholder: 'Ан-148, SSJ-100' },
].map(f => (
<div key={f.key}>
<label className="text-xs font-medium text-gray-600">{f.label}</label>
<input className="input-field w-full mt-1" placeholder={f.placeholder}
value={(form as any)[f.key]} onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))} />
</div>
))}
<div>
<label className="text-xs font-medium text-gray-600">Категория (EASA Part-66 / ФАП-147)</label>
<select className="input-field w-full mt-1" value={form.category} onChange={e => setForm(p => ({ ...p, category: e.target.value }))}>
{['A', 'B1', 'B2', 'B3', 'C', 'I', 'II', 'III'].map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div className="flex gap-2 pt-2">
<button onClick={() => onSubmit({ ...form, specializations: form.specializations.split(',').map(s => s.trim()).filter(Boolean) })}
className="btn-primary px-4 py-2 rounded text-sm">Сохранить</button>
<button onClick={onCancel} className="btn-sm bg-gray-200 text-gray-700 px-4 py-2 rounded text-sm">Отмена</button>
</div>
</div>
);
}