fix: logo, auth, demo data, regulator panel; airworthiness-core placeholder; build fixes
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
33d391778a
commit
90ba3bde20
@ -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 <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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 => (
|
||||
<Link key={l.href} href={l.href}
|
||||
className="card p-3 hover:shadow-md transition-shadow">
|
||||
|
||||
@ -40,12 +40,9 @@ export default function Error({
|
||||
>
|
||||
<div className="max-w-xl">
|
||||
<ErrorDisplay
|
||||
title={friendlyError.title}
|
||||
message={friendlyError.message}
|
||||
type={friendlyError.type}
|
||||
error={error}
|
||||
onRetry={reset}
|
||||
showDetails={process.env.NODE_ENV === 'development'}
|
||||
details={error.stack}
|
||||
variant="page"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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 (
|
||||
<PageLayout title="Задачи Jira" subtitle={loading ? 'Загрузка...' : `Эпиков: ${filtered.length}`}>
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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() {
|
||||
<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 — Федеральные правила использования воздушного пространства.
|
||||
@ -93,10 +92,19 @@ export default function RegulatorPanel() {
|
||||
const [personnelData, setPersonnelData] = useState<any>(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 (
|
||||
<PageLayout
|
||||
title="🏛️ Панель регулятора — ФАВТ"
|
||||
subtitle="Федеральное агентство воздушного транспорта (Росавиация)"
|
||||
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">
|
||||
@ -175,6 +183,15 @@ export default function RegulatorPanel() {
|
||||
Персональные данные и коммерческая тайна не раскрываются.
|
||||
</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 */}
|
||||
<div className="flex gap-1 mb-6 border-b border-gray-200 overflow-x-auto">
|
||||
{TABS.map(t => (
|
||||
|
||||
69
components/AirworthinessCoreView.tsx
Normal file
69
components/AirworthinessCoreView.tsx
Normal 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);
|
||||
}
|
||||
@ -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 (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<div className={wrapperClass}>
|
||||
<svg
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
@ -23,17 +28,13 @@ export default function ReflyLogo({ iconSize = 40, showText = true, className =
|
||||
className="shrink-0"
|
||||
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="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="M16 16 L24 24 M24 16 L16 24" stroke={stroke} strokeWidth="1" strokeLinecap="round" opacity="0.8" />
|
||||
</svg>
|
||||
{showText && (
|
||||
<span className={`text-xl font-bold tracking-wider whitespace-nowrap ${variant === 'light' ? 'text-white' : 'text-primary-600'}`}>
|
||||
REFLY
|
||||
</span>
|
||||
)}
|
||||
{showText && <span className={textClass}>REFLY</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -37,7 +37,6 @@ import {
|
||||
MessageSquareWarning,
|
||||
Clock,
|
||||
BadgeCheck,
|
||||
PlaneOff,
|
||||
CircleDot,
|
||||
LoaderCircle,
|
||||
Check,
|
||||
@ -293,7 +292,7 @@ export const statusIcons: Record<StatusKey, LucideIcon | React.FC<ReflyIconProps
|
||||
remarks: MessageSquareWarning,
|
||||
expired: Clock,
|
||||
active: BadgeCheck,
|
||||
grounded: PlaneOff,
|
||||
grounded: Ban,
|
||||
maintenance: Wrench,
|
||||
open: CircleDot,
|
||||
in_progress: LoaderCircle,
|
||||
|
||||
@ -1,12 +1,25 @@
|
||||
/** 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 };
|
||||
}
|
||||
|
||||
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 {};
|
||||
}
|
||||
|
||||
export function getModalAriaProps(_opts?: { titleId?: string; describedById?: string }) {
|
||||
export function getModalAriaProps(_opts?: {
|
||||
titleId?: string;
|
||||
describedById?: string;
|
||||
descriptionId?: string;
|
||||
}) {
|
||||
return { role: 'dialog', 'aria-modal': true };
|
||||
}
|
||||
|
||||
@ -5,16 +5,16 @@ export class Hotkey {
|
||||
unregister() {}
|
||||
}
|
||||
|
||||
export function createActivationHandler(_keys: string[], _handler: () => 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<string, () => void>) {}
|
||||
|
||||
@ -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 ──────────────────────────────────
|
||||
|
||||
@ -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<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 {
|
||||
user: AuthUser | null;
|
||||
loading: boolean;
|
||||
login: (token: string) => Promise<void>;
|
||||
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 {
|
||||
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;
|
||||
|
||||
@ -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 || 'Произошла ошибка';
|
||||
}
|
||||
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
{"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"]}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user