- .env.example: полный шаблон, защита секретов - .gitignore: явное исключение .env.* и секретов - layout.tsx: XSS — заменён dangerouslySetInnerHTML на next/script для SW - ESLint: no-console error (allow warn/error), ignore scripts/ - scripts/remove-console-logs.js: очистка console.log без glob - backend/routes/modules: README с планом рефакторинга крупных файлов - SECURITY.md: гид по секретам, XSS, CORS, auth, линту - .husky/pre-commit: запуск npm run lint + прочие правки приложения и бэкенда Co-authored-by: Cursor <cursoragent@cursor.com>
166 lines
11 KiB
TypeScript
166 lines
11 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 reload = useCallback(() => {
|
||
api(`/${filter ? `?status=${filter}` : ""}`).then(d => { setOrders(d.items || []); });
|
||
api('/stats/summary').then(setStats);
|
||
}, [api, filter]);
|
||
|
||
useEffect(() => {
|
||
setLoading(true); 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">
|
||
{selected.status === 'draft' && (
|
||
<button onClick={async () => { await api(`/${selected.id}/open`, { method: 'PUT' }); reload(); setSelected(null); }}
|
||
className="btn-primary px-4 py-2 rounded text-xs">▶ В работу</button>
|
||
)}
|
||
{selected.status === 'in_progress' && (
|
||
<button onClick={async () => {
|
||
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: 'Текущий пользователь' }) });
|
||
reload(); setSelected(null);
|
||
}} className="bg-green-600 text-white px-4 py-2 rounded text-xs">✅ Закрыть + CRS</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>
|
||
);
|
||
}
|