klg-asutk-app/app/regulator/page.tsx

418 lines
23 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.

/**
* Панель регулятора ФАВТ
*
* Доступ: только роль favt_inspector (сотрудники ФАВТ) или admin.
* Показывает ТОЛЬКО агрегированные данные согласно:
* - ВК РФ ст. 8, 24.1, 28, 33, 36, 37, 67, 68
* - ФАП-246, ФАП-285, ФГИС РЭВС
* - ICAO Annex 6, 7, 8, 19; Doc 9734, 9760, 9859
* - EASA Part-M, Part-CAMO, Part-145, Part-ARO
*
* Разработчик: АО «REFLY»
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { PageLayout, DataTable, StatusBadge, EmptyState } from '@/components/ui';
import { useAuth } from '@/lib/auth-context';
import { useI18n } from '@/hooks/useI18n';
type Tab = 'overview' | 'aircraft' | 'certifications' | 'safety' | 'audits' | 'personnel';
interface OverviewData {
aircraft: { total: number; airworthy: number; in_maintenance: number; grounded: number; decommissioned: number };
organizations: { total: number };
certification: { total_applications: number; pending: number; approved: number; rejected: number };
safety: { total_risks: number; critical: number; high: number; unresolved: number };
audits_last_30d: number;
legal_basis: string[];
}
const TABS: { id: Tab; label: string; icon: string }[] = [
{ id: 'overview', label: 'Сводка', icon: '📊' },
{ id: 'aircraft', label: 'Реестр ВС', icon: '✈️' },
{ id: 'certifications', label: 'Сертификация', icon: '📋' },
{ id: 'safety', label: 'Безопасность', icon: '🛡️' },
{ id: 'audits', label: 'Аудиты', icon: '🔍' },
{ id: 'personnel' as Tab, label: 'Персонал ПЛГ', icon: '🎓' },
];
function AccessDenied() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center max-w-md">
<div className="text-6xl mb-4">🔒</div>
<h1 className="text-2xl font-bold text-gray-800 mb-2">Доступ ограничен</h1>
<p className="text-gray-500 mb-4">
Панель регулятора доступна только уполномоченным сотрудникам ФАВТ (Росавиации).
</p>
<p className="text-xs text-gray-400">
Основание: ВК РФ ст. 8 Федеральные правила использования воздушного пространства.
Для получения доступа обратитесь к администратору системы.
</p>
</div>
</div>
);
}
function StatCard({ label, value, sub, color = 'primary' }: { label: string; value: number; sub?: string; color?: string }) {
const colors: Record<string, string> = {
primary: 'bg-blue-50 text-blue-700 border-blue-200',
green: 'bg-green-50 text-green-700 border-green-200',
yellow: 'bg-yellow-50 text-yellow-700 border-yellow-200',
red: 'bg-red-50 text-red-700 border-red-200',
gray: 'bg-gray-50 text-gray-700 border-gray-200',
};
return (
<div className={`rounded-lg border p-4 ${colors[color] || colors.primary}`}>
<div className="text-3xl font-bold">{value}</div>
<div className="text-sm font-medium mt-1">{label}</div>
{sub && <div className="text-[10px] opacity-60 mt-0.5">{sub}</div>}
</div>
);
}
function LegalBasisBadge({ items }: { items: string[] }) {
return (
<details className="text-xs text-gray-400 mt-3">
<summary className="cursor-pointer hover:text-gray-600">📜 Правовые основания ({items.length})</summary>
<ul className="mt-1 space-y-0.5 pl-4">
{items.map((b, i) => <li key={i} className="list-disc">{b}</li>)}
</ul>
</details>
);
}
export default function RegulatorPanel() {
const { user } = useAuth();
const [tab, setTab] = useState<Tab>('overview');
const [overview, setOverview] = useState<OverviewData | null>(null);
const [aircraftData, setAircraftData] = useState<any>(null);
const [certData, setCertData] = useState<any>(null);
const [safetyData, setSafetyData] = useState<any>(null);
const [auditData, setAuditData] = useState<any>(null);
const [personnelData, setPersonnelData] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [days, setDays] = useState(90);
// Access control: only favt_inspector or admin
const hasAccess = user?.role === 'favt_inspector' || user?.role === 'admin'
|| user?.roles?.includes('favt_inspector') || user?.roles?.includes('admin');
const fetchData = useCallback(async (endpoint: string) => {
try {
const res = await fetch(`/api/v1/regulator/${endpoint}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (e) {
console.error(`Regulator API error:`, e);
return null;
}
}, []);
useEffect(() => {
if (!hasAccess) return;
setLoading(true);
fetchData('overview').then(d => { setOverview(d); setLoading(false); });
}, [hasAccess, fetchData]);
useEffect(() => {
if (!hasAccess) return;
if (tab === 'aircraft' && !aircraftData) fetchData('aircraft-register').then(setAircraftData);
if (tab === 'certifications' && !certData) fetchData('certifications').then(setCertData);
if (tab === 'safety' && !safetyData) fetchData(`safety-indicators?days=${days}`).then(setSafetyData);
if (tab === 'audits' && !auditData) fetchData(`audits?days=${days}`).then(setAuditData);
if (tab === 'personnel' && !personnelData) fetchData('personnel-summary').then(setPersonnelData);
}, [tab, hasAccess, days, fetchData, aircraftData, certData, safetyData, auditData]);
if (!hasAccess) return <AccessDenied />;
const handlePdfExport = async () => {
try {
const res = await fetch('/api/v1/regulator/report/pdf');
if (!res.ok) throw new Error('PDF generation failed');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `favt_report_${new Date().toISOString().slice(0, 10)}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (e) { console.error(e); }
};
const handleExport = async () => {
const data = await fetchData('report');
if (data) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `favt_report_${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
}
};
return (
<PageLayout
title="🏛️ Панель регулятора — ФАВТ"
subtitle="Федеральное агентство воздушного транспорта (Росавиация)"
actions={
<div className="flex gap-2">
<button onClick={handleExport} className="btn-sm bg-blue-600 text-white px-4 py-2 rounded flex items-center gap-1">
📄 JSON отчёт
</button>
<button onClick={handlePdfExport} className="btn-sm bg-red-600 text-white px-4 py-2 rounded flex items-center gap-1">
📑 PDF отчёт
</button>
</div>
}
>
{/* Disclaimer */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4 text-xs text-blue-700">
<strong> Ограниченный доступ.</strong> Данные предоставляются в агрегированном виде согласно
ВК РФ (60-ФЗ), ФАП-21/128/145/147/148/149/246, ФЗ-488, ICAO Annex 6/8/19, EASA Part-M/CAMO/145/ARO, Поручение Президента Пр-1379, ТЗ АСУ ТК.
Персональные данные и коммерческая тайна не раскрываются.
</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.icon} {t.label}
</button>
))}
</div>
{loading && !overview ? (
<div className="text-center py-12 text-gray-400"> Загрузка данных...</div>
) : (
<>
{/* === OVERVIEW TAB === */}
{tab === 'overview' && overview && (
<div className="space-y-6">
{/* Aircraft section — ВК РФ ст. 33, ICAO Annex 7 */}
<section>
<h3 className="text-sm font-bold text-gray-600 mb-3"> Состояние парка ВС (ВК РФ ст. 33; ICAO Annex 7, 8)</h3>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
<StatCard label="Всего ВС" value={overview.aircraft.total} sub="Государственный реестр" />
<StatCard label="Годные к полётам" value={overview.aircraft.airworthy} color="green" sub="Действующий СЛГ" />
<StatCard label="На ТО" value={overview.aircraft.in_maintenance} color="yellow" sub="Плановое / внеплановое" />
<StatCard label="Приостановлены" value={overview.aircraft.grounded} color="red" sub="Ограничение / запрет" />
<StatCard label="Списаны" value={overview.aircraft.decommissioned} color="gray" sub="Исключены из реестра" />
</div>
</section>
{/* Certification — ФАП-246, ICAO Annex 6 */}
<section>
<h3 className="text-sm font-bold text-gray-600 mb-3">📋 Сертификация эксплуатантов (ФАП-246; ICAO Annex 6)</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard label="Всего заявок" value={overview.certification.total_applications} />
<StatCard label="На рассмотрении" value={overview.certification.pending} color="yellow" sub="Ожидают решения" />
<StatCard label="Одобрено" value={overview.certification.approved} color="green" />
<StatCard label="Отклонено" value={overview.certification.rejected} color="red" />
</div>
</section>
{/* Safety — ВК РФ ст. 24.1, ICAO Annex 19 */}
<section>
<h3 className="text-sm font-bold text-gray-600 mb-3">🛡 Показатели безопасности (ГПБП; ICAO Annex 19; Doc 9859)</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard label="Всего рисков" value={overview.safety.total_risks} />
<StatCard label="Критические" value={overview.safety.critical} color="red" sub="Требуют немедленных мер" />
<StatCard label="Высокие" value={overview.safety.high} color="yellow" />
<StatCard label="Не устранены" value={overview.safety.unresolved} color="red" sub="Открытые риски" />
</div>
</section>
{/* Audits + Orgs */}
<section>
<h3 className="text-sm font-bold text-gray-600 mb-3">🔍 Надзорная деятельность (ВК РФ ст. 28; ICAO Doc 9734)</h3>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
<StatCard label="Аудитов за 30 дн." value={overview.audits_last_30d} color="primary" sub="Инспекции и проверки" />
<StatCard label="Организации" value={overview.organizations.total} sub="Подконтрольные субъекты" />
</div>
</section>
<LegalBasisBadge items={overview.legal_basis || []} />
</div>
)}
{/* === AIRCRAFT REGISTER TAB === */}
{tab === 'aircraft' && (
<div>
<p className="text-xs text-gray-500 mb-3">Данные аналогичны ФГИС РЭВС (приказ Росавиации 180-П от 09.03.2017)</p>
{aircraftData?.items?.length ? (
<DataTable
columns={[
{ key: 'registration_number', label: 'Рег. знак' },
{ key: 'aircraft_type', label: 'Тип ВС' },
{ key: 'status', label: 'Статус', render: (v: string) => (
<StatusBadge status={v} colorMap={{ active: 'bg-green-500', grounded: 'bg-red-500', maintenance: 'bg-yellow-500', decommissioned: 'bg-gray-400' }}
labelMap={{ active: 'Годен', grounded: 'Приостановлен', maintenance: 'На ТО', decommissioned: 'Списан' }} />
)},
{ key: 'organization', label: 'Эксплуатант' },
{ key: 'cert_expiry', label: 'СЛГ до', render: (v: string) => v ? new Date(v).toLocaleDateString('ru-RU') : '—' },
]}
data={aircraftData.items}
/>
) : <EmptyState message="Нет данных в реестре" />}
<LegalBasisBadge items={['ВК РФ ст. 33', 'ФГИС РЭВС (приказ № 180-П)', 'ICAO Annex 7']} />
</div>
)}
{/* === CERTIFICATIONS TAB === */}
{tab === 'certifications' && (
<div>
<p className="text-xs text-gray-500 mb-3">Процедуры подтверждения соответствия по ФАП-246</p>
{certData?.items?.length ? (
<DataTable
columns={[
{ key: 'id', label: '№', render: (v: string) => v?.slice(0, 8) },
{ key: 'type', label: 'Тип' },
{ key: 'status', label: 'Статус', render: (v: string) => (
<StatusBadge status={v} colorMap={{ pending: 'bg-yellow-500', approved: 'bg-green-500', rejected: 'bg-red-500', draft: 'bg-gray-400' }}
labelMap={{ pending: 'На рассмотрении', approved: 'Одобрена', rejected: 'Отклонена', draft: 'Черновик' }} />
)},
{ key: 'organization', label: 'Организация' },
{ key: 'submitted_at', label: 'Дата подачи', render: (v: string) => v ? new Date(v).toLocaleDateString('ru-RU') : '—' },
]}
data={certData.items}
/>
) : <EmptyState message="Нет заявок на сертификацию" />}
<LegalBasisBadge items={['ФАП-246 (приказ Минтранса № 246)', 'ICAO Annex 6 Part I', 'EASA Part-ORO']} />
</div>
)}
{/* === SAFETY TAB === */}
{tab === 'safety' && (
<div className="space-y-4">
<div className="flex gap-2 items-center mb-2">
<span className="text-xs text-gray-500">Период:</span>
{[30, 90, 180, 365].map(d => (
<button key={d} onClick={() => { setDays(d); setSafetyData(null); }}
className={`px-2.5 py-1 rounded text-xs ${days === d ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600'}`}>
{d}д
</button>
))}
</div>
{safetyData ? (
<>
{/* Severity distribution */}
<div className="card p-4">
<h4 className="text-sm font-bold text-gray-600 mb-3">Распределение рисков по степени серьёзности</h4>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{Object.entries(safetyData.severity_distribution || {}).map(([sev, cnt]) => (
<StatCard key={sev} label={sev} value={cnt as number}
color={sev === 'critical' ? 'red' : sev === 'high' ? 'yellow' : sev === 'medium' ? 'primary' : 'gray'} />
))}
</div>
</div>
{/* Monthly trend */}
{safetyData.monthly_trend?.length > 0 && (
<div className="card p-4">
<h4 className="text-sm font-bold text-gray-600 mb-3">Динамика рисков по месяцам</h4>
<div className="flex items-end gap-2 h-32">
{safetyData.monthly_trend.map((m: any, i: number) => {
const maxC = Math.max(...safetyData.monthly_trend.map((t: any) => t.count), 1);
return (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<span className="text-[10px] text-gray-500">{m.count}</span>
<div className="w-full bg-blue-500 rounded-t transition-all"
style={{ height: `${(m.count / maxC) * 100}%`, minHeight: '4px' }} />
<span className="text-[9px] text-gray-400 truncate w-full text-center">
{m.month ? new Date(m.month).toLocaleDateString('ru-RU', { month: 'short' }) : '?'}
</span>
</div>
);
})}
</div>
</div>
)}
<div className="card p-4 bg-red-50 border-red-200">
<div className="flex items-center gap-2">
<span className="text-2xl"></span>
<div>
<div className="text-lg font-bold text-red-700">{safetyData.critical_unresolved}</div>
<div className="text-xs text-red-600">Критических неустранённых рисков</div>
</div>
</div>
</div>
</>
) : <div className="text-center py-8 text-gray-400">Загрузка...</div>}
<LegalBasisBadge items={['ВК РФ ст. 24.1 (ГПБП)', 'ICAO Annex 19', 'ICAO Doc 9859 (SMM)', 'EASA Part-ORO.GEN.200']} />
</div>
)}
{/* === AUDITS TAB === */}
{tab === 'audits' && (
<div>
<p className="text-xs text-gray-500 mb-3">Результаты инспекционного контроля (ВК РФ ст. 28)</p>
{auditData?.items?.length ? (
<DataTable
columns={[
{ key: 'id', label: '№', render: (v: string) => v?.slice(0, 8) },
{ key: 'type', label: 'Тип проверки' },
{ key: 'status', label: 'Результат', render: (v: string) => (
<StatusBadge status={v} colorMap={{ completed: 'bg-green-500', open: 'bg-yellow-500', failed: 'bg-red-500' }}
labelMap={{ completed: 'Завершён', open: 'В процессе', failed: 'Замечания' }} />
)},
{ key: 'aircraft_reg', label: 'Рег. знак ВС' },
{ key: 'conducted_at', label: 'Дата', render: (v: string) => v ? new Date(v).toLocaleDateString('ru-RU') : '—' },
]}
data={auditData.items}
/>
) : <EmptyState message="Нет данных об аудитах" />}
<LegalBasisBadge items={['ВК РФ ст. 28', 'ICAO Doc 9734 (Safety Oversight Manual)', 'EASA Part-ARO.GEN.300']} />
</div>
)}
</>
)}
{/* === PERSONNEL PLG TAB === */}
{tab === 'personnel' && (
<div className="space-y-4">
<p className="text-xs text-gray-500 mb-3">Агрегированные данные о персонале ПЛГ (ВК РФ ст. 52-54; ФАП-147; ICAO Annex 1)</p>
{personnelData ? (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div className="card p-4 text-center"><div className="text-3xl font-bold">{personnelData.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">{personnelData.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">{personnelData.non_compliant}</div><div className="text-xs text-red-600">Нарушения</div></div>
<div className="card p-4 text-center bg-blue-50"><div className="text-3xl font-bold text-blue-600">{personnelData.compliance_rate}%</div><div className="text-xs text-blue-600">Compliance rate</div></div>
</div>
{Object.keys(personnelData.by_category || {}).length > 0 && (
<div className="card p-4">
<h4 className="text-sm font-bold text-gray-600 mb-3">Распределение по категориям (EASA Part-66 / ФАП-147)</h4>
<div className="grid grid-cols-4 gap-2">
{Object.entries(personnelData.by_category).map(([cat, cnt]) => (
<div key={cat} className="text-center p-2 rounded bg-gray-50">
<div className="text-lg font-bold">{cnt as number}</div>
<div className="text-xs text-gray-500">Кат. {cat}</div>
</div>
))}
</div>
</div>
)}
<div className="text-[10px] text-gray-400">Персональные данные (ФИО, табельные номера) не раскрываются (ФЗ-152)</div>
</>
) : <div className="text-center py-8 text-gray-400">Загрузка...</div>}
</div>
)}
{/* Footer */}
<div className="mt-8 pt-4 border-t border-gray-100 text-[10px] text-gray-400 space-y-1">
<div>Данные предоставлены из АСУ ТК КЛГ согласно ТЗ (утв. 24.07.2022) и Поручению Президента Пр-1379. Агрегированные показатели коммерческая тайна и ПДн не раскрываются.</div>
<div>Система соответствует требованиям ФЗ-152 «О персональных данных» и ФЗ-149 «Об информации».</div>
<div>© {new Date().getFullYear()} АО «REFLY» Разработчик АСУ ТК</div>
</div>
</PageLayout>
);
}