klg-asutk-app/app/maintenance/page.tsx
Yuriy aa052763f6 Безопасность и качество: 8 исправлений + обновления
- .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>
2026-02-14 21:29:16 +03:00

166 lines
11 KiB
TypeScript
Raw 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 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>
);
}