klg-asutk-app/app/maintenance/page.tsx
Yuriy e9ef17ba16 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>
2026-02-17 16:19:39 +03:00

181 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Техническое обслуживание — наряды на ТО (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>
);
}