- Персонал ПЛГ: демо-программы, таблица прохождений, кнопки Добавить/Редактировать/Экспорт - ВС: AircraftEditModal, кнопка Редактировать в строке - Заявки: Создать по шаблону, 5 демо-заявок - Аудиты: AuditEditModal, чек-лист замечаний, auditsApi.update - Риски: RiskDetailModal при клике, Скачать отчёт, Закрыть - Пользователи: таблица с демо (10), CRUD, Экспорт, поиск и фильтры - Контроль ЛГ: вкладка с 7 демо-записями, модалка, Скачать сертификат - Тех. обслуживание: демо-наряды при ошибке API, Скачать наряд - Дефекты: 7 демо-дефектов, детальный просмотр, цвет по серьёзности - Настройки: Профиль организации, Шаблоны, Справочники, Экспорт/Импорт - Справка: HelpDocumentModal при клике, оглавление, Скачать PDF - ReflyLogo: компонент, Sidebar и страница логина Co-authored-by: Cursor <cursoragent@cursor.com>
181 lines
14 KiB
TypeScript
181 lines
14 KiB
TypeScript
/**
|
||
* Техническое обслуживание — наряды на ТО (Work Orders)
|
||
* ФАП-145 п.A.50-65; EASA Part-145; ICAO Annex 6 Part I 8.7
|
||
*/
|
||
'use client';
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import { PageLayout, DataTable, StatusBadge, Modal, EmptyState } from '@/components/ui';
|
||
|
||
export default function MaintenancePage() {
|
||
const [orders, setOrders] = useState<any[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [stats, setStats] = useState<any>(null);
|
||
const [filter, setFilter] = useState('');
|
||
const [showAdd, setShowAdd] = useState(false);
|
||
const [selected, setSelected] = useState<any>(null);
|
||
|
||
const api = useCallback(async (ep: string, opts?: RequestInit) => {
|
||
const r = await fetch(`/api/v1/work-orders${ep}`, opts);
|
||
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(() => {
|
||
setLoading(true);
|
||
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]);
|
||
|
||
useEffect(() => { reload(); }, [reload]);
|
||
|
||
const handleCreate = async (data: any) => {
|
||
const r = await api('/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
||
if (r.id) { reload(); setShowAdd(false); }
|
||
};
|
||
|
||
const priorityColors: Record<string, string> = { aog: 'bg-red-600', urgent: 'bg-orange-500', normal: 'bg-blue-500', deferred: 'bg-gray-400' };
|
||
const statusColors: Record<string, string> = { draft: 'bg-gray-400', in_progress: 'bg-blue-500', closed: 'bg-green-500', cancelled: 'bg-red-400' };
|
||
const statusLabels: Record<string, string> = { draft: 'Черновик', in_progress: 'В работе', closed: 'Закрыт', cancelled: 'Отменён' };
|
||
const typeLabels: Record<string, string> = { scheduled: 'Плановое', unscheduled: 'Внеплановое', ad_compliance: 'Выполн. ДЛГ', sb_compliance: 'Выполн. SB', defect_rectification: 'Устранение дефекта', modification: 'Модификация' };
|
||
|
||
return (
|
||
<>
|
||
{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="Наряды на ТО — ФАП-145; EASA Part-145"
|
||
actions={<button onClick={() => setShowAdd(true)} className="btn-primary text-sm px-4 py-2 rounded">+ Создать наряд</button>}>
|
||
|
||
{/* Stats */}
|
||
{stats && (
|
||
<div className="grid grid-cols-2 lg:grid-cols-6 gap-3 mb-6">
|
||
<div className="card p-3 text-center"><div className="text-2xl font-bold">{stats.total}</div><div className="text-[10px] text-gray-500">Всего</div></div>
|
||
<div className="card p-3 text-center bg-gray-50"><div className="text-2xl font-bold text-gray-600">{stats.draft}</div><div className="text-[10px] text-gray-500">Черновик</div></div>
|
||
<div className="card p-3 text-center bg-blue-50"><div className="text-2xl font-bold text-blue-600">{stats.in_progress}</div><div className="text-[10px] text-blue-600">В работе</div></div>
|
||
<div className="card p-3 text-center bg-green-50"><div className="text-2xl font-bold text-green-600">{stats.closed}</div><div className="text-[10px] text-green-600">Закрыто</div></div>
|
||
<div className="card p-3 text-center bg-red-50"><div className="text-2xl font-bold text-red-600">{stats.aog}</div><div className="text-[10px] text-red-600">AOG</div></div>
|
||
<div className="card p-3 text-center bg-purple-50"><div className="text-2xl font-bold text-purple-600">{stats.total_manhours}</div><div className="text-[10px] text-purple-600">Человеко-часов</div></div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Filters */}
|
||
<div className="flex gap-2 mb-4">
|
||
{['', 'draft', 'in_progress', 'closed', 'cancelled'].map(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 text-gray-600'}`}>
|
||
{statusLabels[s] || 'Все'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Table */}
|
||
{orders.length > 0 ? (
|
||
<DataTable columns={[
|
||
{ key: 'wo_number', label: '№ наряда' },
|
||
{ key: 'aircraft_reg', label: 'Борт' },
|
||
{ key: 'wo_type', label: 'Тип', render: (v: string) => <span className="text-xs">{typeLabels[v] || v}</span> },
|
||
{ key: 'title', label: 'Наименование' },
|
||
{ key: 'priority', label: 'Приоритет', render: (v: string) => <StatusBadge status={v} colorMap={priorityColors} /> },
|
||
{ key: 'estimated_manhours', label: 'План. ч/ч' },
|
||
{ key: 'status', label: 'Статус', render: (v: string) => <StatusBadge status={v} colorMap={statusColors} labelMap={statusLabels} /> },
|
||
]} data={orders} onRowClick={setSelected} />
|
||
) : <EmptyState message="Нет нарядов на ТО" />}
|
||
|
||
{/* Detail modal */}
|
||
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title={selected ? `WO ${selected.wo_number}` : ''} size="lg">
|
||
{selected && (
|
||
<div className="space-y-3 text-sm">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div><span className="text-gray-500">Борт:</span> {selected.aircraft_reg}</div>
|
||
<div><span className="text-gray-500">Тип:</span> {typeLabels[selected.wo_type] || selected.wo_type}</div>
|
||
<div><span className="text-gray-500">Приоритет:</span> <StatusBadge status={selected.priority} colorMap={priorityColors} /></div>
|
||
<div><span className="text-gray-500">Статус:</span> <StatusBadge status={selected.status} colorMap={statusColors} labelMap={statusLabels} /></div>
|
||
<div><span className="text-gray-500">План. ч/ч:</span> {selected.estimated_manhours}</div>
|
||
<div><span className="text-gray-500">Факт. ч/ч:</span> {selected.actual_manhours || '—'}</div>
|
||
</div>
|
||
{selected.description && <div className="text-gray-600">{selected.description}</div>}
|
||
{selected.crs_signed_by && (
|
||
<div className="bg-green-50 border border-green-200 rounded p-2 text-xs text-green-700">
|
||
✅ CRS подписан: {selected.crs_signed_by} ({selected.crs_date ? new Date(selected.crs_date).toLocaleDateString('ru-RU') : ''})
|
||
</div>
|
||
)}
|
||
{selected.findings && <div><span className="text-gray-500 font-medium">Замечания:</span> {selected.findings}</div>}
|
||
<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' && (
|
||
<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>
|
||
)}
|
||
{selected.status === 'in_progress' && (
|
||
<button onClick={async () => {
|
||
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 */ }
|
||
reload(); setSelected(null);
|
||
}} className="bg-green-600 text-white px-4 py-2 rounded text-xs">✅ Завершить</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
|
||
{/* Create modal */}
|
||
<Modal isOpen={showAdd} onClose={() => setShowAdd(false)} title="Создать наряд на ТО">
|
||
<WOForm onSubmit={handleCreate} onCancel={() => setShowAdd(false)} />
|
||
</Modal>
|
||
</PageLayout>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function WOForm({ onSubmit, onCancel }: { onSubmit: (d: any) => void; onCancel: () => void }) {
|
||
const [f, setF] = useState({
|
||
wo_number: `WO-${Date.now().toString(36).toUpperCase()}`,
|
||
aircraft_reg: '', wo_type: 'scheduled', title: '', description: '',
|
||
priority: 'normal', estimated_manhours: 0,
|
||
});
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div><label className="text-xs font-medium text-gray-600">№ наряда</label>
|
||
<input className="input-field w-full mt-1" value={f.wo_number} onChange={e => setF(p => ({ ...p, wo_number: e.target.value }))} /></div>
|
||
<div><label className="text-xs font-medium text-gray-600">Борт</label>
|
||
<input className="input-field w-full mt-1" placeholder="RA-89001" value={f.aircraft_reg} onChange={e => setF(p => ({ ...p, aircraft_reg: e.target.value }))} /></div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div><label className="text-xs font-medium text-gray-600">Тип работ</label>
|
||
<select className="input-field w-full mt-1" value={f.wo_type} onChange={e => setF(p => ({ ...p, wo_type: e.target.value }))}>
|
||
<option value="scheduled">Плановое ТО</option><option value="unscheduled">Внеплановое</option>
|
||
<option value="ad_compliance">Выполнение ДЛГ</option><option value="sb_compliance">Выполнение SB</option>
|
||
<option value="defect_rectification">Устранение дефекта</option><option value="modification">Модификация</option>
|
||
</select></div>
|
||
<div><label className="text-xs font-medium text-gray-600">Приоритет</label>
|
||
<select className="input-field w-full mt-1" value={f.priority} onChange={e => setF(p => ({ ...p, priority: e.target.value }))}>
|
||
<option value="aog">AOG (ВС на земле)</option><option value="urgent">Срочный</option>
|
||
<option value="normal">Обычный</option><option value="deferred">Отложенный</option>
|
||
</select></div>
|
||
</div>
|
||
<div><label className="text-xs font-medium text-gray-600">Наименование работ</label>
|
||
<input className="input-field w-full mt-1" value={f.title} onChange={e => setF(p => ({ ...p, title: e.target.value }))} /></div>
|
||
<div><label className="text-xs font-medium text-gray-600">Описание</label>
|
||
<textarea className="input-field w-full mt-1" rows={2} value={f.description} onChange={e => setF(p => ({ ...p, description: e.target.value }))} /></div>
|
||
<div><label className="text-xs font-medium text-gray-600">Планируемые человеко-часы</label>
|
||
<input type="number" className="input-field w-full mt-1" value={f.estimated_manhours} onChange={e => setF(p => ({ ...p, estimated_manhours: +e.target.value }))} /></div>
|
||
<div className="flex gap-2 pt-2">
|
||
<button onClick={() => onSubmit(f)} className="btn-primary px-4 py-2 rounded text-sm">Создать</button>
|
||
<button onClick={onCancel} className="bg-gray-200 text-gray-700 px-4 py-2 rounded text-sm">Отмена</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|