feat: дизайн, иконки REFLY, демо-данные, UI (ВС/пользователи/чек-листы/настройки/документы)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9d49c9bb6b
commit
d011dcdbba
@ -11,9 +11,15 @@ export default function AircraftPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
const { data, isLoading, mutate } = useAircraftData({ q: search || undefined, page, limit: 25 });
|
||||
const aircraft = data?.items || (Array.isArray(data) ? data : []);
|
||||
const total = data?.total || aircraft.length;
|
||||
const pages = data?.pages || 1;
|
||||
const aircraft = Array.isArray(data?.items) ? data.items : (Array.isArray(data) ? data : []);
|
||||
const total = data?.total ?? aircraft.length;
|
||||
const pages = data?.pages ?? 1;
|
||||
|
||||
const typeLabel = (a: any) => {
|
||||
const t = a?.aircraft_type;
|
||||
if (!t) return '—';
|
||||
return [t.manufacturer, t.model].filter(Boolean).join(' ') || t.icao_code || '—';
|
||||
};
|
||||
|
||||
const handleAdd = async (d: any) => { try { await aircraftApi.create(d); mutate(); setIsAddOpen(false); } catch (e: any) { alert(e.message); } };
|
||||
const handleDelete = async (id: string) => { if (!confirm('Удалить ВС?')) return; try { await aircraftApi.delete(id); mutate(); } catch (e: any) { alert(e.message); } };
|
||||
@ -31,19 +37,19 @@ export default function AircraftPage() {
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead><tr className="bg-gray-50">
|
||||
<th className="table-header">Регистрация</th><th className="table-header">Тип</th><th className="table-header">Модель</th>
|
||||
<th className="table-header">Оператор</th><th className="table-header">Налёт (ч)</th><th className="table-header">Статус</th>
|
||||
<th className="table-header">Регистрация</th><th className="table-header">Тип</th><th className="table-header">Оператор</th>
|
||||
<th className="table-header">Серийный №</th><th className="table-header">Налёт (ч)</th><th className="table-header">Статус</th>
|
||||
<th className="table-header">Действия</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{aircraft.map((a: any) => (
|
||||
<tr key={a.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="table-cell font-medium text-primary-500">{a.registration_number || a.registrationNumber}</td>
|
||||
<td className="table-cell">{a.aircraft_type || a.aircraftType}</td>
|
||||
<td className="table-cell text-gray-500">{a.model || '—'}</td>
|
||||
<td className="table-cell text-gray-500">{a.operator || a.operator_name || '—'}</td>
|
||||
<td className="table-cell text-right font-mono">{a.flight_hours || a.flightHours || '—'}</td>
|
||||
<td className="table-cell"><StatusBadge status={a.status || 'active'} /></td>
|
||||
<td className="table-cell font-medium text-primary-500">{a.registration_number ?? a.registrationNumber ?? '—'}</td>
|
||||
<td className="table-cell">{typeLabel(a)}</td>
|
||||
<td className="table-cell text-gray-500">{a.operator_name ?? a.operator ?? '—'}</td>
|
||||
<td className="table-cell text-gray-500 font-mono text-sm">{a.serial_number ?? '—'}</td>
|
||||
<td className="table-cell text-right font-mono">{a.total_time ?? a.flight_hours ?? a.flightHours ?? '—'}</td>
|
||||
<td className="table-cell"><StatusBadge status={a.status ?? a.current_status ?? 'active'} /></td>
|
||||
<td className="table-cell">
|
||||
<RequireRole roles={['admin', 'authority_inspector', 'operator_manager']}>
|
||||
<button onClick={() => handleDelete(a.id)} className="btn-sm bg-red-100 text-red-600 hover:bg-red-200">Удалить</button>
|
||||
|
||||
@ -10,7 +10,7 @@ import { RequireRole } from '@/lib/auth-context';
|
||||
|
||||
export default function ChecklistsPage() {
|
||||
const [domain, setDomain] = useState<string | undefined>();
|
||||
const { data, isLoading, mutate } = useChecklistsData({ domain });
|
||||
const { data, isLoading, mutate } = useChecklistsData({ domain: domain === 'part_m_ru' ? undefined : domain });
|
||||
const templates = data?.items || [];
|
||||
const [exp, setExp] = useState<string | null>(null);
|
||||
const [editTemplate, setEditTemplate] = useState<any | null>(null);
|
||||
@ -18,7 +18,7 @@ export default function ChecklistsPage() {
|
||||
const gen = async (src: string) => { const n = prompt('Название:'); if (!n) return; await checklistsApi.generate(src, n); mutate(); };
|
||||
|
||||
return (
|
||||
<PageLayout title="Чек-листы Part-M RU" subtitle={isLoading ? 'Загрузка...' : (data?.total ? `Гармонизировано с ICAO Annex 8 · EASA Part-M · FAA 14 CFR Part 43/91 · Шаблонов: ${data.total}` : 'Гармонизировано с ICAO Annex 8 · EASA Part-M · FAA 14 CFR Part 43/91')}
|
||||
<PageLayout title="Чек-листы Part-M RU" subtitle={isLoading ? 'Загрузка...' : (data?.total ? `Part-M RU · Гармонизировано с ICAO Annex 8 · EASA Part-M · Шаблонов: ${data.total}` : 'Part-M RU · Гармонизировано с ICAO Annex 8 · EASA Part-M')}
|
||||
actions={<RequireRole roles={['admin', 'authority_inspector']}>
|
||||
<button onClick={() => gen('fap_m_inspection')} className="btn-primary">+ ФАП-М</button>
|
||||
<button onClick={() => gen('custom')} className="btn-primary bg-blue-500 hover:bg-blue-600">+ Пользовательский</button>
|
||||
@ -26,14 +26,7 @@ export default function ChecklistsPage() {
|
||||
<FilterBar value={domain} onChange={setDomain} className="mb-4"
|
||||
options={[
|
||||
{ value: undefined, label: 'Все' },
|
||||
{ value: 'continuing_airworthiness', label: 'M.A.301 ПЛГ' },
|
||||
{ value: 'maintenance_program', label: 'M.A.302 Программа ТО' },
|
||||
{ value: 'airworthiness_directives', label: 'M.A.303 ДЛГ/AD' },
|
||||
{ value: 'records', label: 'M.A.305 Учёт ПЛГ' },
|
||||
{ value: 'maintenance_data', label: 'M.A.401 Данные ТО' },
|
||||
{ value: 'components', label: 'M.A.501 Компоненты' },
|
||||
{ value: 'camo_obligations', label: 'M.A.703 CAMO' },
|
||||
{ value: 'airworthiness_review', label: 'M.A.901 Проверка ЛГ' },
|
||||
{ value: 'part_m_ru', label: 'Part-M RU' },
|
||||
]} />
|
||||
{!isLoading && templates.length > 0 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
|
||||
@ -7,7 +7,7 @@ export default function DocumentsPage() {
|
||||
{ title: 'Входящие документы', desc: 'PDF и DOCX файлы', href: '/inbox', icon: '📥' },
|
||||
{ title: 'Вложения аудитов', desc: 'Фото и протоколы', href: '/audits', icon: '🔍' },
|
||||
{ title: 'Сертификаты', desc: 'Сертификаты ЛГ', href: '/airworthiness', icon: '📜' },
|
||||
{ title: 'Нормативные документы', desc: 'ФАП, ICAO, EASA', href: '/regulations', icon: '📚' },
|
||||
{ title: 'Нормативные документы', desc: 'Part-M RU · ФАП', href: '/regulations', icon: '📚' },
|
||||
{ title: 'Чек-листы', desc: 'Шаблоны проверок', href: '/checklists', icon: '✅' },
|
||||
{ title: 'Шаблоны документов', desc: 'Заявки, акты, письма, формы', href: '/templates', icon: '📋' },
|
||||
];
|
||||
|
||||
@ -2,16 +2,21 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { PageLayout } from '@/components/ui';
|
||||
import { apiFetch } from '@/lib/api/api-client';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = { admin: 'Администратор', authority_inspector: 'Инспектор', operator_manager: 'Менеджер оператора', operator_user: 'Оператор', mro_manager: 'Менеджер ТОиР', mro_specialist: 'Специалист ТОиР', mro_user: 'Специалист ТОиР' };
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user } = useAuth();
|
||||
const [prefs, setPrefs] = useState<any>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch('/notification-preferences').then(setPrefs);
|
||||
apiFetch('/notification-preferences').catch(() => null).then(setPrefs);
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
if (!prefs) return;
|
||||
setSaving(true);
|
||||
await apiFetch('/notification-preferences', { method: 'PUT', body: JSON.stringify(prefs) });
|
||||
setSaving(false);
|
||||
@ -27,11 +32,49 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!prefs) return <PageLayout title="⚙️ Настройки"><div className="text-center py-8 text-gray-400">⏳</div></PageLayout>;
|
||||
|
||||
return (
|
||||
<PageLayout title="⚙️ Настройки" subtitle="Уведомления и персонализация">
|
||||
<PageLayout title="⚙️ Настройки" subtitle="Профиль, система, уведомления">
|
||||
<div className="max-w-lg space-y-6">
|
||||
<section className="card p-4">
|
||||
<h3 className="text-sm font-bold text-gray-600 mb-3">👤 Профиль пользователя</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between"><span className="text-gray-500">Имя</span><span className="font-medium">{user?.display_name ?? 'Dev User'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Email</span><span>{user?.email ?? 'dev@local'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Роль</span><span>{user?.role ? ROLE_LABELS[user.role] ?? user.role : 'Администратор'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Организация</span><span>{user?.organization_name ?? '—'}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card p-4">
|
||||
<h3 className="text-sm font-bold text-gray-600 mb-3">🖥️ Настройки системы</h3>
|
||||
<div className="space-y-2 text-sm text-gray-600">
|
||||
<div className="flex justify-between"><span>Название системы</span><span>REFLY АСУ ТК</span></div>
|
||||
<div className="flex justify-between"><span>Версия</span><span>2.0.0-beta</span></div>
|
||||
<div className="flex justify-between"><span>Нормативная база</span><span>Part-M RU</span></div>
|
||||
<div className="flex justify-between"><span>Язык</span><span>Русский</span></div>
|
||||
<div className="flex justify-between"><span>Часовой пояс</span><span>Europe/Moscow (UTC+3)</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card p-4">
|
||||
<h3 className="text-sm font-bold text-gray-600 mb-3">🔗 Интеграции</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between items-center"><span>AI-помощник (Claude)</span><span className="text-amber-600">⚠️ Настройка</span></div>
|
||||
<div className="flex justify-between items-center"><span>ФГИС ЕС ОрВД</span><span className="text-gray-500">Не подключено</span></div>
|
||||
<div className="flex justify-between items-center"><span>Keycloak SSO</span><span className="text-green-600">Подключено (dev)</span></div>
|
||||
<div className="flex justify-between items-center"><span>MinIO (документы)</span><span className="text-green-600">Подключено</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card p-4">
|
||||
<h3 className="text-sm font-bold text-gray-600 mb-3">ℹ️ О системе</h3>
|
||||
<p className="text-sm text-gray-600">REFLY АСУ ТК v2.0.0-beta</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Part-M RU · Гармонизировано с ICAO/EASA</p>
|
||||
<p className="text-xs text-gray-400 mt-2">© 2025–2026 REFLY Aviation Technologies</p>
|
||||
</section>
|
||||
|
||||
{prefs && (
|
||||
<>
|
||||
<section className="card p-4">
|
||||
<h3 className="text-sm font-bold text-gray-600 mb-3">📢 Типы уведомлений</h3>
|
||||
<Toggle label="⚠️ Обязательные ДЛГ (mandatory AD)" field="ad_mandatory" />
|
||||
@ -78,6 +121,8 @@ export default function SettingsPage() {
|
||||
className="btn-primary px-6 py-2 rounded text-sm disabled:opacity-50">
|
||||
{saving ? '⏳ Сохранение...' : '💾 Сохранить настройки'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
@ -4,8 +4,8 @@ import UserEditModal from '@/components/UserEditModal';
|
||||
import { PageLayout, DataTable, FilterBar, StatusBadge } from '@/components/ui';
|
||||
import { useUsersData } from '@/hooks/useSWRData';
|
||||
|
||||
const RL: Record<string,string> = { admin:'Администратор', authority_inspector:'Инспектор', operator_manager:'Менеджер оператора', operator_user:'Оператор', mro_manager:'Менеджер ТОиР', mro_user:'Специалист ТОиР' };
|
||||
const RC: Record<string,string> = { admin:'bg-green-500', authority_inspector:'bg-blue-500', operator_manager:'bg-orange-500', operator_user:'bg-orange-400', mro_manager:'bg-purple-500', mro_user:'bg-purple-400' };
|
||||
const RL: Record<string,string> = { admin:'Администратор', authority_inspector:'Инспектор', operator_manager:'Менеджер оператора', operator_user:'Оператор', mro_manager:'Менеджер ТОиР', mro_specialist:'Специалист ТОиР', mro_user:'Специалист ТОиР' };
|
||||
const RC: Record<string,string> = { admin:'bg-green-500', authority_inspector:'bg-blue-500', operator_manager:'bg-orange-500', operator_user:'bg-orange-400', mro_manager:'bg-purple-500', mro_specialist:'bg-purple-400', mro_user:'bg-purple-400' };
|
||||
|
||||
export default function UsersPage() {
|
||||
const [rf, setRf] = useState<string|undefined>();
|
||||
@ -14,7 +14,7 @@ export default function UsersPage() {
|
||||
return (
|
||||
<PageLayout title="Пользователи" subtitle={isLoading ? 'Загрузка...' : `Всего: ${data?.total || 0}`}>
|
||||
<FilterBar value={rf} onChange={setRf} options={[{ value: undefined, label: 'Все' }, ...Object.entries(RL).map(([v, l]) => ({ value: v, label: l }))]} className="mb-4" />
|
||||
<DataTable loading={isLoading} data={data?.items || []} emptyMessage="Нет пользователей"
|
||||
<DataTable loading={isLoading} data={Array.isArray(data?.items) ? data.items : []} emptyMessage="Нет пользователей"
|
||||
columns={[
|
||||
{ key: 'display_name', header: 'Имя', render: (u: any) => <span className="font-medium">{u.display_name}</span> },
|
||||
{ key: 'email', header: 'Email', render: (u: any) => <span className="text-gray-500">{u.email || '—'}</span> },
|
||||
|
||||
@ -28,11 +28,17 @@ def _serialize_aircraft(a: Aircraft, db: Session) -> AircraftOut:
|
||||
return AircraftOut.model_validate({
|
||||
"id": a.id,
|
||||
"registration_number": a.registration_number,
|
||||
"aircraft_type": {
|
||||
"id": a.aircraft_type.id, "manufacturer": a.aircraft_type.manufacturer,
|
||||
"model": a.aircraft_type.model, "created_at": a.aircraft_type.created_at,
|
||||
"aircraft_type": (
|
||||
{
|
||||
"id": a.aircraft_type.id,
|
||||
"manufacturer": a.aircraft_type.manufacturer,
|
||||
"model": a.aircraft_type.model,
|
||||
"created_at": a.aircraft_type.created_at,
|
||||
"updated_at": a.aircraft_type.updated_at,
|
||||
} if a.aircraft_type else None,
|
||||
}
|
||||
if a.aircraft_type
|
||||
else None
|
||||
),
|
||||
"operator_id": a.operator_id, "operator_name": operator_name,
|
||||
"serial_number": a.serial_number,
|
||||
"manufacture_date": getattr(a, 'manufacture_date', None),
|
||||
@ -88,7 +94,7 @@ def list_aircraft(
|
||||
items = []
|
||||
for a in items_raw:
|
||||
try:
|
||||
if a.aircraft_type: items.append(_serialize_aircraft(a, db))
|
||||
items.append(_serialize_aircraft(a, db))
|
||||
except Exception as e:
|
||||
logger.error(f"Serialization error for aircraft {a.id}: {e}")
|
||||
return {"items": items, "total": total, "page": page, "per_page": per_page,
|
||||
|
||||
@ -34,6 +34,37 @@ _maint_programs: dict = {}
|
||||
_components: dict = {}
|
||||
|
||||
|
||||
def seed_airworthiness_core_demo(aircraft_id: Optional[str] = None) -> None:
|
||||
"""Заполнить демо-данными ДЛГ и Life Limits при первом запуске (если пусто)."""
|
||||
if _directives:
|
||||
return
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
demo_ads = [
|
||||
{"number": "AD-2025-0142-R1", "title": "Замена болтов крепления двигателя", "status": "open", "compliance_deadline": "2026-04-15", "aircraft_types": ["B737", "SSJ100"], "effective_date": "2025-06-01", "compliance_type": "mandatory"},
|
||||
{"number": "AD-2025-0089", "title": "Инспекция лонжерона крыла зона 3", "status": "complied", "compliance_date": "2025-11-20", "aircraft_types": ["B737"], "effective_date": "2025-03-01", "compliance_type": "mandatory"},
|
||||
{"number": "AD-2026-0012", "title": "Модификация системы предупреждения TCAS", "status": "open", "compliance_deadline": "2026-06-01", "aircraft_types": ["B737", "A320"], "effective_date": "2026-01-15", "compliance_type": "mandatory"},
|
||||
{"number": "AD-2025-0201", "title": "Замена топливного насоса", "status": "complied", "compliance_date": "2026-01-15", "aircraft_types": ["SSJ100"], "effective_date": "2025-08-01", "compliance_type": "mandatory"},
|
||||
{"number": "AD-2025-0178", "title": "Инспекция фюзеляжа секции 41-43", "status": "open", "compliance_deadline": "2026-03-30", "aircraft_types": ["B737"], "effective_date": "2025-07-01", "compliance_type": "mandatory"},
|
||||
{"number": "AD-2024-0315-R2", "title": "Обновление ПО FADEC двигателя", "status": "overdue", "compliance_deadline": "2025-12-01", "aircraft_types": ["CFM56"], "effective_date": "2024-10-01", "compliance_type": "mandatory"},
|
||||
]
|
||||
for d in demo_ads:
|
||||
did = str(uuid.uuid4())
|
||||
_directives[did] = {"id": did, "created_at": now, "issuing_authority": "FATA", "ata_chapter": None, "repetitive": False, "description": "", "affected_parts": [], **d}
|
||||
logger.info("seed_airworthiness_core: %s directives", len(demo_ads))
|
||||
|
||||
if not _life_limits and aircraft_id:
|
||||
demo_ll = [
|
||||
{"component_name": "Двигатель CFM56-5B", "part_number": "CFM-56-5B", "serial_number": "SN-001", "limit_type": "combined", "flight_hours_limit": 30000.0, "cycles_limit": 20000, "current_hours": 24580.0, "current_cycles": 15200, "aircraft_id": aircraft_id},
|
||||
{"component_name": "Шасси основное левое", "part_number": "LG-32-001", "serial_number": "SN-102", "limit_type": "cycles", "cycles_limit": 40000, "current_cycles": 38800, "aircraft_id": aircraft_id},
|
||||
{"component_name": "APU GTCP131-9A", "part_number": "APU-131-9A", "serial_number": "SN-201", "limit_type": "flight_hours", "flight_hours_limit": 15000.0, "current_hours": 6500.0, "aircraft_id": aircraft_id},
|
||||
{"component_name": "Лопатки турбины ВД", "part_number": "HPT-7680", "serial_number": "SN-305", "limit_type": "cycles", "cycles_limit": 8000, "current_cycles": 7680, "aircraft_id": aircraft_id},
|
||||
]
|
||||
for ll in demo_ll:
|
||||
lid = str(uuid.uuid4())
|
||||
_life_limits[lid] = {"id": lid, "created_at": now, "calendar_limit_months": None, "install_date": None, "last_overhaul_date": None, "notes": None, **ll}
|
||||
logger.info("seed_airworthiness_core: %s life limits", len(demo_ll))
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 1. ДИРЕКТИВЫ ЛЁТНОЙ ГОДНОСТИ (AD / ДЛГ)
|
||||
# ===================================================================
|
||||
|
||||
@ -544,7 +544,15 @@ def _templates_data() -> list[dict]:
|
||||
def seed_document_templates():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Удалить шаблоны ICAO / EASA / FAA — оставляем только российские (Part-M RU, ФАП)
|
||||
deleted = db.query(DocumentTemplate).filter(DocumentTemplate.standard.in_(["EASA", "FAA", "ICAO"])).delete(synchronize_session=False)
|
||||
if deleted:
|
||||
logger.info("Removed %s non-Russian document templates (EASA/FAA/ICAO)", deleted)
|
||||
db.commit()
|
||||
|
||||
for d in _templates_data():
|
||||
if d.get("standard") in ("EASA", "FAA", "ICAO"):
|
||||
continue
|
||||
if db.query(DocumentTemplate).filter(DocumentTemplate.code == d["code"]).first():
|
||||
continue
|
||||
db.add(
|
||||
|
||||
@ -15,6 +15,7 @@ from app.models import (
|
||||
CertApplication,
|
||||
CertApplicationStatus,
|
||||
RiskAlert,
|
||||
AirworthinessCertificate,
|
||||
)
|
||||
from app.models.aircraft_db import Aircraft
|
||||
from app.models.personnel_plg import PLGSpecialist, PLGQualification
|
||||
@ -212,9 +213,11 @@ def seed_full_demo():
|
||||
if plg_org:
|
||||
specialists_data = [
|
||||
("Петрова Елена В.", "ПЕТРОВА-001", "Инспектор ЛГ", "I", "LIC-2024-001", "2026-12-01"),
|
||||
("Волков Алексей Н.", "ВОЛКОВ-001", "Инженер ТОиР", "B2", "LIC-2024-002", "2026-08-15"),
|
||||
("Волков Алексей Н.", "ВОЛКОВ-001", "Инженер по ТО", "B2", "LIC-2024-002", "2026-08-15"),
|
||||
("Морозова Ольга С.", "МОРОЗОВА-001", "Авиатехник B1", "B1", "LIC-2024-003", "2026-03-20"),
|
||||
("Козлов Дмитрий И.", "КОЗЛОВ-001", "Пилот CAT.A", "CAT-A", "LIC-2024-004", "2027-01-10"),
|
||||
("Козлов Дмитрий И.", "КОЗЛОВ-001", "Инженер CAT.A", "CAT-A", "LIC-2024-004", "2027-01-10"),
|
||||
("Николаев Павел Р.", "НИКОЛАЕВ-001", "Инспектор Росавиации", "I", "LIC-2024-005", "2026-09-30"),
|
||||
("Сидорова Анна М.", "СИДОРОВА-001", "Диспетчер ТОиР", "D", "LIC-2024-006", "2026-11-15"),
|
||||
]
|
||||
for full_name, personnel_number, position, category, license_no, expires in specialists_data:
|
||||
if db.query(PLGSpecialist).filter(PLGSpecialist.personnel_number == personnel_number).first():
|
||||
@ -248,6 +251,69 @@ def seed_full_demo():
|
||||
db.commit()
|
||||
logger.info("seed_full_demo: personnel PLG checked/created")
|
||||
|
||||
# ─── 7. Сертификаты лётной годности (СЛГ) ─────────────────────────
|
||||
issuer = db.query(User).filter(User.role.in_(["admin", "authority_inspector"])).first()
|
||||
issuer_id = str(issuer.id) if issuer else None
|
||||
certs_data = [
|
||||
("KLG-2025-001", "RA-89060", "2026-08-15", "valid"),
|
||||
("KLG-2025-002", "RA-89061", "2026-05-20", "valid"),
|
||||
("KLG-2024-015", "RA-73801", "2026-02-28", "expiring_soon"),
|
||||
("KLG-2025-003", "RA-12345", "2027-01-10", "valid"),
|
||||
]
|
||||
for cert_num, reg, valid_until, status in certs_data:
|
||||
if db.query(AirworthinessCertificate).filter(AirworthinessCertificate.certificate_number == cert_num).first():
|
||||
continue
|
||||
ac_id = _get_aircraft_id_by_reg(db, reg)
|
||||
if not ac_id:
|
||||
ac_id = _get_first_aircraft_id(db)
|
||||
if not ac_id or not issuer_id:
|
||||
continue
|
||||
exp_d = datetime.strptime(valid_until, "%Y-%m-%d").date()
|
||||
issue_d = exp_d - timedelta(days=365)
|
||||
db.add(
|
||||
AirworthinessCertificate(
|
||||
aircraft_id=ac_id,
|
||||
certificate_number=cert_num,
|
||||
certificate_type="standard",
|
||||
issue_date=datetime(issue_d.year, issue_d.month, issue_d.day, tzinfo=timezone.utc),
|
||||
expiry_date=datetime(exp_d.year, exp_d.month, exp_d.day, tzinfo=timezone.utc),
|
||||
issuing_authority="Росавиация",
|
||||
issued_by_user_id=issuer_id,
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
logger.info("seed_full_demo: airworthiness certificates checked/created")
|
||||
|
||||
# ─── 8. Наряды на работу (Work Orders, in-memory) ──────────────────
|
||||
try:
|
||||
from app.api.routes.work_orders import _work_orders
|
||||
aircraft_regs = [r[0] for r in db.query(Aircraft.registration_number).limit(5).all()]
|
||||
reg1 = aircraft_regs[0] if aircraft_regs else "RA-89060"
|
||||
reg2 = aircraft_regs[1] if len(aircraft_regs) > 1 else "RA-89061"
|
||||
reg3 = aircraft_regs[2] if len(aircraft_regs) > 2 else "RA-73801"
|
||||
reg4 = aircraft_regs[3] if len(aircraft_regs) > 3 else "RA-12345"
|
||||
wos_demo = [
|
||||
("WO-2026-001", reg1, "Периодическое ТО A-Check", "in_progress", "urgent"),
|
||||
("WO-2026-002", reg2, "Замена колеса основной стойки", "open", "normal"),
|
||||
("WO-2026-003", reg3, "Устранение течи гидросистемы", "completed", "urgent"),
|
||||
("WO-2026-004", reg1, "Плановая замена фильтров двигателя", "open", "normal"),
|
||||
("WO-2026-005", reg4, "Внеплановое ТО после bird strike", "in_progress", "urgent"),
|
||||
]
|
||||
for wo_num, reg, title, status, priority in wos_demo:
|
||||
if any(w.get("wo_number") == wo_num for w in _work_orders.values()):
|
||||
continue
|
||||
wid = str(uuid.uuid4())
|
||||
_work_orders[wid] = {
|
||||
"id": wid, "wo_number": wo_num, "aircraft_reg": reg, "title": title,
|
||||
"wo_type": "scheduled" if "Планов" in title else "unscheduled",
|
||||
"description": title, "status": status, "priority": priority,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
logger.info("seed_full_demo: work orders (in-memory) populated")
|
||||
except Exception as e:
|
||||
logger.warning("seed_full_demo: work orders skip %s", e)
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("seed_full_demo failed: %s", e)
|
||||
|
||||
@ -78,6 +78,18 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("Full demo seed skipped: %s", e)
|
||||
try:
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.aircraft_db import Aircraft
|
||||
from app.api.routes.airworthiness_core import seed_airworthiness_core_demo
|
||||
db = SessionLocal()
|
||||
ac = db.query(Aircraft).filter(Aircraft.registration_number == "RA-89060").first() or db.query(Aircraft).first()
|
||||
aircraft_id = str(ac.id) if ac else None
|
||||
db.close()
|
||||
seed_airworthiness_core_demo(aircraft_id)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("Airworthiness core demo seed skipped: %s", e)
|
||||
# Планировщик рисков (передаём app для shutdown hook)
|
||||
setup_scheduler(app)
|
||||
yield
|
||||
|
||||
@ -35,7 +35,7 @@ class AircraftUpdate(BaseModel):
|
||||
class AircraftOut(TimestampOut):
|
||||
id: str
|
||||
registration_number: str
|
||||
aircraft_type: AircraftTypeOut
|
||||
aircraft_type: AircraftTypeOut | None = None
|
||||
operator_id: str
|
||||
operator_name: str | None = None # Название организации-оператора
|
||||
serial_number: str | None = None # Серийный номер ВС
|
||||
|
||||
@ -15,6 +15,8 @@ import { sidebarIcons, commonIcons } from '@/icons/refly-icons';
|
||||
import type { SidebarKey } from '@/icons/refly-icons';
|
||||
import { Icon } from '@/components/Icon';
|
||||
|
||||
const HEADER_ICON_KEY: SidebarKey = 'aircraft';
|
||||
|
||||
interface MenuItem { name: string; path: string; iconKey: SidebarKey; roles?: UserRole[]; }
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
@ -73,7 +75,9 @@ export default function Sidebar() {
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-white/10">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center text-xl">✈️</div>
|
||||
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center">
|
||||
<Icon icon={sidebarIcons[HEADER_ICON_KEY]} className="size-6 text-white" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="text-2xl font-bold tracking-wider">REFLY</div>
|
||||
</div>
|
||||
<div className="text-xs opacity-80">КОНТРОЛЬ ЛЁТНОЙ ГОДНОСТИ</div>
|
||||
@ -92,7 +96,7 @@ export default function Sidebar() {
|
||||
{visibleItems.map((item) => {
|
||||
const active = pathname === item.path;
|
||||
return (
|
||||
<Link key={item.path} href={item.path} aria-current={active ? 'page' : undefined}
|
||||
<Link key={item.path} href={item.path} scroll={false} aria-current={active ? 'page' : undefined}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={`flex items-center px-6 py-3 text-white no-underline transition-colors
|
||||
${active ? 'bg-white/[0.15] border-l-[3px] border-accent-blue' : 'border-l-[3px] border-transparent hover:bg-white/[0.07]'}`}>
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { statusIcons, type StatusKey } from '@/icons/refly-icons';
|
||||
import { Icon } from '@/components/Icon';
|
||||
|
||||
interface Props {
|
||||
status: string;
|
||||
colorMap?: Record<string, string>;
|
||||
labelMap?: Record<string, string>;
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
const defaults: Record<string, string> = {
|
||||
@ -17,8 +21,16 @@ const defaults: Record<string, string> = {
|
||||
overdue: 'bg-red-600', deferred: 'bg-yellow-600',
|
||||
};
|
||||
|
||||
export default function StatusBadge({ status, colorMap, labelMap }: Props) {
|
||||
export default function StatusBadge({ status, colorMap, labelMap, showIcon = true }: Props) {
|
||||
const color = colorMap?.[status] || defaults[status] || 'bg-gray-400';
|
||||
const label = labelMap?.[status] || status;
|
||||
return <span className={`status-badge ${color}`}>{label}</span>;
|
||||
const StatusIcon = statusIcons[status as StatusKey];
|
||||
return (
|
||||
<span className={`status-badge ${color} inline-flex items-center gap-1.5`}>
|
||||
{showIcon && StatusIcon && (
|
||||
<Icon icon={StatusIcon} className="size-4 shrink-0 opacity-90" strokeWidth={1.75} />
|
||||
)}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
48
docs/ops/AI_PROXY_SETUP.md
Normal file
48
docs/ops/AI_PROXY_SETUP.md
Normal file
@ -0,0 +1,48 @@
|
||||
# Настройка прокси Anthropic через papa-app (Railway)
|
||||
|
||||
KLG backend в Yandex Cloud (РФ) не может вызывать Anthropic API из-за блокировки по IP.
|
||||
Запросы идут через papa-app на Railway (вне РФ).
|
||||
|
||||
## Что сделано в коде
|
||||
|
||||
- **papa-app:** `POST /api/proxy/anthropic` — принимает тело запроса и заголовок `x-proxy-secret`, подставляет `ANTHROPIC_API_KEY` и вызывает `https://api.anthropic.com/v1/messages`.
|
||||
- **KLG backend:** при заданных `AI_PROXY_URL` и `AI_PROXY_SECRET` использует прокси вместо прямого вызова Anthropic.
|
||||
|
||||
## 1. Railway (papa-app)
|
||||
|
||||
В проекте papa-app в Railway Dashboard → Variables:
|
||||
|
||||
| Переменная | Значение | Секретность |
|
||||
|------------|----------|-------------|
|
||||
| `ANTHROPIC_API_KEY` | Ваш ключ Anthropic (sk-ant-...) | Secret |
|
||||
| `PROXY_SECRET` | `klg-refly-proxy-2026` (или свой) | Secret |
|
||||
|
||||
После сохранения Railway пересоберёт и задеплоит приложение.
|
||||
|
||||
## 2. Сервер KLG (158.160.22.166)
|
||||
|
||||
```bash
|
||||
cd ~/klg-asutk-app
|
||||
git pull origin main
|
||||
|
||||
# Добавить в .env (если ещё не добавлено)
|
||||
grep -q AI_PROXY_URL .env || echo 'AI_PROXY_URL=https://papa-app-production.up.railway.app/api/proxy/anthropic' >> .env
|
||||
grep -q AI_PROXY_SECRET .env || echo 'AI_PROXY_SECRET=klg-refly-proxy-2026' >> .env
|
||||
|
||||
docker compose build backend && docker compose up -d backend
|
||||
```
|
||||
|
||||
Значение `AI_PROXY_SECRET` должно совпадать с `PROXY_SECRET` в Railway.
|
||||
|
||||
## 3. Проверка
|
||||
|
||||
```bash
|
||||
# Прокси (после деплоя papa-app и установки переменных)
|
||||
curl -s -X POST https://papa-app-production.up.railway.app/api/proxy/anthropic \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-proxy-secret: klg-refly-proxy-2026" \
|
||||
-d '{"model":"claude-3-5-sonnet-20241022","max_tokens":50,"messages":[{"role":"user","content":"Hi"}]}'
|
||||
```
|
||||
|
||||
Ожидается JSON с полем `content` (ответ модели) или сообщение об ошибке от Anthropic.
|
||||
401 — неверный `x-proxy-secret`, 500 — не задан `ANTHROPIC_API_KEY` в Railway.
|
||||
@ -36,7 +36,6 @@ import {
|
||||
XCircle,
|
||||
MessageSquareWarning,
|
||||
Clock,
|
||||
ClockAlert,
|
||||
BadgeCheck,
|
||||
PlaneOff,
|
||||
CircleDot,
|
||||
@ -139,6 +138,66 @@ export const ReflyRegulator = (props: ReflyIconProps) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Aircraft (ВС) */
|
||||
export const ReflyAircraft = (props: ReflyIconProps) => (
|
||||
<svg {...base(props)}>
|
||||
<path d="M12 3v10" />
|
||||
<path d="M4.5 11.5L12 14l7.5-2.5" />
|
||||
<path d="M12 13.5v7.5" />
|
||||
<path d="M9 22l3-1.6L15 22" />
|
||||
<path d="M6 8.2c2.2-1.3 4.5-2.5 7.2-3.7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Audits: shield + magnifier */
|
||||
export const ReflyAudits = (props: ReflyIconProps) => (
|
||||
<svg {...base(props)}>
|
||||
<path d="M12 2l7 4v6c0 5-3 9-7 10c-4-1-7-5-7-10V6l7-4z" />
|
||||
<circle cx="10" cy="13.5" r="2.5" />
|
||||
<path d="M11.8 15.3L13.8 17.3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Documents */
|
||||
export const ReflyDocuments = (props: ReflyIconProps) => (
|
||||
<svg {...base(props)}>
|
||||
<path d="M7 3h7l3 3v15H7V3z" />
|
||||
<path d="M14 3v4h4" />
|
||||
<path d="M9 11h6" />
|
||||
<path d="M9 14h6" />
|
||||
<path d="M18 12l2 1v2c0 1.5-1 2.8-2 3c-1-.2-2-1.5-2-3v-2l2-1z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Defects */
|
||||
export const ReflyDefects = (props: ReflyIconProps) => (
|
||||
<svg {...base(props)}>
|
||||
<path d="M12 2l7 4v6c0 5-3 9-7 10c-4-1-7-5-7-10V6l7-4z" />
|
||||
<path d="M12 10.5c1.7 0 3 1.3 3 3v1c0 1.7-1.3 3-3 3s-3-1.3-3-3v-1c0-1.7 1.3-3 3-3z" />
|
||||
<path d="M10 10l-1-1" />
|
||||
<path d="M14 10l1-1" />
|
||||
<path d="M9.5 13H8" />
|
||||
<path d="M16 13h-1.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Risks */
|
||||
export const ReflyRisks = (props: ReflyIconProps) => (
|
||||
<svg {...base(props)}>
|
||||
<path d="M12 2l7 4v6c0 5-3 9-7 10c-4-1-7-5-7-10V6l7-4z" />
|
||||
<path d="M12 9v5" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Maintenance */
|
||||
export const ReflyMaintenance = (props: ReflyIconProps) => (
|
||||
<svg {...base(props)}>
|
||||
<path d="M14.5 6.5a3 3 0 0 0-4 3.5l-4.8 4.8a1.5 1.5 0 0 0 2.1 2.1l4.8-4.8a3 3 0 0 0 3.5-4l-2 2l-2-2l2-2z" />
|
||||
<path d="M19 13l2 1v2c0 1.5-1 2.8-2 3c-1-.2-2-1.5-2-3v-2l2-1z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/* -----------------------
|
||||
ICON MAPS
|
||||
----------------------- */
|
||||
@ -175,19 +234,19 @@ export type SidebarKey =
|
||||
export const sidebarIcons: Record<SidebarKey, LucideIcon | React.FC<ReflyIconProps>> = {
|
||||
dashboard: LayoutDashboard,
|
||||
organizations: Building2,
|
||||
aircraft: Plane,
|
||||
aircraft: ReflyAircraft,
|
||||
applications: ClipboardList,
|
||||
checklists: CheckSquare,
|
||||
audits: SearchCheck,
|
||||
risks: AlertTriangle,
|
||||
audits: ReflyAudits,
|
||||
risks: ReflyRisks,
|
||||
users: Users,
|
||||
airworthiness: ReflyAirworthiness,
|
||||
calendar: Calendar,
|
||||
"airworthiness-core": ReflyContinuedAirworthiness,
|
||||
maintenance: Wrench,
|
||||
defects: Bug,
|
||||
maintenance: ReflyMaintenance,
|
||||
defects: ReflyDefects,
|
||||
modifications: GitBranch,
|
||||
documents: FileText,
|
||||
documents: ReflyDocuments,
|
||||
inbox: Inbox,
|
||||
regulations: BookOpen,
|
||||
monitoring: Activity,
|
||||
@ -232,7 +291,7 @@ export const statusIcons: Record<StatusKey, LucideIcon | React.FC<ReflyIconProps
|
||||
approved: CheckCircle2,
|
||||
rejected: XCircle,
|
||||
remarks: MessageSquareWarning,
|
||||
expired: ClockAlert,
|
||||
expired: Clock,
|
||||
active: BadgeCheck,
|
||||
grounded: PlaneOff,
|
||||
maintenance: Wrench,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user