fix: logo, auth, demo data, regulator panel; airworthiness-core placeholder; build fixes

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yuriy 2026-02-18 10:57:27 +03:00
parent 33d391778a
commit 90ba3bde20
16 changed files with 258 additions and 100 deletions

View File

@ -1,4 +1,82 @@
'use client'; '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() { export default function AirworthinessCorePage() {
return <div>Airworthiness Core - Coming Soon</div>; const [tab, setTab] = useState<Tab>('control');
const [data, setData] = useState<Record<string, { items?: unknown[] }>>({});
const [loading, setLoading] = useState(false);
const [showAddModal, setShowAddModal] = useState(false);
const [controlRecords, setControlRecords] = useState<ControlRecord[]>(DEMO_CONTROL);
const [selectedControl, setSelectedControl] = useState<ControlRecord | null>(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,
});
} }

View File

@ -9,7 +9,8 @@ import Link from 'next/link';
import { apiFetch } from '@/lib/api/api-client'; import { apiFetch } from '@/lib/api/api-client';
interface DashboardData { 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 }) { 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: '/airworthiness-core', label: '🔧 Контроль ЛГ', desc: 'AD, SB, ресурсы, компоненты' },
{ href: '/personnel-plg', label: '🎓 Персонал ПЛГ', desc: 'Аттестация, ПК, 11 программ' }, { href: '/personnel-plg', label: '🎓 Персонал ПЛГ', desc: 'Аттестация, ПК, 11 программ' },
{ href: '/checklists', label: '✅ Чек-листы', desc: 'Инспекции и проверки' }, { href: '/checklists', label: '✅ Чек-листы', desc: 'Инспекции и проверки' },
{ href: '/regulator', label: '🏛️ Панель ФАВТ', desc: 'Данные для регулятора' }, { href: '/regulator', label: '🏛️ Регулятор', desc: 'Минтранс, ФАВТ, Ространснадзор' },
].map(l => ( ].map(l => (
<Link key={l.href} href={l.href} <Link key={l.href} href={l.href}
className="card p-3 hover:shadow-md transition-shadow"> className="card p-3 hover:shadow-md transition-shadow">

View File

@ -40,12 +40,9 @@ export default function Error({
> >
<div className="max-w-xl"> <div className="max-w-xl">
<ErrorDisplay <ErrorDisplay
title={friendlyError.title} error={error}
message={friendlyError.message}
type={friendlyError.type}
onRetry={reset} onRetry={reset}
showDetails={process.env.NODE_ENV === 'development'} variant="page"
details={error.stack}
/> />
</div> </div>
</div> </div>

View File

@ -27,7 +27,7 @@ export default function JiraTasksPage() {
}, []); }, []);
const filtered = priorityFilter ? epics.filter(e => e.priority === priorityFilter) : epics; 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 ( return (
<PageLayout title="Задачи Jira" subtitle={loading ? 'Загрузка...' : `Эпиков: ${filtered.length}`}> <PageLayout title="Задачи Jira" subtitle={loading ? 'Загрузка...' : `Эпиков: ${filtered.length}`}>

View File

@ -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; const filtered = sourceFilter ? regulations.filter(r => r.source === sourceFilter) : regulations;
return ( return (

View File

@ -1,7 +1,6 @@
/** /**
* Панель регулятора ФАВТ * Регулятор Минтранс, ФАВТ, Ространснадзор
* * Доступ: favt_inspector или admin.
* Доступ: только роль favt_inspector (сотрудники ФАВТ) или admin.
* Показывает ТОЛЬКО агрегированные данные согласно: * Показывает ТОЛЬКО агрегированные данные согласно:
* - ВК РФ ст. 8, 24.1, 28, 33, 36, 37, 67, 68 * - ВК РФ ст. 8, 24.1, 28, 33, 36, 37, 67, 68
* - ФАП-246, ФАП-285, ФГИС РЭВС * - ФАП-246, ФАП-285, ФГИС РЭВС
@ -43,7 +42,7 @@ function AccessDenied() {
<div className="text-6xl mb-4">🔒</div> <div className="text-6xl mb-4">🔒</div>
<h1 className="text-2xl font-bold text-gray-800 mb-2">Доступ ограничен</h1> <h1 className="text-2xl font-bold text-gray-800 mb-2">Доступ ограничен</h1>
<p className="text-gray-500 mb-4"> <p className="text-gray-500 mb-4">
Панель регулятора доступна только уполномоченным сотрудникам ФАВТ (Росавиации). Панель доступна уполномоченным сотрудникам Минтранса, ФАВТ и Ространснадзора.
</p> </p>
<p className="text-xs text-gray-400"> <p className="text-xs text-gray-400">
Основание: ВК РФ ст. 8 Федеральные правила использования воздушного пространства. Основание: ВК РФ ст. 8 Федеральные правила использования воздушного пространства.
@ -93,10 +92,19 @@ export default function RegulatorPanel() {
const [personnelData, setPersonnelData] = useState<any>(null); const [personnelData, setPersonnelData] = useState<any>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [days, setDays] = useState(90); 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 // Access control: only favt_inspector or admin
const hasAccess = user?.role === 'favt_inspector' || user?.role === '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) => { const fetchData = useCallback(async (endpoint: string) => {
try { try {
@ -112,7 +120,7 @@ export default function RegulatorPanel() {
useEffect(() => { useEffect(() => {
if (!hasAccess) return; if (!hasAccess) return;
setLoading(true); setLoading(true);
fetchData('overview').then(d => { setOverview(d); setLoading(false); }); fetchData('overview').then(d => { setOverview(d || DEMO_OVERVIEW); setLoading(false); });
}, [hasAccess, fetchData]); }, [hasAccess, fetchData]);
useEffect(() => { useEffect(() => {
@ -155,8 +163,8 @@ export default function RegulatorPanel() {
return ( return (
<PageLayout <PageLayout
title="🏛️ Панель регулятора — ФАВТ" title="🏛️ Регулятор — Минтранс, ФАВТ, Ространснадзор"
subtitle="Федеральное агентство воздушного транспорта (Росавиация)" subtitle="Минтранс России · Федеральное агентство воздушного транспорта · Ространснадзор"
actions={ actions={
<div className="flex gap-2"> <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"> <button onClick={handleExport} className="btn-sm bg-blue-600 text-white px-4 py-2 rounded flex items-center gap-1">
@ -175,6 +183,15 @@ export default function RegulatorPanel() {
Персональные данные и коммерческая тайна не раскрываются. Персональные данные и коммерческая тайна не раскрываются.
</div> </div>
{/* Ведомства */}
<div className="flex gap-1 mb-4 border-b border-gray-200">
{(['mintrans', 'favt', 'rostransnadzor'] as const).map(a => (
<button key={a} onClick={() => setAgency(a)}
className={`px-4 py-2 text-sm font-medium border-b-2 ${agency === a ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500'}`}>
{a === 'mintrans' ? 'Минтранс' : a === 'favt' ? 'ФАВТ' : 'Ространснадзор'}
</button>
))}
</div>
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-gray-200 overflow-x-auto"> <div className="flex gap-1 mb-6 border-b border-gray-200 overflow-x-auto">
{TABS.map(t => ( {TABS.map(t => (

View File

@ -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<string, string> = {
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<string, string> = {
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 (
<PageLayout title="Контроль летной годности"
subtitle="Директивы, бюллетени, ресурсы, программы ТО, компоненты"
actions={<button onClick={() => p.setShowAddModal(true)} className="btn-primary text-sm px-4 py-2 rounded">+ Добавить</button>}>
<div className="p-4 text-gray-600">Модуль контроля лётной годности. Загрузка интерфейса временно упрощена.</div>
</PageLayout>
);
}
export default function AirworthinessCoreView(p: Props) {
return AirworthinessCoreViewContent(p);
}

View File

@ -1,10 +1,11 @@
'use client'; 'use client';
/** Исправлено: все className с шаблонными строками в виде className={\`...\`} */
interface Props { interface Props {
iconSize?: number; iconSize?: number;
showText?: boolean; showText?: boolean;
className?: string; className?: string;
/** light = белый на зелёном (сайдбар), dark = зелёный на светлом (логин) */
variant?: '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) { export default function ReflyLogo({ iconSize = 40, showText = true, className = '', variant = 'light' }: Props) {
const stroke = strokeColor(variant); 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 ( return (
<div className={`flex items-center gap-2 ${className}`}> <div className={wrapperClass}>
<svg <svg
width={iconSize} width={iconSize}
height={iconSize} height={iconSize}
@ -23,17 +28,13 @@ export default function ReflyLogo({ iconSize = 40, showText = true, className =
className="shrink-0" className="shrink-0"
aria-hidden aria-hidden
> >
<circle cx="20" cy="20" r="19" fill={variant === 'light' ? 'rgba(255,255,255,0.2)' : 'rgba(76,175,80,0.15)'} stroke={stroke} strokeWidth="1.5" /> <circle cx="20" cy="20" r="19" fill={circleFill} stroke={stroke} strokeWidth="1.5" />
<path d="M12 20 L20 12 L28 20 L20 28 Z" fill="none" stroke={stroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> <path d="M12 20 L20 12 L28 20 L20 28 Z" fill="none" stroke={stroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M14 20 L26 20" stroke={stroke} strokeWidth="1.5" strokeLinecap="round" /> <path d="M14 20 L26 20" stroke={stroke} strokeWidth="1.5" strokeLinecap="round" />
<path d="M20 14 L20 26" stroke={stroke} strokeWidth="1.5" strokeLinecap="round" /> <path d="M20 14 L20 26" stroke={stroke} strokeWidth="1.5" strokeLinecap="round" />
<path d="M16 16 L24 24 M24 16 L16 24" stroke={stroke} strokeWidth="1" strokeLinecap="round" opacity="0.8" /> <path d="M16 16 L24 24 M24 16 L16 24" stroke={stroke} strokeWidth="1" strokeLinecap="round" opacity="0.8" />
</svg> </svg>
{showText && ( {showText && <span className={textClass}>REFLY</span>}
<span className={`text-xl font-bold tracking-wider whitespace-nowrap ${variant === 'light' ? 'text-white' : 'text-primary-600'}`}>
REFLY
</span>
)}
</div> </div>
); );
} }

View File

@ -38,11 +38,15 @@ export function useErrorHandler(options: UseErrorHandlerOptions = {}) {
// Установка ошибки // Установка ошибки
setError(errorObj); setError(errorObj);
// Получение понятного сообщения // Получение понятного сообщения (единый тип { title, action? } | null)
const friendlyError = context const friendlyError = context
? getContextualErrorMessage(errorObj, context) ? getContextualErrorMessage(errorObj, context)
: getUserFriendlyError(errorObj); : getUserFriendlyError(errorObj);
setUserFriendlyError(friendlyError); const resolved =
typeof friendlyError === 'string'
? friendlyError ? { title: friendlyError } : null
: friendlyError;
setUserFriendlyError(resolved);
// Вызов пользовательского обработчика // Вызов пользовательского обработчика
if (options.onError) { if (options.onError) {

View File

@ -37,7 +37,6 @@ import {
MessageSquareWarning, MessageSquareWarning,
Clock, Clock,
BadgeCheck, BadgeCheck,
PlaneOff,
CircleDot, CircleDot,
LoaderCircle, LoaderCircle,
Check, Check,
@ -293,7 +292,7 @@ export const statusIcons: Record<StatusKey, LucideIcon | React.FC<ReflyIconProps
remarks: MessageSquareWarning, remarks: MessageSquareWarning,
expired: Clock, expired: Clock,
active: BadgeCheck, active: BadgeCheck,
grounded: PlaneOff, grounded: Ban,
maintenance: Wrench, maintenance: Wrench,
open: CircleDot, open: CircleDot,
in_progress: LoaderCircle, in_progress: LoaderCircle,

View File

@ -1,12 +1,25 @@
/** ARIA-атрибуты для доступности. MVP: минимальные возвращаемые объекты */ /** ARIA-атрибуты для доступности. MVP: минимальные возвращаемые объекты */
export function getButtonAriaProps(_opts?: { disabled?: boolean; pressed?: boolean }) { export function getButtonAriaProps(_opts?: {
label?: string; describedBy?: string; disabled?: boolean; pressed?: boolean; expanded?: boolean; controls?: string;
}) {
return { 'aria-disabled': false }; return { 'aria-disabled': false };
} }
export function getFormFieldAriaProps(_opts?: { id?: string; labelId?: string; errorId?: string; invalid?: boolean }) { export function getFormFieldAriaProps(_opts?: {
id?: string;
labelId?: string;
errorId?: string;
invalid?: boolean;
describedBy?: string;
required?: boolean;
}) {
return {}; return {};
} }
export function getModalAriaProps(_opts?: { titleId?: string; describedById?: string }) { export function getModalAriaProps(_opts?: {
titleId?: string;
describedById?: string;
descriptionId?: string;
}) {
return { role: 'dialog', 'aria-modal': true }; return { role: 'dialog', 'aria-modal': true };
} }

View File

@ -5,16 +5,16 @@ export class Hotkey {
unregister() {} unregister() {}
} }
export function createActivationHandler(_keys: string[], _handler: () => void) { export function createActivationHandler(_handler: () => void, _keys?: string[]) {
return () => {}; return (_e: KeyboardEvent) => {};
} }
export function createEscapeHandler(_handler: () => void) { export function createEscapeHandler(_handler: () => void) {
return (e: KeyboardEvent) => { if (e.key === 'Escape') _handler(); }; return (e: KeyboardEvent) => { if (e.key === 'Escape') _handler(); };
} }
export function createFocusTrap(_container: HTMLElement) { export function createFocusTrap(_container: HTMLElement | null, _onClose?: () => void): () => void {
return { activate: () => {}, deactivate: () => {} }; return () => {};
} }
export function registerHotkeys(_map: Record<string, () => void>) {} export function registerHotkeys(_map: Record<string, () => void>) {}

View File

@ -10,21 +10,36 @@
// In development, Next.js proxies via rewrites in next.config. // In development, Next.js proxies via rewrites in next.config.
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '/api/v1'; const API_BASE = process.env.NEXT_PUBLIC_API_URL || '/api/v1';
// ─── Auth ─────────────────────────────────────── // ─── Auth (persist token: sessionStorage + cookie)
let _token: string | null = null; let _token: string | null = null;
const AUTH_KEY = 'klg_auth_token';
export function setAuthToken(token: string) { export function setAuthToken(token: string) {
_token = token; _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 { 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() { export function clearAuthToken() {
_token = null; _token = null;
if (typeof window !== 'undefined') {
try { sessionStorage.removeItem(AUTH_KEY); } catch { /* ignore */ }
document.cookie = `${AUTH_KEY}=; path=/; max-age=0`;
}
} }
// ─── Base fetch ────────────────────────────────── // ─── Base fetch ──────────────────────────────────

View File

@ -1,7 +1,6 @@
/** /**
* Auth context provider for КЛГ АСУ ТК. * Auth context provider for КЛГ АСУ ТК.
* Manages JWT token, user info, RBAC. * Fallback на demo-пользователей при недоступности бэкенда.
* Разработчик: АО «REFLY»
*/ */
'use client'; '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 { setAuthToken, getAuthToken, clearAuthToken, usersApi } from '@/lib/api/api-client';
import { wsClient } from '@/lib/ws-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 { export interface AuthUser {
id: string; id: string;
@ -20,13 +19,18 @@ export interface AuthUser {
organization_name: string | null; organization_name: string | null;
} }
const DEMO_USERS: Record<string, AuthUser> = {
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 { interface AuthContextType {
user: AuthUser | null; user: AuthUser | null;
loading: boolean; loading: boolean;
login: (token: string) => Promise<void>; login: (token: string) => Promise<void>;
logout: () => void; logout: () => void;
isAuthenticated: boolean; isAuthenticated: boolean;
// RBAC helpers
isAdmin: boolean; isAdmin: boolean;
isAuthority: boolean; isAuthority: boolean;
isOperator: boolean; isOperator: boolean;
@ -52,14 +56,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const fetchUser = useCallback(async () => { const fetchUser = useCallback(async () => {
const token = getAuthToken();
try { try {
const me = await usersApi.me(); const me = await usersApi.me();
setUser(me as AuthUser); setUser(me as AuthUser);
// Connect WebSocket
wsClient.connect(me.id, me.organization_id || undefined); wsClient.connect(me.id, me.organization_id || undefined);
} catch { } catch {
setUser(null); if (token && DEMO_USERS[token]) {
clearAuthToken(); setUser(DEMO_USERS[token]);
} else {
setUser(null);
clearAuthToken();
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -72,9 +80,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} else { } else {
setLoading(false); setLoading(false);
} }
return () => { return () => { wsClient.disconnect(); };
wsClient.disconnect();
};
}, [fetchUser]); }, [fetchUser]);
const login = async (token: string) => { const login = async (token: string) => {
@ -98,7 +104,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
logout, logout,
isAuthenticated: !!user, isAuthenticated: !!user,
isAdmin: role === 'admin', isAdmin: role === 'admin',
isAuthority: role === 'admin' || role === 'authority_inspector', isAuthority: role === 'admin' || role === 'authority_inspector' || role === 'favt_inspector',
isOperator: role.startsWith('operator'), isOperator: role.startsWith('operator'),
isMRO: role.startsWith('mro'), isMRO: role.startsWith('mro'),
hasRole: (...roles) => roles.includes(role as UserRole), hasRole: (...roles) => roles.includes(role as UserRole),
@ -112,9 +118,6 @@ export function useAuth() {
return useContext(AuthContext); return useContext(AuthContext);
} }
/**
* RBAC guard component: shows children only if user has required role.
*/
export function RequireRole({ roles, children, fallback }: { export function RequireRole({ roles, children, fallback }: {
roles: UserRole[]; roles: UserRole[];
children: ReactNode; children: ReactNode;

View File

@ -18,7 +18,10 @@ export function getUserFriendlyError(error: unknown): { title: string; action?:
return { title, 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); const r = getUserFriendlyError(error);
return r?.title || 'Произошла ошибка'; return r?.title || 'Произошла ошибка';
} }

View File

@ -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","supabase"]}
"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"
]
}