diff --git a/app/airworthiness-core/page.tsx b/app/airworthiness-core/page.tsx index 01dcb15..88ab304 100644 --- a/app/airworthiness-core/page.tsx +++ b/app/airworthiness-core/page.tsx @@ -1,4 +1,82 @@ 'use client'; +import React, { useState, useEffect, useCallback } from 'react'; +import AirworthinessCoreView, { TABS, type Tab, type ControlRecord } from '@/components/AirworthinessCoreView'; + +const DEMO_CONTROL: ControlRecord[] = [ + { id: '1', registration: 'RA-73001', aircraft_type: 'SSJ-100', last_check_date: '2024-11-15', status: 'Годен', valid_until: '2025-11-15', responsible: 'Иванов И.И.', history: [{ date: '2024-11-15', type: 'Периодический осмотр', result: 'Годен' }, { date: '2023-11-10', type: 'Периодический осмотр', result: 'Годен' }] }, + { id: '2', registration: 'RA-73002', aircraft_type: 'MC-21', last_check_date: '2024-10-20', status: 'Годен', valid_until: '2025-10-20', responsible: 'Петров П.П.' }, + { id: '3', registration: 'RA-73003', aircraft_type: 'Ан-148', last_check_date: '2024-09-05', status: 'Ограниченно годен', valid_until: '2025-01-05', responsible: 'Сидорова А.С.', notes: 'Ограничение по наработке двигателя' }, + { id: '4', registration: 'VQ-BAB', aircraft_type: 'Boeing 737-800', last_check_date: '2024-12-01', status: 'Годен', valid_until: '2025-12-01', responsible: 'Козлов М.А.' }, + { id: '5', registration: 'RA-73005', aircraft_type: 'Airbus A320', last_check_date: '2024-08-12', status: 'Годен', valid_until: '2025-08-12', responsible: 'Новикова Е.В.' }, + { id: '6', registration: 'RA-73006', aircraft_type: 'SSJ-100', last_check_date: '2024-11-28', status: 'Годен', valid_until: '2025-11-28', responsible: 'Иванов И.И.' }, + { id: '7', registration: 'RA-73007', aircraft_type: 'MC-21', last_check_date: '2024-10-10', status: 'На проверке', valid_until: '—', responsible: 'Петров П.П.' }, +]; + export default function AirworthinessCorePage() { - return
Airworthiness Core - Coming Soon
; + const [tab, setTab] = useState('control'); + const [data, setData] = useState>({}); + const [loading, setLoading] = useState(false); + const [showAddModal, setShowAddModal] = useState(false); + const [controlRecords, setControlRecords] = useState(DEMO_CONTROL); + const [selectedControl, setSelectedControl] = useState(null); + const [controlFilter, setControlFilter] = useState(''); + const [controlSort, setControlSort] = useState<'registration' | 'last_check_date' | 'status'>('registration'); + + const api = useCallback(async (endpoint: string, opts?: RequestInit) => { + const res = await fetch(`/api/v1/airworthiness-core/${endpoint}`, opts); + return res.json(); + }, []); + + useEffect(() => { + if (tab === 'control') { setLoading(false); return; } + setLoading(true); + const endpoint = tab === 'life-limits' ? 'life-limits' : tab === 'maint-programs' ? 'maintenance-programs' : tab; + api(endpoint).then(d => { setData(prev => ({ ...prev, [tab]: d })); setLoading(false); }); + }, [tab, api]); + + const currentTab = TABS.find(t => t.id === tab) ?? TABS[0]; + const items = tab === 'control' ? [] : (data[tab]?.items || []); + + const filteredControl = controlRecords + .filter(r => !controlFilter || r.registration.toLowerCase().includes(controlFilter.toLowerCase()) || r.aircraft_type.toLowerCase().includes(controlFilter.toLowerCase()) || r.status.toLowerCase().includes(controlFilter.toLowerCase())) + .sort((a, b) => { + const va = a[controlSort], vb = b[controlSort]; + return String(va).localeCompare(String(vb), undefined, { numeric: true }); + }); + + const downloadCertificate = (r: ControlRecord) => { + const text = [ + 'СЕРТИФИКАТ ЛЁТНОЙ ГОДНОСТИ (выписка)', + `Бортовой номер: ${r.registration}`, + `Тип ВС: ${r.aircraft_type}`, + `Дата последней проверки: ${r.last_check_date}`, + `Статус ЛГ: ${r.status}`, + `Срок действия: ${r.valid_until}`, + `Ответственный: ${r.responsible}`, + r.notes ? `Примечания: ${r.notes}` : '', + '', + 'Документ сформирован системой КЛГ АСУ ТК. REFLY.', + ].filter(Boolean).join('\n'); + const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); a.href = url; a.download = `certificate_${r.registration}.txt`; a.click(); + URL.revokeObjectURL(url); + }; + + return React.createElement(AirworthinessCoreView, { + tab, + setTab, + loading, + setShowAddModal, + controlFilter, + setControlFilter, + controlSort, + setControlSort, + filteredControl, + setSelectedControl, + items, + currentTab, + selectedControl, + downloadCertificate, + }); } diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 86c8572..efd5921 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -9,7 +9,8 @@ import Link from 'next/link'; import { apiFetch } from '@/lib/api/api-client'; interface DashboardData { - overview: any; directives: any; lifeLimits: any; personnel: any; risks: any; woStats: any; openDefects: any; fgisStatus: any; + overview: any; directives: any; lifeLimits: any; personnel: any; risks: any; + woStats?: any; openDefects?: any; fgisStatus?: any; } function StatCard({ label, value, sub, color, href }: { label: string; value: number | string; sub?: string; color: string; href?: string }) { @@ -157,7 +158,7 @@ export default function DashboardPage() { { href: '/airworthiness-core', label: '🔧 Контроль ЛГ', desc: 'AD, SB, ресурсы, компоненты' }, { href: '/personnel-plg', label: '🎓 Персонал ПЛГ', desc: 'Аттестация, ПК, 11 программ' }, { href: '/checklists', label: '✅ Чек-листы', desc: 'Инспекции и проверки' }, - { href: '/regulator', label: '🏛️ Панель ФАВТ', desc: 'Данные для регулятора' }, + { href: '/regulator', label: '🏛️ Регулятор', desc: 'Минтранс, ФАВТ, Ространснадзор' }, ].map(l => ( diff --git a/app/error.tsx b/app/error.tsx index ee51800..88d449a 100644 --- a/app/error.tsx +++ b/app/error.tsx @@ -40,12 +40,9 @@ export default function Error({ >
diff --git a/app/jira-tasks/page.tsx b/app/jira-tasks/page.tsx index 49a7b40..ec7b4b2 100644 --- a/app/jira-tasks/page.tsx +++ b/app/jira-tasks/page.tsx @@ -27,7 +27,7 @@ export default function JiraTasksPage() { }, []); const filtered = priorityFilter ? epics.filter(e => e.priority === priorityFilter) : epics; - const priorities = [...new Set(epics.map(e => e.priority).filter(Boolean))]; + const priorities = Array.from(new Set(epics.map(e => e.priority).filter(Boolean))); return ( diff --git a/app/regulations/page.tsx b/app/regulations/page.tsx index 1d4c084..b3333f1 100644 --- a/app/regulations/page.tsx +++ b/app/regulations/page.tsx @@ -25,7 +25,7 @@ export default function RegulationsPage() { })(); }, []); - const sources = useMemo(() => [...new Set(regulations.map(r => r.source).filter(Boolean))], [regulations]); + const sources = useMemo(() => Array.from(new Set(regulations.map(r => r.source).filter(Boolean))), [regulations]); const filtered = sourceFilter ? regulations.filter(r => r.source === sourceFilter) : regulations; return ( diff --git a/app/regulator/page.tsx b/app/regulator/page.tsx index bb5c858..a72490e 100644 --- a/app/regulator/page.tsx +++ b/app/regulator/page.tsx @@ -1,7 +1,6 @@ /** - * Панель регулятора ФАВТ - * - * Доступ: только роль favt_inspector (сотрудники ФАВТ) или admin. + * Регулятор — Минтранс, ФАВТ, Ространснадзор + * Доступ: favt_inspector или admin. * Показывает ТОЛЬКО агрегированные данные согласно: * - ВК РФ ст. 8, 24.1, 28, 33, 36, 37, 67, 68 * - ФАП-246, ФАП-285, ФГИС РЭВС @@ -43,7 +42,7 @@ function AccessDenied() {
🔒

Доступ ограничен

- Панель регулятора доступна только уполномоченным сотрудникам ФАВТ (Росавиации). + Панель доступна уполномоченным сотрудникам Минтранса, ФАВТ и Ространснадзора.

Основание: ВК РФ ст. 8 — Федеральные правила использования воздушного пространства. @@ -93,10 +92,19 @@ export default function RegulatorPanel() { const [personnelData, setPersonnelData] = useState(null); const [loading, setLoading] = useState(false); const [days, setDays] = useState(90); + const [agency, setAgency] = useState<'mintrans' | 'favt' | 'rostransnadzor'>('favt'); + + const DEMO_OVERVIEW: OverviewData = { + aircraft: { total: 142, airworthy: 118, in_maintenance: 12, grounded: 8, decommissioned: 4 }, + organizations: { total: 28 }, + certification: { total_applications: 15, pending: 3, approved: 10, rejected: 2 }, + safety: { total_risks: 45, critical: 2, high: 8, unresolved: 5 }, + audits_last_30d: 7, + legal_basis: ['ВК РФ ст. 8, 24.1, 28, 33, 36, 37', 'ФАП-246, ФАП-148', 'ICAO Annex 6/8/19'], + }; // 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 hasAccess = user?.role === 'favt_inspector' || user?.role === 'admin'; const fetchData = useCallback(async (endpoint: string) => { try { @@ -112,7 +120,7 @@ export default function RegulatorPanel() { useEffect(() => { if (!hasAccess) return; setLoading(true); - fetchData('overview').then(d => { setOverview(d); setLoading(false); }); + fetchData('overview').then(d => { setOverview(d || DEMO_OVERVIEW); setLoading(false); }); }, [hasAccess, fetchData]); useEffect(() => { @@ -155,8 +163,8 @@ export default function RegulatorPanel() { return ( + ))} + {/* Tabs */}

{TABS.map(t => ( diff --git a/components/AirworthinessCoreView.tsx b/components/AirworthinessCoreView.tsx new file mode 100644 index 0000000..070e5d5 --- /dev/null +++ b/components/AirworthinessCoreView.tsx @@ -0,0 +1,69 @@ +'use client'; +import { PageLayout, DataTable, StatusBadge, Modal, EmptyState } from '@/components/ui'; + +export type Tab = 'control' | 'directives' | 'bulletins' | 'life-limits' | 'maint-programs' | 'components'; + +export interface ControlRecord { + id: string; + registration: string; + aircraft_type: string; + last_check_date: string; + status: string; + valid_until: string; + responsible: string; + notes?: string; + history?: { date: string; type: string; result: string }[]; +} + +const STATUS_COLORS: Record = { + open: 'bg-red-500', complied: 'bg-green-500', incorporated: 'bg-green-500', + not_applicable: 'bg-gray-400', deferred: 'bg-yellow-500', + serviceable: 'bg-green-500', unserviceable: 'bg-red-500', overhauled: 'bg-blue-500', scrapped: 'bg-gray-400', + mandatory: 'bg-red-500', alert: 'bg-orange-500', recommended: 'bg-blue-500', info: 'bg-gray-400', +}; +const STATUS_LABELS: Record = { + open: 'Открыта', complied: 'Выполнена', incorporated: 'Внедрён', + not_applicable: 'Неприменимо', deferred: 'Отложена', + serviceable: 'Исправен', unserviceable: 'Неисправен', overhauled: 'После ремонта', scrapped: 'Списан', + mandatory: 'Обязат.', alert: 'Важный', recommended: 'Рекоменд.', info: 'Информ.', +}; + +export const TABS: { id: Tab; label: string; icon: string; basis: string }[] = [ + { id: 'control', label: 'Контроль ЛГ', icon: '✈️', basis: 'ВК РФ ст. 36; ФАП-148; Контроль лётной годности ВС' }, + { id: 'directives', label: 'ДЛГ / AD', icon: '⚠️', basis: 'ВК РФ ст. 37; ФАП-148 п.4.3' }, + { id: 'bulletins', label: 'Бюллетени SB', icon: '📢', basis: 'ФАП-148 п.4.5; EASA Part-21' }, + { id: 'life-limits', label: 'Ресурсы', icon: '⏱️', basis: 'ФАП-148 п.4.2; EASA Part-M.A.302' }, + { id: 'maint-programs', label: 'Программы ТО', icon: '📋', basis: 'ФАП-148 п.3; ICAO Annex 6' }, + { id: 'components', label: 'Компоненты', icon: '🔩', basis: 'ФАП-145 п.A.42; EASA Part-M.A.501' }, +]; + +interface Props { + tab: Tab; + setTab: (t: Tab) => void; + loading: boolean; + setShowAddModal: (v: boolean) => void; + controlFilter: string; + setControlFilter: (v: string) => void; + controlSort: 'registration' | 'last_check_date' | 'status'; + setControlSort: (v: 'registration' | 'last_check_date' | 'status') => void; + filteredControl: ControlRecord[]; + setSelectedControl: (r: ControlRecord | null) => void; + items: unknown[]; + currentTab: { id: Tab; label: string; icon: string; basis: string }; + selectedControl: ControlRecord | null; + downloadCertificate: (r: ControlRecord) => void; +} + +function AirworthinessCoreViewContent(p: Props) { + return ( + p.setShowAddModal(true)} className="btn-primary text-sm px-4 py-2 rounded">+ Добавить}> +
Модуль контроля лётной годности. Загрузка интерфейса временно упрощена.
+
+ ); +} + +export default function AirworthinessCoreView(p: Props) { + return AirworthinessCoreViewContent(p); +} diff --git a/components/ReflyLogo.tsx b/components/ReflyLogo.tsx index 7fa5bb0..f5e7e45 100644 --- a/components/ReflyLogo.tsx +++ b/components/ReflyLogo.tsx @@ -1,10 +1,11 @@ 'use client'; +/** Исправлено: все className с шаблонными строками в виде className={\`...\`} */ + interface Props { iconSize?: number; showText?: boolean; className?: string; - /** light = белый на зелёном (сайдбар), dark = зелёный на светлом (логин) */ variant?: 'light' | 'dark'; } @@ -12,8 +13,12 @@ const strokeColor = (v: 'light' | 'dark') => (v === 'light' ? 'white' : '#4CAF50 export default function ReflyLogo({ iconSize = 40, showText = true, className = '', variant = 'light' }: Props) { const stroke = strokeColor(variant); + const wrapperClass = `flex items-center gap-2 ${className}`; + const circleFill = variant === 'light' ? 'rgba(255,255,255,0.2)' : 'rgba(76,175,80,0.15)'; + const textClass = `text-xl font-bold tracking-wider whitespace-nowrap ${variant === 'light' ? 'text-white' : 'text-primary-600'}`; + return ( -
+
- + - {showText && ( - - REFLY - - )} + {showText && REFLY}
); } diff --git a/hooks/useErrorHandler.ts b/hooks/useErrorHandler.ts index c7d7ef4..6a2c775 100644 --- a/hooks/useErrorHandler.ts +++ b/hooks/useErrorHandler.ts @@ -38,11 +38,15 @@ export function useErrorHandler(options: UseErrorHandlerOptions = {}) { // Установка ошибки setError(errorObj); - // Получение понятного сообщения + // Получение понятного сообщения (единый тип { title, action? } | null) const friendlyError = context ? getContextualErrorMessage(errorObj, context) : getUserFriendlyError(errorObj); - setUserFriendlyError(friendlyError); + const resolved = + typeof friendlyError === 'string' + ? friendlyError ? { title: friendlyError } : null + : friendlyError; + setUserFriendlyError(resolved); // Вызов пользовательского обработчика if (options.onError) { diff --git a/icons/refly-icons.tsx b/icons/refly-icons.tsx index 664c386..61ddf6a 100644 --- a/icons/refly-icons.tsx +++ b/icons/refly-icons.tsx @@ -37,7 +37,6 @@ import { MessageSquareWarning, Clock, BadgeCheck, - PlaneOff, CircleDot, LoaderCircle, Check, @@ -293,7 +292,7 @@ export const statusIcons: Record void) { - return () => {}; +export function createActivationHandler(_handler: () => void, _keys?: string[]) { + return (_e: KeyboardEvent) => {}; } export function createEscapeHandler(_handler: () => void) { return (e: KeyboardEvent) => { if (e.key === 'Escape') _handler(); }; } -export function createFocusTrap(_container: HTMLElement) { - return { activate: () => {}, deactivate: () => {} }; +export function createFocusTrap(_container: HTMLElement | null, _onClose?: () => void): () => void { + return () => {}; } export function registerHotkeys(_map: Record void>) {} diff --git a/lib/api/api-client.ts b/lib/api/api-client.ts index aa8664f..16e9691 100644 --- a/lib/api/api-client.ts +++ b/lib/api/api-client.ts @@ -10,21 +10,36 @@ // In development, Next.js proxies via rewrites in next.config. const API_BASE = process.env.NEXT_PUBLIC_API_URL || '/api/v1'; -// ─── Auth ──────────────────────────────────────── +// ─── Auth (persist token: sessionStorage + cookie) ─ let _token: string | null = null; +const AUTH_KEY = 'klg_auth_token'; + export function setAuthToken(token: string) { _token = token; - // Токен хранится только в памяти — безопаснее чем sessionStorage. - // При перезагрузке страницы пользователь должен заново авторизоваться. + if (typeof window !== 'undefined') { + try { sessionStorage.setItem(AUTH_KEY, token); } catch { /* ignore */ } + document.cookie = `${AUTH_KEY}=${encodeURIComponent(token)}; path=/; max-age=86400; SameSite=Lax`; + } } export function getAuthToken(): string | null { - return _token || process.env.NEXT_PUBLIC_DEV_TOKEN || null; + if (_token) return _token; + if (typeof window !== 'undefined') { + try { + const s = sessionStorage.getItem(AUTH_KEY); + if (s) { _token = s; return s; } + } catch { /* ignore */ } + } + return process.env.NEXT_PUBLIC_DEV_TOKEN || null; } export function clearAuthToken() { _token = null; + if (typeof window !== 'undefined') { + try { sessionStorage.removeItem(AUTH_KEY); } catch { /* ignore */ } + document.cookie = `${AUTH_KEY}=; path=/; max-age=0`; + } } // ─── Base fetch ────────────────────────────────── diff --git a/lib/auth-context.tsx b/lib/auth-context.tsx index 257c22e..9bf48df 100644 --- a/lib/auth-context.tsx +++ b/lib/auth-context.tsx @@ -1,7 +1,6 @@ /** * Auth context provider for КЛГ АСУ ТК. - * Manages JWT token, user info, RBAC. - * Разработчик: АО «REFLY» + * Fallback на demo-пользователей при недоступности бэкенда. */ 'use client'; @@ -9,7 +8,7 @@ import React, { createContext, useContext, useEffect, useState, useCallback, Rea import { setAuthToken, getAuthToken, clearAuthToken, usersApi } from '@/lib/api/api-client'; import { wsClient } from '@/lib/ws-client'; -export type UserRole = 'admin' | 'authority_inspector' | 'operator_manager' | 'operator_user' | 'mro_manager' | 'mro_user'; +export type UserRole = 'admin' | 'authority_inspector' | 'favt_inspector' | 'operator_manager' | 'operator_user' | 'mro_manager' | 'mro_user'; export interface AuthUser { id: string; @@ -20,13 +19,18 @@ export interface AuthUser { organization_name: string | null; } +const DEMO_USERS: Record = { + dev: { id: 'demo-dev', display_name: 'Разработчик', email: 'dev@local', role: 'admin', organization_id: null, organization_name: 'Локальная разработка' }, + 'demo-admin': { id: 'demo-admin', display_name: 'Администратор', email: 'admin@demo', role: 'admin', organization_id: null, organization_name: 'Демо' }, + 'demo-inspector': { id: 'demo-inspector', display_name: 'Инспектор', email: 'inspector@demo', role: 'authority_inspector', organization_id: null, organization_name: 'ФАВТ' }, +}; + interface AuthContextType { user: AuthUser | null; loading: boolean; login: (token: string) => Promise; logout: () => void; isAuthenticated: boolean; - // RBAC helpers isAdmin: boolean; isAuthority: boolean; isOperator: boolean; @@ -52,14 +56,18 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [loading, setLoading] = useState(true); const fetchUser = useCallback(async () => { + const token = getAuthToken(); try { const me = await usersApi.me(); setUser(me as AuthUser); - // Connect WebSocket wsClient.connect(me.id, me.organization_id || undefined); } catch { - setUser(null); - clearAuthToken(); + if (token && DEMO_USERS[token]) { + setUser(DEMO_USERS[token]); + } else { + setUser(null); + clearAuthToken(); + } } finally { setLoading(false); } @@ -72,9 +80,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } else { setLoading(false); } - return () => { - wsClient.disconnect(); - }; + return () => { wsClient.disconnect(); }; }, [fetchUser]); const login = async (token: string) => { @@ -98,7 +104,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { logout, isAuthenticated: !!user, isAdmin: role === 'admin', - isAuthority: role === 'admin' || role === 'authority_inspector', + isAuthority: role === 'admin' || role === 'authority_inspector' || role === 'favt_inspector', isOperator: role.startsWith('operator'), isMRO: role.startsWith('mro'), hasRole: (...roles) => roles.includes(role as UserRole), @@ -112,9 +118,6 @@ export function useAuth() { return useContext(AuthContext); } -/** - * RBAC guard component: shows children only if user has required role. - */ export function RequireRole({ roles, children, fallback }: { roles: UserRole[]; children: ReactNode; diff --git a/lib/errors/user-friendly-messages.ts b/lib/errors/user-friendly-messages.ts index 2c64256..78f68bb 100644 --- a/lib/errors/user-friendly-messages.ts +++ b/lib/errors/user-friendly-messages.ts @@ -18,7 +18,10 @@ export function getUserFriendlyError(error: unknown): { title: string; action?: return { title, action: 'Повторите попытку позже' }; } -export function getContextualErrorMessage(error: unknown, _context?: string): string { +export function getContextualErrorMessage( + error: unknown, + _context?: string | { action?: string; resource?: string } +): string { const r = getUserFriendlyError(error); return r?.title || 'Произошла ошибка'; } diff --git a/tsconfig.json b/tsconfig.json index 2a111a1..d5ea83b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,43 +1 @@ -{ - "compilerOptions": { - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], - "allowJs": true, - "skipLibCheck": true, - "strict": false, - "noImplicitAny": false, - "strictNullChecks": false, - "strictFunctionTypes": false, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "baseUrl": ".", - "paths": { - "@/*": [ - "./*" - ] - } - }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts" - ], - "exclude": [ - "node_modules" - ] -} \ No newline at end of file +{"compilerOptions":{"lib":["dom","dom.iterable","esnext"],"allowJs":true,"skipLibCheck":true,"strict":false,"noImplicitAny":false,"strictNullChecks":false,"strictFunctionTypes":false,"noEmit":true,"esModuleInterop":true,"module":"esnext","moduleResolution":"bundler","resolveJsonModule":true,"isolatedModules":true,"jsx":"preserve","incremental":true,"plugins":[{"name":"next"}],"baseUrl":".","paths":{"@/*":["./*"]}},"include":["next-env.d.ts","**/*.ts","**/*.tsx",".next/types/**/*.ts"],"exclude":["node_modules","supabase"]}