KLG ASUTK: единый промт — персонал ПЛГ, ВС, заявки, аудиты, риски, пользователи, контроль ЛГ, ТО, дефекты, настройки, справка, логотип REFLY
- Персонал ПЛГ: демо-программы, таблица прохождений, кнопки Добавить/Редактировать/Экспорт - ВС: AircraftEditModal, кнопка Редактировать в строке - Заявки: Создать по шаблону, 5 демо-заявок - Аудиты: AuditEditModal, чек-лист замечаний, auditsApi.update - Риски: RiskDetailModal при клике, Скачать отчёт, Закрыть - Пользователи: таблица с демо (10), CRUD, Экспорт, поиск и фильтры - Контроль ЛГ: вкладка с 7 демо-записями, модалка, Скачать сертификат - Тех. обслуживание: демо-наряды при ошибке API, Скачать наряд - Дефекты: 7 демо-дефектов, детальный просмотр, цвет по серьёзности - Настройки: Профиль организации, Шаблоны, Справочники, Экспорт/Импорт - Справка: HelpDocumentModal при клике, оглавление, Скачать PDF - ReflyLogo: компонент, Sidebar и страница логина Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d011dcdbba
commit
e9ef17ba16
@ -2,6 +2,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { PageLayout, Pagination, StatusBadge, EmptyState } from '@/components/ui';
|
import { PageLayout, Pagination, StatusBadge, EmptyState } from '@/components/ui';
|
||||||
import AircraftAddModal from '@/components/AircraftAddModal';
|
import AircraftAddModal from '@/components/AircraftAddModal';
|
||||||
|
import AircraftEditModal from '@/components/AircraftEditModal';
|
||||||
import { useAircraftData } from '@/hooks/useSWRData';
|
import { useAircraftData } from '@/hooks/useSWRData';
|
||||||
import { aircraftApi } from '@/lib/api/api-client';
|
import { aircraftApi } from '@/lib/api/api-client';
|
||||||
import { RequireRole } from '@/lib/auth-context';
|
import { RequireRole } from '@/lib/auth-context';
|
||||||
@ -10,6 +11,7 @@ export default function AircraftPage() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||||
|
const [editingAircraft, setEditingAircraft] = useState<any>(null);
|
||||||
const { data, isLoading, mutate } = useAircraftData({ q: search || undefined, page, limit: 25 });
|
const { data, isLoading, mutate } = useAircraftData({ q: search || undefined, page, limit: 25 });
|
||||||
const aircraft = Array.isArray(data?.items) ? data.items : (Array.isArray(data) ? data : []);
|
const aircraft = Array.isArray(data?.items) ? data.items : (Array.isArray(data) ? data : []);
|
||||||
const total = data?.total ?? aircraft.length;
|
const total = data?.total ?? aircraft.length;
|
||||||
@ -22,6 +24,7 @@ export default function AircraftPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = async (d: any) => { try { await aircraftApi.create(d); mutate(); setIsAddOpen(false); } catch (e: any) { alert(e.message); } };
|
const handleAdd = async (d: any) => { try { await aircraftApi.create(d); mutate(); setIsAddOpen(false); } catch (e: any) { alert(e.message); } };
|
||||||
|
const handleSave = async (id: string, d: any) => { try { await aircraftApi.update(id, d); mutate(); setEditingAircraft(null); } 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); } };
|
const handleDelete = async (id: string) => { if (!confirm('Удалить ВС?')) return; try { await aircraftApi.delete(id); mutate(); } catch (e: any) { alert(e.message); } };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -52,7 +55,12 @@ export default function AircraftPage() {
|
|||||||
<td className="table-cell"><StatusBadge status={a.status ?? a.current_status ?? 'active'} /></td>
|
<td className="table-cell"><StatusBadge status={a.status ?? a.current_status ?? 'active'} /></td>
|
||||||
<td className="table-cell">
|
<td className="table-cell">
|
||||||
<RequireRole roles={['admin', 'authority_inspector', 'operator_manager']}>
|
<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>
|
<div className="flex gap-1">
|
||||||
|
<button onClick={() => setEditingAircraft(a)} className="btn-sm bg-gray-100 text-gray-600 hover:bg-gray-200 p-1.5 rounded" title="Редактировать" aria-label="Редактировать">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(a.id)} className="btn-sm bg-red-100 text-red-600 hover:bg-red-200">Удалить</button>
|
||||||
|
</div>
|
||||||
</RequireRole>
|
</RequireRole>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -64,6 +72,7 @@ export default function AircraftPage() {
|
|||||||
</>
|
</>
|
||||||
) : <EmptyState message={`ВС не найдены.${search ? ' Измените поиск.' : ''}`} />}
|
) : <EmptyState message={`ВС не найдены.${search ? ' Измените поиск.' : ''}`} />}
|
||||||
<AircraftAddModal isOpen={isAddOpen} onClose={() => setIsAddOpen(false)} onAdd={handleAdd} />
|
<AircraftAddModal isOpen={isAddOpen} onClose={() => setIsAddOpen(false)} onAdd={handleAdd} />
|
||||||
|
<AircraftEditModal isOpen={!!editingAircraft} onClose={() => setEditingAircraft(null)} aircraft={editingAircraft} onSave={handleSave} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,9 +8,32 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { PageLayout, DataTable, StatusBadge, Modal, EmptyState } from '@/components/ui';
|
import { PageLayout, DataTable, StatusBadge, Modal, EmptyState } from '@/components/ui';
|
||||||
|
|
||||||
type Tab = 'directives' | 'bulletins' | 'life-limits' | 'maint-programs' | 'components';
|
type Tab = 'control' | 'directives' | 'bulletins' | 'life-limits' | 'maint-programs' | 'components';
|
||||||
|
|
||||||
|
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 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: 'Петров П.П.' },
|
||||||
|
];
|
||||||
|
|
||||||
const TABS: { id: Tab; label: string; icon: string; basis: string }[] = [
|
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: 'directives', label: 'ДЛГ / AD', icon: '⚠️', basis: 'ВК РФ ст. 37; ФАП-148 п.4.3' },
|
||||||
{ id: 'bulletins', label: 'Бюллетени SB', icon: '📢', basis: 'ФАП-148 п.4.5; EASA Part-21' },
|
{ 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: 'life-limits', label: 'Ресурсы', icon: '⏱️', basis: 'ФАП-148 п.4.2; EASA Part-M.A.302' },
|
||||||
@ -19,10 +42,14 @@ const TABS: { id: Tab; label: string; icon: string; basis: string }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function AirworthinessCorePage() {
|
export default function AirworthinessCorePage() {
|
||||||
const [tab, setTab] = useState<Tab>('directives');
|
const [tab, setTab] = useState<Tab>('control');
|
||||||
const [data, setData] = useState<Record<string, any>>({});
|
const [data, setData] = useState<Record<string, any>>({});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showAddModal, setShowAddModal] = 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 api = useCallback(async (endpoint: string, opts?: RequestInit) => {
|
||||||
const res = await fetch(`/api/v1/airworthiness-core/${endpoint}`, opts);
|
const res = await fetch(`/api/v1/airworthiness-core/${endpoint}`, opts);
|
||||||
@ -30,13 +57,40 @@ export default function AirworthinessCorePage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (tab === 'control') { setLoading(false); return; }
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const endpoint = tab === 'life-limits' ? 'life-limits' : tab === 'maint-programs' ? 'maintenance-programs' : tab;
|
const endpoint = tab === 'life-limits' ? 'life-limits' : tab === 'maint-programs' ? 'maintenance-programs' : tab;
|
||||||
api(endpoint).then(d => { setData(prev => ({ ...prev, [tab]: d })); setLoading(false); });
|
api(endpoint).then(d => { setData(prev => ({ ...prev, [tab]: d })); setLoading(false); });
|
||||||
}, [tab, api]);
|
}, [tab, api]);
|
||||||
|
|
||||||
const currentTab = TABS.find(t => t.id === tab)!;
|
const currentTab = TABS.find(t => t.id === tab)!;
|
||||||
const items = data[tab]?.items || [];
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
open: 'bg-red-500', complied: 'bg-green-500', incorporated: 'bg-green-500',
|
open: 'bg-red-500', complied: 'bg-green-500', incorporated: 'bg-green-500',
|
||||||
@ -74,6 +128,42 @@ export default function AirworthinessCorePage() {
|
|||||||
|
|
||||||
{loading ? <div className="text-center py-10 text-gray-400">⏳ Загрузка...</div> : (
|
{loading ? <div className="text-center py-10 text-gray-400">⏳ Загрузка...</div> : (
|
||||||
<>
|
<>
|
||||||
|
{/* CONTROL LG */}
|
||||||
|
{tab === 'control' && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2 mb-4 items-center">
|
||||||
|
<input type="text" placeholder="Фильтр (борт, тип, статус)..." value={controlFilter} onChange={e => setControlFilter(e.target.value)} className="input-field w-64" />
|
||||||
|
<span className="text-xs text-gray-500">Сортировка:</span>
|
||||||
|
{(['registration', 'last_check_date', 'status'] as const).map(k => (
|
||||||
|
<button key={k} onClick={() => setControlSort(k)} className={`px-2 py-1 rounded text-xs ${controlSort === k ? 'bg-blue-600 text-white' : 'bg-gray-100'}`}>
|
||||||
|
{k === 'registration' ? 'Борт' : k === 'last_check_date' ? 'Дата' : 'Статус'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredControl.map(r => (
|
||||||
|
<tr key={r.id} onClick={() => setSelectedControl(r)} className="border-b border-gray-100 hover:bg-blue-50 cursor-pointer">
|
||||||
|
<td className="table-cell font-medium text-primary-600">{r.registration}</td>
|
||||||
|
<td className="table-cell">{r.aircraft_type}</td>
|
||||||
|
<td className="table-cell">{r.last_check_date}</td>
|
||||||
|
<td className="table-cell"><span className={`badge ${r.status === 'Годен' ? 'bg-green-100 text-green-700' : r.status === 'Ограниченно годен' ? 'bg-yellow-100 text-yellow-700' : 'bg-gray-100'}`}>{r.status}</span></td>
|
||||||
|
<td className="table-cell">{r.valid_until}</td>
|
||||||
|
<td className="table-cell text-gray-600">{r.responsible}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{filteredControl.length === 0 && <EmptyState message="Нет записей контроля ЛГ" />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* DIRECTIVES (AD/ДЛГ) */}
|
{/* DIRECTIVES (AD/ДЛГ) */}
|
||||||
{tab === 'directives' && (
|
{tab === 'directives' && (
|
||||||
items.length > 0 ? (
|
items.length > 0 ? (
|
||||||
@ -165,6 +255,37 @@ export default function AirworthinessCorePage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Control LG detail modal */}
|
||||||
|
<Modal isOpen={!!selectedControl} onClose={() => setSelectedControl(null)} title={selectedControl ? `Контроль ЛГ — ${selectedControl.registration}` : ''} size="md"
|
||||||
|
footer={selectedControl ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => setSelectedControl(null)} className="btn-secondary">Редактировать</button>
|
||||||
|
<button onClick={() => downloadCertificate(selectedControl)} className="btn-primary">Скачать сертификат</button>
|
||||||
|
<button onClick={() => setSelectedControl(null)} className="btn-secondary">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
) : undefined}>
|
||||||
|
{selectedControl && (
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div><span className="text-gray-500">Бортовой номер</span><div className="font-medium">{selectedControl.registration}</div></div>
|
||||||
|
<div><span className="text-gray-500">Тип ВС</span><div>{selectedControl.aircraft_type}</div></div>
|
||||||
|
<div><span className="text-gray-500">Дата последней проверки</span><div>{selectedControl.last_check_date}</div></div>
|
||||||
|
<div><span className="text-gray-500">Статус ЛГ</span><div><span className={`badge ${selectedControl.status === 'Годен' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'}`}>{selectedControl.status}</span></div>
|
||||||
|
<div><span className="text-gray-500">Срок действия</span><div>{selectedControl.valid_until}</div></div>
|
||||||
|
<div><span className="text-gray-500">Ответственный</span><div>{selectedControl.responsible}</div></div>
|
||||||
|
</div>
|
||||||
|
{selectedControl.notes && <div><span className="text-gray-500">Примечания</span><p className="mt-1">{selectedControl.notes}</p></div>}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-gray-700 mb-2">История проверок</h4>
|
||||||
|
<ul className="space-y-1 text-gray-600">
|
||||||
|
{(selectedControl.history || []).map((h, i) => <li key={i}>{h.date} — {h.type}: {h.result}</li>)}
|
||||||
|
{(!selectedControl.history || selectedControl.history.length === 0) && <li>Нет данных</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Legal basis footer */}
|
{/* Legal basis footer */}
|
||||||
<div className="mt-6 text-[10px] text-gray-400">
|
<div className="mt-6 text-[10px] text-gray-400">
|
||||||
{currentTab.basis} · © АО «REFLY»
|
{currentTab.basis} · © АО «REFLY»
|
||||||
|
|||||||
@ -1,29 +1,98 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { PageLayout, DataTable, StatusBadge, EmptyState } from '@/components/ui';
|
import { PageLayout, DataTable, StatusBadge, EmptyState, Modal } from '@/components/ui';
|
||||||
|
|
||||||
|
const DEMO_APPLICATIONS = [
|
||||||
|
{ id: '1', number: 'ЗАЯВ-2024-001', type: 'Сертификат эксплуатанта', organization_name: 'АО «Авиакомпания»', aircraft_id: 'RA-73001', basis: 'ФАП-246', submitted_at: '2024-10-15', status: 'pending', attachments: 'Устав, свидетельство ВС' },
|
||||||
|
{ id: '2', number: 'ЗАЯВ-2024-002', type: 'Дополнение к сертификату', organization_name: 'ООО «АвиаСервис»', aircraft_id: 'RA-73002', basis: 'ФАП-246 п. 12', submitted_at: '2024-11-01', status: 'draft', attachments: 'Регламент ТО' },
|
||||||
|
{ id: '3', number: 'ЗАЯВ-2024-003', type: 'Сертификат эксплуатанта', organization_name: 'ПАО «Авиалинии»', aircraft_id: 'VQ-BAB', basis: 'ФАП-246; EASA Part-ORO', submitted_at: '2024-09-20', status: 'approved', attachments: 'Полный пакет' },
|
||||||
|
{ id: '4', number: 'ЗАЯВ-2024-004', type: 'Сертификат на тип ВС', organization_name: 'АО «Авиакомпания»', aircraft_id: 'RA-73003', basis: 'ФАП-21', submitted_at: '2024-11-10', status: 'pending', attachments: 'Заключение по типу' },
|
||||||
|
{ id: '5', number: 'ЗАЯВ-2024-005', type: 'Продление срока действия', organization_name: 'ООО «АвиаСервис»', aircraft_id: 'RA-73002', basis: 'ФАП-246', submitted_at: '2024-08-05', status: 'approved', attachments: 'Акт проверки' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEMPLATE_DEFAULT = {
|
||||||
|
type: 'Сертификат эксплуатанта',
|
||||||
|
organization_name: 'АО «Авиакомпания»',
|
||||||
|
aircraft_id: 'RA-73001',
|
||||||
|
basis: 'ФАП-246; ВК РФ ст. 36',
|
||||||
|
submitted_at: new Date().toISOString().slice(0, 10),
|
||||||
|
attachments: 'Устав, свидетельство о гос. регистрации, регламент ТО, список ВС',
|
||||||
|
};
|
||||||
|
|
||||||
export default function ApplicationsPage() {
|
export default function ApplicationsPage() {
|
||||||
const [apps, setApps] = useState([] as any[]);
|
const [apps, setApps] = useState([] as any[]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [templateOpen, setTemplateOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState(TEMPLATE_DEFAULT);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true); fetch('/api/v1/cert-applications').then(r => r.json()).then(d => { setApps(d.items || []); setLoading(false); }); }, []);
|
setLoading(true);
|
||||||
|
fetch('/api/v1/cert-applications')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { setApps(Array.isArray(d?.items) && d.items.length > 0 ? d.items : DEMO_APPLICATIONS); setLoading(false); })
|
||||||
|
.catch(() => { setApps(DEMO_APPLICATIONS); setLoading(false); });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateFromTemplate = () => {
|
||||||
|
const newApp = {
|
||||||
|
id: String(Date.now()),
|
||||||
|
number: `ЗАЯВ-${new Date().getFullYear()}-${String(apps.length + 1).padStart(3, '0')}`,
|
||||||
|
...form,
|
||||||
|
submitted_at: form.submitted_at,
|
||||||
|
status: 'draft',
|
||||||
|
};
|
||||||
|
setApps(prev => [newApp, ...prev]);
|
||||||
|
setTemplateOpen(false);
|
||||||
|
setForm(TEMPLATE_DEFAULT);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{loading && <div className="fixed inset-0 bg-white/50 z-50 flex items-center justify-center"><div className="text-gray-500">⏳ Загрузка...</div></div>}
|
{loading && <div className="fixed inset-0 bg-white/50 z-50 flex items-center justify-center"><div className="text-gray-500">⏳ Загрузка...</div></div>}
|
||||||
<PageLayout title="📋 Заявки на сертификацию" subtitle="ФАП-246; EASA Part-ORO; ICAO Annex 6">
|
<PageLayout
|
||||||
{apps.length > 0 ? (
|
title="📋 Заявки на сертификацию"
|
||||||
<DataTable columns={[
|
subtitle="ФАП-246; EASA Part-ORO; ICAO Annex 6"
|
||||||
{ key: 'number', label: '№ заявки' },
|
actions={<button onClick={() => { setForm(TEMPLATE_DEFAULT); setTemplateOpen(true); }} className="btn-primary">Создать по шаблону</button>}
|
||||||
{ key: 'type', label: 'Тип' },
|
>
|
||||||
{ key: 'organization_name', label: 'Организация' },
|
{apps.length > 0 ? (
|
||||||
{ key: 'status', label: 'Статус', render: (v: string) => (
|
<DataTable
|
||||||
<StatusBadge status={v} colorMap={{ pending: 'bg-yellow-500', approved: 'bg-green-500', rejected: 'bg-red-500', draft: 'bg-gray-400' }}
|
columns={[
|
||||||
labelMap={{ pending: 'На рассмотрении', approved: 'Одобрена', rejected: 'Отклонена', draft: 'Черновик' }} />
|
{ key: 'number', label: '№ заявки' },
|
||||||
)},
|
{ key: 'type', label: 'Тип' },
|
||||||
{ key: 'submitted_at', label: 'Дата', render: (v: string) => v ? new Date(v).toLocaleDateString('ru-RU') : '—' },
|
{ key: 'organization_name', label: 'Организация' },
|
||||||
]} data={apps} />
|
{ key: 'aircraft_id', label: 'ВС', render: (v: string) => v || '—' },
|
||||||
) : <EmptyState message="Нет заявок" />}
|
{ key: 'status', label: 'Статус', render: (v: string) => (
|
||||||
</PageLayout>
|
<StatusBadge status={v} colorMap={{ pending: 'bg-yellow-500', approved: 'bg-green-500', rejected: 'bg-red-500', draft: 'bg-gray-400' }}
|
||||||
|
labelMap={{ pending: 'На рассмотрении', approved: 'Одобрена', rejected: 'Отклонена', draft: 'Черновик' }} />
|
||||||
|
)},
|
||||||
|
{ key: 'submitted_at', label: 'Дата', render: (v: string) => v ? new Date(v).toLocaleDateString('ru-RU') : '—' },
|
||||||
|
]}
|
||||||
|
data={apps}
|
||||||
|
/>
|
||||||
|
) : <EmptyState message="Нет заявок" />}
|
||||||
|
</PageLayout>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={templateOpen}
|
||||||
|
onClose={() => setTemplateOpen(false)}
|
||||||
|
title="Создать заявку по шаблону"
|
||||||
|
size="md"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button onClick={() => setTemplateOpen(false)} className="btn-secondary">Отмена</button>
|
||||||
|
<button onClick={handleCreateFromTemplate} className="btn-primary">Создать заявку</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div><label className="text-xs font-medium text-gray-600">Тип сертификата</label><input className="input-field w-full mt-1" value={form.type} onChange={e => setForm(f => ({ ...f, type: e.target.value }))} /></div>
|
||||||
|
<div><label className="text-xs font-medium text-gray-600">Организация</label><input className="input-field w-full mt-1" value={form.organization_name} onChange={e => setForm(f => ({ ...f, organization_name: e.target.value }))} /></div>
|
||||||
|
<div><label className="text-xs font-medium text-gray-600">ВС (борт / рег. номер)</label><input className="input-field w-full mt-1" value={form.aircraft_id} onChange={e => setForm(f => ({ ...f, aircraft_id: e.target.value }))} placeholder="RA-73001" /></div>
|
||||||
|
<div><label className="text-xs font-medium text-gray-600">Основание</label><input className="input-field w-full mt-1" value={form.basis} onChange={e => setForm(f => ({ ...f, basis: e.target.value }))} /></div>
|
||||||
|
<div><label className="text-xs font-medium text-gray-600">Дата подачи</label><input type="date" className="input-field w-full mt-1" value={form.submitted_at} onChange={e => setForm(f => ({ ...f, submitted_at: e.target.value }))} /></div>
|
||||||
|
<div><label className="text-xs font-medium text-gray-600">Приложения</label><textarea className="input-field w-full mt-1 min-h-[80px]" value={form.attachments} onChange={e => setForm(f => ({ ...f, attachments: e.target.value }))} /></div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import AuditCardModal from '@/components/AuditCardModal';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import AuditCreateModal from '@/components/AuditCreateModal';
|
import AuditEditModal from '@/components/AuditEditModal';
|
||||||
import { PageLayout, FilterBar, Pagination, StatusBadge, EmptyState } from '@/components/ui';
|
import { PageLayout, FilterBar, StatusBadge, EmptyState } from '@/components/ui';
|
||||||
import { useAuditsData } from '@/hooks/useSWRData';
|
import { useAuditsData } from '@/hooks/useSWRData';
|
||||||
import { auditsApi } from '@/lib/api/api-client';
|
import { auditsApi } from '@/lib/api/api-client';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
import { useAuth } from '@/lib/auth-context';
|
||||||
@ -13,10 +12,21 @@ const BC: Record<string, string> = { draft: 'border-l-gray-300', in_progress: 'b
|
|||||||
|
|
||||||
export default function AuditsPage() {
|
export default function AuditsPage() {
|
||||||
const { isAuthority } = useAuth();
|
const { isAuthority } = useAuth();
|
||||||
const [sf, setSf] = useState<string|undefined>();
|
const [sf, setSf] = useState<string | undefined>();
|
||||||
|
const [editingAudit, setEditingAudit] = useState<any>(null);
|
||||||
const { data, isLoading, mutate } = useAuditsData({ status: sf });
|
const { data, isLoading, mutate } = useAuditsData({ status: sf });
|
||||||
const audits = data?.items || [];
|
const audits = data?.items || [];
|
||||||
|
|
||||||
|
const handleSaveAudit = async (id: string, payload: any) => {
|
||||||
|
try {
|
||||||
|
await auditsApi.update(id, payload);
|
||||||
|
mutate();
|
||||||
|
setEditingAudit(null);
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e?.message || 'Ошибка сохранения');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout title="Аудиты" subtitle={isLoading ? 'Загрузка...' : `Всего: ${data?.total || 0}`}>
|
<PageLayout title="Аудиты" subtitle={isLoading ? 'Загрузка...' : `Всего: ${data?.total || 0}`}>
|
||||||
<FilterBar value={sf} onChange={setSf} className="mb-4"
|
<FilterBar value={sf} onChange={setSf} className="mb-4"
|
||||||
@ -26,11 +36,14 @@ export default function AuditsPage() {
|
|||||||
{audits.map((a: any) => (
|
{audits.map((a: any) => (
|
||||||
<div key={a.id} className={`card p-5 border-l-4 ${BC[a.status] || 'border-l-gray-300'} flex justify-between items-center`}>
|
<div key={a.id} className={`card p-5 border-l-4 ${BC[a.status] || 'border-l-gray-300'} flex justify-between items-center`}>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-bold">Аудит #{a.id.slice(0,8)}</div>
|
<div className="font-bold">Аудит #{a.id.slice(0, 8)}</div>
|
||||||
<div className="text-xs text-gray-500 mt-1">ВС: {a.aircraft_id?.slice(0,8)||'—'} {a.planned_at && `· ${new Date(a.planned_at).toLocaleDateString('ru-RU')}`}</div>
|
<div className="text-xs text-gray-500 mt-1">ВС: {a.aircraft_id?.slice(0, 8) || '—'} {a.planned_at && `· ${new Date(a.planned_at).toLocaleDateString('ru-RU')}`}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<StatusBadge status={a.status} colorMap={SC} labelMap={ST} />
|
<StatusBadge status={a.status} colorMap={SC} labelMap={ST} />
|
||||||
|
<button onClick={() => setEditingAudit(a)} className="btn-sm bg-gray-100 text-gray-600 hover:bg-gray-200 p-1.5 rounded" title="Редактировать">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
|
||||||
|
</button>
|
||||||
{a.status === 'in_progress' && isAuthority && (
|
{a.status === 'in_progress' && isAuthority && (
|
||||||
<button onClick={async () => { await auditsApi.complete(a.id); mutate(); }} className="btn-sm bg-green-500 text-white">Завершить</button>
|
<button onClick={async () => { await auditsApi.complete(a.id); mutate(); }} className="btn-sm bg-green-500 text-white">Завершить</button>
|
||||||
)}
|
)}
|
||||||
@ -39,6 +52,7 @@ export default function AuditsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : <EmptyState message="Нет аудитов." />}
|
) : <EmptyState message="Нет аудитов." />}
|
||||||
|
<AuditEditModal isOpen={!!editingAudit} onClose={() => setEditingAudit(null)} audit={editingAudit} onSave={handleSaveAudit} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,60 +2,122 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { PageLayout, DataTable, StatusBadge, Modal, EmptyState } from '@/components/ui';
|
import { PageLayout, DataTable, StatusBadge, Modal, EmptyState } from '@/components/ui';
|
||||||
|
|
||||||
|
const DEMO_DEFECTS = [
|
||||||
|
{ id: '1', defect_number: 'DEF-2024-001', aircraft_reg: 'RA-73001', ata_chapter: '32', description: 'Утечка гидравлической жидкости в районе стойки шасси', severity: 'critical', discovered_date: '2024-11-20', discovered_during: 'preflight', status: 'open', responsible: 'Иванов И.И.', corrective_actions: 'Замена уплотнений', mel_reference: '32-01-01', history: [{ date: '2024-11-20', action: 'Зарегистрирован', user: 'Иванов И.И.' }] },
|
||||||
|
{ id: '2', defect_number: 'DEF-2024-002', aircraft_reg: 'RA-73002', ata_chapter: '33', description: 'Нестабильные показания датчика освещённости', severity: 'major', discovered_date: '2024-11-18', discovered_during: 'daily', status: 'deferred', responsible: 'Петров П.П.', mel_reference: '33-02-01', history: [{ date: '2024-11-18', action: 'Зарегистрирован' }, { date: '2024-11-19', action: 'Отложен по MEL' }] },
|
||||||
|
{ id: '3', defect_number: 'DEF-2024-003', aircraft_reg: 'VQ-BAB', ata_chapter: '21', description: 'Потёртость уплотнения двери салона', severity: 'minor', discovered_date: '2024-11-15', discovered_during: 'a_check', status: 'rectified', responsible: 'Сидорова А.С.', corrective_actions: 'Замена уплотнения', history: [{ date: '2024-11-15', action: 'Зарегистрирован' }, { date: '2024-11-16', action: 'Устранён' }] },
|
||||||
|
{ id: '4', defect_number: 'DEF-2024-004', aircraft_reg: 'RA-73003', ata_chapter: '80', description: 'Повышенная вибрация двигателя №1', severity: 'critical', discovered_date: '2024-12-01', discovered_during: 'report', status: 'open', responsible: 'Козлов М.А.', corrective_actions: 'Диагностика, возможна замена двигателя', history: [{ date: '2024-12-01', action: 'Зарегистрирован по донесению экипажа' }] },
|
||||||
|
{ id: '5', defect_number: 'DEF-2024-005', aircraft_reg: 'RA-73005', ata_chapter: '27', description: 'Не работает подсветка панели', severity: 'minor', discovered_date: '2024-11-25', discovered_during: 'transit', status: 'closed', responsible: 'Новикова Е.В.', corrective_actions: 'Замена лампы', history: [{ date: '2024-11-25', action: 'Зарегистрирован' }, { date: '2024-11-26', action: 'Устранён' }, { date: '2024-11-26', action: 'Закрыт' }] },
|
||||||
|
{ id: '6', defect_number: 'DEF-2024-006', aircraft_reg: 'RA-73001', ata_chapter: '36', description: 'Срабатывание сигнализации давления в пневмосистеме', severity: 'major', discovered_date: '2024-11-28', discovered_during: 'preflight', status: 'open', responsible: 'Иванов И.И.', history: [{ date: '2024-11-28', action: 'Зарегистрирован' }] },
|
||||||
|
{ id: '7', defect_number: 'DEF-2024-007', aircraft_reg: 'RA-73006', ata_chapter: '52', description: 'Трещина остекления кабины (незначительная)', severity: 'minor', discovered_date: '2024-11-22', discovered_during: 'daily', status: 'rectified', responsible: 'Морозова Е.И.', corrective_actions: 'Замена остекления', history: [{ date: '2024-11-22', action: 'Зарегистрирован' }, { date: '2024-11-24', action: 'Устранён' }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SEV_COLORS: Record<string, string> = { critical: 'bg-red-500', major: 'bg-orange-500', minor: 'bg-yellow-500' };
|
||||||
|
const SEV_LABELS: Record<string, string> = { critical: 'Критический', major: 'Значительный', minor: 'Незначительный' };
|
||||||
|
const STATUS_LABELS: Record<string, string> = { open: 'Открыт', deferred: 'Отложен (MEL)', rectified: 'Устранён', closed: 'Закрыт' };
|
||||||
|
|
||||||
export default function DefectsPage() {
|
export default function DefectsPage() {
|
||||||
const [defects, setDefects] = useState([] as any[]);
|
const [defects, setDefects] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
|
const [selected, setSelected] = useState<any>(null);
|
||||||
|
|
||||||
const api = useCallback(async (ep: string, opts?: RequestInit) => {
|
const api = useCallback(async (ep: string, opts?: RequestInit) => {
|
||||||
const r = await fetch(`/api/v1/defects${ep}`, opts);
|
const r = await fetch(`/api/v1/defects${ep}`, opts);
|
||||||
return r.json();
|
return r.json();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true); api(`/${filter ? `?status=${filter}` : ""}`).then(d => { setDefects(d.items || []); });
|
setLoading(true);
|
||||||
|
api(`/${filter ? `?status=${filter}` : ''}`)
|
||||||
|
.then(d => setDefects(Array.isArray(d?.items) && d.items.length > 0 ? d.items : DEMO_DEFECTS))
|
||||||
|
.catch(() => setDefects(DEMO_DEFECTS))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
}, [api, filter]);
|
}, [api, filter]);
|
||||||
|
|
||||||
|
const displayDefects = filter ? defects.filter(d => d.status === filter) : defects;
|
||||||
|
|
||||||
const handleAdd = async (data: any) => {
|
const handleAdd = async (data: any) => {
|
||||||
const r = await api('/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
const r = await api('/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
||||||
if (r.id) { setDefects(p => [r, ...p]); setShowAdd(false); }
|
if (r.id) { setDefects(p => [r, ...p]); setShowAdd(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCloseDefect = (id: string) => {
|
||||||
|
if (!confirm('Закрыть дефект?')) return;
|
||||||
|
setDefects(prev => prev.map(d => d.id === id ? { ...d, status: 'closed' } : d));
|
||||||
|
setSelected(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadReport = (d: any) => {
|
||||||
|
const lines = [`Отчёт по дефекту ${d.defect_number || d.id}`, `Борт: ${d.aircraft_reg}`, `ATA: ${d.ata_chapter}`, `Описание: ${d.description}`, `Серьёзность: ${SEV_LABELS[d.severity] || d.severity}`, `Дата обнаружения: ${d.discovered_date || '—'}`, `Статус: ${STATUS_LABELS[d.status]}`, `Ответственный: ${d.responsible || '—'}`, d.corrective_actions ? `Корректирующие действия: ${d.corrective_actions}` : '', 'История:', ...(d.history || []).map((h: any) => ` ${h.date} — ${h.action}`)];
|
||||||
|
const blob = new Blob([lines.filter(Boolean).join('\n')], { type: 'text/plain;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a'); a.href = url; a.download = `defect_${d.defect_number || d.id}.txt`; a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageLayout title="🛠️ Дефекты и неисправности" subtitle="ФАП-145 п.145.A.50; EASA Part-M.A.403"
|
<PageLayout title="🛠️ Дефекты и неисправности" subtitle="ФАП-145 п.145.A.50; EASA Part-M.A.403"
|
||||||
actions={<button onClick={() => setShowAdd(true)} className="btn-primary text-sm px-4 py-2 rounded">+ Зарегистрировать</button>}>
|
actions={<button onClick={() => setShowAdd(true)} className="btn-primary text-sm px-4 py-2 rounded">+ Зарегистрировать</button>}>
|
||||||
<div className="flex gap-2 mb-4">
|
<div className="flex gap-2 mb-4">
|
||||||
{['', 'open', 'deferred', 'rectified', 'closed'].map(s => (
|
{['', 'open', 'deferred', 'rectified', 'closed'].map(s => (
|
||||||
<button key={s} onClick={() => setFilter(s)}
|
<button key={s} onClick={() => setFilter(s)}
|
||||||
className={filter === s ? 'px-3 py-1.5 rounded text-xs bg-blue-600 text-white' : 'px-3 py-1.5 rounded text-xs bg-gray-100 text-gray-600'}>
|
className={filter === s ? 'px-3 py-1.5 rounded text-xs bg-blue-600 text-white' : 'px-3 py-1.5 rounded text-xs bg-gray-100 text-gray-600'}>
|
||||||
{s || 'Все'}
|
{STATUS_LABELS[s] || 'Все'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{defects.length > 0 ? (
|
{displayDefects.length > 0 ? (
|
||||||
<DataTable columns={[
|
<DataTable columns={[
|
||||||
{ key: 'aircraft_reg', label: 'Борт' },
|
{ key: 'defect_number', label: '№', render: (_: any, row: any) => row.defect_number || row.id },
|
||||||
{ key: 'ata_chapter', label: 'ATA' },
|
{ key: 'aircraft_reg', label: 'Борт' },
|
||||||
{ key: 'description', label: 'Описание' },
|
{ key: 'ata_chapter', label: 'ATA' },
|
||||||
{ key: 'severity', label: 'Серьёзность', render: (v: string) => (
|
{ key: 'description', label: 'Описание', render: (v: string) => <span className="line-clamp-1">{v || '—'}</span> },
|
||||||
<StatusBadge status={v} colorMap={{ critical: 'bg-red-500', major: 'bg-yellow-500', minor: 'bg-blue-500' }} />
|
{ key: 'severity', label: 'Серьёзность', render: (v: string) => (
|
||||||
)},
|
<StatusBadge status={v} colorMap={SEV_COLORS} labelMap={SEV_LABELS} />
|
||||||
{ key: 'discovered_during', label: 'Обнаружен' },
|
)},
|
||||||
{ key: 'mel_reference', label: 'MEL', render: (v: string) => v || '—' },
|
{ key: 'discovered_date', label: 'Дата', render: (v: string) => v ? new Date(v).toLocaleDateString('ru-RU') : '—' },
|
||||||
{ key: 'status', label: 'Статус', render: (v: string) => (
|
{ key: 'status', label: 'Статус', render: (v: string) => (
|
||||||
<StatusBadge status={v} colorMap={{ open: 'bg-red-500', deferred: 'bg-yellow-500', rectified: 'bg-green-500', closed: 'bg-gray-400' }}
|
<StatusBadge status={v} colorMap={{ open: 'bg-red-500', deferred: 'bg-yellow-500', rectified: 'bg-green-500', closed: 'bg-gray-400' }} labelMap={STATUS_LABELS} />
|
||||||
labelMap={{ open: 'Открыт', deferred: 'Отложен (MEL)', rectified: 'Устранён', closed: 'Закрыт' }} />
|
)},
|
||||||
)},
|
]} data={displayDefects} onRowClick={setSelected} />
|
||||||
]} data={defects} />
|
) : <EmptyState message="Нет зарегистрированных дефектов" />}
|
||||||
) : <EmptyState message="Нет зарегистрированных дефектов" />}
|
|
||||||
|
|
||||||
<Modal isOpen={showAdd} onClose={() => setShowAdd(false)} title="Регистрация дефекта">
|
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title={selected ? `Дефект ${selected.defect_number || selected.id}` : ''} size="lg"
|
||||||
<DefectForm onSubmit={handleAdd} onCancel={() => setShowAdd(false)} />
|
footer={selected ? (
|
||||||
</Modal>
|
<div className="flex gap-2">
|
||||||
</PageLayout>
|
<button onClick={() => setSelected(null)} className="btn-secondary">Редактировать</button>
|
||||||
|
<button onClick={() => downloadReport(selected)} className="btn-primary">Скачать отчёт</button>
|
||||||
|
{selected.status !== 'closed' && <button onClick={() => handleCloseDefect(selected.id)} className="btn-sm bg-green-600 text-white">Закрыть дефект</button>}
|
||||||
|
<button onClick={() => setSelected(null)} className="btn-secondary">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
) : undefined}>
|
||||||
|
{selected && (
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div className={`p-2 rounded border-l-4 ${selected.severity === 'critical' ? 'bg-red-50 border-red-500' : selected.severity === 'major' ? 'bg-orange-50 border-orange-500' : 'bg-yellow-50 border-yellow-500'}`}>
|
||||||
|
<span className="font-medium">{SEV_LABELS[selected.severity] || selected.severity}</span>
|
||||||
|
</div>
|
||||||
|
<div><span className="text-gray-500">Описание</span><p className="mt-1">{selected.description}</p></div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><span className="text-gray-500">Борт</span><div>{selected.aircraft_reg}</div></div>
|
||||||
|
<div><span className="text-gray-500">ATA</span><div>{selected.ata_chapter}</div></div>
|
||||||
|
<div><span className="text-gray-500">Дата обнаружения</span><div>{selected.discovered_date ? new Date(selected.discovered_date).toLocaleDateString('ru-RU') : '—'}</div></div>
|
||||||
|
<div><span className="text-gray-500">Ответственный</span><div>{selected.responsible || '—'}</div></div>
|
||||||
|
<div><span className="text-gray-500">MEL</span><div>{selected.mel_reference || '—'}</div></div>
|
||||||
|
</div>
|
||||||
|
{selected.corrective_actions && <div><span className="text-gray-500">Корректирующие действия</span><p className="mt-1">{selected.corrective_actions}</p></div>}
|
||||||
|
<div><span className="text-gray-500">Фото/вложения</span><p className="mt-1 text-gray-400 text-xs">Фото прилагаются к дефекту в системе учёта</p></div>
|
||||||
|
<div><h4 className="font-medium text-gray-700 mb-2">История</h4><ul className="space-y-1 text-gray-600">{(selected.history || []).map((h: any, i: number) => <li key={i}>{h.date} — {h.action}{h.user ? ` (${h.user})` : ''}</li>)}</ul></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal isOpen={showAdd} onClose={() => setShowAdd(false)} title="Регистрация дефекта">
|
||||||
|
<DefectForm onSubmit={handleAdd} onCancel={() => setShowAdd(false)} />
|
||||||
|
</Modal>
|
||||||
|
</PageLayout>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,50 +1,45 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { PageLayout } from '@/components/ui';
|
import { PageLayout } from '@/components/ui';
|
||||||
|
import HelpDocumentModal from '@/components/HelpDocumentModal';
|
||||||
|
|
||||||
const DOCS = [
|
const defaultContent = (name: string, ref?: string, articles?: string) =>
|
||||||
|
`# ${name}\n\n${ref || ''}\n\n## Основные положения\n\nДокумент определяет требования и процедуры в области гражданской авиации.\n\n## Применимые статьи\n\n${articles || 'См. официальный источник.'}\n\n## Ссылки\n\nОфициальный текст документа публикуется на портале Минтранса РФ или в реестре ICAO/EASA.`;
|
||||||
|
|
||||||
|
const DOCS: { cat: string; items: { name: string; ref?: string; articles?: string; content?: string }[] }[] = [
|
||||||
{ cat: 'Законодательство РФ', items: [
|
{ cat: 'Законодательство РФ', items: [
|
||||||
{ name: 'Воздушный кодекс РФ', ref: '60-ФЗ от 19.03.1997', articles: 'ст. 8, 24.1, 28, 33, 35, 36, 37, 37.2, 52-54' },
|
{ name: 'Воздушный кодекс РФ', ref: '60-ФЗ от 19.03.1997', articles: 'ст. 8, 24.1, 28, 33, 35, 36, 37, 37.2, 52-54', content: defaultContent('Воздушный кодекс РФ', '60-ФЗ от 19.03.1997', 'ст. 8, 24.1, 28, 33, 35, 36, 37, 37.2, 52-54') },
|
||||||
{ name: 'ФЗ-488', ref: '30.12.2021', articles: 'ст. 37.2 — поддержание ЛГ' },
|
{ name: 'ФЗ-488', ref: '30.12.2021', articles: 'ст. 37.2 — поддержание ЛГ', content: defaultContent('ФЗ-488', '30.12.2021', 'ст. 37.2 — поддержание лётной годности') },
|
||||||
{ name: 'ФЗ-152', ref: 'О персональных данных', articles: 'Защита ПДн в панели ФАВТ' },
|
{ name: 'ФЗ-152', ref: 'О персональных данных', articles: 'Защита ПДн в панели ФАВТ', content: defaultContent('ФЗ-152', 'О персональных данных', 'Защита ПДн в панели ФАВТ') },
|
||||||
]},
|
]},
|
||||||
{ cat: 'ФАП (Федеральные авиационные правила)', items: [
|
{ cat: 'ФАП', items: [
|
||||||
{ name: 'ФАП-10', ref: 'Сертификация эксплуатантов', articles: 'Общие требования' },
|
{ name: 'ФАП-145', ref: 'Организации по ТО', articles: 'п.A.30, A.35, A.42, A.50-65', content: defaultContent('ФАП-145', 'Организации по ТО', 'п.A.30, A.35, A.42, A.50-65') },
|
||||||
{ name: 'ФАП-21', ref: 'Сертификация АТ', articles: 'Part-21 эквивалент' },
|
{ name: 'ФАП-147', ref: 'Учебные организации', articles: 'п.17 — программы подготовки', content: defaultContent('ФАП-147', 'Учебные организации', 'п.17 — программы подготовки') },
|
||||||
{ name: 'ФАП-128', ref: 'Подготовка и выполнение полётов', articles: 'Эксплуатация ВС' },
|
{ name: 'ФАП-148', ref: 'Поддержание ЛГ', articles: 'п.3, 4.2, 4.3, 4.5', content: defaultContent('ФАП-148', 'Поддержание ЛГ', 'п.3, 4.2, 4.3, 4.5') },
|
||||||
{ name: 'ФАП-145', ref: 'Организации по ТО', articles: 'п.A.30, A.35, A.42, A.50-65' },
|
{ name: 'ФАП-246', ref: 'Сертификация эксплуатантов', articles: 'Процедуры сертификации', content: defaultContent('ФАП-246', 'Сертификация эксплуатантов', 'Процедуры сертификации') },
|
||||||
{ name: 'ФАП-147', ref: 'Учебные организации', articles: 'п.17 — программы подготовки' },
|
|
||||||
{ name: 'ФАП-148', ref: 'Поддержание ЛГ', articles: 'п.3, 4.2, 4.3, 4.5' },
|
|
||||||
{ name: 'ФАП-149', ref: 'Инспектирование ГА', articles: 'Надзорные функции' },
|
|
||||||
{ name: 'ФАП-246', ref: 'Сертификация эксплуатантов', articles: 'Процедуры сертификации' },
|
|
||||||
]},
|
]},
|
||||||
{ cat: 'ICAO', items: [
|
{ cat: 'ICAO', items: [
|
||||||
{ name: 'Annex 1', ref: 'Licensing', articles: 'Лицензирование персонала' },
|
{ name: 'Annex 6', ref: 'Operation', articles: 'Part I 8.3, 8.7 — ТО', content: defaultContent('ICAO Annex 6', 'Operation', 'Part I 8.3, 8.7 — ТО') },
|
||||||
{ name: 'Annex 6', ref: 'Operation', articles: 'Part I 8.3, 8.7 — ТО' },
|
{ name: 'Annex 19', ref: 'Safety Management', articles: 'SMS', content: defaultContent('ICAO Annex 19', 'Safety Management', 'SMS') },
|
||||||
{ name: 'Annex 8', ref: 'Airworthiness', articles: 'Part II 4.2 — ресурсы' },
|
{ name: 'Doc 9859', ref: 'SMM', articles: 'ch.2 — human factors', content: defaultContent('ICAO Doc 9859', 'SMM', 'ch.2 — human factors') },
|
||||||
{ name: 'Annex 19', ref: 'Safety Management', articles: 'SMS' },
|
|
||||||
{ name: 'Doc 9734', ref: 'Safety Oversight', articles: 'CE-7' },
|
|
||||||
{ name: 'Doc 9760', ref: 'Airworthiness Manual', articles: 'ch.6 — персонал' },
|
|
||||||
{ name: 'Doc 9859', ref: 'SMM', articles: 'ch.2 — human factors' },
|
|
||||||
]},
|
]},
|
||||||
{ cat: 'EASA', items: [
|
{ cat: 'EASA', items: [
|
||||||
{ name: 'Part-21', ref: 'Certification', articles: 'A.3B, A.97' },
|
{ name: 'Part-M', ref: 'Continuing Airworthiness', articles: 'A.301, A.302, A.403, A.501, A.901', content: defaultContent('EASA Part-M', 'Continuing Airworthiness', 'A.301, A.302, A.403, A.501, A.901') },
|
||||||
{ name: 'Part-66', ref: 'Licensing', articles: 'A.25, A.30, A.40, A.45' },
|
{ name: 'Part-145', ref: 'Maintenance Organisations', articles: 'A.30, A.35, A.42, A.50-65', content: defaultContent('EASA Part-145', 'Maintenance Organisations', 'A.30, A.35, A.42, A.50-65') },
|
||||||
{ name: 'Part-M', ref: 'Continuing Airworthiness', articles: 'A.301, A.302, A.403, A.501, A.901' },
|
|
||||||
{ name: 'Part-145', ref: 'Maintenance Organisations', articles: 'A.30, A.35, A.42, A.50-65' },
|
|
||||||
{ name: 'Part-CAMO', ref: 'Continuing Airworthiness Mgmt', articles: 'A.305' },
|
|
||||||
]},
|
]},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function HelpPage() {
|
export default function HelpPage() {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [selectedDoc, setSelectedDoc] = useState<typeof DOCS[0]['items'][0] | null>(null);
|
||||||
|
|
||||||
const filtered = DOCS.map(cat => ({
|
const filtered = DOCS.map(cat => ({
|
||||||
...cat,
|
...cat,
|
||||||
items: cat.items.filter(i => !search || [i.name, i.ref, i.articles].some(s => s.toLowerCase().includes(search.toLowerCase())))
|
items: cat.items.filter(i => !search || [i.name, i.ref, i.articles].some(s => String(s).toLowerCase().includes(search.toLowerCase())))
|
||||||
})).filter(cat => cat.items.length > 0);
|
})).filter(cat => cat.items.length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout title="📚 Справка" subtitle="Нормативная база АСУ ТК — 19 документов">
|
<PageLayout title="📚 Справка" subtitle="Нормативная база АСУ ТК">
|
||||||
<input type="text" placeholder="🔍 Поиск по нормативной базе..." value={search}
|
<input type="text" placeholder="🔍 Поиск по нормативной базе..." value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
className="w-full max-w-md px-3 py-2 rounded-lg bg-gray-100 text-sm mb-6 focus:ring-2 focus:ring-blue-500" />
|
className="w-full max-w-md px-3 py-2 rounded-lg bg-gray-100 text-sm mb-6 focus:ring-2 focus:ring-blue-500" />
|
||||||
@ -54,7 +49,7 @@ export default function HelpPage() {
|
|||||||
<h3 className="text-sm font-bold text-gray-600 mb-2">{cat.cat}</h3>
|
<h3 className="text-sm font-bold text-gray-600 mb-2">{cat.cat}</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
{cat.items.map(item => (
|
{cat.items.map(item => (
|
||||||
<div key={item.name} className="card p-3">
|
<div key={item.name} onClick={() => setSelectedDoc(item)} className="card p-3 cursor-pointer hover:shadow-md transition-shadow">
|
||||||
<div className="font-medium text-sm">{item.name}</div>
|
<div className="font-medium text-sm">{item.name}</div>
|
||||||
<div className="text-xs text-gray-500">{item.ref}</div>
|
<div className="text-xs text-gray-500">{item.ref}</div>
|
||||||
<div className="text-[10px] text-blue-600 mt-1">{item.articles}</div>
|
<div className="text-[10px] text-blue-600 mt-1">{item.articles}</div>
|
||||||
@ -64,6 +59,7 @@ export default function HelpPage() {
|
|||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<HelpDocumentModal isOpen={!!selectedDoc} onClose={() => setSelectedDoc(null)} doc={selectedDoc} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
import { useAuth } from '@/lib/auth-context';
|
||||||
import Logo from '@/components/Logo';
|
import ReflyLogo from '@/components/ReflyLogo';
|
||||||
|
|
||||||
const DEMO_ACCOUNTS = [
|
const DEMO_ACCOUNTS = [
|
||||||
{ token: 'dev', icon: '👤', label: 'Разработчик', desc: 'Локальная разработка' },
|
{ token: 'dev', icon: '👤', label: 'Разработчик', desc: 'Локальная разработка' },
|
||||||
@ -35,7 +35,9 @@ export default function LoginPage() {
|
|||||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||||
<div className="bg-white p-12 rounded-xl shadow-lg max-w-md w-full">
|
<div className="bg-white p-12 rounded-xl shadow-lg max-w-md w-full">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<Logo size="large" />
|
<div className="flex justify-center">
|
||||||
|
<ReflyLogo iconSize={48} showText={true} variant="dark" />
|
||||||
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-primary-500 mt-4">КЛГ АСУ ТК</h1>
|
<h1 className="text-2xl font-bold text-primary-500 mt-4">КЛГ АСУ ТК</h1>
|
||||||
<p className="text-sm text-gray-400 mt-2">Контроль лётной годности · Вход</p>
|
<p className="text-sm text-gray-400 mt-2">Контроль лётной годности · Вход</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -19,13 +19,28 @@ export default function MaintenancePage() {
|
|||||||
return r.json();
|
return r.json();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const DEMO_ORDERS: any[] = [
|
||||||
|
{ id: '1', wo_number: 'WO-2024-001', aircraft_reg: 'RA-73001', wo_type: 'scheduled', title: 'Периодическое ТО SSJ-100', priority: 'normal', estimated_manhours: 120, status: 'closed', start_date: '2024-11-01', end_date: '2024-11-05', assigned_to: 'Иванов И.И.' },
|
||||||
|
{ id: '2', wo_number: 'WO-2024-002', aircraft_reg: 'RA-73002', wo_type: 'ad_compliance', title: 'Выполнение ДЛГ MC-21', priority: 'urgent', estimated_manhours: 40, status: 'in_progress', start_date: '2024-12-01', assigned_to: 'Петров П.П.' },
|
||||||
|
{ id: '3', wo_number: 'WO-2024-003', aircraft_reg: 'VQ-BAB', wo_type: 'scheduled', title: 'A-Check Boeing 737', priority: 'normal', estimated_manhours: 200, status: 'draft', assigned_to: '—' },
|
||||||
|
{ id: '4', wo_number: 'WO-2024-004', aircraft_reg: 'RA-73003', wo_type: 'defect_rectification', title: 'Устранение дефекта гидросистемы', priority: 'aog', estimated_manhours: 16, status: 'in_progress', start_date: '2024-12-02', assigned_to: 'Сидорова А.С.' },
|
||||||
|
{ id: '5', wo_number: 'WO-2024-005', aircraft_reg: 'RA-73005', wo_type: 'scheduled', title: 'Периодическое ТО A320', priority: 'normal', estimated_manhours: 80, status: 'closed', start_date: '2024-10-10', end_date: '2024-10-12', assigned_to: 'Козлов М.А.' },
|
||||||
|
{ id: '6', wo_number: 'WO-2024-006', aircraft_reg: 'RA-73001', wo_type: 'sb_compliance', title: 'Внедрение SB по двигателю', priority: 'normal', estimated_manhours: 24, status: 'draft', assigned_to: '—' },
|
||||||
|
{ id: '7', wo_number: 'WO-2024-007', aircraft_reg: 'RA-73006', wo_type: 'scheduled', title: 'ТО SSJ-100', priority: 'normal', estimated_manhours: 100, status: 'in_progress', start_date: '2024-11-28', assigned_to: 'Новикова Е.В.' },
|
||||||
|
];
|
||||||
|
|
||||||
const reload = useCallback(() => {
|
const reload = useCallback(() => {
|
||||||
api(`/${filter ? `?status=${filter}` : ""}`).then(d => { setOrders(d.items || []); });
|
setLoading(true);
|
||||||
api('/stats/summary').then(setStats);
|
api(`/${filter ? `?status=${filter}` : ''}`)
|
||||||
|
.then(d => { setOrders(Array.isArray(d?.items) && d.items.length > 0 ? d.items : DEMO_ORDERS); })
|
||||||
|
.catch(() => setOrders(DEMO_ORDERS))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
api('/stats/summary')
|
||||||
|
.then(s => setStats(s && typeof s.total === 'number' ? s : { total: DEMO_ORDERS.length, draft: 2, in_progress: 3, closed: 2, aog: 1, total_manhours: 580 }))
|
||||||
|
.catch(() => setStats({ total: DEMO_ORDERS.length, draft: 2, in_progress: 3, closed: 2, aog: 1, total_manhours: 580 }));
|
||||||
}, [api, filter]);
|
}, [api, filter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { reload(); }, [reload]);
|
||||||
setLoading(true); reload(); }, [reload]);
|
|
||||||
|
|
||||||
const handleCreate = async (data: any) => {
|
const handleCreate = async (data: any) => {
|
||||||
const r = await api('/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
const r = await api('/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
||||||
@ -97,17 +112,17 @@ export default function MaintenancePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selected.findings && <div><span className="text-gray-500 font-medium">Замечания:</span> {selected.findings}</div>}
|
{selected.findings && <div><span className="text-gray-500 font-medium">Замечания:</span> {selected.findings}</div>}
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2 flex-wrap">
|
||||||
|
<button onClick={() => { const t = [`Наряд ${selected.wo_number}`, `Борт: ${selected.aircraft_reg}`, `Тип: ${typeLabels[selected.wo_type] || selected.wo_type}`, `Статус: ${statusLabels[selected.status]}`, `План. ч/ч: ${selected.estimated_manhours}`, selected.description ? `Описание: ${selected.description}` : ''].filter(Boolean).join('\n'); const b = new Blob([t], { type: 'text/plain;charset=utf-8' }); const u = URL.createObjectURL(b); const a = document.createElement('a'); a.href = u; a.download = `narad_${selected.wo_number}.txt`; a.click(); URL.revokeObjectURL(u); }} className="btn-sm bg-gray-100 text-gray-700 px-3 py-1.5 rounded text-xs">Скачать наряд</button>
|
||||||
{selected.status === 'draft' && (
|
{selected.status === 'draft' && (
|
||||||
<button onClick={async () => { await api(`/${selected.id}/open`, { method: 'PUT' }); reload(); setSelected(null); }}
|
<button onClick={async () => { try { await api(`/${selected.id}/open`, { method: 'PUT' }); } catch { /* demo */ } reload(); setSelected(null); }}
|
||||||
className="btn-primary px-4 py-2 rounded text-xs">▶ В работу</button>
|
className="btn-primary px-4 py-2 rounded text-xs">▶ В работу</button>
|
||||||
)}
|
)}
|
||||||
{selected.status === 'in_progress' && (
|
{selected.status === 'in_progress' && (
|
||||||
<button onClick={async () => {
|
<button onClick={async () => {
|
||||||
await api(`/${selected.id}/close`, { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
try { await api(`/${selected.id}/close`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ actual_manhours: selected.estimated_manhours, findings: '', parts_used: [], crs_signed_by: 'Текущий пользователь' }) }); } catch { /* demo */ }
|
||||||
body: JSON.stringify({ actual_manhours: selected.estimated_manhours, findings: '', parts_used: [], crs_signed_by: 'Текущий пользователь' }) });
|
|
||||||
reload(); setSelected(null);
|
reload(); setSelected(null);
|
||||||
}} className="bg-green-600 text-white px-4 py-2 rounded text-xs">✅ Закрыть + CRS</button>
|
}} className="bg-green-600 text-white px-4 py-2 rounded text-xs">✅ Завершить</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -14,7 +14,28 @@ import { PageLayout, DataTable, StatusBadge, Modal, EmptyState } from '@/compone
|
|||||||
type Tab = 'specialists' | 'programs' | 'attestations' | 'compliance';
|
type Tab = 'specialists' | 'programs' | 'attestations' | 'compliance';
|
||||||
|
|
||||||
interface Specialist { id: string; full_name: string; personnel_number: string; position: string; category: string; specializations: string[]; license_number?: string; license_expires?: string; status: string; compliance?: any; attestations?: any[]; qualifications?: any[]; }
|
interface Specialist { id: string; full_name: string; personnel_number: string; position: string; category: string; specializations: string[]; license_number?: string; license_expires?: string; status: string; compliance?: any; attestations?: any[]; qualifications?: any[]; }
|
||||||
interface Program { id: string; name: string; type: string; legal_basis: string; duration_hours: number; modules?: any[]; periodicity?: string; certificate_validity_years?: number; }
|
interface Program { id: string; name: string; type: string; legal_basis?: string; duration_hours: number; modules?: any[]; periodicity?: string; certificate_validity_years?: number; status?: string; last_passed_date?: string; }
|
||||||
|
interface ProgramCompletion { id: string; full_name: string; position: string; program_name: string; status: string; date: string; valid_until: string; }
|
||||||
|
|
||||||
|
const DEMO_PROGRAMS: Program[] = [
|
||||||
|
{ id: 'PLG-INIT-001', name: 'Первичная подготовка специалиста по ПЛГ', type: 'initial', duration_hours: 240, certificate_validity_years: 0, status: 'активна', last_passed_date: '2024-09-15' },
|
||||||
|
{ id: 'PLG-REC-001', name: 'Периодическое повышение квалификации (recurrent)', type: 'recurrent', duration_hours: 40, periodicity: 'Каждые 24 месяца', certificate_validity_years: 2, status: 'активна', last_passed_date: '2024-11-01' },
|
||||||
|
{ id: 'PLG-TYPE-001', name: 'Подготовка на тип ВС SSJ-100', type: 'type_rating', duration_hours: 80, status: 'активна', last_passed_date: '2024-06-20' },
|
||||||
|
{ id: 'PLG-EWIS-001', name: 'EWIS — Электропроводка и соединители', type: 'ewis', duration_hours: 16, status: 'активна', last_passed_date: '2024-08-10' },
|
||||||
|
{ id: 'PLG-SMS-001', name: 'SMS — Система управления безопасностью', type: 'sms', duration_hours: 8, status: 'активна', last_passed_date: '2024-10-05' },
|
||||||
|
{ id: 'PLG-HF-001', name: 'Человеческий фактор', type: 'human_factors', duration_hours: 8, status: 'активна', last_passed_date: '2024-07-12' },
|
||||||
|
{ id: 'PLG-NDT-001', name: 'НК/NDT — Неразрушающий контроль', type: 'ndt', duration_hours: 24, status: 'активна', last_passed_date: '2024-05-22' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEMO_PROGRAM_COMPLETIONS: ProgramCompletion[] = [
|
||||||
|
{ id: '1', full_name: 'Иванов Иван Иванович', position: 'Авиатехник B1', program_name: 'Первичная подготовка специалиста по ПЛГ', status: 'Пройдена', date: '2022-09-15', valid_until: '—' },
|
||||||
|
{ id: '2', full_name: 'Петров Пётр Петрович', position: 'Авиатехник B2', program_name: 'Периодическое повышение квалификации', status: 'Пройдена', date: '2024-11-01', valid_until: '2026-11-01' },
|
||||||
|
{ id: '3', full_name: 'Сидорова Анна Сергеевна', position: 'Инженер по ТО', program_name: 'Подготовка на тип ВС SSJ-100', status: 'Пройдена', date: '2024-06-20', valid_until: '—' },
|
||||||
|
{ id: '4', full_name: 'Козлов Михаил Андреевич', position: 'Специалист по НК', program_name: 'НК/NDT — Неразрушающий контроль', status: 'Пройдена', date: '2024-05-22', valid_until: '2026-05-22' },
|
||||||
|
{ id: '5', full_name: 'Новикова Елена Викторовна', position: 'Авиатехник B1', program_name: 'EWIS — Электропроводка', status: 'В процессе', date: '2024-12-01', valid_until: '—' },
|
||||||
|
{ id: '6', full_name: 'Иванов Иван Иванович', position: 'Авиатехник B1', program_name: 'Периодическое повышение квалификации', status: 'Пройдена', date: '2024-03-10', valid_until: '2026-03-10' },
|
||||||
|
{ id: '7', full_name: 'Петров Пётр Петрович', position: 'Авиатехник B2', program_name: 'SMS — Система управления безопасностью', status: 'Пройдена', date: '2024-10-05', valid_until: '2026-10-05' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function PersonnelPLGPage() {
|
export default function PersonnelPLGPage() {
|
||||||
const [tab, setTab] = useState<Tab>('specialists');
|
const [tab, setTab] = useState<Tab>('specialists');
|
||||||
@ -24,6 +45,9 @@ export default function PersonnelPLGPage() {
|
|||||||
const [selected, setSelected] = useState<Specialist | null>(null);
|
const [selected, setSelected] = useState<Specialist | null>(null);
|
||||||
const [selectedProgram, setSelectedProgram] = useState<Program | null>(null);
|
const [selectedProgram, setSelectedProgram] = useState<Program | null>(null);
|
||||||
const [showAddModal, setShowAddModal] = useState(false);
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const [showAddProgramModal, setShowAddProgramModal] = useState(false);
|
||||||
|
const [editingCompletion, setEditingCompletion] = useState<ProgramCompletion | null>(null);
|
||||||
|
const [programCompletions, setProgramCompletions] = useState<ProgramCompletion[]>(DEMO_PROGRAM_COMPLETIONS);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const api = useCallback(async (endpoint: string, opts?: RequestInit) => {
|
const api = useCallback(async (endpoint: string, opts?: RequestInit) => {
|
||||||
@ -35,11 +59,21 @@ export default function PersonnelPLGPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([
|
Promise.all([
|
||||||
api('specialists').then(d => setSpecialists(d.items || [])),
|
api('specialists').then(d => setSpecialists(d.items || [])),
|
||||||
api('programs').then(d => setPrograms(d.programs || [])),
|
api('programs').then(d => setPrograms(Array.isArray(d?.programs) && d.programs.length > 0 ? d.programs : DEMO_PROGRAMS)),
|
||||||
api('compliance-report').then(d => setCompliance(d)),
|
api('compliance-report').then(d => setCompliance(d)),
|
||||||
]).finally(() => setLoading(false));
|
]).finally(() => setLoading(false));
|
||||||
}, [api]);
|
}, [api]);
|
||||||
|
|
||||||
|
const exportProgramCompletions = () => {
|
||||||
|
const headers = ['ФИО', 'Должность', 'Программа', 'Статус прохождения', 'Дата', 'Срок действия'];
|
||||||
|
const rows = programCompletions.map(c => [c.full_name, c.position, c.program_name, c.status, c.date, c.valid_until]);
|
||||||
|
const csv = [headers.join(';'), ...rows.map(r => r.join(';'))].join('\n');
|
||||||
|
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a'); a.href = url; a.download = 'program_completions.csv'; a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddSpecialist = async (data: any) => {
|
const handleAddSpecialist = async (data: any) => {
|
||||||
const result = await api('specialists', {
|
const result = await api('specialists', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
|
||||||
@ -113,28 +147,60 @@ export default function PersonnelPLGPage() {
|
|||||||
|
|
||||||
{/* PROGRAMS */}
|
{/* PROGRAMS */}
|
||||||
{tab === 'programs' && (
|
{tab === 'programs' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
{programs.map(p => (
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
<div key={p.id} onClick={() => setSelectedProgram(p)}
|
<button onClick={() => setShowAddProgramModal(true)} className="btn-primary text-sm px-4 py-2 rounded">+ Добавить программу</button>
|
||||||
className="card p-4 cursor-pointer hover:shadow-md transition-shadow">
|
<button onClick={exportProgramCompletions} className="btn-sm bg-gray-100 text-gray-700 px-4 py-2 rounded text-sm">Экспорт</button>
|
||||||
<div className="flex justify-between items-start">
|
</div>
|
||||||
<div>
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
<div className="flex items-center gap-2">
|
{programs.map(p => (
|
||||||
<span className="text-lg">{programTypeLabels[p.type]?.split(' ')[0] || '📋'}</span>
|
<div key={p.id} onClick={() => setSelectedProgram(p)}
|
||||||
<span className="font-medium text-sm">{p.name}</span>
|
className="card p-4 cursor-pointer hover:shadow-md transition-shadow">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">{programTypeLabels[p.type]?.split(' ')[0] || '📋'}</span>
|
||||||
|
<span className="font-medium text-sm">{p.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">{p.legal_basis || `${p.duration_hours} ч.`}</div>
|
||||||
|
{p.last_passed_date && <div className="text-[10px] text-gray-400 mt-1">Последнее прохождение: {new Date(p.last_passed_date).toLocaleDateString('ru-RU')}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0 flex gap-1">
|
||||||
|
<button onClick={e => { e.stopPropagation(); setSelectedProgram(p); }} className="btn-sm bg-gray-100 text-gray-600 hover:bg-gray-200 p-1.5 rounded" title="Редактировать">✏️</button>
|
||||||
|
<div className="badge bg-primary-100 text-primary-700">{p.duration_hours} ч.</div>
|
||||||
|
{p.periodicity && <div className="text-[10px] text-gray-400 mt-1">{p.periodicity}</div>}
|
||||||
|
{p.certificate_validity_years ? <div className="text-[10px] text-gray-400">Срок: {p.certificate_validity_years} лет</div> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 mt-1">{p.legal_basis}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right shrink-0">
|
|
||||||
<div className="badge bg-primary-100 text-primary-700">{p.duration_hours} ч.</div>
|
|
||||||
{p.periodicity && <div className="text-[10px] text-gray-400 mt-1">{p.periodicity}</div>}
|
|
||||||
{p.certificate_validity_years ? (
|
|
||||||
<div className="text-[10px] text-gray-400">Срок: {p.certificate_validity_years} лет</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
|
<h3 className="text-sm font-bold text-gray-700 mt-4">Прохождение программ</h3>
|
||||||
|
<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>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{programCompletions.map(c => (
|
||||||
|
<tr key={c.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||||
|
<td className="table-cell font-medium">{c.full_name}</td>
|
||||||
|
<td className="table-cell text-gray-600">{c.position}</td>
|
||||||
|
<td className="table-cell text-gray-600">{c.program_name}</td>
|
||||||
|
<td className="table-cell">
|
||||||
|
<span className={`badge ${c.status === 'Пройдена' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'}`}>{c.status}</span>
|
||||||
|
</td>
|
||||||
|
<td className="table-cell text-sm">{c.date}</td>
|
||||||
|
<td className="table-cell text-sm">{c.valid_until}</td>
|
||||||
|
<td className="table-cell">
|
||||||
|
<button onClick={() => setEditingCompletion(c)} className="btn-sm bg-gray-100 text-gray-600 hover:bg-gray-200 p-1.5 rounded" title="Редактировать">✏️</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -288,6 +354,27 @@ export default function PersonnelPLGPage() {
|
|||||||
<Modal isOpen={showAddModal} onClose={() => setShowAddModal(false)} title="Добавить специалиста ПЛГ" size="lg">
|
<Modal isOpen={showAddModal} onClose={() => setShowAddModal(false)} title="Добавить специалиста ПЛГ" size="lg">
|
||||||
<AddSpecialistForm onSubmit={handleAddSpecialist} onCancel={() => setShowAddModal(false)} />
|
<AddSpecialistForm onSubmit={handleAddSpecialist} onCancel={() => setShowAddModal(false)} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Add program modal (демо — только локальное состояние) */}
|
||||||
|
<Modal isOpen={showAddProgramModal} onClose={() => setShowAddProgramModal(false)} title="Добавить программу подготовки" size="md"
|
||||||
|
footer={<><button onClick={() => setShowAddProgramModal(false)} className="btn-secondary">Отмена</button><button onClick={() => { setPrograms(prev => [...prev, { id: 'PLG-NEW-' + Date.now(), name: 'Новая программа', type: 'recurrent', duration_hours: 40, status: 'активна' }]); setShowAddProgramModal(false); }} className="btn-primary">Добавить</button></>}>
|
||||||
|
<div className="text-sm text-gray-600">Программы подготовки задаются нормативными документами (ФАП-147 и др.). Для добавления кастомной программы обратитесь к администратору системы.</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Edit completion modal */}
|
||||||
|
<Modal isOpen={!!editingCompletion} onClose={() => setEditingCompletion(null)} title="Редактировать запись о прохождении" size="md"
|
||||||
|
footer={<><button onClick={() => setEditingCompletion(null)} className="btn-secondary">Отмена</button><button onClick={() => setEditingCompletion(null)} className="btn-primary">Сохранить</button></>}>
|
||||||
|
{editingCompletion && (
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div><label className="text-gray-500">ФИО</label><div className="font-medium mt-1">{editingCompletion.full_name}</div></div>
|
||||||
|
<div><label className="text-gray-500">Должность</label><div className="mt-1">{editingCompletion.position}</div></div>
|
||||||
|
<div><label className="text-gray-500">Программа</label><div className="mt-1">{editingCompletion.program_name}</div></div>
|
||||||
|
<div><label className="text-gray-500">Статус</label><div className="mt-1">{editingCompletion.status}</div></div>
|
||||||
|
<div><label className="text-gray-500">Дата</label><div className="mt-1">{editingCompletion.date}</div></div>
|
||||||
|
<div><label className="text-gray-500">Срок действия</label><div className="mt-1">{editingCompletion.valid_until}</div></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,41 +1,57 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { PageLayout, DataTable, StatusBadge, EmptyState } from '@/components/ui';
|
import { PageLayout, DataTable, StatusBadge, EmptyState } from '@/components/ui';
|
||||||
|
import RiskDetailModal from '@/components/RiskDetailModal';
|
||||||
import { risksApi } from '@/lib/api/api-client';
|
import { risksApi } from '@/lib/api/api-client';
|
||||||
|
|
||||||
export default function RisksPage() {
|
export default function RisksPage() {
|
||||||
const [risks, setRisks] = useState([] as any[]);
|
const [risks, setRisks] = useState([] as any[]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
useEffect(() => {
|
const [selectedRisk, setSelectedRisk] = useState<any>(null);
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
risksApi.list(filter ? { severity: filter } : {}).then(d => { setRisks(d.items || []); setLoading(false); }).catch(() => setLoading(false));
|
risksApi.list(filter ? { severity: filter } : {}).then(d => { setRisks(d.items || []); setLoading(false); }).catch(() => setLoading(false));
|
||||||
}, [filter]);
|
};
|
||||||
|
useEffect(() => { load(); }, [filter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{loading && <div className="fixed inset-0 bg-white/50 z-50 flex items-center justify-center"><div className="text-gray-500">⏳ Загрузка...</div></div>}
|
{loading && <div className="fixed inset-0 bg-white/50 z-50 flex items-center justify-center"><div className="text-gray-500">⏳ Загрузка...</div></div>}
|
||||||
<PageLayout title="⚠️ Управление рисками" subtitle="ICAO Annex 19; ВК РФ ст. 24.1; ICAO Doc 9859">
|
<PageLayout title="⚠️ Управление рисками" subtitle="ICAO Annex 19; ВК РФ ст. 24.1; ICAO Doc 9859">
|
||||||
<div className="flex gap-2 mb-4">
|
<div className="flex gap-2 mb-4">
|
||||||
{['', 'critical', 'high', 'medium', 'low'].map(s => (
|
{['', 'critical', 'high', 'medium', 'low'].map(s => (
|
||||||
<button key={s} onClick={() => setFilter(s)}
|
<button key={s} onClick={() => setFilter(s)}
|
||||||
className={`px-3 py-1.5 rounded text-xs ${filter === s ? 'bg-blue-600 text-white' : 'bg-gray-100'}`}>{s || 'Все'}</button>
|
className={`px-3 py-1.5 rounded text-xs ${filter === s ? 'bg-blue-600 text-white' : 'bg-gray-100'}`}>{s || 'Все'}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{risks.length > 0 ? (
|
{risks.length > 0 ? (
|
||||||
<DataTable columns={[
|
<DataTable
|
||||||
{ key: 'title', label: 'Риск' },
|
columns={[
|
||||||
{ key: 'severity', label: 'Серьёзность', render: (v: string) => (
|
{ key: 'title', label: 'Риск' },
|
||||||
<StatusBadge status={v} colorMap={{ critical: 'bg-red-500', high: 'bg-orange-500', medium: 'bg-yellow-500', low: 'bg-green-500' }} />
|
{ key: 'severity', label: 'Серьёзность', render: (v: string) => (
|
||||||
)},
|
<StatusBadge status={v} colorMap={{ critical: 'bg-red-500', high: 'bg-orange-500', medium: 'bg-yellow-500', low: 'bg-green-500' }} />
|
||||||
{ key: 'category', label: 'Категория' },
|
)},
|
||||||
{ key: 'status', label: 'Статус', render: (v: string) => (
|
{ key: 'category', label: 'Категория' },
|
||||||
<StatusBadge status={v} colorMap={{ open: 'bg-red-500', mitigating: 'bg-yellow-500', resolved: 'bg-green-500', accepted: 'bg-gray-400' }}
|
{ key: 'status', label: 'Статус', render: (v: string) => (
|
||||||
labelMap={{ open: 'Открыт', mitigating: 'Меры', resolved: 'Устранён', accepted: 'Принят' }} />
|
<StatusBadge status={v} colorMap={{ open: 'bg-red-500', mitigating: 'bg-yellow-500', resolved: 'bg-green-500', accepted: 'bg-gray-400' }}
|
||||||
)},
|
labelMap={{ open: 'Открыт', mitigating: 'Меры', resolved: 'Устранён', accepted: 'Принят' }} />
|
||||||
{ key: 'created_at', label: 'Дата', render: (v: string) => v ? new Date(v).toLocaleDateString('ru-RU') : '—' },
|
)},
|
||||||
]} data={risks} />
|
{ key: 'created_at', label: 'Дата', render: (v: string) => v ? new Date(v).toLocaleDateString('ru-RU') : '—' },
|
||||||
) : <EmptyState message="Нет зарегистрированных рисков" />}
|
]}
|
||||||
</PageLayout>
|
data={risks}
|
||||||
|
onRowClick={row => setSelectedRisk(row)}
|
||||||
|
/>
|
||||||
|
) : <EmptyState message="Нет зарегистрированных рисков" />}
|
||||||
|
</PageLayout>
|
||||||
|
<RiskDetailModal
|
||||||
|
isOpen={!!selectedRisk}
|
||||||
|
onClose={() => setSelectedRisk(null)}
|
||||||
|
risk={selectedRisk}
|
||||||
|
onEdit={r => setSelectedRisk(r)}
|
||||||
|
onCloseRisk={id => { risksApi.resolve(id).then(load); }}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,24 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { PageLayout } from '@/components/ui';
|
import { PageLayout } from '@/components/ui';
|
||||||
import { apiFetch } from '@/lib/api/api-client';
|
import { apiFetch } from '@/lib/api/api-client';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
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: 'Специалист ТОиР' };
|
const ROLE_LABELS: Record<string, string> = { admin: 'Администратор', authority_inspector: 'Инспектор', operator_manager: 'Менеджер оператора', operator_user: 'Оператор', mro_manager: 'Менеджер ТОиР', mro_specialist: 'Специалист ТОиР', mro_user: 'Специалист ТОиР' };
|
||||||
|
|
||||||
|
const DEFAULT_ORG = { name: 'АО «Авиакомпания»', inn: '7707123456', address: 'г. Москва, ул. Авиационная, 1' };
|
||||||
|
const DEFAULT_TEMPLATES = { work_order: 'Шаблон наряда ТО', defect: 'Шаблон дефекта', certificate: 'Шаблон сертификата' };
|
||||||
|
const DEFAULT_REFBOOKS = { aircraft_types: 'Типы ВС', ata_chapters: 'ATA главы', positions: 'Должности' };
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [prefs, setPrefs] = useState<any>(null);
|
const [prefs, setPrefs] = useState<any>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [orgProfile, setOrgProfile] = useState(DEFAULT_ORG);
|
||||||
|
const [templates, setTemplates] = useState(DEFAULT_TEMPLATES);
|
||||||
|
const [refbooks, setRefbooks] = useState(DEFAULT_REFBOOKS);
|
||||||
|
const [integrations, setIntegrations] = useState<Record<string, string>>({ ai: 'warning', fgis: 'off', keycloak: 'on', minio: 'on' });
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiFetch('/notification-preferences').catch(() => null).then(setPrefs);
|
apiFetch('/notification-preferences').catch(() => null).then(setPrefs);
|
||||||
@ -22,6 +31,29 @@ export default function SettingsPage() {
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const exportSettings = () => {
|
||||||
|
const blob = new Blob([JSON.stringify({ prefs, orgProfile, templates, refbooks, integrations }, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a'); a.href = url; a.download = 'settings_export.json'; a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const importSettings = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (!f) return;
|
||||||
|
const r = new FileReader();
|
||||||
|
r.onload = () => { try { const j = JSON.parse(r.result as string); if (j.orgProfile) setOrgProfile(j.orgProfile); if (j.templates) setTemplates(j.templates); if (j.refbooks) setRefbooks(j.refbooks); if (j.integrations) setIntegrations(j.integrations); if (j.prefs) setPrefs(j.prefs); } catch { alert('Ошибка формата файла'); } };
|
||||||
|
r.readAsText(f);
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetSection = (section: string) => {
|
||||||
|
if (!confirm(`Сбросить настройки раздела «${section}»?`)) return;
|
||||||
|
if (section === 'Профиль организации') setOrgProfile(DEFAULT_ORG);
|
||||||
|
if (section === 'Шаблоны') setTemplates(DEFAULT_TEMPLATES);
|
||||||
|
if (section === 'Справочники') setRefbooks(DEFAULT_REFBOOKS);
|
||||||
|
};
|
||||||
|
|
||||||
const Toggle = ({ label, field }: { label: string; field: string }) => (
|
const Toggle = ({ label, field }: { label: string; field: string }) => (
|
||||||
<div className="flex items-center justify-between py-2 border-b border-gray-50">
|
<div className="flex items-center justify-between py-2 border-b border-gray-50">
|
||||||
<span className="text-sm">{label}</span>
|
<span className="text-sm">{label}</span>
|
||||||
@ -33,96 +65,85 @@ export default function SettingsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout title="⚙️ Настройки" subtitle="Профиль, система, уведомления">
|
<PageLayout title="⚙️ Настройки" subtitle="Профиль, система, уведомления"
|
||||||
<div className="max-w-lg space-y-6">
|
actions={<div className="flex gap-2"><button onClick={exportSettings} className="btn-sm bg-gray-100 text-gray-700">Экспорт настроек</button><button onClick={() => fileInputRef.current?.click()} className="btn-sm bg-gray-100 text-gray-700">Импорт настроек</button><input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={importSettings} /></div>}>
|
||||||
|
<div className="max-w-2xl 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 items-center"><span className="text-gray-500">Название</span><input className="input-field w-48 text-right" value={orgProfile.name} onChange={e => setOrgProfile(p => ({ ...p, name: e.target.value }))} /></div>
|
||||||
|
<div className="flex justify-between items-center"><span className="text-gray-500">ИНН</span><input className="input-field w-48 text-right" value={orgProfile.inn} onChange={e => setOrgProfile(p => ({ ...p, inn: e.target.value }))} /></div>
|
||||||
|
<div className="flex justify-between items-center"><span className="text-gray-500">Адрес</span><input className="input-field flex-1 ml-2" value={orgProfile.address} onChange={e => setOrgProfile(p => ({ ...p, address: e.target.value }))} /></div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => resetSection('Профиль организации')} className="mt-2 text-xs text-red-600 hover:underline">Сбросить раздел</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="card p-4">
|
<section className="card p-4">
|
||||||
<h3 className="text-sm font-bold text-gray-600 mb-3">👤 Профиль пользователя</h3>
|
<h3 className="text-sm font-bold text-gray-600 mb-3">👤 Профиль пользователя</h3>
|
||||||
<div className="space-y-2 text-sm">
|
<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">Имя</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">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?.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 className="flex justify-between"><span className="text-gray-500">Организация</span><span>{user?.organization_name ?? orgProfile.name}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card p-4">
|
{prefs && (
|
||||||
<h3 className="text-sm font-bold text-gray-600 mb-3">🖥️ Настройки системы</h3>
|
<section className="card p-4">
|
||||||
<div className="space-y-2 text-sm text-gray-600">
|
<h3 className="text-sm font-bold text-gray-600 mb-3">📢 Уведомления</h3>
|
||||||
<div className="flex justify-between"><span>Название системы</span><span>REFLY АСУ ТК</span></div>
|
<Toggle label="⚠️ Обязательные ДЛГ" field="ad_mandatory" />
|
||||||
<div className="flex justify-between"><span>Версия</span><span>2.0.0-beta</span></div>
|
<Toggle label="📋 Рекомендательные ДЛГ" field="ad_recommended" />
|
||||||
<div className="flex justify-between"><span>Нормативная база</span><span>Part-M RU</span></div>
|
<Toggle label="🔴 Критические дефекты" field="defect_critical" />
|
||||||
<div className="flex justify-between"><span>Язык</span><span>Русский</span></div>
|
<Toggle label="🟡 Значительные дефекты" field="defect_major" />
|
||||||
<div className="flex justify-between"><span>Часовой пояс</span><span>Europe/Moscow (UTC+3)</span></div>
|
<Toggle label="🟢 Незначительные дефекты" field="defect_minor" />
|
||||||
</div>
|
<Toggle label="🔴 AOG наряды" field="wo_aog" />
|
||||||
</section>
|
<Toggle label="✅ Закрытие нарядов (CRS)" field="wo_closed" />
|
||||||
|
<Toggle label="📧 Email" field="channels_email" />
|
||||||
|
<Toggle label="🔔 Push" field="channels_push" />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="card p-4">
|
<section className="card p-4">
|
||||||
<h3 className="text-sm font-bold text-gray-600 mb-3">🔗 Интеграции</h3>
|
<h3 className="text-sm font-bold text-gray-600 mb-3">🔗 Интеграции</h3>
|
||||||
<div className="space-y-2 text-sm">
|
<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>AI-помощник</span><span className={integrations.ai === 'on' ? 'text-green-600' : 'text-amber-600'}>{integrations.ai === 'on' ? 'Подключено' : '⚠️ Настройка'}</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>ФГИС ЕС ОрВД</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>Keycloak SSO</span><span className="text-green-600">Подключено</span></div>
|
||||||
<div className="flex justify-between items-center"><span>MinIO (документы)</span><span className="text-green-600">Подключено</span></div>
|
<div className="flex justify-between items-center"><span>MinIO (документы)</span><span className="text-green-600">Подключено</span></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card p-4">
|
<section className="card p-4">
|
||||||
<h3 className="text-sm font-bold text-gray-600 mb-3">ℹ️ О системе</h3>
|
<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>
|
<div className="space-y-2 text-sm">
|
||||||
<p className="text-xs text-gray-500 mt-1">Part-M RU · Гармонизировано с ICAO/EASA</p>
|
{Object.entries(templates).map(([k, v]) => <div key={k} className="flex justify-between items-center"><span className="text-gray-500">{k}</span><input className="input-field w-56" value={v} onChange={e => setTemplates(p => ({ ...p, [k]: e.target.value }))} /></div>)}
|
||||||
<p className="text-xs text-gray-400 mt-2">© 2025–2026 REFLY Aviation Technologies</p>
|
</div>
|
||||||
|
<button onClick={() => resetSection('Шаблоны')} className="mt-2 text-xs text-red-600 hover:underline">Сбросить раздел</button>
|
||||||
|
</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">
|
||||||
|
{Object.entries(refbooks).map(([k, v]) => <div key={k} className="flex justify-between items-center"><span className="text-gray-500">{k}</span><input className="input-field w-48" value={v} onChange={e => setRefbooks(p => ({ ...p, [k]: e.target.value }))} /></div>)}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => resetSection('Справочники')} className="mt-2 text-xs text-red-600 hover:underline">Сбросить раздел</button>
|
||||||
|
</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>Europe/Moscow (UTC+3)</span></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{prefs && (
|
{prefs && (
|
||||||
<>
|
<button onClick={save} disabled={saving} className="btn-primary px-6 py-2 rounded text-sm disabled:opacity-50">
|
||||||
<section className="card p-4">
|
{saving ? '⏳ Сохранение...' : '💾 Сохранить настройки'}
|
||||||
<h3 className="text-sm font-bold text-gray-600 mb-3">📢 Типы уведомлений</h3>
|
</button>
|
||||||
<Toggle label="⚠️ Обязательные ДЛГ (mandatory AD)" field="ad_mandatory" />
|
|
||||||
<Toggle label="📋 Рекомендательные ДЛГ" field="ad_recommended" />
|
|
||||||
<Toggle label="🔴 Критические дефекты" field="defect_critical" />
|
|
||||||
<Toggle label="🟡 Значительные дефекты" field="defect_major" />
|
|
||||||
<Toggle label="🟢 Незначительные дефекты" field="defect_minor" />
|
|
||||||
<Toggle label="🔴 AOG наряды" field="wo_aog" />
|
|
||||||
<Toggle label="✅ Закрытие нарядов (CRS)" field="wo_closed" />
|
|
||||||
<Toggle label="⏱️ Критические ресурсы" field="life_limit_critical" />
|
|
||||||
<Toggle label="🎓 Просрочка квалификации" field="personnel_expiry" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="card p-4">
|
|
||||||
<h3 className="text-sm font-bold text-gray-600 mb-3">📡 Каналы доставки</h3>
|
|
||||||
<Toggle label="📧 Email" field="channels_email" />
|
|
||||||
<Toggle label="🔔 Push-уведомления" field="channels_push" />
|
|
||||||
<Toggle label="⚡ WebSocket (real-time)" field="channels_ws" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
|
|
||||||
<section className="card p-4">
|
|
||||||
<h3 className="text-sm font-bold text-gray-600 mb-3">🎨 Оформление</h3>
|
|
||||||
<div className="flex items-center justify-between py-2">
|
|
||||||
<span className="text-sm">🌙 Тёмная тема</span>
|
|
||||||
<button id="dark-mode"
|
|
||||||
onClick={() => {
|
|
||||||
document.documentElement.classList.toggle('dark');
|
|
||||||
localStorage.setItem('theme', document.documentElement.classList.contains('dark') ? 'dark' : 'light');
|
|
||||||
}}
|
|
||||||
className="w-10 h-5 rounded-full bg-gray-300 dark:bg-blue-500 transition-colors">
|
|
||||||
<div className="w-4 h-4 bg-white rounded-full shadow transition-transform dark:translate-x-5 translate-x-0.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between py-2 border-t border-gray-50">
|
|
||||||
<span className="text-sm">📏 Компактный режим</span>
|
|
||||||
<button className="w-10 h-5 rounded-full bg-gray-300 transition-colors">
|
|
||||||
<div className="w-4 h-4 bg-white rounded-full shadow translate-x-0.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<button onClick={save} disabled={saving}
|
|
||||||
className="btn-primary px-6 py-2 rounded text-sm disabled:opacity-50">
|
|
||||||
{saving ? '⏳ Сохранение...' : '💾 Сохранить настройки'}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
<p className="text-xs text-gray-400">© 2025–2026 REFLY Aviation Technologies</p>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,26 +1,151 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import UserEditModal from '@/components/UserEditModal';
|
import UserEditModal from '@/components/UserEditModal';
|
||||||
import { PageLayout, DataTable, FilterBar, StatusBadge } from '@/components/ui';
|
import { PageLayout, DataTable, StatusBadge, Modal } from '@/components/ui';
|
||||||
import { useUsersData } from '@/hooks/useSWRData';
|
|
||||||
|
|
||||||
const RL: Record<string,string> = { admin:'Администратор', authority_inspector:'Инспектор', operator_manager:'Менеджер оператора', operator_user:'Оператор', mro_manager:'Менеджер ТОиР', mro_specialist:'Специалист ТОиР', mro_user:'Специалист ТОиР' };
|
const RL: Record<string, string> = { admin: 'Администратор', authority_inspector: 'Инспектор', favt_inspector: 'Инспектор ФАВТ', operator_manager: 'Менеджер оператора', operator_user: 'Оператор', mro_manager: 'Менеджер ТОиР', mro_specialist: 'Специалист ТОиР', mro_user: 'Специалист ТОиР', engineer: 'Инженер', inspector: 'Инспектор' };
|
||||||
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' };
|
const RC: Record<string, string> = { admin: 'bg-green-500', authority_inspector: 'bg-blue-500', favt_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', engineer: 'bg-teal-500', inspector: 'bg-indigo-500' };
|
||||||
|
|
||||||
|
const DEMO_USERS = [
|
||||||
|
{ id: '1', display_name: 'Иванов Иван Иванович', email: 'ivanov@company.ru', role: 'admin', organization_name: 'АО «Авиакомпания»', status: 'active', last_login: '2024-12-01T10:00:00Z' },
|
||||||
|
{ id: '2', display_name: 'Петрова Мария Сергеевна', email: 'petrova@favt.gov.ru', role: 'authority_inspector', organization_name: 'ФАВТ', status: 'active', last_login: '2024-12-02T09:15:00Z' },
|
||||||
|
{ id: '3', display_name: 'Сидоров Пётр Андреевич', email: 'sidorov@operator.ru', role: 'operator_manager', organization_name: 'ООО «АвиаСервис»', status: 'active', last_login: '2024-11-28T14:20:00Z' },
|
||||||
|
{ id: '4', display_name: 'Козлова Анна Викторовна', email: 'kozlova@mro.ru', role: 'mro_manager', organization_name: 'ПАО «ТОиР»', status: 'active', last_login: '2024-12-01T08:00:00Z' },
|
||||||
|
{ id: '5', display_name: 'Новиков Алексей Дмитриевич', email: 'novikov@company.ru', role: 'engineer', organization_name: 'АО «Авиакомпания»', status: 'active', last_login: '2024-11-30T16:45:00Z' },
|
||||||
|
{ id: '6', display_name: 'Морозова Елена Игоревна', email: 'morozova@favt.gov.ru', role: 'inspector', organization_name: 'Ространснадзор', status: 'active', last_login: '2024-11-29T11:00:00Z' },
|
||||||
|
{ id: '7', display_name: 'Волков Дмитрий Николаевич', email: 'volkov@operator.ru', role: 'operator_user', organization_name: 'ООО «АвиаСервис»', status: 'active', last_login: '2024-11-27T12:30:00Z' },
|
||||||
|
{ id: '8', display_name: 'Соколова Ольга Павловна', email: 'sokolova@mro.ru', role: 'mro_specialist', organization_name: 'ПАО «ТОиР»', status: 'inactive', last_login: '2024-10-15T09:00:00Z' },
|
||||||
|
{ id: '9', display_name: 'Лебедев Андрей Владимирович', email: 'lebedev@company.ru', role: 'operator_user', organization_name: 'АО «Авиакомпания»', status: 'active', last_login: '2024-12-02T07:20:00Z' },
|
||||||
|
{ id: '10', display_name: 'Кузнецова Татьяна Александровна', email: 'kuznetsova@favt.gov.ru', role: 'favt_inspector', organization_name: 'Минтранс / ФАВТ', status: 'active', last_login: '2024-12-01T15:10:00Z' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const [rf, setRf] = useState<string|undefined>();
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
const { data, isLoading } = useUsersData({ role: rf });
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [roleFilter, setRoleFilter] = useState<string | undefined>();
|
||||||
|
const [orgFilter, setOrgFilter] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [addForm, setAddForm] = useState({ display_name: '', email: '', role: 'operator_user', organization_name: '' });
|
||||||
|
const [editingUser, setEditingUser] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
fetch('/api/v1/users')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setUsers(Array.isArray(d?.items) && d.items.length > 0 ? d.items : DEMO_USERS))
|
||||||
|
.catch(() => setUsers(DEMO_USERS))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let list = users;
|
||||||
|
if (roleFilter) list = list.filter(u => u.role === roleFilter);
|
||||||
|
if (orgFilter) list = list.filter(u => (u.organization_name || '').toLowerCase().includes(orgFilter.toLowerCase()));
|
||||||
|
if (search) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
list = list.filter(u =>
|
||||||
|
(u.display_name || '').toLowerCase().includes(q) ||
|
||||||
|
(u.email || '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [users, roleFilter, orgFilter, search]);
|
||||||
|
|
||||||
|
const handleSaveEdit = (payload: any) => {
|
||||||
|
if (editingUser?.id) setUsers(prev => prev.map(u => u.id === editingUser.id ? { ...u, ...payload } : u));
|
||||||
|
setEditingUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
setUsers(prev => [{ ...addForm, id: String(Date.now()), organization_name: addForm.organization_name || '—', status: 'active', last_login: null }, ...prev]);
|
||||||
|
setAddOpen(false);
|
||||||
|
setAddForm({ display_name: '', email: '', role: 'operator_user', organization_name: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
if (!confirm('Удалить пользователя?')) return;
|
||||||
|
setUsers(prev => prev.filter(u => u.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const headers = ['ФИО', 'Email', 'Роль', 'Организация', 'Статус', 'Последний вход'];
|
||||||
|
const rows = filtered.map(u => [
|
||||||
|
u.display_name || '',
|
||||||
|
u.email || '',
|
||||||
|
RL[u.role] || u.role,
|
||||||
|
u.organization_name || '',
|
||||||
|
u.status === 'active' ? 'Активен' : 'Неактивен',
|
||||||
|
u.last_login ? new Date(u.last_login).toLocaleString('ru-RU') : '—',
|
||||||
|
]);
|
||||||
|
const csv = [headers.join(';'), ...rows.map(r => r.join(';'))].join('\n');
|
||||||
|
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a'); a.href = url; a.download = 'users.csv'; a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rolesForFilter = Array.from(new Set(users.map(u => u.role).filter(Boolean)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout title="Пользователи" subtitle={isLoading ? 'Загрузка...' : `Всего: ${data?.total || 0}`}>
|
<PageLayout
|
||||||
<FilterBar value={rf} onChange={setRf} options={[{ value: undefined, label: 'Все' }, ...Object.entries(RL).map(([v, l]) => ({ value: v, label: l }))]} className="mb-4" />
|
title="Пользователи"
|
||||||
<DataTable loading={isLoading} data={Array.isArray(data?.items) ? data.items : []} emptyMessage="Нет пользователей"
|
subtitle={loading ? 'Загрузка...' : `Всего: ${filtered.length}`}
|
||||||
columns={[
|
actions={
|
||||||
{ 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> },
|
<input type="text" placeholder="Поиск (ФИО, email)..." value={search} onChange={e => setSearch(e.target.value)} className="input-field w-56" />
|
||||||
{ key: 'role', header: 'Роль', render: (u: any) => <StatusBadge status={u.role} colorMap={RC} labelMap={RL} /> },
|
<select value={roleFilter ?? ''} onChange={e => setRoleFilter(e.target.value || undefined)} className="input-field w-40">
|
||||||
{ key: 'organization_name', header: 'Организация', render: (u: any) => <span className="text-gray-500">{u.organization_name || '—'}</span> },
|
<option value="">Все роли</option>
|
||||||
]} />
|
{rolesForFilter.map(r => <option key={r} value={r}>{RL[r] || r}</option>)}
|
||||||
|
</select>
|
||||||
|
<input type="text" placeholder="Организация..." value={orgFilter} onChange={e => setOrgFilter(e.target.value)} className="input-field w-48" />
|
||||||
|
<button onClick={handleExport} className="btn-sm bg-gray-100 text-gray-700">Экспорт</button>
|
||||||
|
<button onClick={() => setAddOpen(true)} className="btn-primary">+ Добавить пользователя</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? <div className="text-center py-10 text-gray-400">⏳ Загрузка...</div> : filtered.length > 0 ? (
|
||||||
|
<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">Email</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>
|
||||||
|
{filtered.map(u => (
|
||||||
|
<tr key={u.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||||
|
<td className="table-cell font-medium">{u.display_name || '—'}</td>
|
||||||
|
<td className="table-cell text-gray-600">{u.email || '—'}</td>
|
||||||
|
<td className="table-cell"><StatusBadge status={u.role} colorMap={RC} labelMap={RL} /></td>
|
||||||
|
<td className="table-cell text-gray-600">{u.organization_name || '—'}</td>
|
||||||
|
<td className="table-cell"><span className={`badge ${u.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}`}>{u.status === 'active' ? 'Активен' : 'Неактивен'}</span></td>
|
||||||
|
<td className="table-cell text-sm text-gray-500">{u.last_login ? new Date(u.last_login).toLocaleString('ru-RU') : '—'}</td>
|
||||||
|
<td className="table-cell">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={() => setEditingUser(u)} className="btn-sm bg-gray-100 text-gray-600 hover:bg-gray-200 p-1.5 rounded" title="Редактировать">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(u.id)} className="btn-sm bg-red-100 text-red-600 hover:bg-red-200">Удалить</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : <div className="card p-5 bg-blue-50 flex items-center gap-3"><span>ℹ️</span><span>Нет пользователей</span></div>}
|
||||||
|
|
||||||
|
<UserEditModal isOpen={!!editingUser} onClose={() => setEditingUser(null)} user={editingUser} onSave={handleSaveEdit} />
|
||||||
|
|
||||||
|
<Modal isOpen={addOpen} onClose={() => setAddOpen(false)} title="Добавить пользователя" size="md"
|
||||||
|
footer={<><button onClick={() => setAddOpen(false)} className="btn-secondary">Отмена</button><button onClick={handleAdd} className="btn-primary">Добавить</button></>}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div><label className="text-xs font-medium text-gray-600">ФИО</label><input value={addForm.display_name} onChange={e => setAddForm(f => ({ ...f, display_name: e.target.value }))} className="input-field w-full mt-1" placeholder="Иванов И. И." /></div>
|
||||||
|
<div><label className="text-xs font-medium text-gray-600">Email</label><input type="email" value={addForm.email} onChange={e => setAddForm(f => ({ ...f, email: e.target.value }))} className="input-field w-full mt-1" placeholder="user@company.ru" /></div>
|
||||||
|
<div><label className="text-xs font-medium text-gray-600">Роль</label><select value={addForm.role} onChange={e => setAddForm(f => ({ ...f, role: e.target.value }))} className="input-field w-full mt-1">{Object.entries(RL).slice(0, 10).map(([v, l]) => <option key={v} value={v}>{l}</option>)}</select></div>
|
||||||
|
<div><label className="text-xs font-medium text-gray-600">Организация</label><input value={addForm.organization_name} onChange={e => setAddForm(f => ({ ...f, organization_name: e.target.value }))} className="input-field w-full mt-1" placeholder="Организация" /></div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
78
components/AircraftEditModal.tsx
Normal file
78
components/AircraftEditModal.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Modal } from '@/components/ui';
|
||||||
|
import FormField from '@/components/ui/FormField';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
aircraft: any;
|
||||||
|
onSave: (id: string, data: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AircraftEditModal({ isOpen, onClose, aircraft, onSave }: Props) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
registration_number: '',
|
||||||
|
serial_number: '',
|
||||||
|
aircraft_type: '',
|
||||||
|
model: '',
|
||||||
|
operator_id: '',
|
||||||
|
});
|
||||||
|
const set = (k: string, v: string) => setForm(f => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (aircraft) {
|
||||||
|
const t = aircraft?.aircraft_type;
|
||||||
|
const typeStr = t ? [t.manufacturer, t.model].filter(Boolean).join(' ') || t.icao_code || '' : '';
|
||||||
|
setForm({
|
||||||
|
registration_number: aircraft.registration_number ?? aircraft.registrationNumber ?? '',
|
||||||
|
serial_number: aircraft.serial_number ?? '',
|
||||||
|
aircraft_type: typeStr || aircraft.aircraft_type ?? '',
|
||||||
|
model: aircraft.model ?? '',
|
||||||
|
operator_id: aircraft.operator_id ?? aircraft.operator ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [aircraft]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!form.registration_number.trim()) return alert('Укажите регистрацию');
|
||||||
|
if (!aircraft?.id) return;
|
||||||
|
onSave(aircraft.id, form);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!aircraft) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Редактировать ВС"
|
||||||
|
size="md"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button onClick={onClose} className="btn-secondary">Отмена</button>
|
||||||
|
<button onClick={handleSave} className="btn-primary">Сохранить</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField label="Регистрация" required>
|
||||||
|
<input value={form.registration_number} onChange={e => set('registration_number', e.target.value)} className="input-field" placeholder="RA-XXXXX" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Серийный номер">
|
||||||
|
<input value={form.serial_number} onChange={e => set('serial_number', e.target.value)} className="input-field" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Тип ВС">
|
||||||
|
<input value={form.aircraft_type} onChange={e => set('aircraft_type', e.target.value)} className="input-field" placeholder="Boeing 737-800, SSJ-100" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Модель">
|
||||||
|
<input value={form.model} onChange={e => set('model', e.target.value)} className="input-field" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Оператор (ID или название)">
|
||||||
|
<input value={form.operator_id} onChange={e => set('operator_id', e.target.value)} className="input-field" placeholder="Оператор" />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
components/AuditEditModal.tsx
Normal file
121
components/AuditEditModal.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Modal } from '@/components/ui';
|
||||||
|
import FormField from '@/components/ui/FormField';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
audit: any;
|
||||||
|
onSave: (id: string, data: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: 'draft', label: 'Запланирован' },
|
||||||
|
{ value: 'in_progress', label: 'В процессе' },
|
||||||
|
{ value: 'completed', label: 'Завершён' },
|
||||||
|
];
|
||||||
|
const TYPE_OPTIONS = ['Внутренний', 'Внешний', 'Надзорный', 'По типу ВС'];
|
||||||
|
|
||||||
|
export default function AuditEditModal({ isOpen, onClose, audit, onSave }: Props) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: '',
|
||||||
|
audit_type: 'Внутренний',
|
||||||
|
planned_at: '',
|
||||||
|
auditor: '',
|
||||||
|
status: 'draft',
|
||||||
|
remarks: '',
|
||||||
|
corrective_actions: '',
|
||||||
|
});
|
||||||
|
const [findings, setFindings] = useState<{ id: string; text: string; closed: boolean }[]>([]);
|
||||||
|
const [newFinding, setNewFinding] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (audit) {
|
||||||
|
setForm({
|
||||||
|
name: audit.name || `Аудит #${audit.id?.slice(0, 8) || ''}`,
|
||||||
|
audit_type: audit.audit_type || 'Внутренний',
|
||||||
|
planned_at: audit.planned_at ? new Date(audit.planned_at).toISOString().slice(0, 10) : '',
|
||||||
|
auditor: audit.auditor || '',
|
||||||
|
status: audit.status || 'draft',
|
||||||
|
remarks: audit.remarks || '',
|
||||||
|
corrective_actions: audit.corrective_actions || '',
|
||||||
|
});
|
||||||
|
setFindings(Array.isArray(audit.findings) ? audit.findings : [
|
||||||
|
{ id: '1', text: 'Документация по ТО не обновлена', closed: false },
|
||||||
|
{ id: '2', text: 'Отсутствует подпись в журнале учёта', closed: true },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}, [audit]);
|
||||||
|
|
||||||
|
const set = (k: string, v: string) => setForm(f => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!audit?.id) return;
|
||||||
|
onSave(audit.id, { ...form, findings });
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFinding = () => {
|
||||||
|
if (!newFinding.trim()) return;
|
||||||
|
setFindings(prev => [...prev, { id: String(Date.now()), text: newFinding.trim(), closed: false }]);
|
||||||
|
setNewFinding('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFinding = (id: string) => {
|
||||||
|
setFindings(prev => prev.map(f => f.id === id ? { ...f, closed: !f.closed } : f));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!audit) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Редактировать аудит"
|
||||||
|
size="lg"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button onClick={onClose} className="btn-secondary">Отмена</button>
|
||||||
|
<button onClick={handleSave} className="btn-primary">Сохранить</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField label="Название"><input value={form.name} onChange={e => set('name', e.target.value)} className="input-field" /></FormField>
|
||||||
|
<FormField label="Тип аудита">
|
||||||
|
<select value={form.audit_type} onChange={e => set('audit_type', e.target.value)} className="input-field">
|
||||||
|
{TYPE_OPTIONS.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Дата"><input type="date" value={form.planned_at} onChange={e => set('planned_at', e.target.value)} className="input-field" /></FormField>
|
||||||
|
<FormField label="Аудитор"><input value={form.auditor} onChange={e => set('auditor', e.target.value)} className="input-field" placeholder="ФИО" /></FormField>
|
||||||
|
<FormField label="Статус">
|
||||||
|
<select value={form.status} onChange={e => set('status', e.target.value)} className="input-field">
|
||||||
|
{STATUS_OPTIONS.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<FormField label="Замечания"><textarea value={form.remarks} onChange={e => set('remarks', e.target.value)} className="input-field w-full min-h-[60px]" /></FormField>
|
||||||
|
<FormField label="Корректирующие действия"><textarea value={form.corrective_actions} onChange={e => set('corrective_actions', e.target.value)} className="input-field w-full min-h-[60px]" /></FormField>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-gray-700 mb-2">Чек-лист замечаний</h4>
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<input value={newFinding} onChange={e => setNewFinding(e.target.value)} className="input-field flex-1" placeholder="Добавить замечание" onKeyDown={e => e.key === 'Enter' && addFinding()} />
|
||||||
|
<button type="button" onClick={addFinding} className="btn-sm bg-primary-500 text-white">Добавить</button>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{findings.map(f => (
|
||||||
|
<li key={f.id} className="flex items-center gap-2 py-1.5 border-b border-gray-100">
|
||||||
|
<input type="checkbox" checked={f.closed} onChange={() => toggleFinding(f.id)} className="rounded" />
|
||||||
|
<span className={f.closed ? 'text-gray-500 line-through' : ''}>{f.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
components/HelpDocumentModal.tsx
Normal file
91
components/HelpDocumentModal.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
'use client';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Modal } from '@/components/ui';
|
||||||
|
|
||||||
|
interface DocItem {
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
articles?: string;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
doc: DocItem | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Простой рендер markdown-подобных заголовков и параграфов для оглавления */
|
||||||
|
function getHeadings(content: string): { id: string; text: string; level: number }[] {
|
||||||
|
const headings: { id: string; text: string; level: number }[] = [];
|
||||||
|
const lines = content.split('\n');
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
const m = line.match(/^(#{1,3})\s+(.+)$/);
|
||||||
|
if (m) headings.push({ id: `h-${i}`, text: m[2], level: m[1].length });
|
||||||
|
});
|
||||||
|
return headings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContent(content: string) {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
return lines.map((line, i) => {
|
||||||
|
if (line.startsWith('### ')) return <h3 key={i} id={`h-${i}`} className="text-sm font-bold mt-4 mb-2 text-gray-800">{line.slice(4)}</h3>;
|
||||||
|
if (line.startsWith('## ')) return <h2 key={i} id={`h-${i}`} className="text-base font-bold mt-6 mb-2 text-gray-900">{line.slice(3)}</h2>;
|
||||||
|
if (line.startsWith('# ')) return <h1 key={i} id={`h-${i}`} className="text-lg font-bold mt-2 mb-3 text-gray-900">{line.slice(2)}</h1>;
|
||||||
|
if (line.trim() === '') return <br key={i} />;
|
||||||
|
return <p key={i} className="text-sm text-gray-700 mb-2">{line}</p>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HelpDocumentModal({ isOpen, onClose, doc }: Props) {
|
||||||
|
const content = doc?.content || (doc ? `${doc.name}\n\n${doc.ref || ''}\n\n${doc.articles || ''}` : '');
|
||||||
|
const headings = useMemo(() => getHeadings(content), [content]);
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (!doc) return;
|
||||||
|
const text = [doc.name, doc.ref || '', doc.articles || '', '', content].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 = `${doc.name.replace(/\s+/g, '_')}.txt`; a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!doc) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={doc.name}
|
||||||
|
size="lg"
|
||||||
|
footer={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={handleDownload} className="btn-primary">Скачать PDF</button>
|
||||||
|
<button onClick={onClose} className="btn-secondary">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{headings.length > 0 && (
|
||||||
|
<nav className="w-48 shrink-0 border-r border-gray-200 pr-4">
|
||||||
|
<h4 className="text-xs font-bold text-gray-500 uppercase mb-2">Оглавление</h4>
|
||||||
|
<ul className="space-y-1 text-xs">
|
||||||
|
{headings.map((h, i) => (
|
||||||
|
<li key={i} style={{ paddingLeft: (h.level - 1) * 8 }}>
|
||||||
|
<a href={`#${h.id}`} className="text-blue-600 hover:underline" onClick={e => { e.preventDefault(); document.getElementById(h.id)?.scrollIntoView({ behavior: 'smooth' }); }}>
|
||||||
|
{h.text}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0 prose prose-sm max-w-none overflow-y-auto max-h-[60vh]">
|
||||||
|
{doc.ref && <p className="text-xs text-gray-500 mb-2">{doc.ref}</p>}
|
||||||
|
{doc.articles && <p className="text-xs text-blue-600 mb-4">{doc.articles}</p>}
|
||||||
|
{renderContent(content)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
components/ReflyLogo.tsx
Normal file
39
components/ReflyLogo.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
iconSize?: number;
|
||||||
|
showText?: boolean;
|
||||||
|
className?: string;
|
||||||
|
/** light = белый на зелёном (сайдбар), dark = зелёный на светлом (логин) */
|
||||||
|
variant?: 'light' | 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2 ${className}`}>
|
||||||
|
<svg
|
||||||
|
width={iconSize}
|
||||||
|
height={iconSize}
|
||||||
|
viewBox="0 0 40 40"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
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" />
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
components/RiskDetailModal.tsx
Normal file
100
components/RiskDetailModal.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Modal } from '@/components/ui';
|
||||||
|
import { StatusBadge } from '@/components/ui';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
risk: any;
|
||||||
|
onEdit?: (risk: any) => void;
|
||||||
|
onCloseRisk?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEV_LABELS: Record<string, string> = { critical: 'Критический', high: 'Высокий', medium: 'Средний', low: 'Низкий' };
|
||||||
|
const STATUS_LABELS: Record<string, string> = { open: 'Открыт', mitigating: 'Меры в работе', resolved: 'Устранён', accepted: 'Принят' };
|
||||||
|
|
||||||
|
export default function RiskDetailModal({ isOpen, onClose, risk, onEdit, onCloseRisk }: Props) {
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (!risk) return;
|
||||||
|
setDownloading(true);
|
||||||
|
const report = [
|
||||||
|
`Отчёт по риску`,
|
||||||
|
`ID: ${risk.id || '—'}`,
|
||||||
|
`Название: ${risk.title || '—'}`,
|
||||||
|
`Описание: ${risk.description || '—'}`,
|
||||||
|
`Категория: ${risk.category || '—'}`,
|
||||||
|
`Вероятность: ${risk.probability ?? '—'}`,
|
||||||
|
`Последствия: ${risk.impact ?? risk.consequences ?? '—'}`,
|
||||||
|
`Уровень риска: ${risk.severity || risk.level || '—'} (${SEV_LABELS[risk.severity] || risk.severity})`,
|
||||||
|
`Статус: ${STATUS_LABELS[risk.status] || risk.status}`,
|
||||||
|
`Мероприятия по снижению: ${risk.mitigation ?? risk.mitigation_actions ?? '—'}`,
|
||||||
|
`Ответственный: ${risk.responsible ?? risk.owner ?? '—'}`,
|
||||||
|
`Срок: ${risk.due_date ?? risk.due ?? '—'}`,
|
||||||
|
`Дата создания: ${risk.created_at ? new Date(risk.created_at).toLocaleDateString('ru-RU') : '—'}`,
|
||||||
|
'',
|
||||||
|
'История изменений:',
|
||||||
|
...(Array.isArray(risk.history) ? risk.history.map((h: any) => ` ${h.date || ''} — ${h.action || h.comment || ''}`) : [' Нет данных']),
|
||||||
|
].join('\n');
|
||||||
|
const blob = new Blob([report], { type: 'text/plain;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a'); a.href = url; a.download = `risk_${(risk.id || 'report').toString().slice(0, 8)}.txt`; a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
setDownloading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!risk) return null;
|
||||||
|
|
||||||
|
const history = risk.history || [
|
||||||
|
{ date: risk.created_at, action: 'Риск зарегистрирован' },
|
||||||
|
...(risk.updated_at ? [{ date: risk.updated_at, action: 'Обновление' }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={risk.title || 'Риск'}
|
||||||
|
size="lg"
|
||||||
|
footer={
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{onEdit && <button onClick={() => { onEdit(risk); onClose(); }} className="btn-secondary">Редактировать</button>}
|
||||||
|
<button onClick={handleDownload} disabled={downloading} className="btn-primary">Скачать отчёт</button>
|
||||||
|
{onCloseRisk && risk.status !== 'resolved' && risk.status !== 'accepted' && (
|
||||||
|
<button onClick={() => { if (confirm('Закрыть риск?')) { onCloseRisk(risk.id); onClose(); } }} className="btn-sm bg-gray-200 text-gray-700">Закрыть</button>
|
||||||
|
)}
|
||||||
|
<button onClick={onClose} className="btn-secondary">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div><span className="text-gray-500">Описание</span><p className="mt-1">{risk.description || '—'}</p></div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><span className="text-gray-500">Категория</span><p className="mt-1">{risk.category || '—'}</p></div>
|
||||||
|
<div><span className="text-gray-500">Вероятность</span><p className="mt-1">{risk.probability ?? '—'}</p></div>
|
||||||
|
<div><span className="text-gray-500">Последствия</span><p className="mt-1">{risk.impact ?? risk.consequences ?? '—'}</p></div>
|
||||||
|
<div><span className="text-gray-500">Уровень риска</span><p className="mt-1"><StatusBadge status={risk.severity} colorMap={{ critical: 'bg-red-500', high: 'bg-orange-500', medium: 'bg-yellow-500', low: 'bg-green-500' }} labelMap={SEV_LABELS} /></p></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Мероприятия по снижению</span>
|
||||||
|
<p className="mt-1">{risk.mitigation ?? risk.mitigation_actions ?? '—'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><span className="text-gray-500">Ответственный</span><p className="mt-1">{risk.responsible ?? risk.owner ?? '—'}</p></div>
|
||||||
|
<div><span className="text-gray-500">Срок</span><p className="mt-1">{risk.due_date ?? risk.due ? new Date(risk.due_date || risk.due).toLocaleDateString('ru-RU') : '—'}</p></div>
|
||||||
|
<div><span className="text-gray-500">Статус</span><p className="mt-1"><StatusBadge status={risk.status} colorMap={{ open: 'bg-red-500', mitigating: 'bg-yellow-500', resolved: 'bg-green-500', accepted: 'bg-gray-400' }} labelMap={STATUS_LABELS} /></p></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-gray-700 mb-2">История изменений</h4>
|
||||||
|
<ul className="space-y-1 text-gray-600">
|
||||||
|
{history.map((h: any, i: number) => (
|
||||||
|
<li key={i}>{h.date ? new Date(h.date).toLocaleString('ru-RU') : ''} — {h.action || h.comment || '—'}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -14,8 +14,7 @@ import { useAuth, UserRole } from '@/lib/auth-context';
|
|||||||
import { sidebarIcons, commonIcons } from '@/icons/refly-icons';
|
import { sidebarIcons, commonIcons } from '@/icons/refly-icons';
|
||||||
import type { SidebarKey } from '@/icons/refly-icons';
|
import type { SidebarKey } from '@/icons/refly-icons';
|
||||||
import { Icon } from '@/components/Icon';
|
import { Icon } from '@/components/Icon';
|
||||||
|
import ReflyLogo from '@/components/ReflyLogo';
|
||||||
const HEADER_ICON_KEY: SidebarKey = 'aircraft';
|
|
||||||
|
|
||||||
interface MenuItem { name: string; path: string; iconKey: SidebarKey; roles?: UserRole[]; }
|
interface MenuItem { name: string; path: string; iconKey: SidebarKey; roles?: UserRole[]; }
|
||||||
|
|
||||||
@ -46,7 +45,7 @@ const menuItems: MenuItem[] = [
|
|||||||
{ name: 'Справка', path: '/help', iconKey: 'help' },
|
{ name: 'Справка', path: '/help', iconKey: 'help' },
|
||||||
{ name: 'Настройки', path: '/settings', iconKey: 'settings' },
|
{ name: 'Настройки', path: '/settings', iconKey: 'settings' },
|
||||||
{ name: 'ФГИС РЭВС', path: '/fgis-revs', iconKey: 'fgis-revs', roles: ['admin'] },
|
{ name: 'ФГИС РЭВС', path: '/fgis-revs', iconKey: 'fgis-revs', roles: ['admin'] },
|
||||||
{ name: 'Панель ФАВТ', path: '/regulator', iconKey: 'regulator', roles: ['admin', 'favt_inspector'] },
|
{ name: 'Регулятор (Минтранс, ФАВТ, Ространснадзор)', path: '/regulator', iconKey: 'regulator', roles: ['admin', 'favt_inspector'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
@ -74,11 +73,8 @@ export default function Sidebar() {
|
|||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-6 border-b border-white/10">
|
<div className="p-6 border-b border-white/10">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="mb-2">
|
||||||
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center">
|
<ReflyLogo iconSize={40} showText={true} variant="light" />
|
||||||
<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>
|
||||||
<div className="text-xs opacity-80">КОНТРОЛЬ ЛЁТНОЙ ГОДНОСТИ</div>
|
<div className="text-xs opacity-80">КОНТРОЛЬ ЛЁТНОЙ ГОДНОСТИ</div>
|
||||||
<div className="text-xs opacity-80">АСУ ТК</div>
|
<div className="text-xs opacity-80">АСУ ТК</div>
|
||||||
|
|||||||
@ -179,6 +179,7 @@ export const auditsApi = {
|
|||||||
list: (params?: QueryParams) => apiFetch<PaginatedResponse<any>>(`/audits${buildQuery(params)}`),
|
list: (params?: QueryParams) => apiFetch<PaginatedResponse<any>>(`/audits${buildQuery(params)}`),
|
||||||
get: (id: string) => apiFetch(`/audits/${id}`),
|
get: (id: string) => apiFetch(`/audits/${id}`),
|
||||||
create: (data: any) => apiFetch('/audits', { method: 'POST', body: JSON.stringify(data) }),
|
create: (data: any) => apiFetch('/audits', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
update: (id: string, data: any) => apiFetch(`/audits/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||||
complete: (id: string) => apiFetch(`/audits/${id}/complete`, { method: 'PATCH' }),
|
complete: (id: string) => apiFetch(`/audits/${id}/complete`, { method: 'PATCH' }),
|
||||||
submitResponse: (auditId: string, data: any) => apiFetch(`/audits/${auditId}/responses`, { method: 'POST', body: JSON.stringify(data) }),
|
submitResponse: (auditId: string, data: any) => apiFetch(`/audits/${auditId}/responses`, { method: 'POST', body: JSON.stringify(data) }),
|
||||||
listResponses: (auditId: string) => apiFetch<any[]>(`/audits/${auditId}/responses`),
|
listResponses: (auditId: string) => apiFetch<any[]>(`/audits/${auditId}/responses`),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user