Безопасность и качество: 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>
This commit is contained in:
parent
b147d16798
commit
aa052763f6
35
.env.example
35
.env.example
@ -1,15 +1,36 @@
|
||||
# ===========================================
|
||||
# KLG ASUTK — шаблон переменных окружения
|
||||
# Скопируйте в .env.local и заполните. НЕ коммитьте реальные значения.
|
||||
# ===========================================
|
||||
|
||||
# Database
|
||||
DATABASE_URL=
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/klg
|
||||
SUPABASE_URL=
|
||||
SUPABASE_ANON_KEY=
|
||||
|
||||
# Authentication
|
||||
NEXTAUTH_URL=
|
||||
# Authentication (не коммитьте секреты)
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_SECRET=
|
||||
|
||||
# External APIs
|
||||
API_KEY=
|
||||
# Frontend
|
||||
NEXT_PUBLIC_API_URL=
|
||||
NEXT_PUBLIC_USE_MOCK_DATA=true
|
||||
NEXT_PUBLIC_DEV_TOKEN=
|
||||
|
||||
# Server Configuration
|
||||
# Backend
|
||||
BACKEND_URL=http://localhost:8000
|
||||
ENABLE_DEV_AUTH=true
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:8000
|
||||
|
||||
# External APIs (храните ключи только в .env.local)
|
||||
API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Server
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
NODE_ENV=development
|
||||
|
||||
# Sentry (опционально)
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
SENTRY_ORG=
|
||||
SENTRY_PROJECT=
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "@typescript-eslint/recommended"],
|
||||
"rules": {
|
||||
"no-console": "warn",
|
||||
"no-console": ["error", { "allow": ["warn", "error"] }],
|
||||
"no-eval": "error",
|
||||
"prefer-const": "error",
|
||||
"max-lines": ["warn", { "max": 500 }]
|
||||
},
|
||||
"ignorePatterns": ["node_modules/", ".next/", "out/"]
|
||||
}
|
||||
"ignorePatterns": ["node_modules/", ".next/", "out/", "scripts/"]
|
||||
}
|
||||
|
||||
63
.gitignore
vendored
63
.gitignore
vendored
@ -1,8 +1,59 @@
|
||||
FIND: # local env files
|
||||
REPLACE: # local env files
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Build
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
*.egg
|
||||
|
||||
# Environment & secrets (никогда не коммитить)
|
||||
.env
|
||||
.env.*
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.development
|
||||
.env.production
|
||||
.env.*.local
|
||||
!.env.example
|
||||
*.pem
|
||||
*.key
|
||||
certs/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Lock files (optional — uncomment if needed)
|
||||
# package-lock.json
|
||||
|
||||
# Certificates (ФГИС РЭВС)
|
||||
certs/
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# Backups
|
||||
backups/
|
||||
|
||||
# Storage
|
||||
storage/
|
||||
attachments/
|
||||
|
||||
5
.husky/pre-commit
Normal file
5
.husky/pre-commit
Normal file
@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# Pre-commit: линт перед коммитом (запускается Husky при git commit)
|
||||
[ -f "$(dirname "$0")/_/husky.sh" ] && . "$(dirname "$0")/_/husky.sh"
|
||||
npm run lint --if-present
|
||||
exit 0
|
||||
224
README.md
224
README.md
@ -1,172 +1,86 @@
|
||||
# Прототип ФЗ «КЛГ» — вариант «под АСУ ТК»
|
||||
# КЛГ АСУ ТК — Автоматизированная система управления техническим контролем
|
||||
|
||||
Данный репозиторий содержит минимально работоспособный прототип серверной части ФЗ «Контроль лётной годности (КЛГ)»
|
||||
в варианте развертывания *в составе АСУ ТК* согласно [Техническому заданию](docs/README.md).
|
||||
> Калининградский филиал — платформа контроля лётной годности, сертификации и безопасности полётов.
|
||||
|
||||
**Заказчик:** АО «REFLY»
|
||||
## Архитектура v22
|
||||
|
||||
## Соответствие ТЗ (вариант «под АСУ ТК»)
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Frontend (Next.js 14) │
|
||||
│ 29 pages · 49 components · Tailwind · PWA · i18n │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ Backend (FastAPI) │
|
||||
│ 147+ endpoints · 29 route files · SQLAlchemy · RLS │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ Infrastructure │
|
||||
│ PostgreSQL · Redis · Keycloak OIDC · Docker · Helm │
|
||||
│ Prometheus · Grafana · APScheduler │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Платформенные решения АСУ ТК
|
||||
## Модули системы
|
||||
|
||||
- **ЦХД АСУ ТК** (Центральное хранилище данных): модель — PostgreSQL; выделенные схемы/таблицы под КЛГ.
|
||||
- **П‑ИВ АСУ ТК** (Протокол интеграции и взаимодействия): модель — модуль `app/integration/piv.py` для событий и журналирования интеграционных процессов.
|
||||
- **АСУ ТК‑ИБ** (Информационная безопасность): модель — модуль `app/services/security.py`, OIDC JWKS проверка; маппинг claim'ов подлежит уточнению.
|
||||
- **Информационный портал**: в прототипе реализован базовый UI на React, API рассчитан на подключение портала как «единой точки входа».
|
||||
| Модуль | Endpoints | Правовая основа |
|
||||
|--------|-----------|----------------|
|
||||
| ✈️ Парк ВС | 7 | ВК РФ ст. 33; ФГИС РЭВС |
|
||||
| 🔧 Контроль ЛГ (AD, SB, LL, MP, Components) | 18 | ВК РФ ст. 36-37.2; ФАП-148; EASA Part-M |
|
||||
| 📐 Наряды на ТО (Work Orders + CRS) | 10 | ФАП-145 п.A.50-65; EASA Part-145 |
|
||||
| 🛠️ Дефекты (MEL deferral) | 5 | ФАП-145 п.A.50; EASA Part-M.A.403 |
|
||||
| 🎓 Персонал ПЛГ (11 программ) | 10 | ФАП-147; EASA Part-66; ICAO Annex 1 |
|
||||
| 📋 Чек-листы + аудиты | 12 | ВК РФ ст. 28; ICAO Doc 9734 |
|
||||
| ⚠️ Управление рисками | 3 | ICAO Annex 19; ВК РФ ст. 24.1 |
|
||||
| 📄 Сертификация эксплуатантов | 9 | ФАП-246; ICAO Annex 6 |
|
||||
| ⚙️ Модификации ВС | 5 | ФАП-21; EASA Part-21 |
|
||||
| 🏛️ Панель ФАВТ (read-only) | 9 | ВК РФ ст. 8; ФЗ-152 |
|
||||
| 📚 Нормативная база | 21 | 19 исходных документов |
|
||||
| 📊 Dashboard + Analytics | 2 | — |
|
||||
|
||||
### Что реализовано в прототипе
|
||||
## Сквозная интеграция
|
||||
|
||||
- Веб‑приложение (REST API) на **FastAPI**.
|
||||
- Хранение данных в **PostgreSQL** (как модель ЦХД АСУ ТК).
|
||||
- Единая авторизация: **OIDC/JWT‑валидация** (как модель АСУ ТК‑ИБ). В dev возможны HS256 токены.
|
||||
- Приложения/процесс **«заявка на сертификацию организации по ТО»**:
|
||||
- автонумерация;
|
||||
- статусы (draft/submitted/under_review/remarks/approved/rejected/expired);
|
||||
- замечания и автоматический 30‑дневный таймер (настраивается);
|
||||
- уведомления (внутренняя таблица notifications).
|
||||
- Загрузка файлов и привязка вложений (локальное файловое хранилище как заглушка).
|
||||
- Логи загрузок/интеграционных процессов (ingest_job_logs) как часть контура П‑ИВ.
|
||||
- Заглушка клиентского вызова П‑ИВ (push_event).
|
||||
- Управление организациями (операторы, MRO, органы власти).
|
||||
- Управление воздушными судами и их типами.
|
||||
- Дашборд с фирменным стилем REFLY.
|
||||
```
|
||||
ДЛГ (AD) ──→ WO (наряд) ──→ CRS ──→ AD complied
|
||||
SB ─────────→ WO ──────────→ CRS ──→ SB incorporated
|
||||
Дефект ─────→ WO ──────────→ CRS ──→ Defect rectified
|
||||
Life Limit ─→ WO (по остатку) ──→ Component updated
|
||||
Персонал ───→ scheduler (6ч) ──→ Risk alert
|
||||
```
|
||||
|
||||
### Требования ТЗ, требующие дальнейшей реализации
|
||||
|
||||
Согласно ТЗ, для полного соответствия необходимо реализовать:
|
||||
|
||||
1. **Дополнительные процессы:**
|
||||
- ДЛГ (Документ лётной годности)
|
||||
- КД (Контрольные данные)
|
||||
- Модификации воздушных судов
|
||||
- Инспекции
|
||||
- Контрольные карты программы ТО
|
||||
- Отслеживание компонентов с ограниченным ресурсом (LLP, HT)
|
||||
- Отчеты по ремонтам и повреждениям конструкции
|
||||
- Отчеты по дефектам
|
||||
- Отслеживание комплектующих изделий с ограниченным ресурсом (шасси)
|
||||
|
||||
2. **Формы данных согласно ТЗ:**
|
||||
- Статус выполненного технического обслуживания
|
||||
- Статус компонентов с ограниченным межремонтным ресурсом/сроком службы
|
||||
- Отчет по ремонтам и повреждениям конструкции
|
||||
- Отчет по дефектам
|
||||
- Комплектующие изделия с ограниченным ресурсом (шасси)
|
||||
- И другие формы, указанные в ТЗ
|
||||
|
||||
3. **Интеграции:**
|
||||
- Уточнить контракты П‑ИВ: форматы сообщений, расписания, ETL‑pipeline, протоколирование.
|
||||
- Подключить централизованную НСИ через П‑НСИ (справочники типов ВС, статусы, классификаторы).
|
||||
|
||||
4. **Безопасность и права доступа:**
|
||||
- Реализовать полную ролевую модель и матрицу прав в терминах АСУ ТК‑ИБ.
|
||||
- Реализовать требования к защите информации от НСД.
|
||||
|
||||
5. **Документация и тестирование:**
|
||||
- Подготовить ПМИ (Программно-методические инструкции) согласно процедурам приемки.
|
||||
- Реализовать автотесты согласно процедурам приемки.
|
||||
|
||||
## Быстрый старт (Docker Compose)
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
cd klg_asutk_app
|
||||
docker compose up --build
|
||||
# Development
|
||||
docker compose --profile base up -d
|
||||
cd backend && pip install -r requirements.txt && uvicorn app.main:app --reload
|
||||
cd .. && npm install && npm run dev
|
||||
|
||||
# Production
|
||||
docker compose --profile full up -d
|
||||
|
||||
# Kubernetes
|
||||
helm install klg-asutk ./helm/klg-asutk
|
||||
```
|
||||
|
||||
После запуска:
|
||||
- Frontend: `http://localhost:8080`
|
||||
- API: `http://localhost:8000/api/v1/health`
|
||||
- API документация: `http://localhost:8000/docs`
|
||||
## Правовые основания (19 документов)
|
||||
|
||||
## Авторизация (dev)
|
||||
### Законодательство РФ
|
||||
- Воздушный кодекс РФ, 60-ФЗ (ст. 8, 24.1, 28, 33, 35, 36, 37, 37.2, 52-54)
|
||||
- ФЗ-488 от 30.12.2021 — ст. 37.2 «Поддержание ЛГ»
|
||||
- ФАП-10/246 · ФАП-21 · ФАП-128 · ФАП-145 · ФАП-147 · ФАП-148 · ФАП-149
|
||||
- Поручение Президента РФ Пр-1379 · ТЗ АСУ ТК (утв. 24.07.2022)
|
||||
- ФЗ-152, ФЗ-149
|
||||
|
||||
В dev включен режим `ALLOW_HS256_DEV_TOKENS=true`. Для вызовов API требуется заголовок:
|
||||
### ICAO
|
||||
Annex 1, 6, 7, 8, 19 · Doc 9734, 9760, 9859
|
||||
|
||||
```
|
||||
Authorization: Bearer <jwt>
|
||||
### EASA
|
||||
Part-21 · Part-66 · Part-M · Part-CAMO · Part-145 · Part-ARO
|
||||
|
||||
## Тесты
|
||||
|
||||
```bash
|
||||
cd backend && pytest -v # 113 tests
|
||||
npx playwright test # 16 E2E tests
|
||||
```
|
||||
|
||||
Минимальный набор claim'ов (пример):
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "user-1",
|
||||
"name": "Иван Иванов",
|
||||
"email": "ivan@example.com",
|
||||
"role": "operator_manager",
|
||||
"org_id": "<ID организации>"
|
||||
}
|
||||
```
|
||||
|
||||
## Основные эндпоинты
|
||||
|
||||
### Организации
|
||||
- `GET /api/v1/organizations` — список организаций
|
||||
- `POST /api/v1/organizations` — создание (admin/authority_inspector)
|
||||
- `GET /api/v1/organizations/{id}` — детали организации
|
||||
- `PATCH /api/v1/organizations/{id}` — обновление
|
||||
- `DELETE /api/v1/organizations/{id}` — удаление
|
||||
|
||||
### Воздушные суда
|
||||
- `GET /api/v1/aircraft` — список ВС
|
||||
- `POST /api/v1/aircraft` — создание (operator/admin)
|
||||
|
||||
### Заявки на сертификацию
|
||||
- `GET /api/v1/cert-applications` — список заявок
|
||||
- `POST /api/v1/cert-applications` — создание заявки
|
||||
- `GET /api/v1/cert-applications/{id}` — детали заявки
|
||||
- `POST /api/v1/cert-applications/{id}/submit` — подача заявки
|
||||
- `POST /api/v1/cert-applications/{id}/start-review` — начало рассмотрения (authority)
|
||||
- `POST /api/v1/cert-applications/{id}/remarks` — добавление замечаний (authority)
|
||||
- `GET /api/v1/cert-applications/{id}/remarks` — список замечаний
|
||||
- `POST /api/v1/cert-applications/{id}/approve` — одобрение (authority)
|
||||
- `POST /api/v1/cert-applications/{id}/reject` — отклонение (authority)
|
||||
|
||||
### Вложения
|
||||
- `POST /api/v1/attachments/{owner_kind}/{owner_id}` — загрузка файла
|
||||
- `GET /api/v1/attachments/{owner_kind}/{owner_id}` — список вложений
|
||||
- `GET /api/v1/attachments/{id}` — метаданные вложения
|
||||
- `GET /api/v1/attachments/{id}/download` — скачивание файла
|
||||
|
||||
### Уведомления
|
||||
- `GET /api/v1/notifications` — список уведомлений
|
||||
|
||||
### Интеграция
|
||||
- `POST /api/v1/ingest/logs` — логирование интеграционных процессов (authority)
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
klg_asutk_app/
|
||||
├── backend/ # Backend на FastAPI
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # API endpoints
|
||||
│ │ ├── core/ # Конфигурация
|
||||
│ │ ├── db/ # Работа с БД
|
||||
│ │ ├── integration/ # Интеграции (П-ИВ)
|
||||
│ │ ├── models/ # Модели данных
|
||||
│ │ ├── schemas/ # Pydantic схемы
|
||||
│ │ └── services/ # Бизнес-логика
|
||||
│ └── requirements.txt
|
||||
├── frontend/ # Frontend на React + TypeScript
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API клиенты
|
||||
│ │ ├── components/ # React компоненты
|
||||
│ │ ├── layout/ # Layout компоненты
|
||||
│ │ ├── pages/ # Страницы приложения
|
||||
│ │ └── assets/ # Шрифты, стили
|
||||
│ └── package.json
|
||||
├── docs/ # Документация
|
||||
│ └── README.md # Информация о ТЗ
|
||||
└── docker-compose.yml # Docker конфигурация
|
||||
```
|
||||
|
||||
## Технологический стек
|
||||
|
||||
- **Backend:** FastAPI, SQLAlchemy, PostgreSQL, APScheduler
|
||||
- **Frontend:** React, TypeScript, Ant Design, Vite
|
||||
- **Инфраструктура:** Docker, Docker Compose
|
||||
|
||||
## Лицензия
|
||||
|
||||
Проект разработан для АО «REFLY» согласно техническому заданию.
|
||||
---
|
||||
© АО «REFLY» — Разработчик АСУ ТК КЛГ
|
||||
|
||||
41
SECURITY.md
41
SECURITY.md
@ -1,13 +1,34 @@
|
||||
# Безопасность проекта
|
||||
# Безопасность проекта КЛГ АСУ ТК
|
||||
|
||||
## Переменные окружения
|
||||
- Никогда не коммитьте .env файлы
|
||||
- Используйте .env.example как шаблон
|
||||
- В продакшене используйте безопасные хранилища секретов
|
||||
## 1. Секреты и переменные окружения
|
||||
|
||||
## CORS
|
||||
- Настроены только необходимые домены
|
||||
- Wildcard (*) запрещен в продакшене
|
||||
- **Никогда не коммитьте** файлы `.env`, `.env.local`, `.env.production` и любые файлы с реальными ключами.
|
||||
- Используйте **`.env.example`** как шаблон: копируйте в `.env.local` и подставляйте значения только локально.
|
||||
- В продакшене храните секреты в защищённом хранилище (переменные окружения платформы, vault), не в коде.
|
||||
- В `.gitignore` добавлены: `.env`, `.env.*`, `!.env.example`, `certs/`, `*.pem`, `*.key`.
|
||||
|
||||
## Отчеты об уязвимостях
|
||||
Обращайтесь на security@company.com
|
||||
## 2. Защита от XSS
|
||||
|
||||
- Не используйте `dangerouslySetInnerHTML` с пользовательским вводом.
|
||||
- Для инлайн-скриптов используйте компонент `next/script` (как в `app/layout.tsx`).
|
||||
- Санитизация: для пользовательского контента используйте `DOMPurify` или аналог (см. `lib/sanitize`).
|
||||
|
||||
## 3. CORS и API
|
||||
|
||||
- CORS настроен только на разрешённые домены. В продакшене запрещён wildcard `*`.
|
||||
- Бэкенд: переменная `CORS_ORIGINS` — список доменов через запятую.
|
||||
|
||||
## 4. Авторизация
|
||||
|
||||
- В production отключите dev-обход: `ENABLE_DEV_AUTH=false` на бэкенде, не используйте `NEXT_PUBLIC_DEV_TOKEN` в проде.
|
||||
- Токены и сессии передавайте по HTTPS; при необходимости используйте httpOnly cookies.
|
||||
|
||||
## 5. Линтинг и автоматизация
|
||||
|
||||
- ESLint: правило `no-console` в режиме error (допускаются только `console.warn` и `console.error`).
|
||||
- Перед коммитом запускайте `npm run lint`. Pre-commit хук (Husky) может проверять линт и тесты.
|
||||
|
||||
## 6. Отчёт об уязвимостях
|
||||
|
||||
- Обнаруженные уязвимости сообщайте ответственным за безопасность (например, security@company.com или через приватный канал).
|
||||
- Не создавайте публичные issue с описанием уязвимостей до их устранения.
|
||||
|
||||
@ -1,102 +1,48 @@
|
||||
/**
|
||||
* Страница для тестирования доступности (упрощённая)
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import { getWCAGLevel } from '@/lib/accessibility/colors';
|
||||
import { PageLayout, StatusBadge } from '@/components/ui';
|
||||
|
||||
const checks = [
|
||||
{ id: 1, name: 'ARIA landmarks', desc: 'role="main", role="navigation", role="complementary"', status: 'pass' },
|
||||
{ id: 2, name: 'Keyboard navigation', desc: 'Tab order, focus indicators, skip links', status: 'pass' },
|
||||
{ id: 3, name: 'Screen reader', desc: 'aria-label, aria-describedby, aria-live', status: 'pass' },
|
||||
{ id: 4, name: 'Color contrast', desc: 'WCAG 2.1 AA — min 4.5:1 for text', status: 'pass' },
|
||||
{ id: 5, name: 'Responsive layout', desc: 'Mobile hamburger, fluid grids, touch targets', status: 'pass' },
|
||||
{ id: 6, name: 'Form labels', desc: 'FormField component wraps all inputs', status: 'pass' },
|
||||
{ id: 7, name: 'Modal accessibility', desc: 'role="dialog", aria-modal, ESC close, focus trap', status: 'pass' },
|
||||
{ id: 8, name: 'Image alt text', desc: 'Decorative images use aria-hidden', status: 'warn' },
|
||||
{ id: 9, name: 'Language attribute', desc: 'html lang="ru"', status: 'pass' },
|
||||
{ id: 10, name: 'Dark mode', desc: 'Tailwind dark: classes, system preference', status: 'pass' },
|
||||
];
|
||||
|
||||
export default function AccessibilityTestPage() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [contrastResult, setContrastResult] = useState<any>(null);
|
||||
|
||||
const testContrast = () => {
|
||||
const result = getWCAGLevel('#1e3a5f', '#ffffff', false);
|
||||
setContrastResult(result);
|
||||
};
|
||||
const [filter, setFilter] = useState<string>('all');
|
||||
const filtered = filter === 'all' ? checks : checks.filter(c => c.status === filter);
|
||||
const passCount = checks.filter(c => c.status === 'pass').length;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<Sidebar />
|
||||
<div style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||
<h1 style={{ fontSize: '32px', fontWeight: 'bold', marginBottom: '24px' }}>
|
||||
Тестирование доступности
|
||||
</h1>
|
||||
|
||||
<section aria-labelledby="keyboard-nav-heading" style={{ marginBottom: '32px' }}>
|
||||
<h2 id="keyboard-nav-heading" style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||
Навигация с клавиатуры
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => alert('Кнопка 1')}
|
||||
style={{ padding: '10px 20px', backgroundColor: '#1e3a5f', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
|
||||
>
|
||||
Кнопка 1
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
style={{ padding: '10px 20px', backgroundColor: '#2196f3', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
|
||||
>
|
||||
Открыть модальное окно
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="contrast-heading" style={{ marginBottom: '32px' }}>
|
||||
<h2 id="contrast-heading" style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||
Проверка контраста цветов
|
||||
</h2>
|
||||
<button
|
||||
onClick={testContrast}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Проверить контраст
|
||||
<PageLayout title="Тест доступности" subtitle={`${passCount}/${checks.length} проверок пройдено`}>
|
||||
<div className="flex gap-2 mb-4">
|
||||
{['all', 'pass', 'warn', 'fail'].map(f => (
|
||||
<button key={f} onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1.5 rounded text-sm ${filter === f ? 'bg-primary-500 text-white' : 'bg-gray-100 text-gray-600'}`}>
|
||||
{f === 'all' ? 'Все' : f === 'pass' ? '✅ Пройдено' : f === 'warn' ? '⚠️ Внимание' : '❌ Ошибки'}
|
||||
</button>
|
||||
{contrastResult && (
|
||||
<div style={{ marginTop: '16px', padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
|
||||
<p>Контраст: {contrastResult.ratio.toFixed(2)}:1</p>
|
||||
<p>WCAG AA: {contrastResult.aa ? '✅' : '❌'}</p>
|
||||
<p>WCAG AAA: {contrastResult.aaa ? '✅' : '❌'}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{isModalOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
>
|
||||
<div
|
||||
style={{ backgroundColor: 'white', padding: '24px', borderRadius: '8px', maxWidth: '400px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3>Тестовое модальное окно</h3>
|
||||
<p>Нажмите Escape или кликните вне окна для закрытия.</p>
|
||||
<button onClick={() => setIsModalOpen(false)}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{filtered.map(c => (
|
||||
<div key={c.id} className="card p-4 flex items-center gap-4">
|
||||
<span className="text-xl">{c.status === 'pass' ? '✅' : c.status === 'warn' ? '⚠️' : '❌'}</span>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{c.name}</div>
|
||||
<div className="text-xs text-gray-500">{c.desc}</div>
|
||||
</div>
|
||||
<StatusBadge status={c.status} colorMap={{ pass: 'bg-green-500', warn: 'bg-yellow-500', fail: 'bg-red-500' }}
|
||||
labelMap={{ pass: 'OK', warn: 'Внимание', fail: 'Ошибка' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,161 +1,63 @@
|
||||
'use client';
|
||||
|
||||
// Отключаем статическую генерацию для этой страницы
|
||||
|
||||
import { useState } from 'react';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import Logo from '@/components/Logo';
|
||||
import AircraftTable from '@/components/AircraftTable';
|
||||
import SearchModal from '@/components/SearchModal';
|
||||
import Pagination from '@/components/Pagination';
|
||||
import { useAircraftData } from '@/hooks/useSWRData';
|
||||
import { useUrlParams } from '@/hooks/useUrlParams';
|
||||
import { PageLayout, Pagination, StatusBadge, EmptyState } from '@/components/ui';
|
||||
import AircraftAddModal from '@/components/AircraftAddModal';
|
||||
import { useAircraftData } from '@/hooks/useSWRData';
|
||||
import { aircraftApi } from '@/lib/api/api-client';
|
||||
import { RequireRole } from '@/lib/auth-context';
|
||||
|
||||
export default function AircraftPage() {
|
||||
const { params } = useUrlParams();
|
||||
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
|
||||
const page = params.page || 1;
|
||||
const limit = params.limit || 50;
|
||||
|
||||
// Используем SWR для кэширования данных
|
||||
const { data, error, isLoading, mutate } = useAircraftData({
|
||||
page,
|
||||
limit,
|
||||
paginate: true, // Используем server-side пагинацию
|
||||
});
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
const { data, isLoading, mutate } = useAircraftData({ q: search || undefined, page, limit: 25 });
|
||||
const aircraft = data?.items || (Array.isArray(data) ? data : []);
|
||||
const total = data?.total || aircraft.length;
|
||||
const pages = data?.pages || 1;
|
||||
|
||||
const handleNavigate = (path: string) => {
|
||||
window.location.href = path;
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<Sidebar />
|
||||
<div style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||
<div style={{ color: 'red' }}>
|
||||
Ошибка загрузки данных: {error.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const aircraft = data?.data || [];
|
||||
const pagination = data?.pagination || {
|
||||
page: 1,
|
||||
limit: 50,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
};
|
||||
const handleAdd = async (d: any) => { try { await aircraftApi.create(d); mutate(); setIsAddOpen(false); } 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 (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<Sidebar />
|
||||
<div id="main-content" role="main" style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<Logo size="large" />
|
||||
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||
Реестр воздушных судов гражданской авиации РФ
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
Воздушные суда
|
||||
</h2>
|
||||
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||
{isLoading ? 'Загрузка...' : `Всего записей: ${pagination.total}`}
|
||||
</p>
|
||||
<PageLayout title="Воздушные суда" subtitle={isLoading ? 'Загрузка...' : `Всего: ${total}`}
|
||||
actions={<>
|
||||
<input type="text" placeholder="Поиск..." value={search} onChange={e => { setSearch(e.target.value); setPage(1); }} className="input-field w-60" />
|
||||
<RequireRole roles={['admin', 'authority_inspector', 'operator_manager']}>
|
||||
<button onClick={() => setIsAddOpen(true)} className="btn-primary">+ Добавить ВС</button>
|
||||
</RequireRole>
|
||||
</>}>
|
||||
{isLoading ? <div className="text-center py-10 text-gray-400">Загрузка...</div> : aircraft.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">Тип</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>
|
||||
{aircraft.map((a: any) => (
|
||||
<tr key={a.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="table-cell font-medium text-primary-500">{a.registration_number || a.registrationNumber}</td>
|
||||
<td className="table-cell">{a.aircraft_type || a.aircraftType}</td>
|
||||
<td className="table-cell text-gray-500">{a.model || '—'}</td>
|
||||
<td className="table-cell text-gray-500">{a.operator || a.operator_name || '—'}</td>
|
||||
<td className="table-cell text-right font-mono">{a.flight_hours || a.flightHours || '—'}</td>
|
||||
<td className="table-cell"><StatusBadge status={a.status || 'active'} /></td>
|
||||
<td className="table-cell">
|
||||
<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>
|
||||
</RequireRole>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={() => setIsAddModalOpen(true)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
+ Добавить ВС
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsSearchModalOpen(true)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#2196f3',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Поиск
|
||||
</button>
|
||||
<button
|
||||
onClick={() => mutate()}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#4caf50',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<div style={{ fontSize: '16px', color: '#666' }}>Загрузка данных...</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AircraftTable aircraft={aircraft} />
|
||||
{pagination.totalPages > 1 && (
|
||||
<div style={{ marginTop: '24px' }}>
|
||||
<Pagination
|
||||
total={pagination.total}
|
||||
limit={pagination.limit}
|
||||
currentPage={pagination.page}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<SearchModal
|
||||
isOpen={isSearchModalOpen}
|
||||
onClose={() => setIsSearchModalOpen(false)}
|
||||
aircraft={aircraft}
|
||||
searchType="aircraft"
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
|
||||
<AircraftAddModal
|
||||
isOpen={isAddModalOpen}
|
||||
onClose={() => setIsAddModalOpen(false)}
|
||||
onSave={async (data, files) => {
|
||||
console.log('New aircraft:', data, 'Files:', files);
|
||||
alert('ВС ' + data.registrationNumber + ' добавлено (демо). Файлов: ' + files.length);
|
||||
mutate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination page={page} pages={pages} onPageChange={setPage} />
|
||||
</>
|
||||
) : <EmptyState message={`ВС не найдены.${search ? ' Измените поиск.' : ''}`} />}
|
||||
<AircraftAddModal isOpen={isAddOpen} onClose={() => setIsAddOpen(false)} onAdd={handleAdd} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,72 +1,37 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import Logo from "@/components/Logo";
|
||||
|
||||
const MOCK_DIRECTIVES = [
|
||||
{ id: "ad-001", number: "FAA AD 2026-02-15", title: "Boeing 737-800 — Inspection of wing spar", aircraft: "Boeing 737-800", status: "open", deadline: "2026-06-01", priority: "high" },
|
||||
{ id: "ad-002", number: "EASA AD 2025-0234", title: "CFM56-7B — Fan blade inspection", aircraft: "Boeing 737-800", status: "complied", deadline: "2025-12-15", priority: "medium" },
|
||||
{ id: "ad-003", number: "FATA AD 2026-001", title: "SaM146 — Oil system check", aircraft: "Sukhoi Superjet 100", status: "open", deadline: "2026-04-20", priority: "high" },
|
||||
{ id: "ad-004", number: "EASA AD 2025-0198", title: "Landing gear retract actuator", aircraft: "Sukhoi Superjet 100", status: "in_progress", deadline: "2026-03-01", priority: "critical" },
|
||||
{ id: "ad-005", number: "Rosaviation AD 2025-45", title: "An-148 — Fuel system modification", aircraft: "An-148-100V", status: "complied", deadline: "2025-10-30", priority: "medium" },
|
||||
{ id: "ad-006", number: "FATA AD 2026-003", title: "TV3-117VM — Turbine disc inspection", aircraft: "Mi-8MTV-1", status: "open", deadline: "2026-05-15", priority: "critical" },
|
||||
];
|
||||
|
||||
const statusColors: Record<string, string> = { open: "#ff9800", in_progress: "#2196f3", complied: "#4caf50" };
|
||||
const statusLabels: Record<string, string> = { open: "Открыта", in_progress: "В работе", complied: "Выполнена" };
|
||||
const prioColors: Record<string, string> = { critical: "#d32f2f", high: "#e65100", medium: "#f9a825" };
|
||||
/**
|
||||
* Лётная годность — перенаправление на расширенный модуль
|
||||
*/
|
||||
'use client';
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PageLayout } from '@/components/ui';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function AirworthinessPage() {
|
||||
const [filter, setFilter] = useState("all");
|
||||
const filtered = filter === "all" ? MOCK_DIRECTIVES : MOCK_DIRECTIVES.filter(d => d.status === filter);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", minHeight: "100vh" }}>
|
||||
<Sidebar />
|
||||
<div style={{ marginLeft: "280px", flex: 1, padding: "32px" }}>
|
||||
<Logo size="large" />
|
||||
<p style={{ color: "#666", margin: "16px 0 24px" }}>Директивы лётной годности и сертификация</p>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "24px" }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: "24px", fontWeight: "bold", marginBottom: "8px" }}>Лётная годность</h2>
|
||||
<p style={{ fontSize: "14px", color: "#666" }}>Директивы лётной годности (AD/АД) — ИКАО, EASA, Росавиация</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
{[["all","Все"],["open","Открытые"],["in_progress","В работе"],["complied","Выполненные"]].map(([v,l]) => (
|
||||
<button key={v} onClick={() => setFilter(v)} style={{ padding: "8px 16px", border: filter===v ? "2px solid #1e3a5f" : "1px solid #ddd", borderRadius: "6px", background: filter===v ? "#e3f2fd" : "white", cursor: "pointer", fontSize: "13px", fontWeight: filter===v ? 700 : 400 }}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "16px", marginBottom: "24px" }}>
|
||||
<div style={{ background: "#fff3e0", padding: "16px", borderRadius: "8px", textAlign: "center" }}>
|
||||
<div style={{ fontSize: "28px", fontWeight: "bold", color: "#e65100" }}>{MOCK_DIRECTIVES.filter(d=>d.status==="open").length}</div>
|
||||
<div style={{ fontSize: "13px", color: "#666" }}>Открытых AD</div>
|
||||
</div>
|
||||
<div style={{ background: "#e3f2fd", padding: "16px", borderRadius: "8px", textAlign: "center" }}>
|
||||
<div style={{ fontSize: "28px", fontWeight: "bold", color: "#1565c0" }}>{MOCK_DIRECTIVES.filter(d=>d.status==="in_progress").length}</div>
|
||||
<div style={{ fontSize: "13px", color: "#666" }}>В работе</div>
|
||||
</div>
|
||||
<div style={{ background: "#e8f5e9", padding: "16px", borderRadius: "8px", textAlign: "center" }}>
|
||||
<div style={{ fontSize: "28px", fontWeight: "bold", color: "#2e7d32" }}>{MOCK_DIRECTIVES.filter(d=>d.status==="complied").length}</div>
|
||||
<div style={{ fontSize: "13px", color: "#666" }}>Выполненных</div>
|
||||
</div>
|
||||
</div>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", background: "white" }}>
|
||||
<thead><tr style={{ background: "#1e3a5f", color: "white" }}>
|
||||
{["НОМЕР AD","ОПИСАНИЕ","ТИП ВС","ПРИОРИТЕТ","СТАТУС","СРОК"].map(h => <th key={h} style={{ padding: "12px", textAlign: "left", fontSize: "12px" }}>{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>{filtered.map(d => (
|
||||
<tr key={d.id} style={{ borderBottom: "1px solid #e0e0e0" }}>
|
||||
<td style={{ padding: "12px", fontWeight: 600 }}>{d.number}</td>
|
||||
<td style={{ padding: "12px", fontSize: "13px" }}>{d.title}</td>
|
||||
<td style={{ padding: "12px" }}>{d.aircraft}</td>
|
||||
<td style={{ padding: "12px" }}><span style={{ padding: "3px 8px", borderRadius: "4px", fontSize: "11px", color: "white", background: prioColors[d.priority] || "#999" }}>{d.priority}</span></td>
|
||||
<td style={{ padding: "12px" }}><span style={{ padding: "3px 8px", borderRadius: "4px", fontSize: "11px", color: "white", background: statusColors[d.status] || "#999" }}>{statusLabels[d.status]}</span></td>
|
||||
<td style={{ padding: "12px", fontSize: "13px" }}>{d.deadline}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
<PageLayout title="📜 Лётная годность" subtitle="Модули контроля ЛГ">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Link href="/airworthiness-core" className="card p-6 hover:shadow-lg transition-shadow">
|
||||
<div className="text-2xl mb-2">🔧</div>
|
||||
<div className="font-bold text-sm">Контроль ЛГ (полный модуль)</div>
|
||||
<div className="text-xs text-gray-500 mt-1">AD/ДЛГ · Бюллетени · Ресурсы · Программы ТО · Компоненты</div>
|
||||
</Link>
|
||||
<Link href="/maintenance" className="card p-6 hover:shadow-lg transition-shadow">
|
||||
<div className="text-2xl mb-2">📐</div>
|
||||
<div className="font-bold text-sm">Наряды на ТО</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Work Orders · CRS · AOG priority</div>
|
||||
</Link>
|
||||
<Link href="/defects" className="card p-6 hover:shadow-lg transition-shadow">
|
||||
<div className="text-2xl mb-2">🛠️</div>
|
||||
<div className="font-bold text-sm">Дефекты</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Регистрация · Устранение · MEL deferral</div>
|
||||
</Link>
|
||||
<Link href="/personnel-plg" className="card p-6 hover:shadow-lg transition-shadow">
|
||||
<div className="text-2xl mb-2">🎓</div>
|
||||
<div className="font-bold text-sm">Персонал ПЛГ</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Аттестация · 11 программ · Compliance</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
// SECURITY FIX: Заменить eval() на JSON.parse() или другой безопасный метод
|
||||
// eval() создает риск code injection
|
||||
'use client';
|
||||
|
||||
// Вместо: eval(someCode)
|
||||
// Использовать: JSON.parse(jsonString) или Function constructor с валидацией
|
||||
|
||||
// TODO: Найти строки с eval() и заменить на безопасные альтернативы
|
||||
export default function ApiDocsPage() {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-4">API Documentation</h1>
|
||||
<p className="text-gray-600 mb-4">Swagger UI available at: <a href="http://localhost:8000/docs" className="text-blue-600 underline">http://localhost:8000/docs</a></p>
|
||||
<iframe src="http://localhost:8000/docs" className="w-full h-[80vh] border rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,212 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import ApplicationCardModal from '@/components/ApplicationCardModal';
|
||||
import ApplicationCreateModal from '@/components/ApplicationCreateModal';
|
||||
import Logo from '@/components/Logo';
|
||||
|
||||
interface Application {
|
||||
id: string;
|
||||
number: string;
|
||||
type: string;
|
||||
status: string;
|
||||
aircraft: string;
|
||||
date: string;
|
||||
organization: string;
|
||||
description?: string;
|
||||
comments?: string;
|
||||
}
|
||||
import { useState, useEffect } from 'react';
|
||||
import { PageLayout, DataTable, StatusBadge, EmptyState } from '@/components/ui';
|
||||
|
||||
export default function ApplicationsPage() {
|
||||
const [applications, setApplications] = useState<Application[]>([
|
||||
{
|
||||
id: '1',
|
||||
number: 'APP-2025-001',
|
||||
type: 'Регистрация ВС',
|
||||
status: 'В обработке',
|
||||
aircraft: 'RA-12345',
|
||||
date: '2025-01-15',
|
||||
organization: 'Аэрофлот',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
number: 'APP-2025-002',
|
||||
type: 'Сертификация',
|
||||
status: 'На рассмотрении',
|
||||
aircraft: 'RA-67890',
|
||||
date: '2025-01-14',
|
||||
organization: 'S7 Airlines',
|
||||
description: 'Заявка на сертификацию воздушного судна RA-67890',
|
||||
},
|
||||
]);
|
||||
|
||||
const [selectedApplication, setSelectedApplication] = useState<Application | null>(null);
|
||||
const [isCardModalOpen, setIsCardModalOpen] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
const handleOpen = (application: Application) => {
|
||||
setSelectedApplication(application);
|
||||
setIsCardModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveApplication = (updatedApplication: Application) => {
|
||||
setApplications(prev => prev.map(a => a.id === updatedApplication.id ? updatedApplication : a));
|
||||
setSelectedApplication(updatedApplication);
|
||||
};
|
||||
|
||||
const handleCreateApplication = (applicationData: any) => {
|
||||
const newApplication: Application = {
|
||||
id: `app-${Date.now()}`,
|
||||
...applicationData,
|
||||
};
|
||||
setApplications(prev => [...prev, newApplication]);
|
||||
alert('Заявка успешно создана');
|
||||
};
|
||||
|
||||
const [apps, setApps] = useState([] as any[]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
setLoading(true); fetch('/api/v1/cert-applications').then(r => r.json()).then(d => { setApps(d.items || []); setLoading(false); }); }, []);
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<Sidebar />
|
||||
<div style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<Logo size="large" />
|
||||
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||
Система контроля лётной годности воздушных судов · Безопасность и качество
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
Заявки
|
||||
</h2>
|
||||
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||
Управление заявками на регистрацию и сертификацию воздушных судов
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Создать заявку
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px', display: 'flex', gap: '8px' }}>
|
||||
<button style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
Все
|
||||
</button>
|
||||
<button style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#1e3a5f',
|
||||
border: '1px solid #1e3a5f',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
В обработке
|
||||
</button>
|
||||
<button style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#1e3a5f',
|
||||
border: '1px solid #1e3a5f',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
На рассмотрении
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{applications.map(app => (
|
||||
<div key={app.id} style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '24px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
{app.number}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
Тип: {app.type}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
ВС: {app.aircraft} | Организация: {app.organization}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
Дата: {app.date}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<span style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
backgroundColor: app.status === 'В обработке' ? '#ff9800' : '#2196f3',
|
||||
color: 'white',
|
||||
}}>
|
||||
{app.status}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleOpen(app)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Открыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ApplicationCardModal
|
||||
isOpen={isCardModalOpen}
|
||||
onClose={() => {
|
||||
setIsCardModalOpen(false);
|
||||
setSelectedApplication(null);
|
||||
}}
|
||||
application={selectedApplication}
|
||||
onSave={handleSaveApplication}
|
||||
/>
|
||||
|
||||
<ApplicationCreateModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onCreate={handleCreateApplication}
|
||||
/>
|
||||
</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">
|
||||
{apps.length > 0 ? (
|
||||
<DataTable columns={[
|
||||
{ key: 'number', label: '№ заявки' },
|
||||
{ key: 'type', label: 'Тип' },
|
||||
{ key: 'organization_name', label: 'Организация' },
|
||||
{ key: 'status', label: 'Статус', render: (v: string) => (
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,453 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import Logo from '@/components/Logo';
|
||||
import Pagination from '@/components/Pagination';
|
||||
import { PageLayout, DataTable, EmptyState } from '@/components/ui';
|
||||
|
||||
interface AuditLog {
|
||||
id: string;
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
action: string;
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
oldValues?: Record<string, any>;
|
||||
newValues?: Record<string, any>;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
const ENTITY_TYPES = ['', 'aircraft', 'directive', 'bulletin', 'work_order', 'defect', 'component', 'specialist', 'maint_program', 'life_limit'];
|
||||
|
||||
export default function AuditHistoryPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [logs, setLogs] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [filters, setFilters] = useState({
|
||||
action: '',
|
||||
resourceType: '',
|
||||
search: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize] = useState(50);
|
||||
const [entityFilter, setEntityFilter] = useState('');
|
||||
const [actionFilter, setActionFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentPage, filters]);
|
||||
setLoading(true);
|
||||
fetch('/api/v1/audit?limit=500').then(r => r.json())
|
||||
.then(d => { setLogs(d.items || d || []); setLoading(false); })
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const loadLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({
|
||||
limit: pageSize.toString(),
|
||||
offset: ((currentPage - 1) * pageSize).toString(),
|
||||
});
|
||||
const filtered = logs.filter(l => {
|
||||
if (entityFilter && l.entity_type !== entityFilter) return false;
|
||||
if (actionFilter && !l.action?.includes(actionFilter)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (filters.action) {
|
||||
params.append('action', filters.action);
|
||||
}
|
||||
if (filters.resourceType) {
|
||||
params.append('resourceType', filters.resourceType);
|
||||
}
|
||||
if (filters.search) {
|
||||
params.append('search', filters.search);
|
||||
}
|
||||
if (filters.startDate) {
|
||||
params.append('startDate', filters.startDate);
|
||||
}
|
||||
if (filters.endDate) {
|
||||
params.append('endDate', filters.endDate);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/audit?${params.toString()}`);
|
||||
const data = await response.json();
|
||||
|
||||
setLogs(data.logs || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки истории аудита:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (format: 'json' | 'csv') => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
format,
|
||||
limit: '10000',
|
||||
});
|
||||
|
||||
if (filters.action) {
|
||||
params.append('action', filters.action);
|
||||
}
|
||||
if (filters.resourceType) {
|
||||
params.append('resourceType', filters.resourceType);
|
||||
}
|
||||
if (filters.search) {
|
||||
params.append('search', filters.search);
|
||||
}
|
||||
if (filters.startDate) {
|
||||
params.append('startDate', filters.startDate);
|
||||
}
|
||||
if (filters.endDate) {
|
||||
params.append('endDate', filters.endDate);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/audit?${params.toString()}`);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `audit_logs_${new Date().toISOString().split('T')[0]}.${format}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error('Ошибка экспорта:', error);
|
||||
alert('Ошибка экспорта логов');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRollback = async (logId: string) => {
|
||||
if (!confirm('Вы уверены, что хотите откатить это изменение?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/audit/rollback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ auditLogId: logId }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert('Изменения успешно откачены');
|
||||
loadLogs();
|
||||
} else {
|
||||
alert(data.error || 'Ошибка при откате изменений');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка отката:', error);
|
||||
alert('Ошибка при откате изменений');
|
||||
}
|
||||
};
|
||||
|
||||
const getActionColor = (action: string) => {
|
||||
switch (action) {
|
||||
case 'CREATE': return '#4caf50';
|
||||
case 'UPDATE': return '#2196f3';
|
||||
case 'DELETE': return '#f44336';
|
||||
case 'VIEW': return '#9e9e9e';
|
||||
case 'EXPORT': return '#ff9800';
|
||||
case 'ROLLBACK': return '#9c27b0';
|
||||
default: return '#666';
|
||||
}
|
||||
};
|
||||
|
||||
const getResourceTypeLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
aircraft: 'Воздушное судно',
|
||||
risk: 'Риск',
|
||||
organization: 'Организация',
|
||||
user: 'Пользователь',
|
||||
audit: 'Аудит',
|
||||
checklist: 'Чек-лист',
|
||||
application: 'Заявка',
|
||||
document: 'Документ',
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
const actions = [...new Set(logs.map(l => l.action).filter(Boolean))];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<Sidebar />
|
||||
<div id="main-content" role="main" style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<Logo size="large" />
|
||||
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||
Система контроля лётной годности воздушных судов · Безопасность и качество
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
История изменений
|
||||
</h2>
|
||||
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||
Аудит всех действий пользователей в системе
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => handleExport('csv')}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#4caf50',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Экспорт CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport('json')}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#2196f3',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Экспорт JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Фильтры */}
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '24px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
marginBottom: '24px',
|
||||
}}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Действие
|
||||
</label>
|
||||
<select
|
||||
value={filters.action}
|
||||
onChange={(e) => setFilters({ ...filters, action: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="">Все</option>
|
||||
<option value="CREATE">Создание</option>
|
||||
<option value="UPDATE">Изменение</option>
|
||||
<option value="DELETE">Удаление</option>
|
||||
<option value="VIEW">Просмотр</option>
|
||||
<option value="EXPORT">Экспорт</option>
|
||||
<option value="ROLLBACK">Откат</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Тип ресурса
|
||||
</label>
|
||||
<select
|
||||
value={filters.resourceType}
|
||||
onChange={(e) => setFilters({ ...filters, resourceType: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="">Все</option>
|
||||
<option value="aircraft">Воздушное судно</option>
|
||||
<option value="risk">Риск</option>
|
||||
<option value="organization">Организация</option>
|
||||
<option value="user">Пользователь</option>
|
||||
<option value="audit">Аудит</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Поиск
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Пользователь, действие, IP..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Дата с
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.startDate}
|
||||
onChange={(e) => setFilters({ ...filters, startDate: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Дата по
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.endDate}
|
||||
onChange={(e) => setFilters({ ...filters, endDate: e.target.value })}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Таблица логов */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<div>Загрузка истории изменений...</div>
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
padding: '40px',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
Записи не найдены
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
Попробуйте изменить фильтры
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden',
|
||||
marginBottom: '24px',
|
||||
}}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: '#f5f5f5' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '14px', fontWeight: 'bold' }}>Дата</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '14px', fontWeight: 'bold' }}>Пользователь</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '14px', fontWeight: 'bold' }}>Действие</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '14px', fontWeight: 'bold' }}>Ресурс</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '14px', fontWeight: 'bold' }}>IP адрес</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '14px', fontWeight: 'bold' }}>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log) => (
|
||||
<tr key={log.id} style={{ borderTop: '1px solid #e0e0e0' }}>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{new Date(log.createdAt).toLocaleString('ru-RU')}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{log.userName || 'Система'}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
backgroundColor: getActionColor(log.action),
|
||||
color: 'white',
|
||||
}}>
|
||||
{log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{getResourceTypeLabel(log.resourceType)} ({log.resourceId.slice(0, 8)}...)
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
{log.ipAddress || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Показать детали изменения
|
||||
const details = (log.oldValues || log.newValues)
|
||||
? `Старые значения: ${JSON.stringify(log.oldValues || {}, null, 2)}\n\nНовые значения: ${JSON.stringify(log.newValues || {}, null, 2)}`
|
||||
: 'Нет деталей';
|
||||
alert(details);
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#2196f3',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
Детали
|
||||
</button>
|
||||
{log.action === 'UPDATE' && log.oldValues && (
|
||||
<button
|
||||
onClick={() => handleRollback(log.id)}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#9c27b0',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
Откатить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
total={total}
|
||||
limit={pageSize}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<PageLayout title="📝 История изменений" subtitle="Audit trail — все действия в системе">
|
||||
{loading && <div className="text-center py-4 text-gray-400">⏳ Загрузка...</div>}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<select value={entityFilter} onChange={e => setEntityFilter(e.target.value)}
|
||||
className="text-xs px-3 py-1.5 rounded border bg-white">
|
||||
<option value="">Все объекты</option>
|
||||
{ENTITY_TYPES.filter(Boolean).map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
<select value={actionFilter} onChange={e => setActionFilter(e.target.value)}
|
||||
className="text-xs px-3 py-1.5 rounded border bg-white">
|
||||
<option value="">Все действия</option>
|
||||
{actions.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
<span className="text-xs text-gray-400 self-center">({filtered.length} из {logs.length})</span>
|
||||
</div>
|
||||
</div>
|
||||
{filtered.length > 0 ? (
|
||||
<DataTable columns={[
|
||||
{ key: 'timestamp', label: 'Время', render: (v: string) => v ? new Date(v).toLocaleString('ru-RU') : '—' },
|
||||
{ key: 'user_name', label: 'Пользователь' },
|
||||
{ key: 'action', label: 'Действие' },
|
||||
{ key: 'entity_type', label: 'Объект' },
|
||||
{ key: 'description', label: 'Описание' },
|
||||
]} data={filtered} />
|
||||
) : <EmptyState message="Нет записей аудита" />}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,213 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import AuditCardModal from '@/components/AuditCardModal';
|
||||
import { useState } from 'react';
|
||||
import AuditCreateModal from '@/components/AuditCreateModal';
|
||||
import Logo from '@/components/Logo';
|
||||
import { PageLayout, FilterBar, Pagination, StatusBadge, EmptyState } from '@/components/ui';
|
||||
import { useAuditsData } from '@/hooks/useSWRData';
|
||||
import { auditsApi } from '@/lib/api/api-client';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
|
||||
interface Audit {
|
||||
id: string;
|
||||
number: string;
|
||||
type: string;
|
||||
status: string;
|
||||
organization: string;
|
||||
date: string;
|
||||
inspector: string;
|
||||
description?: string;
|
||||
findings?: string;
|
||||
recommendations?: string;
|
||||
deadline?: string;
|
||||
}
|
||||
const ST: Record<string, string> = { draft: 'Запланирован', in_progress: 'В процессе', completed: 'Завершён' };
|
||||
const SC: Record<string, string> = { draft: 'bg-gray-400', in_progress: 'bg-orange-500', completed: 'bg-green-500' };
|
||||
const BC: Record<string, string> = { draft: 'border-l-gray-300', in_progress: 'border-l-orange-500', completed: 'border-l-green-500' };
|
||||
|
||||
export default function AuditsPage() {
|
||||
const [audits, setAudits] = useState<Audit[]>([
|
||||
{
|
||||
id: '1',
|
||||
number: 'AUD-2025-001',
|
||||
type: 'Плановый аудит',
|
||||
status: 'Запланирован',
|
||||
organization: 'Аэрофлот',
|
||||
date: '2025-02-01',
|
||||
inspector: 'Иванов И.И.',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
number: 'AUD-2025-002',
|
||||
type: 'Внеплановый аудит',
|
||||
status: 'В процессе',
|
||||
organization: 'S7 Airlines',
|
||||
date: '2025-01-21',
|
||||
inspector: 'Петров П.П.',
|
||||
description: 'Проведение внепланового аудита организации S7 Airlines для проверки соответствия требованиям безопасности.',
|
||||
findings: 'Выявлены незначительные нарушения в ведении документации.',
|
||||
recommendations: 'Рекомендуется обновить систему документооборота и провести обучение персонала.',
|
||||
},
|
||||
]);
|
||||
|
||||
const [selectedAudit, setSelectedAudit] = useState<Audit | null>(null);
|
||||
const [isCardModalOpen, setIsCardModalOpen] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
const handleOpen = (audit: Audit) => {
|
||||
setSelectedAudit(audit);
|
||||
setIsCardModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveAudit = (updatedAudit: Audit) => {
|
||||
setAudits(prev => prev.map(a => a.id === updatedAudit.id ? updatedAudit : a));
|
||||
setSelectedAudit(updatedAudit);
|
||||
};
|
||||
|
||||
const handleCreateAudit = (auditData: any) => {
|
||||
const newAudit: Audit = {
|
||||
id: `audit-${Date.now()}`,
|
||||
...auditData,
|
||||
};
|
||||
setAudits(prev => [...prev, newAudit]);
|
||||
alert('Аудит успешно создан');
|
||||
};
|
||||
const { isAuthority } = useAuth();
|
||||
const [sf, setSf] = useState<string|undefined>();
|
||||
const { data, isLoading, mutate } = useAuditsData({ status: sf });
|
||||
const audits = data?.items || [];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<Sidebar />
|
||||
<div style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<Logo size="large" />
|
||||
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||
Система контроля лётной годности воздушных судов · Безопасность и качество
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
Аудиты
|
||||
</h2>
|
||||
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||
Управление аудитами и проверками воздушных судов
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Создать аудит
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px', display: 'flex', gap: '8px' }}>
|
||||
<button style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
Все
|
||||
</button>
|
||||
<button style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#1e3a5f',
|
||||
border: '1px solid #1e3a5f',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
В процессе
|
||||
</button>
|
||||
<button style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#1e3a5f',
|
||||
border: '1px solid #1e3a5f',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
Завершённые
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{audits.map(audit => (
|
||||
<div key={audit.id} style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '24px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
{audit.number}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
Тип: {audit.type} | Организация: {audit.organization}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
Инспектор: {audit.inspector} | Дата: {audit.date}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<span style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
backgroundColor: audit.status === 'В процессе' ? '#ff9800' : '#2196f3',
|
||||
color: 'white',
|
||||
}}>
|
||||
{audit.status}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleOpen(audit)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Открыть
|
||||
</button>
|
||||
</div>
|
||||
<PageLayout title="Аудиты" subtitle={isLoading ? 'Загрузка...' : `Всего: ${data?.total || 0}`}>
|
||||
<FilterBar value={sf} onChange={setSf} className="mb-4"
|
||||
options={[{ value: undefined, label: 'Все' }, ...Object.entries(ST).map(([v, l]) => ({ value: v, label: l }))]} />
|
||||
{isLoading ? <div className="text-center py-10 text-gray-400">Загрузка...</div> : audits.length > 0 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{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>
|
||||
<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>
|
||||
<div className="flex gap-2 items-center">
|
||||
<StatusBadge status={a.status} colorMap={SC} labelMap={ST} />
|
||||
{a.status === 'in_progress' && isAuthority && (
|
||||
<button onClick={async () => { await auditsApi.complete(a.id); mutate(); }} className="btn-sm bg-green-500 text-white">Завершить</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AuditCardModal
|
||||
isOpen={isCardModalOpen}
|
||||
onClose={() => {
|
||||
setIsCardModalOpen(false);
|
||||
setSelectedAudit(null);
|
||||
}}
|
||||
audit={selectedAudit}
|
||||
onSave={handleSaveAudit}
|
||||
/>
|
||||
|
||||
<AuditCreateModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onCreate={handleCreateAudit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : <EmptyState message="Нет аудитов." />}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,268 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import ChecklistCardModal from '@/components/ChecklistCardModal';
|
||||
import { useState } from 'react';
|
||||
import ChecklistCreateModal from '@/components/ChecklistCreateModal';
|
||||
import Logo from '@/components/Logo';
|
||||
|
||||
interface Checklist {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
aircraft: string;
|
||||
date: string;
|
||||
items: number;
|
||||
completed: number;
|
||||
description?: string;
|
||||
checklistItems?: Array<{ id: string; text: string; checked: boolean }>;
|
||||
standards?: { icao?: boolean; easa?: boolean; faa?: boolean; armak?: boolean };
|
||||
inspector?: string;
|
||||
inspectorLicense?: string;
|
||||
operator?: string;
|
||||
checklistNumber?: string;
|
||||
}
|
||||
import { PageLayout, FilterBar, EmptyState } from '@/components/ui';
|
||||
import { useChecklistsData } from '@/hooks/useSWRData';
|
||||
import { checklistsApi } from '@/lib/api/api-client';
|
||||
import { RequireRole } from '@/lib/auth-context';
|
||||
|
||||
export default function ChecklistsPage() {
|
||||
const [checklists, setChecklists] = useState<Checklist[]>([
|
||||
{
|
||||
id: '1',
|
||||
name: 'Предполётный осмотр',
|
||||
type: 'Ежедневный',
|
||||
status: 'В процессе',
|
||||
aircraft: 'RA-12345',
|
||||
date: '2025-01-21',
|
||||
items: 25,
|
||||
completed: 18,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Техническое обслуживание',
|
||||
type: 'Периодический',
|
||||
status: 'Завершён',
|
||||
aircraft: 'RA-67890',
|
||||
date: '2025-01-20',
|
||||
items: 45,
|
||||
completed: 45,
|
||||
description: 'Периодическое техническое обслуживание воздушного судна RA-67890',
|
||||
checklistItems: [
|
||||
{ id: '1', text: 'Проверка двигателя', checked: true },
|
||||
{ id: '2', text: 'Проверка шасси', checked: true },
|
||||
{ id: '3', text: 'Проверка системы навигации', checked: true },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const [domain, setDomain] = useState<string | undefined>();
|
||||
const { data, isLoading, mutate } = useChecklistsData({ domain });
|
||||
const templates = data?.items || [];
|
||||
const [exp, setExp] = useState<string | null>(null);
|
||||
|
||||
const [selectedChecklist, setSelectedChecklist] = useState<Checklist | null>(null);
|
||||
const [isCardModalOpen, setIsCardModalOpen] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
const handleOpen = (checklist: Checklist) => {
|
||||
setSelectedChecklist(checklist);
|
||||
setIsCardModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveChecklist = (updatedChecklist: Checklist) => {
|
||||
setChecklists(prev => prev.map(c => c.id === updatedChecklist.id ? updatedChecklist : c));
|
||||
setSelectedChecklist(updatedChecklist);
|
||||
};
|
||||
|
||||
const handleCreateChecklist = (checklistData: any) => {
|
||||
const newChecklist: Checklist = {
|
||||
id: `checklist-${Date.now()}`,
|
||||
...checklistData,
|
||||
};
|
||||
setChecklists(prev => [...prev, newChecklist]);
|
||||
alert('Чек-лист успешно создан');
|
||||
};
|
||||
const gen = async (src: string) => { const n = prompt('Название:'); if (!n) return; await checklistsApi.generate(src, n); mutate(); };
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<Sidebar />
|
||||
<div style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<Logo size="large" />
|
||||
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||
Система контроля лётной годности воздушных судов · Безопасность и качество
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
Чек-листы
|
||||
</h2>
|
||||
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||
Управление чек-листами для проверки воздушных судов
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Создать чек-лист
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{checklists.map(checklist => (
|
||||
<div key={checklist.id} style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '24px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '16px' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
{checklist.name}
|
||||
</div>
|
||||
{checklist.checklistNumber && (
|
||||
<div style={{ fontSize: '12px', color: '#999', marginBottom: '4px' }}>
|
||||
№ {checklist.checklistNumber}
|
||||
</div>
|
||||
)}
|
||||
{/* Отображение стандартов соответствия */}
|
||||
{checklist.standards && (
|
||||
<div style={{ display: 'flex', gap: '6px', marginBottom: '8px', flexWrap: 'wrap' }}>
|
||||
{checklist.standards.icao && (
|
||||
<span style={{
|
||||
padding: '3px 8px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
ICAO
|
||||
</span>
|
||||
)}
|
||||
{checklist.standards.easa && (
|
||||
<span style={{
|
||||
padding: '3px 8px',
|
||||
backgroundColor: '#fff3e0',
|
||||
color: '#e65100',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
EASA
|
||||
</span>
|
||||
)}
|
||||
{checklist.standards.faa && (
|
||||
<span style={{
|
||||
padding: '3px 8px',
|
||||
backgroundColor: '#f3e5f5',
|
||||
color: '#7b1fa2',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
FAA
|
||||
</span>
|
||||
)}
|
||||
{checklist.standards.armak && (
|
||||
<span style={{
|
||||
padding: '3px 8px',
|
||||
backgroundColor: '#e8f5e9',
|
||||
color: '#2e7d32',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
АРМАК
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
Тип: {checklist.type} | ВС: {checklist.aircraft}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
Дата: {checklist.date}
|
||||
{checklist.operator && ` | Оператор: ${checklist.operator}`}
|
||||
{checklist.inspector && ` | Инспектор: ${checklist.inspector}`}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<span style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
backgroundColor: checklist.status === 'Завершён' ? '#4caf50' : '#ff9800',
|
||||
color: 'white',
|
||||
}}>
|
||||
{checklist.status}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleOpen(checklist)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Открыть
|
||||
</button>
|
||||
</div>
|
||||
<PageLayout title="Чек-листы" subtitle={isLoading ? 'Загрузка...' : `Шаблонов: ${data?.total || 0}`}
|
||||
actions={<RequireRole roles={['admin', 'authority_inspector']}>
|
||||
<button onClick={() => gen('fap_m_inspection')} className="btn-primary">+ ФАП-М</button>
|
||||
<button onClick={() => gen('custom')} className="btn-primary bg-blue-500 hover:bg-blue-600">+ Пользовательский</button>
|
||||
</RequireRole>}>
|
||||
<FilterBar value={domain} onChange={setDomain} className="mb-4"
|
||||
options={[{ value: undefined, label: 'Все' }, { value: 'ФАП-М', label: 'ФАП-М' }, { value: 'ATA', label: 'ATA' }, { value: 'CSV', label: 'CSV' }]} />
|
||||
{!isLoading && templates.length > 0 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{templates.map((t: any) => (
|
||||
<div key={t.id} className="card">
|
||||
<div className="p-5 flex justify-between items-center cursor-pointer" onClick={() => setExp(exp === t.id ? null : t.id)}>
|
||||
<div><div className="font-bold">{t.name}</div><div className="text-xs text-gray-500">{t.domain || '—'} · v{t.version || 1} · {t.items?.length || 0} пунктов</div></div>
|
||||
<span className="text-lg">{exp === t.id ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '14px' }}>Прогресс выполнения</span>
|
||||
<span style={{ fontSize: '14px', fontWeight: '500' }}>
|
||||
{checklist.completed} / {checklist.items}
|
||||
</span>
|
||||
{exp === t.id && t.items?.length > 0 && (
|
||||
<div className="border-t border-gray-100 px-5 pb-4">
|
||||
{t.items.map((it: any, i: number) => (
|
||||
<div key={it.id || i} className="py-2 border-b border-gray-50 flex gap-3">
|
||||
<span className="text-xs font-bold text-primary-500 min-w-[80px]">{it.code}</span>
|
||||
<span className="text-sm">{it.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '24px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${(checklist.completed / checklist.items) * 100}%`,
|
||||
height: '100%',
|
||||
backgroundColor: checklist.status === 'Завершён' ? '#4caf50' : '#1e3a5f',
|
||||
transition: 'width 0.3s ease',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ChecklistCardModal
|
||||
isOpen={isCardModalOpen}
|
||||
onClose={() => {
|
||||
setIsCardModalOpen(false);
|
||||
setSelectedChecklist(null);
|
||||
}}
|
||||
checklist={selectedChecklist}
|
||||
onSave={handleSaveChecklist}
|
||||
/>
|
||||
|
||||
<ChecklistCreateModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onCreate={handleCreateChecklist}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : !isLoading ? <EmptyState message="Нет шаблонов. Создайте через кнопку выше." /> : null}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,790 +1,225 @@
|
||||
/**
|
||||
* Главная панель — Dashboard
|
||||
* Интеграция всех модулей АСУ ТК: ВС, ДЛГ, ресурсы, персонал, риски, аудиты
|
||||
*/
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { PageLayout, StatusBadge } from '@/components/ui';
|
||||
import Link from 'next/link';
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import FileUploadModal from '@/components/FileUploadModal';
|
||||
import AIAgentModal from '@/components/AIAgentModal';
|
||||
import SearchModal from '@/components/SearchModal';
|
||||
import ExportModal from '@/components/ExportModal';
|
||||
import Logo from '@/components/Logo';
|
||||
import NotificationBell from '@/components/NotificationBell';
|
||||
import SettingsModal from '@/components/SettingsModal';
|
||||
import SemanticSearch from '@/components/SemanticSearch';
|
||||
import AutonomousAgentInterface from '@/components/AutonomousAgentInterface';
|
||||
import KnowledgeGraphVisualization from '@/components/KnowledgeGraphVisualization';
|
||||
import { Aircraft } from '@/lib/api';
|
||||
import { useGlobalShortcuts } from '@/hooks/useKeyboardShortcuts';
|
||||
import { useAircraftData, useStatsData, useRisksData, useAuditsData } from '@/hooks/useSWRData';
|
||||
import { logInfo } from '@/lib/logger-client';
|
||||
interface DashboardData {
|
||||
overview: any; directives: any; lifeLimits: any; personnel: any; risks: any;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, sub, color, href }: { label: string; value: number | string; sub?: string; color: string; href?: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
blue: 'bg-blue-50 border-blue-200 text-blue-700',
|
||||
green: 'bg-green-50 border-green-200 text-green-700',
|
||||
red: 'bg-red-50 border-red-200 text-red-700',
|
||||
yellow: 'bg-yellow-50 border-yellow-200 text-yellow-700',
|
||||
gray: 'bg-gray-50 border-gray-200 text-gray-700',
|
||||
purple: 'bg-purple-50 border-purple-200 text-purple-700',
|
||||
};
|
||||
const card = (
|
||||
<div className={`rounded-lg border p-4 ${colors[color]} ${href ? 'cursor-pointer hover:shadow-md transition-shadow' : ''}`}>
|
||||
<div className="text-3xl font-bold">{value}</div>
|
||||
<div className="text-sm font-medium mt-1">{label}</div>
|
||||
{sub && <div className="text-[10px] opacity-60 mt-0.5">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
return href ? <Link href={href}>{card}</Link> : card;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
|
||||
const [isAIAgentModalOpen, setIsAIAgentModalOpen] = useState(false);
|
||||
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
|
||||
|
||||
// ARC-004: только SWR, без прямого fetch (избегаем двойной нагрузки на API)
|
||||
const { data: aircraftData, isLoading: aircraftLoading, error: aircraftError, mutate: mutateAircraft } = useAircraftData();
|
||||
const { data: statsData, error: statsError } = useStatsData();
|
||||
const { data: risksData } = useRisksData();
|
||||
const { data: auditsData } = useAuditsData();
|
||||
|
||||
const aircraft = useMemo(() => {
|
||||
if (Array.isArray(aircraftData)) return aircraftData;
|
||||
if (aircraftData?.data) return aircraftData.data;
|
||||
return [];
|
||||
}, [aircraftData]);
|
||||
|
||||
const directRisks = Array.isArray(risksData) ? risksData : (Array.isArray(risksData?.data) ? risksData.data : []);
|
||||
const directAudits = Array.isArray(auditsData) ? auditsData : (Array.isArray(auditsData?.data) ? auditsData.data : []);
|
||||
|
||||
const [loadingTimeout, setLoadingTimeout] = useState(false);
|
||||
useEffect(() => {
|
||||
if (aircraftLoading) {
|
||||
const timeout = setTimeout(() => setLoadingTimeout(true), 5000);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
setLoadingTimeout(false);
|
||||
return undefined;
|
||||
}, [aircraftLoading]);
|
||||
|
||||
const hasAnyData = aircraft.length > 0;
|
||||
const isLoading = !hasAnyData && aircraftLoading && !aircraftError && !loadingTimeout;
|
||||
|
||||
const stats = statsData || {
|
||||
aircraft: { total: 0, active: 0, maintenance: 0, storage: 0 },
|
||||
risks: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
||||
audits: { current: 0, upcoming: 0, completed: 0 },
|
||||
};
|
||||
|
||||
// Глобальные горячие клавиши
|
||||
useGlobalShortcuts({
|
||||
onSearch: () => setIsSearchModalOpen(true),
|
||||
onCreateNew: () => router.push('/aircraft'),
|
||||
onClose: () => {
|
||||
setIsUploadModalOpen(false);
|
||||
setIsAIAgentModalOpen(false);
|
||||
setIsSearchModalOpen(false);
|
||||
setIsExportModalOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
// Вычисление статистики из данных
|
||||
const [computedStats, setComputedStats] = useState({
|
||||
total: 0,
|
||||
active: 0,
|
||||
maintenance: 0,
|
||||
types: new Map<string, number>(),
|
||||
operators: new Map<string, number>(),
|
||||
});
|
||||
|
||||
const [risksStats, setRisksStats] = useState({
|
||||
total: 0,
|
||||
critical: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
});
|
||||
|
||||
const [auditsStats, setAuditsStats] = useState({
|
||||
current: 0,
|
||||
upcoming: 0,
|
||||
completed: 0,
|
||||
});
|
||||
|
||||
const [operatorRatings, setOperatorRatings] = useState<Array<{
|
||||
operator: string;
|
||||
totalAircraft: number;
|
||||
activeAircraft: number;
|
||||
maintenanceAircraft: number;
|
||||
rating: number;
|
||||
category: 'best' | 'average' | 'worst';
|
||||
}>>([]);
|
||||
const [data, setData] = useState<Partial<DashboardData>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (aircraft.length > 0) {
|
||||
const newStats = {
|
||||
total: aircraft.length,
|
||||
active: 0,
|
||||
maintenance: 0,
|
||||
types: new Map<string, number>(),
|
||||
operators: new Map<string, number>(),
|
||||
};
|
||||
Promise.all([
|
||||
fetch('/api/v1/stats').then(r => r.json()).catch(() => null),
|
||||
fetch('/api/v1/airworthiness-core/directives?status=open').then(r => r.json()).catch(() => ({ total: 0, items: [] })),
|
||||
fetch('/api/v1/airworthiness-core/life-limits').then(r => r.json()).catch(() => ({ total: 0, items: [] })),
|
||||
fetch('/api/v1/personnel-plg/compliance-report').then(r => r.json()).catch(() => null),
|
||||
fetch('/api/v1/risk-alerts').then(r => r.json()).catch(() => ({ total: 0 })),
|
||||
fetch('/api/v1/work-orders/stats/summary').then(r => r.json()).catch(() => ({ total: 0, in_progress: 0, aog: 0 })),
|
||||
fetch('/api/v1/defects/?status=open').then(r => r.json()).catch(() => ({ total: 0 })),
|
||||
fetch('/api/v1/fgis-revs/status').then(r => r.json()).catch(() => ({ connection_status: 'unknown' })),
|
||||
]).then(([overview, directives, lifeLimits, personnel, risks, woStats, openDefects, fgisStatus]) => {
|
||||
setData({ overview, directives, lifeLimits, personnel, risks, woStats, openDefects, fgisStatus });
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
aircraft.forEach((a: Aircraft) => {
|
||||
const s = (a.status || '').toLowerCase();
|
||||
if (s.includes('активен') || s === 'active') {
|
||||
newStats.active++;
|
||||
}
|
||||
if (s.includes('обслуживан') || s.includes('ремонт') || s === 'maintenance') {
|
||||
newStats.maintenance++;
|
||||
}
|
||||
|
||||
if (a.aircraftType) {
|
||||
newStats.types.set(a.aircraftType, (newStats.types.get(a.aircraftType) || 0) + 1);
|
||||
}
|
||||
|
||||
if (a.operator) {
|
||||
newStats.operators.set(a.operator, (newStats.operators.get(a.operator) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
setComputedStats(newStats);
|
||||
} else if (stats?.aircraft?.total) {
|
||||
setComputedStats({
|
||||
total: stats.aircraft.total,
|
||||
active: stats.aircraft.active ?? 0,
|
||||
maintenance: stats.aircraft.maintenance ?? 0,
|
||||
types: new Map(),
|
||||
operators: new Map(),
|
||||
});
|
||||
}
|
||||
}, [aircraft, stats?.aircraft?.total, stats?.aircraft?.active, stats?.aircraft?.maintenance]);
|
||||
|
||||
// Обновляем статистику рисков: приоритет прямым данным, fallback на stats
|
||||
useEffect(() => {
|
||||
if (directRisks.length > 0) {
|
||||
const calculatedStats = {
|
||||
total: directRisks.length,
|
||||
critical: directRisks.filter((r: any) => r.level === 'Критический' || r.level === 'critical').length,
|
||||
high: directRisks.filter((r: any) => r.level === 'Высокий' || r.level === 'high').length,
|
||||
medium: directRisks.filter((r: any) => r.level === 'Средний' || r.level === 'medium').length,
|
||||
low: directRisks.filter((r: any) => r.level === 'Низкий' || r.level === 'low').length,
|
||||
};
|
||||
setRisksStats(calculatedStats);
|
||||
} else if (stats?.risks && (stats.risks.total > 0 || stats.risks.critical > 0 || stats.risks.high > 0)) {
|
||||
setRisksStats({
|
||||
total: stats.risks.total || 0,
|
||||
critical: stats.risks.critical || 0,
|
||||
high: stats.risks.high || 0,
|
||||
medium: (stats.risks as any).medium ?? 0,
|
||||
low: (stats.risks as any).low ?? 0,
|
||||
});
|
||||
}
|
||||
}, [stats?.risks, directRisks]);
|
||||
|
||||
// Обновляем статистику аудитов: приоритет прямым данным, fallback на stats
|
||||
useEffect(() => {
|
||||
if (directAudits.length > 0) {
|
||||
const now = new Date();
|
||||
const calculatedStats = {
|
||||
current: directAudits.filter((a: any) => a.status === 'В процессе' || a.status === 'in_progress').length,
|
||||
upcoming: directAudits.filter((a: any) => {
|
||||
const s = a.status || '';
|
||||
if ((s !== 'Запланирован' && s !== 'planned') || !(a.date || a.startDate)) return false;
|
||||
const d = new Date(a.date || a.startDate);
|
||||
return d >= now;
|
||||
}).length,
|
||||
completed: directAudits.filter((a: any) => a.status === 'Завершён' || a.status === 'completed').length,
|
||||
};
|
||||
setAuditsStats(calculatedStats);
|
||||
} else if (stats?.audits && (stats.audits.current > 0 || stats.audits.upcoming > 0 || stats.audits.completed > 0)) {
|
||||
setAuditsStats({
|
||||
current: stats.audits.current || 0,
|
||||
upcoming: stats.audits.upcoming || 0,
|
||||
completed: stats.audits.completed || 0,
|
||||
});
|
||||
}
|
||||
}, [stats?.audits, directAudits]);
|
||||
|
||||
useEffect(() => {
|
||||
if (aircraft.length > 0) {
|
||||
const operatorData = new Map<string, { total: number; active: number; maintenance: number }>();
|
||||
|
||||
aircraft.forEach((a: Aircraft) => {
|
||||
if (!a.operator) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!operatorData.has(a.operator)) {
|
||||
operatorData.set(a.operator, { total: 0, active: 0, maintenance: 0 });
|
||||
}
|
||||
|
||||
const data = operatorData.get(a.operator)!;
|
||||
data.total++;
|
||||
|
||||
const s = (a.status || '').toLowerCase();
|
||||
if (s.includes('активен') || s === 'active') {
|
||||
data.active++;
|
||||
}
|
||||
if (s.includes('обслуживан') || s.includes('ремонт') || s === 'maintenance') {
|
||||
data.maintenance++;
|
||||
}
|
||||
});
|
||||
|
||||
const ratings = Array.from(operatorData.entries()).map(([operator, data]) => {
|
||||
const activePercent = data.total > 0 ? (data.active / data.total) * 100 : 0;
|
||||
const maintenancePercent = data.total > 0 ? (data.maintenance / data.total) * 100 : 0;
|
||||
|
||||
const rating = Math.round(
|
||||
activePercent * 0.5 +
|
||||
(100 - maintenancePercent) * 0.3 +
|
||||
Math.min(data.total / 10, 1) * 100 * 0.2
|
||||
);
|
||||
|
||||
return {
|
||||
operator,
|
||||
totalAircraft: data.total,
|
||||
activeAircraft: data.active,
|
||||
maintenanceAircraft: data.maintenance,
|
||||
rating,
|
||||
category: rating >= 80 ? 'best' as const : rating >= 50 ? 'average' as const : 'worst' as const,
|
||||
};
|
||||
}).sort((a, b) => b.rating - a.rating);
|
||||
|
||||
setOperatorRatings(ratings);
|
||||
}
|
||||
}, [aircraft]);
|
||||
|
||||
const handleFileUpload = async (files: File[]) => {
|
||||
logInfo('Загружено файлов', { count: files.length });
|
||||
// Здесь будет логика загрузки файлов
|
||||
};
|
||||
|
||||
const handleNavigate = (path: string) => {
|
||||
router.push(path);
|
||||
};
|
||||
const criticalLL = data.lifeLimits?.items?.filter((ll: any) => ll.critical)?.length || 0;
|
||||
const openADs = data.directives?.total || 0;
|
||||
const personnelIssues = data.personnel?.non_compliant || 0;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<Sidebar />
|
||||
<div id="main-content" role="main" style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<Logo size="large" />
|
||||
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||
Система контроля лётной годности воздушных судов · Безопасность и качество
|
||||
</p>
|
||||
<PageLayout title="📊 Дашборд АСУ ТК" subtitle="Калининградский филиал — контроль лётной годности">
|
||||
{loading ? <div className="text-center py-16 text-gray-400">⏳ Загрузка данных...</div> : (
|
||||
<div className="space-y-6">
|
||||
{/* Critical alerts banner */}
|
||||
{(openADs > 0 || criticalLL > 0 || personnelIssues > 0) && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-bold text-red-700 mb-2">⚠️ Требуют внимания</h3>
|
||||
<div className="flex gap-4 text-sm text-red-600">
|
||||
{openADs > 0 && <span>• {openADs} открытых ДЛГ</span>}
|
||||
{criticalLL > 0 && <span>• {criticalLL} критических ресурсов</span>}
|
||||
{personnelIssues > 0 && <span>• {personnelIssues} просроченных квалификаций</span>}
|
||||
{(data as any).fgisStatus?.connection_status === 'mock' && (
|
||||
<span>• ФГИС РЭВС: тестовый режим</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aircraft fleet */}
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-gray-600 mb-3">✈️ Парк воздушных судов</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<StatCard label="Всего ВС" value={data.overview?.aircraft?.total || 0} color="blue" href="/aircraft" />
|
||||
<StatCard label="Годные" value={data.overview?.aircraft?.active || 0} color="green" sub="Действующий СЛГ" />
|
||||
<StatCard label="На ТО" value={data.overview?.aircraft?.maintenance || 0} color="yellow" href="/maintenance" />
|
||||
<StatCard label="Приостановлены" value={data.overview?.aircraft?.grounded || 0} color="red" />
|
||||
<StatCard label="Организации" value={data.overview?.organizations?.total || 0} color="gray" href="/organizations" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Airworthiness Core */}
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-gray-600 mb-3">🔧 Контроль лётной годности</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard label="Открытые ДЛГ" value={openADs} color={openADs > 0 ? 'red' : 'green'} sub="Директивы ЛГ" href="/airworthiness-core" />
|
||||
<StatCard label="Критич. ресурсы" value={criticalLL} color={criticalLL > 0 ? 'red' : 'green'} sub="Life Limits" href="/airworthiness-core" />
|
||||
<StatCard label="Компоненты" value={data.lifeLimits?.total || 0} color="blue" sub="На контроле" href="/airworthiness-core" />
|
||||
<StatCard label="Бюллетени" value={0} color="blue" sub="Сервисные SB" href="/airworthiness-core" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Personnel PLG */}
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-gray-600 mb-3">🎓 Персонал ПЛГ</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard label="Специалисты" value={data.personnel?.total_specialists || 0} color="blue" href="/personnel-plg" />
|
||||
<StatCard label="Квалификация ОК" value={data.personnel?.compliant || 0} color="green" />
|
||||
<StatCard label="Нарушения" value={personnelIssues} color={personnelIssues > 0 ? 'red' : 'green'} href="/personnel-plg" />
|
||||
<StatCard label="Истекает ≤90д" value={data.personnel?.expiring_soon?.length || 0} color="yellow" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
{/* Work Orders & Defects */}
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-gray-600 mb-3">📐 ТО и дефекты</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<StatCard label="Наряды в работе" value={(data as any).woStats?.in_progress || 0} color="blue" href="/maintenance" />
|
||||
<StatCard label="AOG" value={(data as any).woStats?.aog || 0} color={(data as any).woStats?.aog > 0 ? 'red' : 'green'} sub="ВС на земле" href="/maintenance" />
|
||||
<StatCard label="Открытые дефекты" value={(data as any).openDefects?.total || 0} color={(data as any).openDefects?.total > 0 ? 'yellow' : 'green'} href="/defects" />
|
||||
<StatCard label="Закрыто нарядов" value={(data as any).woStats?.closed || 0} color="green" />
|
||||
<StatCard label="Человеко-часы" value={(data as any).woStats?.total_manhours || 0} color="purple" sub="Факт. (закрытые)" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Safety & Audits */}
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-gray-600 mb-3">🛡️ Безопасность и аудиты</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard label="Всего рисков" value={data.overview?.risks?.total || 0} color="blue" href="/risks" />
|
||||
<StatCard label="Критические" value={data.overview?.risks?.critical || 0} color="red" />
|
||||
<StatCard label="Заявки" value={data.overview?.cert_applications?.total || 0} color="purple" href="/applications" />
|
||||
<StatCard label="Аудиты" value={data.overview?.audits?.total || 0} color="blue" href="/audits" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
{/* Charts */}
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-gray-600 mb-3">📈 Тренды</h3>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="card p-4">
|
||||
<h4 className="text-xs font-medium text-gray-500 mb-3">Наряды на ТО по месяцам</h4>
|
||||
<WOChart />
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<h4 className="text-xs font-medium text-gray-500 mb-3">Распределение дефектов по серьёзности</h4>
|
||||
<DefectChart />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick links */}
|
||||
<section>
|
||||
<h3 className="text-sm font-bold text-gray-600 mb-3">⚡ Быстрый доступ</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
|
||||
{[
|
||||
{ href: '/airworthiness-core', label: '🔧 Контроль ЛГ', desc: 'AD, SB, ресурсы, компоненты' },
|
||||
{ href: '/personnel-plg', label: '🎓 Персонал ПЛГ', desc: 'Аттестация, ПК, 11 программ' },
|
||||
{ href: '/checklists', label: '✅ Чек-листы', desc: 'Инспекции и проверки' },
|
||||
{ href: '/regulator', label: '🏛️ Панель ФАВТ', desc: 'Данные для регулятора' },
|
||||
].map(l => (
|
||||
<Link key={l.href} href={l.href}
|
||||
className="card p-3 hover:shadow-md transition-shadow">
|
||||
<div className="text-sm font-medium">{l.label}</div>
|
||||
<div className="text-[10px] text-gray-400 mt-0.5">{l.desc}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
Дашборд
|
||||
</h2>
|
||||
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||
Общая статистика и аналитика системы контроля лётной годности
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
<NotificationBell />
|
||||
<button
|
||||
onClick={() => setIsSettingsModalOpen(true)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#666',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
title="Настройки"
|
||||
aria-label="Открыть настройки"
|
||||
>
|
||||
<span>⚙️</span>
|
||||
<span>Настройки</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsAIAgentModalOpen(true)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span>🤖</span>
|
||||
<span>ИИ Агент</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsSearchModalOpen(true)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#2196f3',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
title="Ctrl+K"
|
||||
>
|
||||
Поиск
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsExportModalOpen(true)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#4caf50',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span>📥</span>
|
||||
<span>Экспорт</span>
|
||||
</button>
|
||||
function WOChart() {
|
||||
const data = [
|
||||
{ month: 'Сен', closed: 12, opened: 15 },
|
||||
{ month: 'Окт', closed: 18, opened: 14 },
|
||||
{ month: 'Ноя', closed: 22, opened: 20 },
|
||||
{ month: 'Дек', closed: 16, opened: 19 },
|
||||
{ month: 'Янв', closed: 25, opened: 21 },
|
||||
{ month: 'Фев', closed: 14, opened: 11 },
|
||||
];
|
||||
// Simple bar chart using divs (no recharts dep needed in artifact)
|
||||
const max = Math.max(...data.flatMap(d => [d.closed, d.opened]));
|
||||
return (
|
||||
<div className="flex items-end gap-1 h-32">
|
||||
{data.map((d, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-0.5">
|
||||
<div className="w-full flex gap-0.5 items-end justify-center h-24">
|
||||
<div className="w-3 bg-green-400 rounded-t" style={{ height: `${(d.closed / max) * 100}%` }}
|
||||
title={`Закрыто: ${d.closed}`} />
|
||||
<div className="w-3 bg-blue-400 rounded-t" style={{ height: `${(d.opened / max) * 100}%` }}
|
||||
title={`Открыто: ${d.opened}`} />
|
||||
</div>
|
||||
<span className="text-[9px] text-gray-400">{d.month}</span>
|
||||
</div>
|
||||
|
||||
{aircraftError ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<div style={{ fontSize: '16px', color: '#f44336' }}>
|
||||
Ошибка загрузки данных: {aircraftError.message || 'Неизвестная ошибка'}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
marginTop: '16px',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#2196f3',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Перезагрузить страницу
|
||||
</button>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<div style={{ fontSize: '16px', color: '#666' }}>Загрузка данных...</div>
|
||||
<div style={{ marginTop: '16px', fontSize: '14px', color: '#999' }}>
|
||||
Пожалуйста, подождите...
|
||||
</div>
|
||||
<button
|
||||
onClick={() => mutateAircraft()}
|
||||
style={{
|
||||
marginTop: '16px',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#2196f3',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Повторить загрузку
|
||||
</button>
|
||||
</div>
|
||||
) : aircraft.length === 0 && !aircraftLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<div style={{ fontSize: '16px', color: '#666' }}>Нет данных для отображения</div>
|
||||
<button
|
||||
onClick={() => mutateAircraft()}
|
||||
style={{
|
||||
marginTop: '16px',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#2196f3',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Загрузить данные
|
||||
</button>
|
||||
</div>
|
||||
) : aircraft.length > 0 ? (
|
||||
<>
|
||||
{/* Статистика воздушных судов */}
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h3 style={{ fontSize: '20px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||
Статистика воздушных судов
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div style={{
|
||||
padding: '24px',
|
||||
backgroundColor: '#f0f7ff',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 12px rgba(30, 58, 95, 0.15)',
|
||||
border: '2px solid #1e3a5f',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(30, 58, 95, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(30, 58, 95, 0.15)';
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '14px', color: '#1e3a5f', marginBottom: '8px', fontWeight: '500' }}>Всего ВС</div>
|
||||
<div style={{ fontSize: '36px', fontWeight: 'bold', color: '#1e3a5f' }}>
|
||||
{computedStats.total.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '24px',
|
||||
backgroundColor: '#e8f5e9',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 12px rgba(76, 175, 80, 0.15)',
|
||||
border: '2px solid #4caf50',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(76, 175, 80, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(76, 175, 80, 0.15)';
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '14px', color: '#2e7d32', marginBottom: '8px', fontWeight: '500' }}>Активных</div>
|
||||
<div style={{ fontSize: '36px', fontWeight: 'bold', color: '#4caf50' }}>
|
||||
{computedStats.active.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '24px',
|
||||
backgroundColor: '#fff3e0',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 12px rgba(255, 152, 0, 0.15)',
|
||||
border: '2px solid #ff9800',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(255, 152, 0, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(255, 152, 0, 0.15)';
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '14px', color: '#e65100', marginBottom: '8px', fontWeight: '500' }}>На обслуживании</div>
|
||||
<div style={{ fontSize: '36px', fontWeight: 'bold', color: '#ff9800' }}>
|
||||
{computedStats.maintenance.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Статистика рисков */}
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h3 style={{ fontSize: '20px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||
Статистика рисков
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '16px' }}>
|
||||
<div style={{
|
||||
padding: '24px',
|
||||
backgroundColor: '#f0f7ff',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 12px rgba(30, 58, 95, 0.15)',
|
||||
border: '2px solid #1e3a5f',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(30, 58, 95, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(30, 58, 95, 0.15)';
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '14px', color: '#1e3a5f', marginBottom: '8px', fontWeight: '500' }}>Всего рисков</div>
|
||||
<div style={{ fontSize: '36px', fontWeight: 'bold', color: '#1e3a5f' }}>
|
||||
{risksStats.total}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '24px',
|
||||
backgroundColor: '#ffebee',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 12px rgba(244, 67, 54, 0.15)',
|
||||
border: '2px solid #f44336',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(244, 67, 54, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(244, 67, 54, 0.15)';
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '14px', color: '#c62828', marginBottom: '8px', fontWeight: '500' }}>Критических</div>
|
||||
<div style={{ fontSize: '36px', fontWeight: 'bold', color: '#f44336' }}>
|
||||
{risksStats.critical}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '24px',
|
||||
backgroundColor: '#fff3e0',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 12px rgba(255, 152, 0, 0.15)',
|
||||
border: '2px solid #ff9800',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(255, 152, 0, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(255, 152, 0, 0.15)';
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '14px', color: '#e65100', marginBottom: '8px', fontWeight: '500' }}>Высоких</div>
|
||||
<div style={{ fontSize: '36px', fontWeight: 'bold', color: '#ff9800' }}>
|
||||
{risksStats.high}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Статистика аудитов */}
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h3 style={{ fontSize: '20px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||
Статистика аудитов
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '16px' }}>
|
||||
<div style={{
|
||||
padding: '24px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 12px rgba(33, 150, 243, 0.15)',
|
||||
border: '2px solid #2196f3',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(33, 150, 243, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(33, 150, 243, 0.15)';
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '14px', color: '#1565c0', marginBottom: '8px', fontWeight: '500' }}>Текущих</div>
|
||||
<div style={{ fontSize: '36px', fontWeight: 'bold', color: '#2196f3' }}>
|
||||
{auditsStats.current}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '24px',
|
||||
backgroundColor: '#fff3e0',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 12px rgba(255, 152, 0, 0.15)',
|
||||
border: '2px solid #ff9800',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(255, 152, 0, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(255, 152, 0, 0.15)';
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '14px', color: '#e65100', marginBottom: '8px', fontWeight: '500' }}>Предстоящих</div>
|
||||
<div style={{ fontSize: '36px', fontWeight: 'bold', color: '#ff9800' }}>
|
||||
{auditsStats.upcoming}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '24px',
|
||||
backgroundColor: '#e8f5e9',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 12px rgba(76, 175, 80, 0.15)',
|
||||
border: '2px solid #4caf50',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(76, 175, 80, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(76, 175, 80, 0.15)';
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '14px', color: '#2e7d32', marginBottom: '8px', fontWeight: '500' }}>Завершённых</div>
|
||||
<div style={{ fontSize: '36px', fontWeight: 'bold', color: '#4caf50' }}>
|
||||
{auditsStats.completed}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Рейтинг операторов по КЛГ */}
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h3 style={{ fontSize: '20px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||
Рейтинг операторов по КЛГ
|
||||
</h3>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '16px' }}>
|
||||
{/* Лучшие по КЛГ */}
|
||||
<div style={{ padding: '20px', backgroundColor: 'white', borderRadius: '8px', boxShadow: '0 2px 4px rgba(0,0,0,0.1)' }}>
|
||||
<h4 style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '12px', color: '#4caf50' }}>
|
||||
Лучшие по КЛГ
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{operatorRatings
|
||||
.filter(r => r.category === 'best')
|
||||
.slice(0, 5)
|
||||
.map((rating, index) => (
|
||||
<div key={index} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '14px' }}>
|
||||
<span>{rating.operator}</span>
|
||||
<span style={{ fontWeight: 'bold', color: '#4caf50' }}>{rating.rating}</span>
|
||||
</div>
|
||||
))}
|
||||
{operatorRatings.filter(r => r.category === 'best').length === 0 && (
|
||||
<div style={{ fontSize: '14px', color: '#999' }}>Нет данных</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Средние */}
|
||||
<div style={{ padding: '20px', backgroundColor: 'white', borderRadius: '8px', boxShadow: '0 2px 4px rgba(0,0,0,0.1)' }}>
|
||||
<h4 style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '12px', color: '#ff9800' }}>
|
||||
Средние
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{operatorRatings
|
||||
.filter(r => r.category === 'average')
|
||||
.slice(0, 5)
|
||||
.map((rating, index) => (
|
||||
<div key={index} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '14px' }}>
|
||||
<span>{rating.operator}</span>
|
||||
<span style={{ fontWeight: 'bold', color: '#ff9800' }}>{rating.rating}</span>
|
||||
</div>
|
||||
))}
|
||||
{operatorRatings.filter(r => r.category === 'average').length === 0 && (
|
||||
<div style={{ fontSize: '14px', color: '#999' }}>Нет данных</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Требуют внимания */}
|
||||
<div style={{ padding: '20px', backgroundColor: 'white', borderRadius: '8px', boxShadow: '0 2px 4px rgba(0,0,0,0.1)' }}>
|
||||
<h4 style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '12px', color: '#f44336' }}>
|
||||
Требуют внимания
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{operatorRatings
|
||||
.filter(r => r.category === 'worst')
|
||||
.slice(0, 5)
|
||||
.map((rating, index) => (
|
||||
<div key={index} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '14px' }}>
|
||||
<span>{rating.operator}</span>
|
||||
<span style={{ fontWeight: 'bold', color: '#f44336' }}>{rating.rating}</span>
|
||||
</div>
|
||||
))}
|
||||
{operatorRatings.filter(r => r.category === 'worst').length === 0 && (
|
||||
<div style={{ fontSize: '14px', color: '#999' }}>Нет данных</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* AI-Powered Knowledge System - показываем только если есть данные или не идет загрузка */}
|
||||
{(!isLoading && hasAnyData) && (
|
||||
<>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h3 style={{ fontSize: '20px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||
Система знаний на основе ИИ
|
||||
</h3>
|
||||
<SemanticSearch
|
||||
onResultSelect={(result) => {
|
||||
logInfo('Selected result', { result });
|
||||
// Можно добавить навигацию к результату
|
||||
}}
|
||||
placeholder="Семантический поиск по базе знаний..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<AutonomousAgentInterface />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h3 style={{ fontSize: '20px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||
Визуализация графа знаний
|
||||
</h3>
|
||||
<KnowledgeGraphVisualization
|
||||
onNodeClick={(nodeId) => {
|
||||
logInfo('Knowledge graph node clicked', { nodeId });
|
||||
// Можно добавить навигацию к узлу
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Модальные окна */}
|
||||
<AIAgentModal
|
||||
isOpen={isAIAgentModalOpen}
|
||||
onClose={() => setIsAIAgentModalOpen(false)}
|
||||
/>
|
||||
<FileUploadModal
|
||||
isOpen={isUploadModalOpen}
|
||||
onClose={() => setIsUploadModalOpen(false)}
|
||||
onUpload={handleFileUpload}
|
||||
/>
|
||||
<SearchModal
|
||||
isOpen={isSearchModalOpen}
|
||||
onClose={() => setIsSearchModalOpen(false)}
|
||||
aircraft={aircraft}
|
||||
searchType="dashboard"
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
<ExportModal
|
||||
isOpen={isExportModalOpen}
|
||||
onClose={() => setIsExportModalOpen(false)}
|
||||
data={aircraft}
|
||||
filename="aircraft-export"
|
||||
title="Экспорт воздушных судов"
|
||||
availableColumns={aircraft.length > 0 ? Object.keys(aircraft[0]) : []}
|
||||
columnLabels={{
|
||||
registrationNumber: 'Регистрационный номер',
|
||||
aircraftType: 'Тип ВС',
|
||||
operator: 'Оператор',
|
||||
status: 'Статус',
|
||||
manufacturer: 'Производитель',
|
||||
model: 'Модель',
|
||||
}}
|
||||
/>
|
||||
<SettingsModal
|
||||
isOpen={isSettingsModalOpen}
|
||||
onClose={() => setIsSettingsModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DefectChart() {
|
||||
const data = [
|
||||
{ label: 'Критические', value: 3, color: 'bg-red-500' },
|
||||
{ label: 'Значительные', value: 12, color: 'bg-yellow-500' },
|
||||
{ label: 'Незначительные', value: 28, color: 'bg-blue-400' },
|
||||
];
|
||||
const total = data.reduce((s, d) => s + d.value, 0);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{data.map((d, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 w-28">{d.label}</span>
|
||||
<div className="flex-1 bg-gray-100 rounded-full h-5 overflow-hidden">
|
||||
<div className={`${d.color} h-full rounded-full flex items-center justify-end pr-2 transition-all`}
|
||||
style={{ width: `${(d.value / total) * 100}%` }}>
|
||||
<span className="text-[10px] text-white font-bold">{d.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-right text-[10px] text-gray-400">Всего: {total}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,62 +1,89 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import Logo from "@/components/Logo";
|
||||
|
||||
const MOCK_DEFECTS = [
|
||||
{ id: "def-001", number: "DEF-2026-001", aircraft: "RA-02801", aircraftType: "Mi-8MTV-1", title: "Микротрещина стойки основного шасси", category: "structural", severity: "critical", status: "open", reportedBy: "Козлов Д.М.", reportDate: "2026-01-28", ata: "32" },
|
||||
{ id: "def-002", number: "DEF-2026-002", aircraft: "RA-73703", aircraftType: "Boeing 737-800", title: "Коррозия обшивки в зоне крыла", category: "corrosion", severity: "major", status: "deferred", reportedBy: "Белов К.Н.", reportDate: "2025-12-10", ata: "57" },
|
||||
{ id: "def-003", number: "DEF-2026-003", aircraft: "RA-89003", aircraftType: "SSJ-100", title: "Утечка гидрожидкости в шасси", category: "system", severity: "major", status: "in_repair", reportedBy: "Иванов С.К.", reportDate: "2026-02-03", ata: "29" },
|
||||
{ id: "def-004", number: "DEF-2026-004", aircraft: "RA-73701", aircraftType: "Boeing 737-800", title: "Трещина лобового стекла кабины", category: "structural", severity: "minor", status: "repaired", reportedBy: "Петров И.В.", reportDate: "2026-01-15", ata: "56" },
|
||||
{ id: "def-005", number: "DEF-2026-005", aircraft: "RA-76511", aircraftType: "Il-76TD-90VD", title: "Расхождение в формулярах двигателей", category: "documentation", severity: "minor", status: "open", reportedBy: "Морозова Е.А.", reportDate: "2026-01-20", ata: "72" },
|
||||
{ id: "def-006", number: "DEF-2026-006", aircraft: "RA-89001", aircraftType: "SSJ-100", title: "Неисправность датчика температуры EGT", category: "avionics", severity: "major", status: "in_repair", reportedBy: "Сидоров А.П.", reportDate: "2026-02-06", ata: "77" },
|
||||
];
|
||||
|
||||
const sevColors: Record<string,string> = { critical: "#d32f2f", major: "#e65100", minor: "#f9a825" };
|
||||
const stColors: Record<string,string> = { open: "#ff9800", deferred: "#9c27b0", in_repair: "#2196f3", repaired: "#4caf50" };
|
||||
const stLabels: Record<string,string> = { open: "Открыт", deferred: "Отложен (MEL/CDL)", in_repair: "В ремонте", repaired: "Устранён" };
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { PageLayout, DataTable, StatusBadge, Modal, EmptyState } from '@/components/ui';
|
||||
|
||||
export default function DefectsPage() {
|
||||
const [filter, setFilter] = useState("all");
|
||||
const filtered = filter === "all" ? MOCK_DEFECTS : MOCK_DEFECTS.filter(d => d.status === filter);
|
||||
const [defects, setDefects] = useState([] as any[]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const api = useCallback(async (ep: string, opts?: RequestInit) => {
|
||||
const r = await fetch(`/api/v1/defects${ep}`, opts);
|
||||
return r.json();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true); api(`/${filter ? `?status=${filter}` : ""}`).then(d => { setDefects(d.items || []); });
|
||||
}, [api, filter]);
|
||||
|
||||
|
||||
const handleAdd = async (data: any) => {
|
||||
const r = await api('/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
||||
if (r.id) { setDefects(p => [r, ...p]); setShowAdd(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", minHeight: "100vh" }}>
|
||||
<Sidebar />
|
||||
<div style={{ marginLeft: "280px", flex: 1, padding: "32px" }}>
|
||||
<Logo size="large" />
|
||||
<p style={{ color: "#666", margin: "16px 0 24px" }}>Учёт и контроль дефектов воздушных судов</p>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "24px" }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: "24px", fontWeight: "bold", marginBottom: "8px" }}>Дефекты</h2>
|
||||
<p style={{ fontSize: "14px", color: "#666" }}>Реестр дефектов — ATA iSpec 2200, EASA Part-M, MEL/CDL</p>
|
||||
</div>
|
||||
<button style={{ padding: "10px 20px", background: "#1e3a5f", color: "white", border: "none", borderRadius: "6px", cursor: "pointer", fontWeight: 600 }}>+ Зарегистрировать дефект</button>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "12px", marginBottom: "24px" }}>
|
||||
{[["open","Открытые","#fff3e0"],["deferred","Отложенные","#f3e5f5"],["in_repair","В ремонте","#e3f2fd"],["repaired","Устранённые","#e8f5e9"]].map(([s,l,bg]) => (
|
||||
<div key={s} onClick={() => setFilter(filter===s?"all":s)} style={{ background: bg, padding: "16px", borderRadius: "8px", textAlign: "center", cursor: "pointer", border: filter===s ? "2px solid #1e3a5f" : "2px solid transparent" }}>
|
||||
<div style={{ fontSize: "28px", fontWeight: "bold", color: stColors[s] }}>{MOCK_DEFECTS.filter(d=>d.status===s).length}</div>
|
||||
<div style={{ fontSize: "13px", color: "#666" }}>{l}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", background: "white" }}>
|
||||
<thead><tr style={{ background: "#1e3a5f", color: "white" }}>
|
||||
{["№ ДЕФЕКТА","ВС","ATA","ОПИСАНИЕ","СЕРЬЁЗНОСТЬ","СТАТУС","ДАТА"].map(h => <th key={h} style={{ padding: "12px", textAlign: "left", fontSize: "12px" }}>{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>{filtered.map(d => (
|
||||
<tr key={d.id} style={{ borderBottom: "1px solid #e0e0e0", background: d.severity==="critical" ? "#fff5f5" : "transparent" }}>
|
||||
<td style={{ padding: "12px", fontWeight: 600 }}>{d.number}</td>
|
||||
<td style={{ padding: "12px" }}>{d.aircraft}</td>
|
||||
<td style={{ padding: "12px", fontSize: "13px" }}>ATA {d.ata}</td>
|
||||
<td style={{ padding: "12px", fontSize: "13px" }}>{d.title}</td>
|
||||
<td style={{ padding: "12px" }}><span style={{ padding: "3px 8px", borderRadius: "4px", fontSize: "11px", color: "white", background: sevColors[d.severity] }}>{d.severity}</span></td>
|
||||
<td style={{ padding: "12px" }}><span style={{ padding: "3px 8px", borderRadius: "4px", fontSize: "11px", color: "white", background: stColors[d.status] }}>{stLabels[d.status]}</span></td>
|
||||
<td style={{ padding: "12px", fontSize: "13px" }}>{d.reportDate}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
<>
|
||||
<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>}>
|
||||
<div className="flex gap-2 mb-4">
|
||||
{['', 'open', 'deferred', 'rectified', 'closed'].map(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'}>
|
||||
{s || 'Все'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{defects.length > 0 ? (
|
||||
<DataTable columns={[
|
||||
{ key: 'aircraft_reg', label: 'Борт' },
|
||||
{ key: 'ata_chapter', label: 'ATA' },
|
||||
{ key: 'description', label: 'Описание' },
|
||||
{ key: 'severity', label: 'Серьёзность', render: (v: string) => (
|
||||
<StatusBadge status={v} colorMap={{ critical: 'bg-red-500', major: 'bg-yellow-500', minor: 'bg-blue-500' }} />
|
||||
)},
|
||||
{ key: 'discovered_during', label: 'Обнаружен' },
|
||||
{ key: 'mel_reference', label: 'MEL', render: (v: string) => v || '—' },
|
||||
{ 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' }}
|
||||
labelMap={{ open: 'Открыт', deferred: 'Отложен (MEL)', rectified: 'Устранён', closed: 'Закрыт' }} />
|
||||
)},
|
||||
]} data={defects} />
|
||||
) : <EmptyState message="Нет зарегистрированных дефектов" />}
|
||||
|
||||
<Modal isOpen={showAdd} onClose={() => setShowAdd(false)} title="Регистрация дефекта">
|
||||
<DefectForm onSubmit={handleAdd} onCancel={() => setShowAdd(false)} />
|
||||
</Modal>
|
||||
</PageLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DefectForm({ onSubmit, onCancel }: { onSubmit: (d: any) => void; onCancel: () => void }) {
|
||||
const [f, setF] = useState({ aircraft_reg: '', ata_chapter: '', description: '', severity: 'minor', discovered_during: 'preflight' });
|
||||
return (
|
||||
<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={f.aircraft_reg} onChange={e => setF(p => ({ ...p, aircraft_reg: e.target.value }))} /></div>
|
||||
<div><label className="text-xs font-medium text-gray-600">ATA Chapter</label>
|
||||
<input className="input-field w-full mt-1" placeholder="32" value={f.ata_chapter} onChange={e => setF(p => ({ ...p, ata_chapter: e.target.value }))} /></div>
|
||||
<div><label className="text-xs font-medium text-gray-600">Описание дефекта</label>
|
||||
<textarea className="input-field w-full mt-1" rows={3} value={f.description} onChange={e => setF(p => ({ ...p, description: e.target.value }))} /></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.severity} onChange={e => setF(p => ({ ...p, severity: e.target.value }))}>
|
||||
<option value="critical">Критический</option><option value="major">Значительный</option><option value="minor">Незначительный</option>
|
||||
</select></div>
|
||||
<div><label className="text-xs font-medium text-gray-600">Обнаружен при</label>
|
||||
<select className="input-field w-full mt-1" value={f.discovered_during} onChange={e => setF(p => ({ ...p, discovered_during: e.target.value }))}>
|
||||
<option value="preflight">Предполётный</option><option value="transit">Транзит</option><option value="daily">Ежедневный</option>
|
||||
<option value="a_check">A-check</option><option value="c_check">C-check</option><option value="report">Донесение экипажа</option>
|
||||
</select></div>
|
||||
</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>
|
||||
);
|
||||
|
||||
@ -1,226 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import DocumentViewModal from '@/components/DocumentViewModal';
|
||||
import Logo from '@/components/Logo';
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
aircraft: string;
|
||||
date: string;
|
||||
status: string;
|
||||
size: string;
|
||||
}
|
||||
import { PageLayout } from '@/components/ui';
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const [documents, setDocuments] = useState<Document[]>([
|
||||
{
|
||||
id: '1',
|
||||
name: 'Сертификат лётной годности',
|
||||
type: 'Сертификат',
|
||||
aircraft: 'RA-12345',
|
||||
date: '2025-01-15',
|
||||
status: 'Действителен',
|
||||
size: '2.5 МБ',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Техническая документация',
|
||||
type: 'Техническая',
|
||||
aircraft: 'RA-67890',
|
||||
date: '2025-01-10',
|
||||
status: 'Действителен',
|
||||
size: '15.3 МБ',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Отчёт о техническом обслуживании',
|
||||
type: 'Отчёт',
|
||||
aircraft: 'RA-11111',
|
||||
date: '2025-01-20',
|
||||
status: 'Требует обновления',
|
||||
size: '1.2 МБ',
|
||||
},
|
||||
]);
|
||||
|
||||
const [selectedDocument, setSelectedDocument] = useState<Document | null>(null);
|
||||
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
|
||||
|
||||
const handleDownload = (doc: Document) => {
|
||||
// Имитация скачивания документа
|
||||
const link = document.createElement('a');
|
||||
link.href = '#';
|
||||
link.download = `${doc.name}.pdf`;
|
||||
link.click();
|
||||
alert(`Документ "${doc.name}" скачивается...`);
|
||||
};
|
||||
|
||||
const handleView = (doc: Document) => {
|
||||
setSelectedDocument(doc);
|
||||
setIsViewModalOpen(true);
|
||||
};
|
||||
|
||||
const handleStatusChange = (doc: Document) => {
|
||||
const newStatus = doc.status === 'Действителен' ? 'Требует обновления' : 'Действителен';
|
||||
setDocuments(prev => prev.map(d =>
|
||||
d.id === doc.id ? { ...d, status: newStatus } : d
|
||||
));
|
||||
alert(`Статус документа "${doc.name}" изменён на "${newStatus}"`);
|
||||
};
|
||||
|
||||
const links = [
|
||||
{ title: 'Входящие документы', desc: 'PDF и DOCX файлы', href: '/inbox', icon: '📥' },
|
||||
{ title: 'Вложения аудитов', desc: 'Фото и протоколы', href: '/audits', icon: '🔍' },
|
||||
{ title: 'Сертификаты', desc: 'Сертификаты ЛГ', href: '/airworthiness', icon: '📜' },
|
||||
{ title: 'Нормативные документы', desc: 'ФАП, ICAO, EASA', href: '/regulations', icon: '📚' },
|
||||
{ title: 'Чек-листы', desc: 'Шаблоны проверок', href: '/checklists', icon: '✅' },
|
||||
];
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<Sidebar />
|
||||
<div style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<Logo size="large" />
|
||||
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||
Система контроля лётной годности воздушных судов · Безопасность и качество
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
Документы
|
||||
</h2>
|
||||
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||
Управление документацией воздушных судов
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
Загрузить документ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px', display: 'flex', gap: '8px' }}>
|
||||
<button style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
Все
|
||||
</button>
|
||||
<button style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#1e3a5f',
|
||||
border: '1px solid #1e3a5f',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
Сертификаты
|
||||
</button>
|
||||
<button style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#1e3a5f',
|
||||
border: '1px solid #1e3a5f',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
Отчёты
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{documents.map(doc => (
|
||||
<div key={doc.id} style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '24px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
{doc.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
Тип: {doc.type} | ВС: {doc.aircraft} | Размер: {doc.size}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
Дата: {doc.date}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={() => handleStatusChange(doc)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
backgroundColor: doc.status === 'Действителен' ? '#4caf50' : '#ff9800',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Нажмите для изменения статуса"
|
||||
>
|
||||
{doc.status}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownload(doc)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Скачать
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleView(doc)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#1e3a5f',
|
||||
border: '1px solid #1e3a5f',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Просмотр
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DocumentViewModal
|
||||
isOpen={isViewModalOpen}
|
||||
onClose={() => {
|
||||
setIsViewModalOpen(false);
|
||||
setSelectedDocument(null);
|
||||
}}
|
||||
document={selectedDocument}
|
||||
/>
|
||||
<PageLayout title="Документы" subtitle="Просмотр документов, прикреплённых к ВС, аудитам и заявкам">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{links.map(l => (
|
||||
<a key={l.href} href={l.href} className="card p-6 no-underline text-inherit hover:shadow-md transition-shadow">
|
||||
<div className="text-3xl mb-3">{l.icon}</div>
|
||||
<div className="text-base font-bold text-primary-500 mb-1">{l.title}</div>
|
||||
<div className="text-xs text-gray-500">{l.desc}</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -36,16 +36,9 @@ export default function Error({
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
padding: '40px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
}}
|
||||
|
||||
>
|
||||
<div style={{ maxWidth: '600px', width: '100%' }}>
|
||||
<div className="max-w-xl">
|
||||
<ErrorDisplay
|
||||
title={friendlyError.title}
|
||||
message={friendlyError.message}
|
||||
|
||||
@ -14,58 +14,25 @@ export default function GlobalError({
|
||||
<html lang="ru">
|
||||
<body>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
padding: '40px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
}}
|
||||
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '600px',
|
||||
backgroundColor: 'white',
|
||||
padding: '32px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
|
||||
>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>⚠️</div>
|
||||
<div className="">⚠️</div>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '12px',
|
||||
color: '#333',
|
||||
}}
|
||||
|
||||
>
|
||||
Критическая ошибка
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
color: '#666',
|
||||
marginBottom: '24px',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
|
||||
>
|
||||
Произошла критическая ошибка приложения. Пожалуйста, обновите страницу.
|
||||
</p>
|
||||
<button
|
||||
onClick={reset}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
|
||||
>
|
||||
Обновить страницу
|
||||
</button>
|
||||
|
||||
217
app/globals.css
217
app/globals.css
@ -1,135 +1,116 @@
|
||||
/* Глобальные стили с поддержкой доступности */
|
||||
|
||||
/* Базовые стили */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
/* КЛГ АСУ ТК — Global styles + Tailwind */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Base */
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f0f4f8;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Фокус для доступности */
|
||||
*:focus-visible {
|
||||
/* Dark mode */
|
||||
.dark body, html.dark body {
|
||||
background-color: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.dark .card {
|
||||
@apply bg-slate-800 border-slate-700;
|
||||
}
|
||||
.dark .input-field {
|
||||
@apply bg-slate-700 border-slate-600 text-white placeholder:text-slate-400;
|
||||
}
|
||||
.dark .table-header {
|
||||
@apply text-slate-400;
|
||||
}
|
||||
.dark .main-content {
|
||||
@apply bg-slate-900;
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
:focus-visible {
|
||||
outline: 2px solid #4a90e2;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid #4a90e2;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Скрытие элементов для screen readers */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
/* Улучшение контраста для ссылок */
|
||||
a {
|
||||
color: #1e3a5f;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
outline: 2px solid #4a90e2;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Стили для disabled элементов */
|
||||
button:disabled,
|
||||
input:disabled,
|
||||
select:disabled,
|
||||
textarea:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Улучшение читаемости */
|
||||
p, li {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Минимальный размер области клика (44x44px для touch) */
|
||||
button,
|
||||
a[role="button"] {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Skip to main content link (для screen readers) */
|
||||
.skip-to-main {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: #1e3a5f;
|
||||
color: white;
|
||||
padding: 8px;
|
||||
text-decoration: none;
|
||||
z-index: 100;
|
||||
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||||
overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0;
|
||||
}
|
||||
|
||||
/* Skip to main */
|
||||
.skip-to-main:not(:focus) { @apply sr-only; }
|
||||
.skip-to-main:focus {
|
||||
top: 0;
|
||||
position: fixed; top: 8px; left: 8px; z-index: 9999;
|
||||
padding: 12px 24px; background: #1e3a5f; color: white;
|
||||
border-radius: 4px; font-weight: bold;
|
||||
}
|
||||
|
||||
/* Улучшение контраста для текста */
|
||||
.text-primary {
|
||||
color: #1e3a5f; /* Контраст с белым фоном: ~8.5:1 - AAA */
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: #666; /* Контраст с белым фоном: ~7.1:1 - AAA */
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: #c62828; /* Контраст с белым фоном: ~7.5:1 - AAA */
|
||||
}
|
||||
|
||||
/* Анимации с учетом prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Высокий контраст режим */
|
||||
@media (prefers-contrast: high) {
|
||||
* {
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
border: 2px solid currentColor;
|
||||
/* Custom utility classes */
|
||||
@layer components {
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-100;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply px-5 py-2.5 bg-primary-500 text-white rounded font-medium
|
||||
hover:bg-primary-600 transition-colors cursor-pointer border-none;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply px-5 py-2.5 bg-white text-primary-500 rounded font-medium
|
||||
border border-primary-500 hover:bg-primary-50 transition-colors cursor-pointer;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply px-4 py-2 bg-red-500 text-white rounded text-sm
|
||||
hover:bg-red-600 transition-colors cursor-pointer border-none;
|
||||
}
|
||||
.btn-sm {
|
||||
@apply px-3 py-1.5 rounded text-sm cursor-pointer border-none;
|
||||
}
|
||||
.badge {
|
||||
@apply px-2.5 py-1 rounded-full text-xs font-bold;
|
||||
}
|
||||
.input-field {
|
||||
@apply w-full px-4 py-2.5 border border-gray-300 rounded text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-300 focus:border-primary-400;
|
||||
}
|
||||
.page-container {
|
||||
@apply flex min-h-screen;
|
||||
}
|
||||
.main-content {
|
||||
@apply ml-0 lg:ml-[280px] flex-1 p-4 lg:p-8;
|
||||
}
|
||||
.page-title {
|
||||
@apply text-2xl font-bold text-gray-800 mb-2;
|
||||
}
|
||||
.page-subtitle {
|
||||
@apply text-sm text-gray-500 mb-4;
|
||||
}
|
||||
.table-header {
|
||||
@apply px-4 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider;
|
||||
}
|
||||
.table-cell {
|
||||
@apply px-4 py-3 text-sm;
|
||||
}
|
||||
.filter-btn {
|
||||
@apply px-3.5 py-1.5 rounded text-sm cursor-pointer transition-colors;
|
||||
}
|
||||
.filter-btn-active {
|
||||
@apply bg-primary-500 text-white border-primary-500;
|
||||
}
|
||||
.filter-btn-inactive {
|
||||
@apply bg-transparent text-primary-500 border border-gray-300 hover:border-primary-300;
|
||||
}
|
||||
.status-badge {
|
||||
@apply px-2.5 py-1 rounded-full text-xs font-bold text-white;
|
||||
}
|
||||
.nav-pagination {
|
||||
@apply flex justify-center mt-4 gap-2;
|
||||
}
|
||||
.nav-btn {
|
||||
@apply px-4 py-2 border border-gray-300 rounded cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,77 +1,26 @@
|
||||
'use client';
|
||||
|
||||
|
||||
import { useState } from 'react';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import Logo from '@/components/Logo';
|
||||
|
||||
const INBOX_API = '/api/inbox';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { PageLayout, DataTable, StatusBadge, EmptyState } from '@/components/ui';
|
||||
|
||||
export default function InboxPage() {
|
||||
const [files, setFiles] = useState<Array<{ id: string; original_name: string; originalName?: string; size: number; created_at: string; createdAt?: string }>>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadFiles = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/inbox/files');
|
||||
if (!res.ok) throw new Error('Ошибка загрузки');
|
||||
const data = await res.json();
|
||||
setFiles(data.map((f: { id: string; original_name: string; originalName?: string; size: number; created_at: string; createdAt?: string }) => ({
|
||||
id: f.id,
|
||||
original_name: f.original_name || f.originalName,
|
||||
size: f.size,
|
||||
created_at: f.created_at || f.createdAt,
|
||||
})));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Ошибка');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [messages, setMessages] = useState([] as any[]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
setLoading(true); fetch('/api/v1/inbox').then(r => r.json()).then(d => { setMessages(d.items || []); setLoading(false); }); }, []);
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<Sidebar />
|
||||
<div id="main-content" role="main" style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<Logo size="large" />
|
||||
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||
Inbox — загрузка и обработка документов
|
||||
</p>
|
||||
</div>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }}>AI Inbox</h2>
|
||||
<p style={{ color: '#666', marginBottom: '24px' }}>
|
||||
Загрузите PDF или DOCX для извлечения данных. API: FastAPI /api/v1/inbox или Express inbox-server.
|
||||
</p>
|
||||
<button
|
||||
onClick={loadFiles}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{loading ? 'Загрузка...' : 'Обновить список файлов'}
|
||||
</button>
|
||||
{error && <div style={{ color: 'red', marginTop: '16px' }}>{error}</div>}
|
||||
{files.length > 0 && (
|
||||
<ul style={{ marginTop: '24px', listStyle: 'none', padding: 0 }}>
|
||||
{files.map((f) => (
|
||||
<li key={f.id} style={{ padding: '12px', borderBottom: '1px solid #eee', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>{f.original_name}</span>
|
||||
<span style={{ color: '#666' }}>{(f.size / 1024).toFixed(1)} KB</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</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="Уведомления и сообщения">
|
||||
{messages.length > 0 ? (
|
||||
<DataTable columns={[
|
||||
{ key: 'subject', label: 'Тема' },
|
||||
{ key: 'from', label: 'От' },
|
||||
{ key: 'type', label: 'Тип', render: (v: string) => <StatusBadge status={v} colorMap={{ alert: 'bg-red-500', info: 'bg-blue-500', task: 'bg-yellow-500' }} /> },
|
||||
{ key: 'read', label: 'Прочитано', render: (v: boolean) => v ? '✅' : '📩' },
|
||||
{ key: 'created_at', label: 'Дата', render: (v: string) => v ? new Date(v).toLocaleDateString('ru-RU') : '—' },
|
||||
]} data={messages} />
|
||||
) : <EmptyState message="Нет сообщений" />}
|
||||
</PageLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,351 +1,87 @@
|
||||
/**
|
||||
* Страница для просмотра задач из Jira
|
||||
* Данные импортируются из CSV файлов в папке "новая папка"
|
||||
* Jira Tasks page — loads from /api/jira-tasks.
|
||||
* Разработчик: АО «REFLY»
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import { PageLayout, FilterBar, StatusBadge, EmptyState } from '@/components/ui';
|
||||
|
||||
interface JiraEpic {
|
||||
issueId: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
priority: string;
|
||||
components: string[];
|
||||
labels: string[];
|
||||
stories?: JiraStory[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface JiraStory {
|
||||
issueId: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
priority: string;
|
||||
storyPoints?: number;
|
||||
components: string[];
|
||||
labels: string[];
|
||||
acceptanceCriteria?: string;
|
||||
subtasks?: JiraSubtask[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface JiraSubtask {
|
||||
issueId: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
priority: string;
|
||||
components: string[];
|
||||
labels: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Dependency {
|
||||
fromIssueId: string;
|
||||
toIssueId: string;
|
||||
linkType: string;
|
||||
}
|
||||
interface JiraTask { issueId: string; summary: string; description?: string; priority: string; status?: string; components?: string[]; labels?: string[]; stories?: JiraTask[]; createdAt?: string; }
|
||||
|
||||
export default function JiraTasksPage() {
|
||||
const [epics, setEpics] = useState<JiraEpic[]>([]);
|
||||
const [dependencies, setDependencies] = useState<Dependency[]>([]);
|
||||
const [epics, setEpics] = useState<JiraTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedEpic, setSelectedEpic] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [priorityFilter, setPriorityFilter] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/jira-tasks');
|
||||
const data = await r.json();
|
||||
setEpics(data.epics || data || []);
|
||||
} catch { setEpics([]); }
|
||||
finally { setLoading(false); }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const loadTasks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch('/api/jira-tasks?type=epic');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
setEpics(data.data || []);
|
||||
setDependencies(data.dependencies || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка загрузки задач');
|
||||
console.error('Ошибка загрузки задач:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority?.toLowerCase()) {
|
||||
case 'high':
|
||||
return '#f44336';
|
||||
case 'medium':
|
||||
return '#ff9800';
|
||||
case 'low':
|
||||
return '#4caf50';
|
||||
default:
|
||||
return '#757575';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityLabel = (priority: string) => {
|
||||
switch (priority?.toLowerCase()) {
|
||||
case 'high':
|
||||
return 'Высокий';
|
||||
case 'medium':
|
||||
return 'Средний';
|
||||
case 'low':
|
||||
return 'Низкий';
|
||||
default:
|
||||
return priority || 'Не указан';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<div>Загрузка задач...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<div style={{ color: '#f44336', marginBottom: '20px' }}>
|
||||
❌ Ошибка: {error}
|
||||
</div>
|
||||
<button
|
||||
onClick={loadTasks}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Повторить загрузку
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const filtered = priorityFilter ? epics.filter(e => e.priority === priorityFilter) : epics;
|
||||
const priorities = [...new Set(epics.map(e => e.priority).filter(Boolean))];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<Sidebar />
|
||||
<div style={{ flex: 1, padding: '20px', marginLeft: '250px' }}>
|
||||
<div style={{ marginBottom: '30px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '28px', fontWeight: 'bold', margin: '0 0 10px 0' }}>
|
||||
Задачи Jira (REFLY)
|
||||
</h1>
|
||||
<p style={{ color: '#666', margin: 0 }}>
|
||||
Эпики, истории и подзадачи из импортированных CSV файлов
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadTasks}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
🔄 Обновить
|
||||
</button>
|
||||
</div>
|
||||
<PageLayout title="Задачи Jira" subtitle={loading ? 'Загрузка...' : `Эпиков: ${filtered.length}`}>
|
||||
<FilterBar value={priorityFilter} onChange={setPriorityFilter} className="mb-4"
|
||||
options={[{ value: undefined, label: 'Все' }, ...priorities.map(p => ({ value: p, label: p }))]} />
|
||||
|
||||
{epics.length === 0 ? (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#666' }}>
|
||||
<p>Нет данных. Запустите импорт:</p>
|
||||
<code style={{ display: 'block', marginTop: '10px', padding: '10px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
|
||||
npm run import:jira
|
||||
</code>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
{epics.map((epic) => (
|
||||
<div
|
||||
key={epic.issueId}
|
||||
style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '15px' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
|
||||
<span style={{ fontWeight: 'bold', fontSize: '18px' }}>📋 {epic.summary}</span>
|
||||
<span
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: getPriorityColor(epic.priority),
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{getPriorityLabel(epic.priority)}
|
||||
</span>
|
||||
<span style={{ color: '#666', fontSize: '14px' }}>ID: {epic.issueId}</span>
|
||||
</div>
|
||||
{epic.description && (
|
||||
<p style={{ color: '#666', margin: '8px 0', fontSize: '14px' }}>{epic.description}</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap', marginTop: '10px' }}>
|
||||
{epic.components && epic.components.length > 0 && (
|
||||
<div>
|
||||
<strong>Компоненты:</strong> {epic.components.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{epic.labels && epic.labels.length > 0 && (
|
||||
<div>
|
||||
<strong>Метки:</strong>{' '}
|
||||
{epic.labels.map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 6px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
borderRadius: '3px',
|
||||
fontSize: '12px',
|
||||
marginLeft: '4px',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{loading ? <div className="text-center py-10 text-gray-400">Загрузка...</div> : filtered.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{filtered.map(epic => (
|
||||
<div key={epic.issueId} className="card">
|
||||
<div className="p-5 flex justify-between items-center cursor-pointer" onClick={() => setExpanded(expanded === epic.issueId ? null : epic.issueId)}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-xs text-primary-500">{epic.issueId}</span>
|
||||
<StatusBadge status={epic.priority} colorMap={{ Highest: 'bg-red-600', High: 'bg-orange-500', Medium: 'bg-yellow-500', Low: 'bg-green-500', Lowest: 'bg-gray-400' }} />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedEpic(selectedEpic === epic.issueId ? null : epic.issueId)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: selectedEpic === epic.issueId ? '#1e3a5f' : '#f5f5f5',
|
||||
color: selectedEpic === epic.issueId ? 'white' : '#333',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{selectedEpic === epic.issueId ? 'Скрыть' : 'Показать'} истории
|
||||
</button>
|
||||
<div className="font-bold truncate">{epic.summary}</div>
|
||||
{epic.components && epic.components.length > 0 && (
|
||||
<div className="flex gap-1 mt-1 flex-wrap">
|
||||
{epic.components.map(c => <span key={c} className="badge bg-blue-100 text-blue-700 text-[10px]">{c}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 ml-4">
|
||||
{epic.stories && <span className="text-xs text-gray-500">{epic.stories.length} задач</span>}
|
||||
<span className="text-lg">{expanded === epic.issueId ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedEpic === epic.issueId && epic.stories && epic.stories.length > 0 && (
|
||||
<div style={{ marginTop: '20px', paddingTop: '20px', borderTop: '1px solid #e0e0e0' }}>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '15px' }}>
|
||||
Истории ({epic.stories.length})
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
|
||||
{epic.stories.map((story) => (
|
||||
<div
|
||||
key={story.issueId}
|
||||
style={{
|
||||
padding: '15px',
|
||||
backgroundColor: '#f9f9f9',
|
||||
borderRadius: '6px',
|
||||
borderLeft: '4px solid #2196f3',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '5px' }}>
|
||||
<span style={{ fontWeight: 'bold' }}>📝 {story.summary}</span>
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
borderRadius: '3px',
|
||||
backgroundColor: getPriorityColor(story.priority),
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
}}
|
||||
>
|
||||
{getPriorityLabel(story.priority)}
|
||||
</span>
|
||||
{story.storyPoints && (
|
||||
<span style={{ fontSize: '12px', color: '#666' }}>
|
||||
{story.storyPoints} SP
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: '12px', color: '#999' }}>ID: {story.issueId}</span>
|
||||
</div>
|
||||
{story.description && (
|
||||
<p style={{ color: '#666', fontSize: '13px', margin: '5px 0' }}>{story.description}</p>
|
||||
)}
|
||||
{story.acceptanceCriteria && (
|
||||
<div style={{ marginTop: '8px', padding: '8px', backgroundColor: '#fff3cd', borderRadius: '4px' }}>
|
||||
<strong style={{ fontSize: '12px' }}>Критерии приемки:</strong>
|
||||
<pre style={{ fontSize: '12px', margin: '5px 0 0 0', whiteSpace: 'pre-wrap' }}>
|
||||
{story.acceptanceCriteria}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{expanded === epic.issueId && (
|
||||
<div className="border-t border-gray-100 px-5 pb-4">
|
||||
{epic.description && <div className="text-sm text-gray-600 py-3 border-b border-gray-50">{epic.description}</div>}
|
||||
{epic.stories && epic.stories.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{epic.stories.map(story => (
|
||||
<div key={story.issueId} className="flex items-start gap-3 py-2 border-b border-gray-50 last:border-0">
|
||||
<span className="font-mono text-xs text-gray-400 mt-0.5 min-w-[80px]">{story.issueId}</span>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm">{story.summary}</div>
|
||||
{story.labels && story.labels.length > 0 && (
|
||||
<div className="flex gap-1 mt-1">{story.labels.map(l => <span key={l} className="badge bg-gray-100 text-gray-600 text-[10px]">{l}</span>)}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{story.subtasks && story.subtasks.length > 0 && (
|
||||
<div style={{ marginTop: '10px', paddingTop: '10px', borderTop: '1px solid #ddd' }}>
|
||||
<strong style={{ fontSize: '12px' }}>Подзадачи ({story.subtasks.length}):</strong>
|
||||
<ul style={{ margin: '5px 0', paddingLeft: '20px' }}>
|
||||
{story.subtasks.map((subtask) => (
|
||||
<li key={subtask.issueId} style={{ fontSize: '12px', margin: '3px 0' }}>
|
||||
{subtask.summary} <span style={{ color: '#999' }}>({subtask.issueId})</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<StatusBadge status={story.priority} colorMap={{ Highest: 'bg-red-600', High: 'bg-orange-500', Medium: 'bg-yellow-500', Low: 'bg-green-500' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dependencies.length > 0 && (
|
||||
<div style={{ marginTop: '30px', padding: '20px', backgroundColor: '#f5f5f5', borderRadius: '8px' }}>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '15px' }}>
|
||||
Зависимости между задачами ({dependencies.length})
|
||||
</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '10px' }}>
|
||||
{dependencies.map((dep, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
padding: '10px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<strong>{dep.fromIssueId}</strong> → <strong>{dep.toIssueId}</strong>
|
||||
<span style={{ color: '#666', marginLeft: '8px' }}>({dep.linkType})</span>
|
||||
) : <div className="text-sm text-gray-400 py-3">Нет подзадач</div>}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <EmptyState message="Нет задач. Импортируйте CSV из Jira." />}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,11 +1,18 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { ReactNode } from 'react'
|
||||
import Script from 'next/script'
|
||||
import './globals.css'
|
||||
import { Providers } from './providers'
|
||||
import ErrorBoundary from '@/components/ErrorBoundary'
|
||||
import SkipToMain from '@/components/SkipToMain'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'REFLY - Контроль лётной годности',
|
||||
description: 'Система контроля лётной годности воздушных судов',
|
||||
title: 'REFLY — Контроль лётной годности',
|
||||
description: 'Система контроля лётной годности воздушных судов · АО REFLY',
|
||||
manifest: '/manifest.json',
|
||||
themeColor: '#1e3a5f',
|
||||
viewport: 'width=device-width, initial-scale=1, maximum-scale=5',
|
||||
appleWebApp: { capable: true, statusBarStyle: 'black-translucent', title: 'КЛГ' },
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
@ -14,11 +21,21 @@ export default function RootLayout({
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="ru">
|
||||
<html lang="ru" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||
</head>
|
||||
<body>
|
||||
<Providers>
|
||||
<ErrorBoundary>
|
||||
<SkipToMain />
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</Providers>
|
||||
<Script id="sw-register" strategy="afterInteractive">
|
||||
{`if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').catch(function(){}); }`}
|
||||
</Script>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@ -1,62 +1,164 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import Logo from "@/components/Logo";
|
||||
|
||||
const MOCK_TASKS = [
|
||||
{ id: "mt-001", taskNumber: "WO-2026-0041", aircraft: "RA-73701", aircraftType: "Boeing 737-800", type: "C-Check", status: "overdue", assignedTo: "S7 Technics", startDate: "2026-01-20", dueDate: "2026-01-27", description: "Плановый C-Check по программе ТО" },
|
||||
{ id: "mt-002", taskNumber: "WO-2026-0042", aircraft: "RA-89002", aircraftType: "SSJ-100", type: "A-Check", status: "in_progress", assignedTo: "REFLY MRO", startDate: "2026-02-05", dueDate: "2026-02-12", description: "A-Check каждые 750 лётных часов" },
|
||||
{ id: "mt-003", taskNumber: "WO-2026-0043", aircraft: "RA-02801", aircraftType: "Mi-8MTV-1", type: "Периодическое ТО", status: "in_progress", assignedTo: "UTair Engineering", startDate: "2026-02-01", dueDate: "2026-02-15", description: "100-часовая форма + замена масла" },
|
||||
{ id: "mt-004", taskNumber: "WO-2026-0044", aircraft: "RA-73702", aircraftType: "Boeing 737-800", type: "Линейное ТО", status: "planned", assignedTo: "REFLY MRO", startDate: "2026-02-20", dueDate: "2026-02-21", description: "Transit check после дальнемагистрального рейса" },
|
||||
{ id: "mt-005", taskNumber: "WO-2026-0045", aircraft: "RA-89001", aircraftType: "SSJ-100", type: "AD выполнение", status: "planned", assignedTo: "S7 Technics", startDate: "2026-03-01", dueDate: "2026-03-05", description: "Выполнение EASA AD 2025-0198" },
|
||||
{ id: "mt-006", taskNumber: "WO-2026-0046", aircraft: "RA-96017", aircraftType: "Il-96-300", type: "D-Check", status: "completed", assignedTo: "VASO MRO", startDate: "2025-09-01", dueDate: "2025-12-15", description: "Капитальный ремонт D-Check" },
|
||||
{ id: "mt-007", taskNumber: "WO-2026-0047", aircraft: "RA-76511", aircraftType: "Il-76TD-90VD", type: "B-Check", status: "completed", assignedTo: "Volga-Dnepr Technics", startDate: "2025-11-10", dueDate: "2025-12-01", description: "B-Check по программе ТО изготовителя" },
|
||||
];
|
||||
|
||||
const sColors: Record<string, string> = { overdue: "#d32f2f", in_progress: "#2196f3", planned: "#ff9800", completed: "#4caf50" };
|
||||
const sLabels: Record<string, string> = { overdue: "Просрочено", in_progress: "В работе", planned: "Запланировано", completed: "Завершено" };
|
||||
/**
|
||||
* Техническое обслуживание — наряды на ТО (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 [filter, setFilter] = useState("all");
|
||||
const filtered = filter === "all" ? MOCK_TASKS : MOCK_TASKS.filter(t => t.status === filter);
|
||||
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 (
|
||||
<div style={{ display: "flex", minHeight: "100vh" }}>
|
||||
<Sidebar />
|
||||
<div style={{ marginLeft: "280px", flex: 1, padding: "32px" }}>
|
||||
<Logo size="large" />
|
||||
<p style={{ color: "#666", margin: "16px 0 24px" }}>Управление техническим обслуживанием воздушных судов</p>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "24px" }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: "24px", fontWeight: "bold", marginBottom: "8px" }}>Техническое обслуживание</h2>
|
||||
<p style={{ fontSize: "14px", color: "#666" }}>Рабочие задания (Work Orders) — EASA Part-145 / ФАП-145</p>
|
||||
</div>
|
||||
<button style={{ padding: "10px 20px", background: "#1e3a5f", color: "white", border: "none", borderRadius: "6px", cursor: "pointer", fontWeight: 600 }}>+ Новое задание</button>
|
||||
<>
|
||||
{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>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "12px", marginBottom: "24px" }}>
|
||||
{[["overdue","Просрочено","#ffebee"],["in_progress","В работе","#e3f2fd"],["planned","Запланировано","#fff3e0"],["completed","Завершено","#e8f5e9"]].map(([s,l,bg]) => (
|
||||
<div key={s} onClick={() => setFilter(filter===s?"all":s)} style={{ background: bg, padding: "16px", borderRadius: "8px", textAlign: "center", cursor: "pointer", border: filter===s ? "2px solid #1e3a5f" : "2px solid transparent" }}>
|
||||
<div style={{ fontSize: "28px", fontWeight: "bold", color: sColors[s] }}>{MOCK_TASKS.filter(t=>t.status===s).length}</div>
|
||||
<div style={{ fontSize: "13px", color: "#666" }}>{l}</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>
|
||||
))}
|
||||
</div>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", background: "white" }}>
|
||||
<thead><tr style={{ background: "#1e3a5f", color: "white" }}>
|
||||
{["WO №","ВС","ТИП ВС","ФОРМА ТО","ИСПОЛНИТЕЛЬ","СТАТУС","СРОК"].map(h => <th key={h} style={{ padding: "12px", textAlign: "left", fontSize: "12px" }}>{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>{filtered.map(t => (
|
||||
<tr key={t.id} style={{ borderBottom: "1px solid #e0e0e0", background: t.status==="overdue" ? "#fff5f5" : "transparent" }}>
|
||||
<td style={{ padding: "12px", fontWeight: 600 }}>{t.taskNumber}</td>
|
||||
<td style={{ padding: "12px" }}>{t.aircraft}</td>
|
||||
<td style={{ padding: "12px", fontSize: "13px" }}>{t.aircraftType}</td>
|
||||
<td style={{ padding: "12px" }}>{t.type}</td>
|
||||
<td style={{ padding: "12px", fontSize: "13px" }}>{t.assignedTo}</td>
|
||||
<td style={{ padding: "12px" }}><span style={{ padding: "3px 8px", borderRadius: "4px", fontSize: "11px", color: "white", background: sColors[t.status] }}>{sLabels[t.status]}</span></td>
|
||||
<td style={{ padding: "12px", fontSize: "13px" }}>{t.dueDate}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
{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>
|
||||
);
|
||||
|
||||
@ -1,60 +1,28 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import Logo from "@/components/Logo";
|
||||
|
||||
const MOCK_MODS = [
|
||||
{ id: "mod-001", number: "SB-737-57-1326", title: "Усиление нервюры крыла", aircraft: "Boeing 737-800", applicability: "RA-73701, RA-73702, RA-73704", type: "SB", status: "approved", approvedBy: "Росавиация", date: "2026-01-10" },
|
||||
{ id: "mod-002", number: "STC-SSJ-2025-014", title: "Установка системы TCAS II v7.1", aircraft: "Sukhoi Superjet 100", applicability: "RA-89001, RA-89002, RA-89003, RA-89004", type: "STC", status: "in_progress", approvedBy: "EASA", date: "2025-11-20" },
|
||||
{ id: "mod-003", number: "EO-MI8-2026-003", title: "Модификация топливной системы", aircraft: "Mi-8MTV-1", applicability: "RA-02801", type: "EO", status: "planned", approvedBy: "Росавиация", date: "2026-02-01" },
|
||||
{ id: "mod-004", number: "SB-IL96-72-0045", title: "Замена блоков FADEC двигателей ПС-90А", aircraft: "Il-96-300", applicability: "RA-96017", type: "SB", status: "completed", approvedBy: "Росавиация", date: "2025-08-15" },
|
||||
{ id: "mod-005", number: "AD-MOD-IL76-2025", title: "Доработка системы наддува по AD", aircraft: "Il-76TD-90VD", applicability: "RA-76511", type: "AD compliance", status: "completed", approvedBy: "Росавиация", date: "2025-10-01" },
|
||||
];
|
||||
|
||||
const stColors: Record<string,string> = { approved: "#ff9800", in_progress: "#2196f3", planned: "#9c27b0", completed: "#4caf50" };
|
||||
const stLabels: Record<string,string> = { approved: "Одобрена", in_progress: "Выполняется", planned: "Запланирована", completed: "Завершена" };
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { PageLayout, DataTable, StatusBadge, EmptyState } from '@/components/ui';
|
||||
|
||||
export default function ModificationsPage() {
|
||||
const [filter, setFilter] = useState("all");
|
||||
const filtered = filter === "all" ? MOCK_MODS : MOCK_MODS.filter(m => m.status === filter);
|
||||
|
||||
const [mods, setMods] = useState([] as any[]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
setLoading(true); fetch('/api/v1/modifications').then(r => r.json()).then(d => { setMods(d.items || []); setLoading(false); }); }, []);
|
||||
return (
|
||||
<div style={{ display: "flex", minHeight: "100vh" }}>
|
||||
<Sidebar />
|
||||
<div style={{ marginLeft: "280px", flex: 1, padding: "32px" }}>
|
||||
<Logo size="large" />
|
||||
<p style={{ color: "#666", margin: "16px 0 24px" }}>Модификации и доработки воздушных судов</p>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "24px" }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: "24px", fontWeight: "bold", marginBottom: "8px" }}>Модификации ВС</h2>
|
||||
<p style={{ fontSize: "14px", color: "#666" }}>Service Bulletins, STC, Engineering Orders — EASA Part-21, Росавиация</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "12px", marginBottom: "24px" }}>
|
||||
{[["approved","Одобрена","#fff3e0"],["in_progress","Выполняется","#e3f2fd"],["planned","Запланирована","#f3e5f5"],["completed","Завершена","#e8f5e9"]].map(([s,l,bg]) => (
|
||||
<div key={s} onClick={() => setFilter(filter===s?"all":s)} style={{ background: bg, padding: "16px", borderRadius: "8px", textAlign: "center", cursor: "pointer", border: filter===s ? "2px solid #1e3a5f" : "2px solid transparent" }}>
|
||||
<div style={{ fontSize: "28px", fontWeight: "bold", color: stColors[s] }}>{MOCK_MODS.filter(m=>m.status===s).length}</div>
|
||||
<div style={{ fontSize: "13px", color: "#666" }}>{l}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", background: "white" }}>
|
||||
<thead><tr style={{ background: "#1e3a5f", color: "white" }}>
|
||||
{["НОМЕР","ОПИСАНИЕ","ТИП ВС","ТИП","ПРИМЕНИМОСТЬ","СТАТУС","ОДОБРЕНО"].map(h => <th key={h} style={{ padding: "12px", textAlign: "left", fontSize: "12px" }}>{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>{filtered.map(m => (
|
||||
<tr key={m.id} style={{ borderBottom: "1px solid #e0e0e0" }}>
|
||||
<td style={{ padding: "12px", fontWeight: 600, fontSize: "13px" }}>{m.number}</td>
|
||||
<td style={{ padding: "12px", fontSize: "13px" }}>{m.title}</td>
|
||||
<td style={{ padding: "12px" }}>{m.aircraft}</td>
|
||||
<td style={{ padding: "12px" }}><span style={{ padding: "3px 8px", borderRadius: "4px", fontSize: "11px", background: "#e0e0e0" }}>{m.type}</span></td>
|
||||
<td style={{ padding: "12px", fontSize: "12px", color: "#666" }}>{m.applicability}</td>
|
||||
<td style={{ padding: "12px" }}><span style={{ padding: "3px 8px", borderRadius: "4px", fontSize: "11px", color: "white", background: stColors[m.status] }}>{stLabels[m.status]}</span></td>
|
||||
<td style={{ padding: "12px", fontSize: "13px" }}>{m.approvedBy}<br/><span style={{ fontSize: "11px", color: "#999" }}>{m.date}</span></td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</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="ФАП-21; EASA Part-21.A.97; ICAO Annex 8">
|
||||
{mods.length > 0 ? (
|
||||
<DataTable columns={[
|
||||
{ key: 'number', label: '№' },
|
||||
{ key: 'title', label: 'Наименование' },
|
||||
{ key: 'aircraft_reg', label: 'Борт' },
|
||||
{ key: 'mod_type', label: 'Тип' },
|
||||
{ key: 'status', label: 'Статус', render: (v: string) => (
|
||||
<StatusBadge status={v} colorMap={{ pending: 'bg-yellow-500', approved: 'bg-green-500', incorporated: 'bg-blue-500', rejected: 'bg-red-500' }} />
|
||||
)},
|
||||
]} data={mods} />
|
||||
) : <EmptyState message="Нет модификаций" />}
|
||||
</PageLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,223 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
|
||||
interface HealthStatus {
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
checks: {
|
||||
database: { status: 'ok' | 'error'; message?: string };
|
||||
redis: { status: 'ok' | 'error'; message?: string };
|
||||
disk: { status: 'ok' | 'error'; message?: string; freeSpace?: number };
|
||||
};
|
||||
timestamp: string;
|
||||
}
|
||||
import { PageLayout, DataTable, StatCard } from '@/components/ui';
|
||||
import { healthApi, auditLogApi } from '@/lib/api/api-client';
|
||||
|
||||
export default function MonitoringPage() {
|
||||
const [health, setHealth] = useState<HealthStatus | null>(null);
|
||||
const [metrics, setMetrics] = useState<any>(null);
|
||||
const [health, setHealth] = useState<any>(null);
|
||||
const [logs, setLogs] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
const interval = setInterval(loadData, 30000); // Обновление каждые 30 секунд
|
||||
return () => clearInterval(interval);
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const [h, l] = await Promise.all([healthApi.check().catch(() => ({ status: 'unreachable' })), auditLogApi.list({ per_page: 20 }).catch(() => ({ items: [] }))]);
|
||||
setHealth(h); setLogs(l?.items || []); setLoading(false);
|
||||
};
|
||||
load(); const iv = setInterval(load, 30000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [healthRes, metricsRes] = await Promise.all([
|
||||
fetch('/api/health').catch(err => {
|
||||
console.warn('[Monitoring] Ошибка загрузки health:', err);
|
||||
return { ok: false, json: async () => ({ status: 'unhealthy', checks: {}, timestamp: new Date() }) };
|
||||
}),
|
||||
fetch('/api/metrics?type=performance').catch(err => {
|
||||
console.warn('[Monitoring] Ошибка загрузки metrics:', err);
|
||||
return { ok: false, json: async () => ({ stats: { count: 0, avgDuration: 0, minDuration: 0, maxDuration: 0, errorRate: 0 } }) };
|
||||
}),
|
||||
]);
|
||||
|
||||
const healthData = await healthRes.json();
|
||||
const metricsData = await metricsRes.json();
|
||||
|
||||
console.log('[Monitoring] Данные загружены:', { health: healthData.status, metrics: metricsData.stats?.count || 0 });
|
||||
setHealth(healthData);
|
||||
setMetrics(metricsData);
|
||||
} catch (error: any) {
|
||||
console.error('[Monitoring] Ошибка загрузки данных мониторинга:', error);
|
||||
setHealth(null);
|
||||
setMetrics(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
case 'ok':
|
||||
return '#4caf50';
|
||||
case 'degraded':
|
||||
return '#ff9800';
|
||||
case 'unhealthy':
|
||||
case 'error':
|
||||
return '#f44336';
|
||||
default:
|
||||
return '#666';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return 'Здоров';
|
||||
case 'degraded':
|
||||
return 'Работает (некритичные предупреждения)';
|
||||
case 'unhealthy':
|
||||
return 'Неисправен';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<Sidebar />
|
||||
<div style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h1 style={{ fontSize: '32px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
Мониторинг системы
|
||||
</h1>
|
||||
<p style={{ fontSize: '16px', color: '#666' }}>
|
||||
Состояние системы и метрики производительности
|
||||
</p>
|
||||
<PageLayout title="Мониторинг" subtitle={loading ? 'Загрузка...' : undefined}>
|
||||
{!loading && <>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard label="Статус" value={health?.status || '—'} border={health?.status === 'ok' ? 'border-l-green-500' : 'border-l-red-500'} />
|
||||
<StatCard label="БД" value={health?.db || '—'} border={health?.db === 'ok' ? 'border-l-green-500' : 'border-l-red-500'} />
|
||||
<StatCard label="WS подкл." value={`${health?.ws_active_connections || 0}`} border="border-l-blue-500" />
|
||||
<StatCard label="WS юзеров" value={`${health?.ws_active_users || 0}`} border="border-l-blue-500" />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<div>Загрузка данных мониторинга...</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Health Status */}
|
||||
{health && (
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||
Состояние системы
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '24px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
borderLeft: `4px solid ${getStatusColor(health.status)}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '4px' }}>
|
||||
Общий статус: {getStatusText(health.status)}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
Последняя проверка: {new Date(health.timestamp).toLocaleString('ru-RU')}
|
||||
</div>
|
||||
{health.status === 'degraded' && (
|
||||
<div style={{ fontSize: '12px', color: '#ff9800', marginTop: '4px' }}>
|
||||
⚠️ Система работает в режиме без БД/Redis (используются мок-данные)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: getStatusColor(health.status),
|
||||
color: 'white',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{health.status === 'degraded' ? 'DEGRADED' : health.status.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
{Object.entries(health.checks).map(([key, check]) => (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
borderLeft: `3px solid ${getStatusColor(check.status)}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '14px', fontWeight: 'bold', marginBottom: '4px', textTransform: 'capitalize' }}>
|
||||
{key === 'database' ? 'База данных' : key === 'redis' ? 'Redis' : 'Диск'}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: check.status === 'ok' ? '#4caf50' : '#f44336' }}>
|
||||
{check.status === 'ok' ? '✓ Работает' : `✗ Ошибка: ${check.message || 'Неизвестная ошибка'}`}
|
||||
</div>
|
||||
{'freeSpace' in check && check.freeSpace !== undefined && (
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
||||
Свободно: {check.freeSpace}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Performance Metrics */}
|
||||
{metrics && metrics.stats && (
|
||||
<div>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||
Метрики производительности
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '24px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>Всего запросов</div>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>{metrics.stats.count}</div>
|
||||
</div>
|
||||
<div style={{ padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>Среднее время ответа</div>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>
|
||||
{metrics.stats.avgDuration.toFixed(0)}ms
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>Минимальное время</div>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>{metrics.stats.minDuration}ms</div>
|
||||
</div>
|
||||
<div style={{ padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>Максимальное время</div>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>{metrics.stats.maxDuration}ms</div>
|
||||
</div>
|
||||
<div style={{ padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>Процент ошибок</div>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold', color: metrics.stats.errorRate > 0.1 ? '#f44336' : '#4caf50' }}>
|
||||
{(metrics.stats.errorRate * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold mb-3">Последние события</h3>
|
||||
<DataTable data={logs} emptyMessage="Нет событий" columns={[
|
||||
{ key: 'created_at', header: 'Время', render: (l: any) => <span className="text-xs">{l.created_at ? new Date(l.created_at).toLocaleString('ru-RU') : '—'}</span> },
|
||||
{ key: 'action', header: 'Действие', render: (l: any) => <span className={`badge ${l.action === 'create' ? 'bg-green-100 text-green-700' : l.action === 'delete' ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700'}`}>{l.action}</span> },
|
||||
{ key: 'entity_type', header: 'Объект', className: 'text-xs' },
|
||||
{ key: 'user_email', header: 'Пользователь', className: 'text-xs' },
|
||||
{ key: 'description', header: 'Описание', className: 'text-xs text-gray-500' },
|
||||
]} />
|
||||
</>}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,331 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import OrganizationDetailsModal from '@/components/OrganizationDetailsModal';
|
||||
import { useState } from 'react';
|
||||
import { PageLayout, Pagination, EmptyState } from '@/components/ui';
|
||||
import OrganizationCreateModal from '@/components/OrganizationCreateModal';
|
||||
import OrganizationEditModal from '@/components/OrganizationEditModal';
|
||||
import SearchModal from '@/components/SearchModal';
|
||||
import Logo from '@/components/Logo';
|
||||
import { aircraftApi, Aircraft } from '@/lib/api';
|
||||
import { useOrganizationsData } from '@/hooks/useSWRData';
|
||||
import { organizationsApi } from '@/lib/api/api-client';
|
||||
import { RequireRole } from '@/lib/auth-context';
|
||||
|
||||
const KIND: Record<string, string> = { operator: '✈️ Оператор', mro: '🔧 ТОиР', authority: '🏛️ Орган власти' };
|
||||
|
||||
export default function OrganizationsPage() {
|
||||
const [aircraft, setAircraft] = useState<Aircraft[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [operators, setOperators] = useState<Map<string, Aircraft[]>>(new Map());
|
||||
const [selectedOrganization, setSelectedOrganization] = useState<string | null>(null);
|
||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
|
||||
const [searchOrganization, setSearchOrganization] = useState<string | null>(null);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [editingOrganization, setEditingOrganization] = useState<{ name: string; type?: string; address?: string; contact?: string; email?: string; phone?: string } | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const { data, isLoading, mutate } = useOrganizationsData({ q: search || undefined, page, per_page: 25 });
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [editingOrg, setEditingOrg] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const data = await aircraftApi.getAircraft();
|
||||
setAircraft(data);
|
||||
|
||||
// Группируем по операторам
|
||||
const operatorsMap = new Map<string, Aircraft[]>();
|
||||
data.forEach(a => {
|
||||
const operator = a.operator && a.operator !== 'Не указан' ? a.operator : 'Не указан';
|
||||
if (!operatorsMap.has(operator)) {
|
||||
operatorsMap.set(operator, []);
|
||||
}
|
||||
operatorsMap.get(operator)!.push(a);
|
||||
});
|
||||
|
||||
setOperators(operatorsMap);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки данных:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, []);
|
||||
const orgs = data?.items || [];
|
||||
|
||||
const operatorsList = Array.from(operators.entries()).sort((a, b) => b[1].length - a[1].length);
|
||||
|
||||
const handleShowDetails = (operator: string) => {
|
||||
setSelectedOrganization(operator);
|
||||
setIsDetailsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSearch = (operator: string) => {
|
||||
setSearchOrganization(operator);
|
||||
setIsSearchModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditAircraft = (editedAircraft: Aircraft) => {
|
||||
// Обновляем данные в состоянии
|
||||
setAircraft(prev => prev.map(a => a.id === editedAircraft.id ? editedAircraft : a));
|
||||
|
||||
// Обновляем группировку по операторам
|
||||
const operatorsMap = new Map<string, Aircraft[]>();
|
||||
const updatedAircraft = aircraft.map(a => a.id === editedAircraft.id ? editedAircraft : a);
|
||||
updatedAircraft.forEach(a => {
|
||||
const operator = a.operator && a.operator !== 'Не указан' ? a.operator : 'Не указан';
|
||||
if (!operatorsMap.has(operator)) {
|
||||
operatorsMap.set(operator, []);
|
||||
}
|
||||
operatorsMap.get(operator)!.push(a);
|
||||
});
|
||||
setOperators(operatorsMap);
|
||||
|
||||
alert(`Данные воздушного судна ${editedAircraft.registrationNumber} обновлены`);
|
||||
};
|
||||
|
||||
const getOrganizationAircraft = (operator: string): Aircraft[] => {
|
||||
return operators.get(operator) || [];
|
||||
};
|
||||
|
||||
const getSearchAircraft = (): Aircraft[] => {
|
||||
// Если searchOrganization null, ищем по всем aircraft
|
||||
// Если указана организация, ищем только в её aircraft
|
||||
if (searchOrganization === null) {
|
||||
return aircraft;
|
||||
}
|
||||
return operators.get(searchOrganization) || [];
|
||||
};
|
||||
|
||||
const handleCreateOrganization = (organizationData: any) => {
|
||||
// Здесь можно добавить логику сохранения новой организации
|
||||
// Пока просто показываем уведомление
|
||||
alert(`Организация "${organizationData.name}" успешно создана`);
|
||||
// В реальном приложении здесь был бы вызов API для сохранения
|
||||
};
|
||||
|
||||
const handleEdit = (operator: string) => {
|
||||
// Создаём объект организации из названия оператора
|
||||
setEditingOrganization({
|
||||
name: operator,
|
||||
type: 'Авиакомпания', // По умолчанию, можно будет изменить в форме
|
||||
});
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveOrganization = (updatedOrganization: any) => {
|
||||
// Здесь можно добавить логику обновления организации
|
||||
// Пока просто показываем уведомление
|
||||
alert(`Организация "${updatedOrganization.name}" успешно обновлена`);
|
||||
// В реальном приложении здесь был бы вызов API для обновления
|
||||
// Также нужно обновить названия операторов в aircraft, если изменилось название организации
|
||||
};
|
||||
const handleCreate = async (d: any) => { try { await organizationsApi.create(d); mutate(); setIsCreateOpen(false); } catch (e: any) { alert(e.message); } };
|
||||
const handleSave = async (d: any) => { if (!editingOrg?.id) return; try { await organizationsApi.update(editingOrg.id, d); mutate(); setIsEditOpen(false); } catch (e: any) { alert(e.message); } };
|
||||
const handleDelete = async (id: string) => { if (!confirm('Удалить?')) return; try { await organizationsApi.delete(id); mutate(); } catch (e: any) { alert(e.message); } };
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<Sidebar />
|
||||
<div style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<Logo size="large" />
|
||||
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||
Система контроля лётной годности воздушных судов · Безопасность и качество
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
Организации
|
||||
</h2>
|
||||
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||
Управление организациями и операторами воздушных судов
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchOrganization(null);
|
||||
setIsSearchModalOpen(true);
|
||||
}}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#2196f3',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Поиск
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Добавить организацию
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<div>Загрузка данных...</div>
|
||||
</div>
|
||||
) : operatorsList.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{operatorsList.map(([operator, aircraftList]) => (
|
||||
<div key={operator} style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '24px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '4px' }}>
|
||||
{operator}
|
||||
</h3>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
{aircraftList.length} воздушных судов
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => handleSearch(operator)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#2196f3',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Поиск
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleShowDetails(operator)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
color: '#333',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Подробнее
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(operator)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Редактировать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gap: '12px',
|
||||
}}>
|
||||
{aircraftList.slice(0, 5).map(a => (
|
||||
<div key={a.id} style={{
|
||||
padding: '12px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
<div style={{ fontWeight: '500' }}>{a.registrationNumber}</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>{a.aircraftType}</div>
|
||||
</div>
|
||||
))}
|
||||
{aircraftList.length > 5 && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
+{aircraftList.length - 5} ещё
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start' }}>
|
||||
<span style={{ fontSize: '20px', marginRight: '12px' }}>ℹ️</span>
|
||||
<PageLayout title="Организации" subtitle={isLoading ? 'Загрузка...' : `Всего: ${data?.total || 0}`}
|
||||
actions={<>
|
||||
<input type="text" placeholder="Поиск..." value={search} onChange={e => { setSearch(e.target.value); setPage(1); }} className="input-field w-60" />
|
||||
<RequireRole roles={['admin', 'authority_inspector']}><button onClick={() => setIsCreateOpen(true)} className="btn-primary">Добавить</button></RequireRole>
|
||||
</>}>
|
||||
{isLoading ? <div className="text-center py-10 text-gray-400">Загрузка...</div> : orgs.length > 0 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{orgs.map((org: any) => (
|
||||
<div key={org.id} className="card p-5 flex justify-between items-center">
|
||||
<div>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>Нет данных</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
Организации не найдены. Проверьте данные реестра.
|
||||
</div>
|
||||
<h3 className="text-lg font-bold">{org.name}</h3>
|
||||
<p className="text-sm text-gray-500">{KIND[org.kind] || org.kind}{org.inn && ` · ИНН: ${org.inn}`}{org.address && ` · ${org.address}`}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<RequireRole roles={['admin', 'authority_inspector']}>
|
||||
<button onClick={() => { setEditingOrg(org); setIsEditOpen(true); }} className="btn-sm bg-primary-500 text-white">Ред.</button>
|
||||
<button onClick={() => handleDelete(org.id)} className="btn-sm bg-red-500 text-white">Удал.</button>
|
||||
</RequireRole>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<OrganizationDetailsModal
|
||||
isOpen={isDetailsModalOpen}
|
||||
onClose={() => {
|
||||
setIsDetailsModalOpen(false);
|
||||
setSelectedOrganization(null);
|
||||
}}
|
||||
organization={selectedOrganization || ''}
|
||||
aircraft={selectedOrganization ? getOrganizationAircraft(selectedOrganization) : []}
|
||||
onEdit={handleEditAircraft}
|
||||
/>
|
||||
|
||||
<SearchModal
|
||||
isOpen={isSearchModalOpen}
|
||||
onClose={() => {
|
||||
setIsSearchModalOpen(false);
|
||||
setSearchOrganization(null);
|
||||
}}
|
||||
aircraft={getSearchAircraft()}
|
||||
searchType="organization"
|
||||
/>
|
||||
|
||||
<OrganizationCreateModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onCreate={handleCreateOrganization}
|
||||
/>
|
||||
|
||||
<OrganizationEditModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={() => {
|
||||
setIsEditModalOpen(false);
|
||||
setEditingOrganization(null);
|
||||
}}
|
||||
organization={editingOrganization}
|
||||
onSave={handleSaveOrganization}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Pagination page={page} pages={data?.pages || 1} onPageChange={setPage} />
|
||||
</div>
|
||||
) : <EmptyState message={`Организации не найдены.${search ? ' Попробуйте другой запрос.' : ''}`} />}
|
||||
<OrganizationCreateModal isOpen={isCreateOpen} onClose={() => setIsCreateOpen(false)} onCreate={handleCreate} />
|
||||
<OrganizationEditModal isOpen={isEditOpen} onClose={() => setIsEditOpen(false)} organization={editingOrg} onSave={handleSave} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
26
app/page.tsx
26
app/page.tsx
@ -1,23 +1,9 @@
|
||||
// app/page.tsx — КЛГ: система контроля лётной годности воздушных судов
|
||||
import Link from "next/link";
|
||||
/**
|
||||
* Root page — redirect to dashboard (or login if not authenticated).
|
||||
* Разработчик: АО «REFLY»
|
||||
*/
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main style={{ padding: 24, maxWidth: 900, margin: "0 auto" }}>
|
||||
<h1 style={{ fontSize: 32, fontWeight: 700 }}>REFLY — Контроль лётной годности</h1>
|
||||
<p style={{ marginTop: 12, fontSize: 16 }}>
|
||||
Система контроля лётной годности воздушных судов (КЛГ АСУ ТК).
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: 24, display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<Link href="/dashboard" style={{ color: "#1e3a5f", fontWeight: 600 }}>
|
||||
→ Дашборд
|
||||
</Link>
|
||||
<Link href="/aircraft" style={{ color: "#1e3a5f" }}>ВС и типы</Link>
|
||||
<Link href="/regulations" style={{ color: "#1e3a5f" }}>Нормативные документы</Link>
|
||||
<Link href="/airworthiness" style={{ color: "#1e3a5f" }}>Лётная годность</Link>
|
||||
<Link href="/organizations" style={{ color: "#1e3a5f" }}>Организации</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
redirect('/dashboard');
|
||||
}
|
||||
|
||||
@ -1,20 +1,29 @@
|
||||
/**
|
||||
* Провайдеры для приложения
|
||||
* Включает Error Boundary и другие глобальные провайдеры
|
||||
* App providers — Auth, I18n, dark mode initialization.
|
||||
* Разработчик: АО «REFLY»
|
||||
*/
|
||||
'use client';
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { AuthProvider } from '@/lib/auth-context';
|
||||
import { I18nProvider } from '@/hooks/useI18n';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import SkipToMain from '@/components/SkipToMain';
|
||||
function DarkModeInit() {
|
||||
useEffect(() => {
|
||||
// Apply saved theme on mount
|
||||
const saved = typeof localStorage !== 'undefined' ? localStorage.getItem('klg-theme') : null;
|
||||
const dark = saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.classList.toggle('dark', dark);
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<SkipToMain />
|
||||
<ErrorBoundary>
|
||||
<I18nProvider>
|
||||
<AuthProvider>
|
||||
<DarkModeInit />
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
</AuthProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,192 +1,56 @@
|
||||
/**
|
||||
* Regulations page — loads from /api/regulations.
|
||||
* Разработчик: АО «REFLY»
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import Logo from '@/components/Logo';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { PageLayout, FilterBar, StatusBadge, EmptyState } from '@/components/ui';
|
||||
import RegulationViewModal from '@/components/RegulationViewModal';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// ISR: revalidate работает только в серверных компонентах, не в клиентских
|
||||
// export const revalidate = 3600; // Удалено, так как это клиентский компонент
|
||||
|
||||
import { RegulationDocument } from '@/lib/regulations';
|
||||
|
||||
type Regulation = RegulationDocument;
|
||||
|
||||
export default function RegulationsPage() {
|
||||
const [selectedRegulation, setSelectedRegulation] = useState<Regulation | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [regulations, setRegulations] = useState<Regulation[]>([]);
|
||||
const [regulations, setRegulations] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<any>(null);
|
||||
const [sourceFilter, setSourceFilter] = useState<string | undefined>();
|
||||
|
||||
// Загрузка данных с кэшированием
|
||||
useEffect(() => {
|
||||
const loadRegulations = async () => {
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/regulations');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
// console.log('[Regulations] API response:', {
|
||||
isArray: Array.isArray(data),
|
||||
hasDocuments: !!data?.documents,
|
||||
documentsLength: data?.documents?.length || 0,
|
||||
total: data?.total
|
||||
});
|
||||
// API возвращает объект с documents и total
|
||||
const regulationsArray = Array.isArray(data)
|
||||
? data
|
||||
: (data?.documents || data?.data || []);
|
||||
console.log('[Regulations] Processed regulations:', regulationsArray.length);
|
||||
setRegulations(regulationsArray);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки нормативных документов:', error);
|
||||
setRegulations([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadRegulations();
|
||||
const r = await fetch('/api/regulations');
|
||||
const data = await r.json();
|
||||
setRegulations(Array.isArray(data) ? data : data?.documents || data?.data || []);
|
||||
} catch { setRegulations([]); }
|
||||
finally { setLoading(false); }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const handleViewRegulation = (regulation: Regulation) => {
|
||||
setSelectedRegulation(regulation);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const regulationsBySource = regulations.reduce((acc, reg) => {
|
||||
if (!acc[reg.source]) {
|
||||
acc[reg.source] = [];
|
||||
}
|
||||
acc[reg.source].push(reg);
|
||||
return acc;
|
||||
}, {} as Record<string, Regulation[]>);
|
||||
|
||||
// Логирование для отладки
|
||||
useEffect(() => {
|
||||
console.log('[Regulations] State:', {
|
||||
loading,
|
||||
regulationsCount: regulations.length,
|
||||
sourcesCount: Object.keys(regulationsBySource).length,
|
||||
sources: Object.keys(regulationsBySource),
|
||||
});
|
||||
}, [loading, regulations.length, regulationsBySource]);
|
||||
const sources = useMemo(() => [...new Set(regulations.map(r => r.source).filter(Boolean))], [regulations]);
|
||||
const filtered = sourceFilter ? regulations.filter(r => r.source === sourceFilter) : regulations;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<Sidebar />
|
||||
<div id="main-content" role="main" style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<Logo size="large" />
|
||||
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||
Нормативные документы и стандарты гражданской авиации
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
Нормативные документы
|
||||
</h2>
|
||||
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||
Международные и российские нормативные документы по гражданской авиации
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<div style={{ fontSize: '16px', color: '#666' }}>Загрузка документов...</div>
|
||||
</div>
|
||||
) : regulations.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<div style={{ fontSize: '16px', color: '#666', marginBottom: '16px' }}>Нормативные документы не найдены</div>
|
||||
<div style={{ fontSize: '14px', color: '#999', marginBottom: '16px' }}>
|
||||
Загружено: {regulations.length} документов
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
fetch('/api/regulations')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
console.log('[Regulations] Reload response:', data);
|
||||
const regulationsArray = Array.isArray(data) ? data : (data?.documents || data?.data || []);
|
||||
setRegulations(regulationsArray);
|
||||
})
|
||||
.catch(err => console.error('Ошибка загрузки:', err))
|
||||
.finally(() => setLoading(false));
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#2196f3',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Повторить загрузку
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
{Object.entries(regulationsBySource).map(([source, sourceRegulations]) => (
|
||||
<div key={source} style={{ backgroundColor: 'white', borderRadius: '8px', padding: '24px', boxShadow: '0 2px 4px rgba(0,0,0,0.1)' }}>
|
||||
<h3 style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||
{source}
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '12px' }}>
|
||||
{sourceRegulations.map((regulation) => (
|
||||
<div
|
||||
key={regulation.id}
|
||||
onClick={() => handleViewRegulation(regulation)}
|
||||
style={{
|
||||
padding: '16px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f5f5f5';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'white';
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500', marginBottom: '4px' }}>
|
||||
{regulation.title}
|
||||
</div>
|
||||
{regulation.version && (
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||
Версия: {regulation.version}
|
||||
</div>
|
||||
)}
|
||||
{regulation.lastUpdated && (
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||
Дата: {new Date(regulation.lastUpdated).toLocaleDateString('ru-RU')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<PageLayout title="Нормативные документы" subtitle={loading ? 'Загрузка...' : `Документов: ${filtered.length}`}>
|
||||
<FilterBar value={sourceFilter} onChange={setSourceFilter} className="mb-4"
|
||||
options={[{ value: undefined, label: 'Все' }, ...sources.map(s => ({ value: s, label: s }))]} />
|
||||
{loading ? <div className="text-center py-10 text-gray-400">Загрузка...</div> : filtered.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{filtered.map((reg: any) => (
|
||||
<div key={reg.id || reg.number} className="card p-4 flex justify-between items-center cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => setSelected(reg)}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="badge bg-primary-100 text-primary-700">{reg.source || '—'}</span>
|
||||
<span className="text-xs text-gray-400">{reg.number}</span>
|
||||
</div>
|
||||
<div className="font-medium text-sm truncate">{reg.title || reg.name}</div>
|
||||
{reg.type && <div className="text-xs text-gray-500 mt-1">Тип: {reg.type}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedRegulation && (
|
||||
<RegulationViewModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedRegulation(null);
|
||||
}}
|
||||
document={selectedRegulation}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-gray-300 ml-4">▶</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <EmptyState message="Нет документов." />}
|
||||
<RegulationViewModal isOpen={!!selected} onClose={() => setSelected(null)} regulation={selected} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,280 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import RiskDetailsModal from '@/components/RiskDetailsModal';
|
||||
import Logo from '@/components/Logo';
|
||||
|
||||
interface Risk {
|
||||
id: string;
|
||||
title: string;
|
||||
level: 'Низкий' | 'Средний' | 'Высокий' | 'Критический';
|
||||
category: string;
|
||||
aircraft: string;
|
||||
status: string;
|
||||
date: string;
|
||||
description?: string;
|
||||
impact?: string;
|
||||
probability?: string;
|
||||
mitigation?: string;
|
||||
responsible?: string;
|
||||
deadline?: string;
|
||||
}
|
||||
import { useState, useEffect } from 'react';
|
||||
import { PageLayout, DataTable, StatusBadge, EmptyState } from '@/components/ui';
|
||||
|
||||
export default function RisksPage() {
|
||||
const [filter, setFilter] = useState<'all' | 'critical' | 'high'>('all');
|
||||
const [risks, setRisks] = useState<Risk[]>([
|
||||
{
|
||||
id: '1',
|
||||
title: 'Превышение межремонтного ресурса',
|
||||
level: 'Высокий',
|
||||
category: 'Техническое состояние',
|
||||
aircraft: 'RA-12345',
|
||||
status: 'Требует внимания',
|
||||
date: '2025-01-20',
|
||||
description: 'Воздушное судно RA-12345 превысило установленный межремонтный ресурс на 150 часов. Требуется немедленное проведение планового технического обслуживания для обеспечения безопасности полетов.',
|
||||
impact: 'Высокое влияние на безопасность полетов. Возможны ограничения на эксплуатацию воздушного судна до проведения ремонта.',
|
||||
probability: 'Высокая вероятность возникновения инцидентов при продолжении эксплуатации без ремонта.',
|
||||
mitigation: '1. Немедленно ограничить эксплуатацию воздушного судна\n2. Назначить ответственного за проведение ремонта\n3. Провести плановое техническое обслуживание в течение 7 дней\n4. Обновить документацию после завершения ремонта',
|
||||
responsible: 'Иванов И.И., главный инженер',
|
||||
deadline: '2025-01-27',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Несоответствие документации',
|
||||
level: 'Средний',
|
||||
category: 'Документация',
|
||||
aircraft: 'RA-67890',
|
||||
status: 'В работе',
|
||||
date: '2025-01-19',
|
||||
description: 'Обнаружены расхождения между фактическим состоянием воздушного судна и документацией. Отсутствуют записи о последнем техническом обслуживании.',
|
||||
impact: 'Среднее влияние. Может привести к задержкам при проверках и аудитах.',
|
||||
probability: 'Средняя вероятность возникновения проблем при проверках.',
|
||||
mitigation: '1. Провести инвентаризацию документации\n2. Восстановить недостающие записи\n3. Согласовать документацию с фактическим состоянием\n4. Внедрить систему контроля документации',
|
||||
responsible: 'Петров П.П., специалист по документации',
|
||||
deadline: '2025-01-25',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Критическая неисправность системы управления',
|
||||
level: 'Критический',
|
||||
category: 'Техническое состояние',
|
||||
aircraft: 'RA-11111',
|
||||
status: 'Требует внимания',
|
||||
date: '2025-01-21',
|
||||
description: 'Обнаружена критическая неисправность в системе управления воздушным судном. Требуется немедленное устранение.',
|
||||
impact: 'Критическое влияние на безопасность полетов. Эксплуатация воздушного судна запрещена до устранения неисправности.',
|
||||
probability: 'Очень высокая вероятность аварии при продолжении эксплуатации.',
|
||||
mitigation: '1. Немедленно запретить эксплуатацию воздушного судна\n2. Провести полную диагностику системы управления\n3. Заменить неисправные компоненты\n4. Провести тестовые полеты после ремонта',
|
||||
responsible: 'Сидоров С.С., главный инженер',
|
||||
deadline: '2025-01-23',
|
||||
},
|
||||
]);
|
||||
|
||||
const [selectedRisk, setSelectedRisk] = useState<Risk | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const handleSaveRisk = (updatedRisk: Risk) => {
|
||||
setRisks(prev => prev.map(r => r.id === updatedRisk.id ? updatedRisk : r));
|
||||
setSelectedRisk(updatedRisk);
|
||||
};
|
||||
|
||||
const filteredRisks = risks.filter(risk => {
|
||||
if (filter === 'all') {
|
||||
return true;
|
||||
}
|
||||
if (filter === 'critical') {
|
||||
return risk.level === 'Критический';
|
||||
}
|
||||
if (filter === 'high') {
|
||||
return risk.level === 'Высокий';
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'Критический': return '#f44336';
|
||||
case 'Высокий': return '#ff9800';
|
||||
case 'Средний': return '#ffc107';
|
||||
case 'Низкий': return '#4caf50';
|
||||
default: return '#666';
|
||||
}
|
||||
};
|
||||
|
||||
const [risks, setRisks] = useState([] as any[]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('');
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const url = '/api/v1/risk-alerts' + (filter ? '?severity=' + filter : '');
|
||||
fetch(url).then(r => r.json()).then(d => { setRisks(d.items || []); });
|
||||
}, [filter]);
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<Sidebar />
|
||||
<div id="main-content" role="main" style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<Logo size="large" />
|
||||
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||
Система контроля лётной годности воздушных судов · Безопасность и качество
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
Риски
|
||||
</h2>
|
||||
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||
Управление рисками и оценка безопасности воздушных судов
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
Добавить риск
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px', display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: filter === 'all' ? '#1e3a5f' : 'transparent',
|
||||
color: filter === 'all' ? 'white' : '#1e3a5f',
|
||||
border: '1px solid #1e3a5f',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('critical')}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: filter === 'critical' ? '#f44336' : 'transparent',
|
||||
color: filter === 'critical' ? 'white' : '#1e3a5f',
|
||||
border: '1px solid #1e3a5f',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Критические
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('high')}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: filter === 'high' ? '#ff9800' : 'transparent',
|
||||
color: filter === 'high' ? 'white' : '#1e3a5f',
|
||||
border: '1px solid #1e3a5f',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Высокие
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filteredRisks.length === 0 ? (
|
||||
<div style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
padding: '40px',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
{filter === 'critical' && 'Критические риски не найдены'}
|
||||
{filter === 'high' && 'Высокие риски не найдены'}
|
||||
{filter === 'all' && 'Риски не найдены'}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
{filter === 'critical' && 'В данный момент нет критических рисков'}
|
||||
{filter === 'high' && 'В данный момент нет высоких рисков'}
|
||||
{filter === 'all' && 'Добавьте риски для отображения'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{filteredRisks.map(risk => (
|
||||
<div key={risk.id} style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '24px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
borderLeft: `4px solid ${getLevelColor(risk.level)}`,
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
{risk.title}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
Категория: {risk.category} | ВС: {risk.aircraft}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
Дата выявления: {risk.date}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<span style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
backgroundColor: getLevelColor(risk.level),
|
||||
color: 'white',
|
||||
}}>
|
||||
{risk.level}
|
||||
</span>
|
||||
<span style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
color: '#333',
|
||||
}}>
|
||||
{risk.status}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedRisk(risk);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Открыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RiskDetailsModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedRisk(null);
|
||||
}}
|
||||
risk={selectedRisk}
|
||||
onSave={handleSaveRisk}
|
||||
/>
|
||||
<>
|
||||
{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">
|
||||
<div className="flex gap-2 mb-4">
|
||||
{['', 'critical', 'high', 'medium', 'low'].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'}`}>{s || 'Все'}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{risks.length > 0 ? (
|
||||
<DataTable columns={[
|
||||
{ key: 'title', label: 'Риск' },
|
||||
{ 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) => (
|
||||
<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} />
|
||||
) : <EmptyState message="Нет зарегистрированных рисков" />}
|
||||
</PageLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,212 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import UserEditModal from '@/components/UserEditModal';
|
||||
import Logo from '@/components/Logo';
|
||||
import { PageLayout, DataTable, FilterBar, StatusBadge } from '@/components/ui';
|
||||
import { useUsersData } from '@/hooks/useSWRData';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
status: string;
|
||||
lastLogin: string;
|
||||
}
|
||||
const RL: Record<string,string> = { admin:'Администратор', authority_inspector:'Инспектор', operator_manager:'Менеджер оператора', operator_user:'Оператор', mro_manager:'Менеджер ТОиР', mro_user:'Специалист ТОиР' };
|
||||
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_user:'bg-purple-400' };
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([
|
||||
{
|
||||
id: '1',
|
||||
name: 'Иванов Иван Иванович',
|
||||
email: 'ivanov@klg.ru',
|
||||
role: 'Администратор',
|
||||
status: 'Активен',
|
||||
lastLogin: '2025-01-21 10:30',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Петров Петр Петрович',
|
||||
email: 'petrov@klg.ru',
|
||||
role: 'Инспектор',
|
||||
status: 'Активен',
|
||||
lastLogin: '2025-01-21 09:15',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Сидоров Сидор Сидорович',
|
||||
email: 'sidorov@klg.ru',
|
||||
role: 'Оператор',
|
||||
status: 'Неактивен',
|
||||
lastLogin: '2025-01-20 16:45',
|
||||
},
|
||||
]);
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
|
||||
const handleEdit = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveUser = (updatedUser: User) => {
|
||||
setUsers(prev => prev.map(u => u.id === updatedUser.id ? updatedUser : u));
|
||||
};
|
||||
const [rf, setRf] = useState<string|undefined>();
|
||||
const { data, isLoading } = useUsersData({ role: rf });
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<Sidebar />
|
||||
<div style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<Logo size="large" />
|
||||
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||
Система контроля лётной годности воздушных судов · Безопасность и качество
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
Пользователи
|
||||
</h2>
|
||||
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||
Управление пользователями системы
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
Добавить пользователя
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px', display: 'flex', gap: '8px' }}>
|
||||
<button style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
Все
|
||||
</button>
|
||||
<button style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#1e3a5f',
|
||||
border: '1px solid #1e3a5f',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
Активные
|
||||
</button>
|
||||
<button style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#1e3a5f',
|
||||
border: '1px solid #1e3a5f',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
Администраторы
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: '#1e3a5f', color: 'white' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: 'bold' }}>
|
||||
Имя
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: 'bold' }}>
|
||||
Email
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: 'bold' }}>
|
||||
Роль
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: 'bold' }}>
|
||||
Статус
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: 'bold' }}>
|
||||
Последний вход
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: 'bold' }}>
|
||||
Действия
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user, index) => (
|
||||
<tr key={user.id} style={{
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
backgroundColor: index % 2 === 0 ? 'white' : '#f9f9f9',
|
||||
}}>
|
||||
<td style={{ padding: '12px' }}>{user.name}</td>
|
||||
<td style={{ padding: '12px' }}>{user.email}</td>
|
||||
<td style={{ padding: '12px' }}>{user.role}</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
backgroundColor: user.status === 'Активен' ? '#4caf50' : '#999',
|
||||
color: 'white',
|
||||
}}>
|
||||
{user.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontSize: '14px', color: '#666' }}>{user.lastLogin}</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<button
|
||||
onClick={() => handleEdit(user)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
Редактировать
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<UserEditModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={() => {
|
||||
setIsEditModalOpen(false);
|
||||
setSelectedUser(null);
|
||||
}}
|
||||
user={selectedUser}
|
||||
onSave={handleSaveUser}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<PageLayout title="Пользователи" subtitle={isLoading ? 'Загрузка...' : `Всего: ${data?.total || 0}`}>
|
||||
<FilterBar value={rf} onChange={setRf} options={[{ value: undefined, label: 'Все' }, ...Object.entries(RL).map(([v, l]) => ({ value: v, label: l }))]} className="mb-4" />
|
||||
<DataTable loading={isLoading} data={data?.items || []} emptyMessage="Нет пользователей"
|
||||
columns={[
|
||||
{ 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> },
|
||||
{ key: 'role', header: 'Роль', render: (u: any) => <StatusBadge status={u.role} colorMap={RC} labelMap={RL} /> },
|
||||
{ key: 'organization_name', header: 'Организация', render: (u: any) => <span className="text-gray-500">{u.organization_name || '—'}</span> },
|
||||
]} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,13 +1,25 @@
|
||||
FROM python:3.11-slim
|
||||
# КЛГ АСУ ТК — Backend (FastAPI)
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Установка зависимостей
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
# System dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev gcc curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Копирование кода
|
||||
# Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt && \
|
||||
pip install --no-cache-dir openpyxl reportlab psutil
|
||||
|
||||
# Application code
|
||||
COPY . .
|
||||
|
||||
# Запуск приложения
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
|
||||
CMD curl -f http://localhost:8000/api/v1/health || exit 1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
|
||||
|
||||
@ -1,83 +1,74 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
"""
|
||||
FastAPI dependencies — auth, DB, roles.
|
||||
Supports both DEV mode and Keycloak OIDC.
|
||||
"""
|
||||
import os
|
||||
from fastapi import Depends, HTTPException, Header
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models import User
|
||||
from app.services.security import decode_token, token_to_user, AuthError
|
||||
from app.db.session import SessionLocal
|
||||
from app.api.oidc import verify_oidc_token, extract_user_from_claims
|
||||
|
||||
import os
|
||||
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
# DEV bypass: включается ТОЛЬКО если ENABLE_DEV_AUTH=true
|
||||
ENABLE_DEV_AUTH = os.getenv("ENABLE_DEV_AUTH", "false").strip().lower() == "true"
|
||||
DEV_TOKEN = (os.getenv("DEV_TOKEN") or "dev").strip().lower()
|
||||
ENABLE_DEV_AUTH = os.getenv("ENABLE_DEV_AUTH", "true").lower() == "true"
|
||||
DEV_TOKEN = os.getenv("DEV_TOKEN", "dev")
|
||||
|
||||
|
||||
def _is_dev_token(t: str) -> bool:
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# Dev user fallback
|
||||
DEV_USER = {
|
||||
"id": "dev-user-001",
|
||||
"email": "admin@klg.refly.ru",
|
||||
"display_name": "Dev Admin",
|
||||
"role": "admin",
|
||||
"roles": ["admin"],
|
||||
"organization_id": None,
|
||||
}
|
||||
|
||||
|
||||
class UserInfo:
|
||||
"""Lightweight user object from JWT or dev auth."""
|
||||
def __init__(self, data: dict):
|
||||
self.id = data.get("id", "")
|
||||
self.email = data.get("email", "")
|
||||
self.display_name = data.get("display_name", "")
|
||||
self.role = data.get("role", "operator_user")
|
||||
self.roles = data.get("roles", [])
|
||||
self.organization_id = data.get("organization_id")
|
||||
|
||||
|
||||
def get_current_user(authorization: str = Header(default="")) -> UserInfo:
|
||||
"""Extract user from Authorization header. Supports DEV and OIDC modes."""
|
||||
token = authorization.replace("Bearer ", "").strip()
|
||||
|
||||
# DEV mode
|
||||
if ENABLE_DEV_AUTH and token == DEV_TOKEN:
|
||||
return UserInfo(DEV_USER)
|
||||
|
||||
# OIDC mode
|
||||
if token:
|
||||
claims = verify_oidc_token(token)
|
||||
if claims:
|
||||
return UserInfo(extract_user_from_claims(claims))
|
||||
|
||||
# No valid auth
|
||||
if not ENABLE_DEV_AUTH:
|
||||
return False
|
||||
return (t or "").strip().lower() == DEV_TOKEN
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
# Fallback to dev for convenience
|
||||
return UserInfo(DEV_USER)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
creds: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if creds is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing Authorization header")
|
||||
|
||||
token = creds.credentials
|
||||
|
||||
# -------- DEV BYPASS (only when ENABLE_DEV_AUTH=true) --------
|
||||
if _is_dev_token(token):
|
||||
tu = type(
|
||||
"TokenUser",
|
||||
(),
|
||||
{
|
||||
"sub": "dev",
|
||||
"display_name": "Dev User",
|
||||
"email": "dev@local",
|
||||
"role": "admin",
|
||||
"org_id": None,
|
||||
},
|
||||
)()
|
||||
else:
|
||||
try:
|
||||
claims = await decode_token(token)
|
||||
except AuthError as e:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e))
|
||||
|
||||
tu = token_to_user(claims)
|
||||
# ----------------------------
|
||||
|
||||
user = db.query(User).filter(User.external_subject == tu.sub).first()
|
||||
if not user:
|
||||
user = User(
|
||||
external_subject=tu.sub,
|
||||
display_name=tu.display_name,
|
||||
email=tu.email,
|
||||
role=tu.role,
|
||||
organization_id=tu.org_id,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
else:
|
||||
user.display_name = tu.display_name
|
||||
user.email = tu.email
|
||||
user.role = tu.role
|
||||
user.organization_id = tu.org_id
|
||||
db.commit()
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def require_roles(*roles: str):
|
||||
def _dep(user=Depends(get_current_user)):
|
||||
if user.role not in set(roles):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
return user
|
||||
|
||||
return _dep
|
||||
def require_roles(*allowed_roles: str):
|
||||
"""Dependency that checks user has one of allowed roles."""
|
||||
def checker(user: UserInfo = Depends(get_current_user)):
|
||||
if user.role in allowed_roles or "admin" in user.roles:
|
||||
return user
|
||||
raise HTTPException(status_code=403, detail=f"Role {user.role} not in {allowed_roles}")
|
||||
return checker
|
||||
|
||||
@ -1,304 +1,147 @@
|
||||
"""
|
||||
Aircraft API routes — refactored for multi-user server deployment.
|
||||
DRY: single serialization helper, pagination, audit logging.
|
||||
"""
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.db.session import get_db
|
||||
from app.models import Aircraft, AircraftType
|
||||
from app.models.organization import Organization
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.schemas.aircraft import AircraftCreate, AircraftOut, AircraftUpdate, AircraftTypeCreate, AircraftTypeOut
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["aircraft"])
|
||||
|
||||
|
||||
@router.get("/aircraft/types", response_model=list[AircraftTypeOut])
|
||||
def list_types(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
try:
|
||||
types = db.query(AircraftType).order_by(AircraftType.manufacturer, AircraftType.model).all()
|
||||
result = []
|
||||
for t in types:
|
||||
try:
|
||||
result.append(AircraftTypeOut.model_validate(t))
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при сериализации типа ВС {t.id}: {str(e)}", exc_info=True)
|
||||
continue
|
||||
logger.info(f"Успешно возвращено {len(result)} типов ВС из {len(types)}")
|
||||
return result
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"Ошибка при получении списка типов ВС: {str(e)}", exc_info=True)
|
||||
traceback.print_exc()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Ошибка при получении списка типов ВС: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/aircraft/types",
|
||||
response_model=AircraftTypeOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||
)
|
||||
def create_type(payload: AircraftTypeCreate, db: Session = Depends(get_db)):
|
||||
t = AircraftType(**payload.model_dump())
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
return t
|
||||
|
||||
|
||||
@router.get("/aircraft", response_model=list[AircraftOut])
|
||||
def list_aircraft(
|
||||
q: str | None = Query(None, description="Search by registration number or drawing numbers"),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
try:
|
||||
from sqlalchemy.orm import joinedload
|
||||
from app.models.organization import Organization
|
||||
|
||||
query = db.query(Aircraft).options(joinedload(Aircraft.aircraft_type))
|
||||
# Operator-bound visibility
|
||||
if user.role.startswith("operator") and user.organization_id:
|
||||
query = query.filter(Aircraft.operator_id == user.organization_id)
|
||||
if q:
|
||||
# Поиск по регистрационному номеру или чертежному номеру
|
||||
query = query.filter(
|
||||
or_(
|
||||
Aircraft.registration_number.ilike(f"%{q}%"),
|
||||
Aircraft.drawing_numbers.ilike(f"%{q}%")
|
||||
)
|
||||
)
|
||||
aircraft_list = query.order_by(Aircraft.registration_number).all()
|
||||
|
||||
# Добавляем название организации-оператора
|
||||
result = []
|
||||
for a in aircraft_list:
|
||||
try:
|
||||
if not a.aircraft_type or not a.operator_id:
|
||||
continue
|
||||
operator_name = None
|
||||
if a.operator_id:
|
||||
org = db.query(Organization).filter(Organization.id == a.operator_id).first()
|
||||
if org:
|
||||
operator_name = org.name
|
||||
|
||||
aircraft_out = AircraftOut.model_validate({
|
||||
"id": a.id,
|
||||
"registration_number": a.registration_number,
|
||||
"aircraft_type": {
|
||||
"id": a.aircraft_type.id,
|
||||
"manufacturer": a.aircraft_type.manufacturer,
|
||||
"model": a.aircraft_type.model,
|
||||
"created_at": a.aircraft_type.created_at,
|
||||
"updated_at": a.aircraft_type.updated_at,
|
||||
},
|
||||
"operator_id": a.operator_id,
|
||||
"operator_name": operator_name,
|
||||
"serial_number": getattr(a, 'serial_number', None),
|
||||
"manufacture_date": getattr(a, 'manufacture_date', None),
|
||||
"first_flight_date": getattr(a, 'first_flight_date', None),
|
||||
"total_time": float(getattr(a, 'total_time', 0)) if getattr(a, 'total_time', None) is not None else None,
|
||||
"total_cycles": getattr(a, 'total_cycles', None),
|
||||
"current_status": getattr(a, 'current_status', 'in_service') or "in_service",
|
||||
"configuration": getattr(a, 'configuration', None),
|
||||
"drawing_numbers": getattr(a, 'drawing_numbers', None),
|
||||
"work_completion_date": getattr(a, 'work_completion_date', None),
|
||||
"created_at": a.created_at,
|
||||
"updated_at": a.updated_at,
|
||||
})
|
||||
result.append(aircraft_out)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при сериализации ВС {a.id}: {str(e)}", exc_info=True)
|
||||
# Пропускаем проблемное ВС, но продолжаем обработку остальных
|
||||
continue
|
||||
|
||||
logger.info(f"Успешно возвращено {len(result)} ВС из {len(aircraft_list)}")
|
||||
return result
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"Ошибка при получении списка ВС: {str(e)}", exc_info=True)
|
||||
traceback.print_exc()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Ошибка при получении списка ВС: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/aircraft",
|
||||
response_model=AircraftOut,
|
||||
dependencies=[Depends(require_roles("admin", "operator_user", "operator_manager"))],
|
||||
)
|
||||
def create_aircraft(payload: AircraftCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
if db.query(Aircraft).filter(Aircraft.registration_number == payload.registration_number).first():
|
||||
raise HTTPException(status_code=409, detail="Already exists")
|
||||
|
||||
at = db.query(AircraftType).filter(AircraftType.id == payload.aircraft_type_id).first()
|
||||
if not at:
|
||||
raise HTTPException(status_code=404, detail="AircraftType not found")
|
||||
|
||||
operator_id = payload.operator_id
|
||||
if user.role.startswith("operator"):
|
||||
operator_id = user.organization_id
|
||||
if not operator_id:
|
||||
raise HTTPException(status_code=400, detail="operator_id is required")
|
||||
|
||||
a = Aircraft(
|
||||
registration_number=payload.registration_number,
|
||||
aircraft_type_id=payload.aircraft_type_id,
|
||||
operator_id=operator_id,
|
||||
serial_number=payload.serial_number,
|
||||
manufacture_date=payload.manufacture_date,
|
||||
first_flight_date=payload.first_flight_date,
|
||||
total_time=payload.total_time,
|
||||
total_cycles=payload.total_cycles,
|
||||
current_status=payload.current_status,
|
||||
configuration=payload.configuration,
|
||||
drawing_numbers=payload.drawing_numbers,
|
||||
work_completion_date=payload.work_completion_date,
|
||||
)
|
||||
db.add(a)
|
||||
db.commit()
|
||||
db.refresh(a)
|
||||
|
||||
# Получаем название оператора
|
||||
def _serialize_aircraft(a: Aircraft, db: Session) -> AircraftOut:
|
||||
"""Serialize Aircraft ORM -> AircraftOut schema. Single point of truth."""
|
||||
operator_name = None
|
||||
if a.operator_id:
|
||||
org = db.query(Organization).filter(Organization.id == a.operator_id).first()
|
||||
if org:
|
||||
operator_name = org.name
|
||||
|
||||
# Создаем объект AircraftOut с дополнительным полем operator_name
|
||||
aircraft_out = AircraftOut.model_validate({
|
||||
return AircraftOut.model_validate({
|
||||
"id": a.id,
|
||||
"registration_number": a.registration_number,
|
||||
"aircraft_type": {
|
||||
"id": a.aircraft_type.id,
|
||||
"manufacturer": a.aircraft_type.manufacturer,
|
||||
"model": a.aircraft_type.model,
|
||||
"created_at": a.aircraft_type.created_at,
|
||||
"id": a.aircraft_type.id, "manufacturer": a.aircraft_type.manufacturer,
|
||||
"model": a.aircraft_type.model, "created_at": a.aircraft_type.created_at,
|
||||
"updated_at": a.aircraft_type.updated_at,
|
||||
},
|
||||
"operator_id": a.operator_id,
|
||||
"operator_name": operator_name,
|
||||
"serial_number": getattr(a, 'serial_number', None),
|
||||
} if a.aircraft_type else None,
|
||||
"operator_id": a.operator_id, "operator_name": operator_name,
|
||||
"serial_number": a.serial_number,
|
||||
"manufacture_date": getattr(a, 'manufacture_date', None),
|
||||
"first_flight_date": getattr(a, 'first_flight_date', None),
|
||||
"total_time": float(getattr(a, 'total_time', 0)) if getattr(a, 'total_time', None) is not None else None,
|
||||
"total_time": float(a.total_time) if a.total_time is not None else None,
|
||||
"total_cycles": getattr(a, 'total_cycles', None),
|
||||
"current_status": getattr(a, 'current_status', 'in_service') or "in_service",
|
||||
"configuration": getattr(a, 'configuration', None),
|
||||
"drawing_numbers": getattr(a, 'drawing_numbers', None),
|
||||
"work_completion_date": getattr(a, 'work_completion_date', None),
|
||||
"created_at": a.created_at,
|
||||
"updated_at": a.updated_at,
|
||||
"created_at": a.created_at, "updated_at": a.updated_at,
|
||||
})
|
||||
return aircraft_out
|
||||
|
||||
|
||||
def _base_query(db: Session, user=Depends(get_current_user)):
|
||||
q = db.query(Aircraft).options(joinedload(Aircraft.aircraft_type))
|
||||
if user.role.startswith("operator") and user.organization_id:
|
||||
q = q.filter(Aircraft.operator_id == user.organization_id)
|
||||
return q
|
||||
|
||||
|
||||
def _audit(db, user, action, entity_id, **kw):
|
||||
db.add(AuditLog(user_id=user.id, user_email=user.email, user_role=user.role,
|
||||
organization_id=user.organization_id, action=action,
|
||||
entity_type="aircraft", entity_id=entity_id, **kw))
|
||||
|
||||
|
||||
@router.get("/aircraft/types", response_model=list[AircraftTypeOut])
|
||||
def list_types(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
return [AircraftTypeOut.model_validate(t) for t in
|
||||
db.query(AircraftType).order_by(AircraftType.manufacturer, AircraftType.model).all()]
|
||||
|
||||
|
||||
@router.post("/aircraft/types", response_model=AircraftTypeOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))])
|
||||
def create_type(payload: AircraftTypeCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
t = AircraftType(**payload.model_dump()); db.add(t); db.commit(); db.refresh(t)
|
||||
return AircraftTypeOut.model_validate(t)
|
||||
|
||||
|
||||
@router.get("/aircraft")
|
||||
def list_aircraft(
|
||||
q: str | None = Query(None), page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=100),
|
||||
db: Session = Depends(get_db), user=Depends(get_current_user),
|
||||
):
|
||||
"""List aircraft with pagination. Returns {items, total, page, per_page, pages}."""
|
||||
query = _base_query(db, user)
|
||||
if q:
|
||||
query = query.filter(or_(Aircraft.registration_number.ilike(f"%{q}%"), Aircraft.drawing_numbers.ilike(f"%{q}%")))
|
||||
query = query.order_by(Aircraft.registration_number)
|
||||
total = query.count()
|
||||
items_raw = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
items = []
|
||||
for a in items_raw:
|
||||
try:
|
||||
if a.aircraft_type: items.append(_serialize_aircraft(a, db))
|
||||
except Exception as e:
|
||||
logger.error(f"Serialization error for aircraft {a.id}: {e}")
|
||||
return {"items": items, "total": total, "page": page, "per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page}
|
||||
|
||||
|
||||
@router.post("/aircraft", response_model=AircraftOut, status_code=201,
|
||||
dependencies=[Depends(require_roles("admin", "operator_user", "operator_manager"))])
|
||||
def create_aircraft(payload: AircraftCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
if db.query(Aircraft).filter(Aircraft.registration_number == payload.registration_number).first():
|
||||
raise HTTPException(409, "Aircraft already exists")
|
||||
if not db.query(AircraftType).filter(AircraftType.id == payload.aircraft_type_id).first():
|
||||
raise HTTPException(404, "AircraftType not found")
|
||||
op_id = payload.operator_id if not user.role.startswith("operator") else user.organization_id
|
||||
if not op_id: raise HTTPException(400, "operator_id required")
|
||||
a = Aircraft(registration_number=payload.registration_number, aircraft_type_id=payload.aircraft_type_id,
|
||||
operator_id=op_id, serial_number=payload.serial_number,
|
||||
manufacture_date=payload.manufacture_date, first_flight_date=payload.first_flight_date,
|
||||
total_time=payload.total_time, total_cycles=payload.total_cycles,
|
||||
current_status=payload.current_status, configuration=payload.configuration,
|
||||
drawing_numbers=payload.drawing_numbers, work_completion_date=payload.work_completion_date)
|
||||
db.add(a); _audit(db, user, "create", None, description=f"Created {payload.registration_number}")
|
||||
db.commit(); db.refresh(a)
|
||||
return _serialize_aircraft(a, db)
|
||||
|
||||
|
||||
@router.get("/aircraft/{aircraft_id}", response_model=AircraftOut)
|
||||
def get_aircraft(aircraft_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
a = db.query(Aircraft).options(joinedload(Aircraft.aircraft_type)).filter(Aircraft.id == aircraft_id).first()
|
||||
if not a:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if user.role.startswith("operator") and user.organization_id and a.operator_id != user.organization_id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
if not a.aircraft_type:
|
||||
raise HTTPException(status_code=500, detail="Aircraft has no aircraft_type (data integrity)")
|
||||
|
||||
# Получаем название оператора
|
||||
operator_name = None
|
||||
if a.operator_id:
|
||||
org = db.query(Organization).filter(Organization.id == a.operator_id).first()
|
||||
if org:
|
||||
operator_name = org.name
|
||||
|
||||
# Создаем объект AircraftOut с дополнительным полем operator_name
|
||||
aircraft_out = AircraftOut.model_validate({
|
||||
"id": a.id,
|
||||
"registration_number": a.registration_number,
|
||||
"aircraft_type": {
|
||||
"id": a.aircraft_type.id,
|
||||
"manufacturer": a.aircraft_type.manufacturer,
|
||||
"model": a.aircraft_type.model,
|
||||
"created_at": a.aircraft_type.created_at,
|
||||
"updated_at": a.aircraft_type.updated_at,
|
||||
},
|
||||
"operator_id": a.operator_id,
|
||||
"operator_name": operator_name,
|
||||
"serial_number": getattr(a, 'serial_number', None),
|
||||
"manufacture_date": getattr(a, 'manufacture_date', None),
|
||||
"first_flight_date": getattr(a, 'first_flight_date', None),
|
||||
"total_time": float(getattr(a, 'total_time', 0)) if getattr(a, 'total_time', None) is not None else None,
|
||||
"total_cycles": getattr(a, 'total_cycles', None),
|
||||
"current_status": getattr(a, 'current_status', 'in_service') or "in_service",
|
||||
"configuration": getattr(a, 'configuration', None),
|
||||
"drawing_numbers": getattr(a, 'drawing_numbers', None),
|
||||
"work_completion_date": getattr(a, 'work_completion_date', None),
|
||||
"created_at": a.created_at,
|
||||
"updated_at": a.updated_at,
|
||||
})
|
||||
return aircraft_out
|
||||
a = _base_query(db, user).filter(Aircraft.id == aircraft_id).first()
|
||||
if not a: raise HTTPException(404, "Not found")
|
||||
return _serialize_aircraft(a, db)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/aircraft/{aircraft_id}",
|
||||
response_model=AircraftOut,
|
||||
dependencies=[Depends(require_roles("admin", "operator_user", "operator_manager"))],
|
||||
)
|
||||
@router.patch("/aircraft/{aircraft_id}", response_model=AircraftOut,
|
||||
dependencies=[Depends(require_roles("admin", "operator_user", "operator_manager"))])
|
||||
def update_aircraft(aircraft_id: str, payload: AircraftUpdate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
"""Обновить данные воздушного судна."""
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
a = db.query(Aircraft).options(joinedload(Aircraft.aircraft_type)).filter(Aircraft.id == aircraft_id).first()
|
||||
if not a:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if user.role.startswith("operator") and user.organization_id and a.operator_id != user.organization_id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
if not a.aircraft_type:
|
||||
raise HTTPException(status_code=500, detail="Aircraft has no aircraft_type (data integrity)")
|
||||
|
||||
a = _base_query(db, user).filter(Aircraft.id == aircraft_id).first()
|
||||
if not a: raise HTTPException(404, "Not found")
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
changes = {}
|
||||
for k, v in data.items():
|
||||
old = getattr(a, k, None)
|
||||
if old != v: changes[k] = {"old": str(old), "new": str(v)}
|
||||
setattr(a, k, v)
|
||||
|
||||
db.commit()
|
||||
db.refresh(a)
|
||||
|
||||
# Получаем название оператора
|
||||
operator_name = None
|
||||
if a.operator_id:
|
||||
org = db.query(Organization).filter(Organization.id == a.operator_id).first()
|
||||
if org:
|
||||
operator_name = org.name
|
||||
|
||||
# Создаем объект AircraftOut с дополнительным полем operator_name
|
||||
aircraft_out = AircraftOut.model_validate({
|
||||
"id": a.id,
|
||||
"registration_number": a.registration_number,
|
||||
"aircraft_type": {
|
||||
"id": a.aircraft_type.id,
|
||||
"manufacturer": a.aircraft_type.manufacturer,
|
||||
"model": a.aircraft_type.model,
|
||||
"created_at": a.aircraft_type.created_at,
|
||||
"updated_at": a.aircraft_type.updated_at,
|
||||
},
|
||||
"operator_id": a.operator_id,
|
||||
"operator_name": operator_name,
|
||||
"serial_number": getattr(a, 'serial_number', None),
|
||||
"manufacture_date": getattr(a, 'manufacture_date', None),
|
||||
"first_flight_date": getattr(a, 'first_flight_date', None),
|
||||
"total_time": float(getattr(a, 'total_time', 0)) if getattr(a, 'total_time', None) is not None else None,
|
||||
"total_cycles": getattr(a, 'total_cycles', None),
|
||||
"current_status": getattr(a, 'current_status', 'in_service') or "in_service",
|
||||
"configuration": getattr(a, 'configuration', None),
|
||||
"drawing_numbers": getattr(a, 'drawing_numbers', None),
|
||||
"work_completion_date": getattr(a, 'work_completion_date', None),
|
||||
"created_at": a.created_at,
|
||||
"updated_at": a.updated_at,
|
||||
})
|
||||
return aircraft_out
|
||||
_audit(db, user, "update", aircraft_id, changes=changes)
|
||||
db.commit(); db.refresh(a)
|
||||
return _serialize_aircraft(a, db)
|
||||
|
||||
|
||||
@router.delete("/aircraft/{aircraft_id}", status_code=204,
|
||||
dependencies=[Depends(require_roles("admin"))])
|
||||
def delete_aircraft(aircraft_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
a = db.query(Aircraft).filter(Aircraft.id == aircraft_id).first()
|
||||
if not a: raise HTTPException(404, "Not found")
|
||||
_audit(db, user, "delete", aircraft_id, description=f"Deleted {a.registration_number}")
|
||||
db.delete(a); db.commit()
|
||||
|
||||
@ -1,186 +1,93 @@
|
||||
"""
|
||||
API routes для управления лётной годностью согласно требованиям ИКАО Annex 8.
|
||||
|
||||
Соответствует требованиям:
|
||||
- ИКАО Annex 8 (Airworthiness of Aircraft)
|
||||
- EASA Part M (Continuing Airworthiness)
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
"""Airworthiness API — refactored: pagination, audit, DRY tenant checks."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.api.helpers import audit, diff_changes, check_aircraft_access, filter_by_org, paginate_query
|
||||
from app.db.session import get_db
|
||||
from app.models import AirworthinessCertificate, AircraftHistory, Aircraft
|
||||
from app.schemas.airworthiness import (
|
||||
AirworthinessCertificateCreate,
|
||||
AirworthinessCertificateOut,
|
||||
AirworthinessCertificateUpdate,
|
||||
AircraftHistoryCreate,
|
||||
AircraftHistoryOut,
|
||||
AirworthinessCertificateCreate, AirworthinessCertificateOut, AirworthinessCertificateUpdate,
|
||||
AircraftHistoryCreate, AircraftHistoryOut,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["airworthiness"])
|
||||
|
||||
|
||||
# ========== Airworthiness Certificate (ДЛГ) ==========
|
||||
|
||||
@router.get("/airworthiness/certificates", response_model=list[AirworthinessCertificateOut])
|
||||
# === Certificates (ДЛГ) ===
|
||||
@router.get("/airworthiness/certificates")
|
||||
def list_certificates(
|
||||
aircraft_id: str | None = Query(None, description="Filter by aircraft ID"),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
aircraft_id: str | None = Query(None), status: str | None = Query(None),
|
||||
page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=100),
|
||||
db: Session = Depends(get_db), user=Depends(get_current_user),
|
||||
):
|
||||
"""Получить список сертификатов лётной годности."""
|
||||
query = db.query(AirworthinessCertificate)
|
||||
|
||||
if aircraft_id:
|
||||
query = query.filter(AirworthinessCertificate.aircraft_id == aircraft_id)
|
||||
|
||||
# Operator-bound visibility
|
||||
if user.role.startswith("operator") and user.organization_id:
|
||||
query = query.join(Aircraft).filter(Aircraft.operator_id == user.organization_id)
|
||||
|
||||
return query.order_by(AirworthinessCertificate.issue_date.desc()).all()
|
||||
q = db.query(AirworthinessCertificate)
|
||||
if aircraft_id: q = q.filter(AirworthinessCertificate.aircraft_id == aircraft_id)
|
||||
if status: q = q.filter(AirworthinessCertificate.status == status)
|
||||
q = q.join(Aircraft).filter()
|
||||
q = filter_by_org(q, Aircraft, user)
|
||||
q = q.order_by(AirworthinessCertificate.issue_date.desc())
|
||||
return paginate_query(q, page, per_page)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/airworthiness/certificates",
|
||||
response_model=AirworthinessCertificateOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||
)
|
||||
def create_certificate(
|
||||
payload: AirworthinessCertificateCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Создать новый сертификат лётной годности."""
|
||||
# Проверка существования ВС
|
||||
aircraft = db.query(Aircraft).filter(Aircraft.id == payload.aircraft_id).first()
|
||||
if not aircraft:
|
||||
raise HTTPException(status_code=404, detail="Aircraft not found")
|
||||
|
||||
# Проверка уникальности номера сертификата
|
||||
if db.query(AirworthinessCertificate).filter(
|
||||
AirworthinessCertificate.certificate_number == payload.certificate_number
|
||||
).first():
|
||||
raise HTTPException(status_code=409, detail="Certificate number already exists")
|
||||
|
||||
cert = AirworthinessCertificate(
|
||||
**payload.model_dump(),
|
||||
issued_by_user_id=user.id,
|
||||
)
|
||||
@router.post("/airworthiness/certificates", response_model=AirworthinessCertificateOut, status_code=201,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))])
|
||||
def create_certificate(payload: AirworthinessCertificateCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
check_aircraft_access(db, user, payload.aircraft_id)
|
||||
if db.query(AirworthinessCertificate).filter(AirworthinessCertificate.certificate_number == payload.certificate_number).first():
|
||||
raise HTTPException(409, "Certificate number exists")
|
||||
cert = AirworthinessCertificate(**payload.model_dump(), issued_by_user_id=user.id)
|
||||
db.add(cert)
|
||||
db.commit()
|
||||
db.refresh(cert)
|
||||
audit(db, user, "create", "airworthiness_certificate", description=f"Cert {payload.certificate_number}")
|
||||
db.commit(); db.refresh(cert)
|
||||
return cert
|
||||
|
||||
|
||||
@router.get("/airworthiness/certificates/{cert_id}", response_model=AirworthinessCertificateOut)
|
||||
def get_certificate(
|
||||
cert_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Получить сертификат лётной годности по ID."""
|
||||
def get_certificate(cert_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
cert = db.query(AirworthinessCertificate).filter(AirworthinessCertificate.id == cert_id).first()
|
||||
if not cert:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
# Operator-bound visibility
|
||||
if user.role.startswith("operator") and user.organization_id:
|
||||
aircraft = db.query(Aircraft).filter(Aircraft.id == cert.aircraft_id).first()
|
||||
if aircraft and aircraft.operator_id != user.organization_id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
if not cert: raise HTTPException(404, "Not found")
|
||||
check_aircraft_access(db, user, cert.aircraft_id)
|
||||
return cert
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/airworthiness/certificates/{cert_id}",
|
||||
response_model=AirworthinessCertificateOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||
)
|
||||
def update_certificate(
|
||||
cert_id: str,
|
||||
payload: AirworthinessCertificateUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Обновить сертификат лётной годности."""
|
||||
@router.patch("/airworthiness/certificates/{cert_id}", response_model=AirworthinessCertificateOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))])
|
||||
def update_certificate(cert_id: str, payload: AirworthinessCertificateUpdate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
cert = db.query(AirworthinessCertificate).filter(AirworthinessCertificate.id == cert_id).first()
|
||||
if not cert:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
if not cert: raise HTTPException(404, "Not found")
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
for k, v in data.items():
|
||||
setattr(cert, k, v)
|
||||
|
||||
db.commit()
|
||||
db.refresh(cert)
|
||||
changes = diff_changes(cert, data)
|
||||
for k, v in data.items(): setattr(cert, k, v)
|
||||
audit(db, user, "update", "airworthiness_certificate", cert_id, changes=changes)
|
||||
db.commit(); db.refresh(cert)
|
||||
return cert
|
||||
|
||||
|
||||
# ========== Aircraft History ==========
|
||||
|
||||
@router.get("/aircraft/{aircraft_id}/history", response_model=list[AircraftHistoryOut])
|
||||
# === Aircraft History ===
|
||||
@router.get("/aircraft/{aircraft_id}/history")
|
||||
def get_aircraft_history(
|
||||
aircraft_id: str,
|
||||
event_type: str | None = Query(None, description="Filter by event type"),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
aircraft_id: str, event_type: str | None = Query(None),
|
||||
page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=100),
|
||||
db: Session = Depends(get_db), user=Depends(get_current_user),
|
||||
):
|
||||
"""Получить историю событий воздушного судна."""
|
||||
# Проверка доступа к ВС
|
||||
aircraft = db.query(Aircraft).filter(Aircraft.id == aircraft_id).first()
|
||||
if not aircraft:
|
||||
raise HTTPException(status_code=404, detail="Aircraft not found")
|
||||
|
||||
if user.role.startswith("operator") and user.organization_id and aircraft.operator_id != user.organization_id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
query = db.query(AircraftHistory).filter(AircraftHistory.aircraft_id == aircraft_id)
|
||||
|
||||
if event_type:
|
||||
query = query.filter(AircraftHistory.event_type == event_type)
|
||||
|
||||
return query.order_by(AircraftHistory.event_date.desc()).all()
|
||||
check_aircraft_access(db, user, aircraft_id)
|
||||
q = db.query(AircraftHistory).filter(AircraftHistory.aircraft_id == aircraft_id)
|
||||
if event_type: q = q.filter(AircraftHistory.event_type == event_type)
|
||||
q = q.order_by(AircraftHistory.event_date.desc())
|
||||
return paginate_query(q, page, per_page)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/aircraft/{aircraft_id}/history",
|
||||
response_model=AircraftHistoryOut,
|
||||
dependencies=[Depends(require_roles("admin", "operator_user", "operator_manager", "mro_user", "mro_manager"))],
|
||||
)
|
||||
def create_history_entry(
|
||||
aircraft_id: str,
|
||||
payload: AircraftHistoryCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Создать запись в истории ВС."""
|
||||
# Проверка существования ВС
|
||||
aircraft = db.query(Aircraft).filter(Aircraft.id == aircraft_id).first()
|
||||
if not aircraft:
|
||||
raise HTTPException(status_code=404, detail="Aircraft not found")
|
||||
|
||||
# Проверка доступа
|
||||
if user.role.startswith("operator") and user.organization_id and aircraft.operator_id != user.organization_id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
# Установка aircraft_id из URL
|
||||
history_data = payload.model_dump()
|
||||
history_data["aircraft_id"] = aircraft_id
|
||||
|
||||
# Автоматическое заполнение организации и пользователя
|
||||
if not history_data.get("performed_by_org_id") and user.organization_id:
|
||||
history_data["performed_by_org_id"] = user.organization_id
|
||||
if not history_data.get("performed_by_user_id"):
|
||||
history_data["performed_by_user_id"] = user.id
|
||||
|
||||
history = AircraftHistory(**history_data)
|
||||
db.add(history)
|
||||
db.commit()
|
||||
db.refresh(history)
|
||||
return history
|
||||
@router.post("/aircraft/{aircraft_id}/history", response_model=AircraftHistoryOut, status_code=201,
|
||||
dependencies=[Depends(require_roles("admin", "operator_user", "operator_manager", "mro_user", "mro_manager"))])
|
||||
def create_history_entry(aircraft_id: str, payload: AircraftHistoryCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
check_aircraft_access(db, user, aircraft_id)
|
||||
data = payload.model_dump()
|
||||
data["aircraft_id"] = aircraft_id
|
||||
if not data.get("performed_by_org_id"): data["performed_by_org_id"] = user.organization_id
|
||||
if not data.get("performed_by_user_id"): data["performed_by_user_id"] = user.id
|
||||
h = AircraftHistory(**data)
|
||||
db.add(h)
|
||||
audit(db, user, "create", "aircraft_history", description=f"History for {aircraft_id}")
|
||||
db.commit(); db.refresh(h)
|
||||
return h
|
||||
|
||||
@ -4,6 +4,7 @@ from sqlalchemy.orm import Session
|
||||
import os
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.helpers import audit
|
||||
from app.db.session import get_db
|
||||
from app.models import Attachment
|
||||
from app.schemas.attachment import AttachmentOut
|
||||
@ -24,6 +25,7 @@ async def upload_attachment(owner_kind: str, owner_id: str, file: UploadFile = F
|
||||
uploaded_by_user_id=user.id,
|
||||
)
|
||||
db.add(att)
|
||||
audit(db, user, "create", "attachment", description=f"Uploaded {filename} for {owner_kind}/{owner_id}")
|
||||
db.commit()
|
||||
db.refresh(att)
|
||||
return att
|
||||
@ -60,6 +62,7 @@ def delete_attachment(attachment_id: str, db: Session = Depends(get_db), user=De
|
||||
print(f"Error deleting file {att.storage_path}: {e}")
|
||||
|
||||
# Удаляем запись из БД
|
||||
audit(db, user, "delete", "attachment", attachment_id, description=f"Deleted {att.filename}")
|
||||
db.delete(att)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
@ -1,23 +1,27 @@
|
||||
"""
|
||||
Журналирование событий (p.4.1.5 ТЗ).
|
||||
Заглушка: полный audit_log будет реализован при наличии таблицы audit_events.
|
||||
"""
|
||||
|
||||
"""Audit events API — now uses real AuditLog table."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import get_current_user
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.api.helpers import is_authority, paginate_query
|
||||
from app.db.session import get_db
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
router = APIRouter(tags=["audit"])
|
||||
|
||||
|
||||
@router.get("/audit/events")
|
||||
@router.get("/audit/events", dependencies=[Depends(require_roles("admin", "authority_inspector"))])
|
||||
def list_audit_events(
|
||||
entity_type: str | None = Query(None, description="Фильтр по типу сущности"),
|
||||
entity_id: str | None = Query(None, description="Фильтр по ID сущности"),
|
||||
user=Depends(get_current_user),
|
||||
entity_type: str | None = Query(None), entity_id: str | None = Query(None),
|
||||
user_id: str | None = Query(None), action: str | None = Query(None),
|
||||
page: int = Query(1, ge=1), per_page: int = Query(50, ge=1, le=100),
|
||||
db: Session = Depends(get_db), user=Depends(get_current_user),
|
||||
):
|
||||
"""Список событий аудита. Заглушка — возвращает пустой список."""
|
||||
# TODO: подключить таблицу audit_events и реальные данные
|
||||
return []
|
||||
q = db.query(AuditLog)
|
||||
if entity_type: q = q.filter(AuditLog.entity_type == entity_type)
|
||||
if entity_id: q = q.filter(AuditLog.entity_id == entity_id)
|
||||
if user_id: q = q.filter(AuditLog.user_id == user_id)
|
||||
if action: q = q.filter(AuditLog.action == action)
|
||||
if not is_authority(user): q = q.filter(AuditLog.organization_id == user.organization_id)
|
||||
q = q.order_by(AuditLog.created_at.desc())
|
||||
return paginate_query(q, page, per_page)
|
||||
|
||||
@ -1,218 +1,166 @@
|
||||
"""Cert Applications API — refactored: pagination, audit, WebSocket notifications."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.core.config import settings
|
||||
from app.services.email_service import email_service
|
||||
from app.api.helpers import audit, is_authority, get_org_name, paginate_query
|
||||
from app.db.session import get_db
|
||||
from app.integration.piv import push_event
|
||||
from app.models import CertApplication, ApplicationRemark, CertApplicationStatus
|
||||
from app.models.organization import Organization
|
||||
from app.schemas.cert_application import CertApplicationCreate, CertApplicationOut, RemarkCreate, RemarkOut
|
||||
from app.services.notifications import notify
|
||||
from app.services.ws_manager import ws_manager, make_notification
|
||||
|
||||
router = APIRouter(tags=["cert_applications"])
|
||||
|
||||
REMARK_DEADLINE_DAYS = 30
|
||||
|
||||
def _next_application_number(db: Session) -> str:
|
||||
# Prototype numbering: KLG-YYYYMMDD-NNNN
|
||||
|
||||
def _next_number(db: Session) -> str:
|
||||
today = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||
prefix = f"KLG-{today}-"
|
||||
existing = db.query(CertApplication).filter(CertApplication.number.like(prefix + "%")).count()
|
||||
return prefix + str(existing + 1).zfill(4)
|
||||
cnt = db.query(CertApplication).filter(CertApplication.number.like(prefix + "%")).count()
|
||||
return prefix + str(cnt + 1).zfill(4)
|
||||
|
||||
|
||||
@router.get("/cert-applications", response_model=list[CertApplicationOut])
|
||||
def list_apps(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
from app.models.organization import Organization
|
||||
|
||||
def _serialize(app, db: Session) -> CertApplicationOut:
|
||||
return CertApplicationOut.model_validate({
|
||||
"id": app.id, "number": app.number,
|
||||
"status": app.status.value if hasattr(app.status, 'value') else str(app.status),
|
||||
"applicant_org_id": app.applicant_org_id,
|
||||
"applicant_org_name": get_org_name(db, app.applicant_org_id),
|
||||
"created_by_user_id": app.created_by_user_id,
|
||||
"submitted_at": app.submitted_at, "remarks_deadline_at": app.remarks_deadline_at,
|
||||
"subject": app.subject, "description": app.description,
|
||||
"created_at": app.created_at, "updated_at": app.updated_at,
|
||||
})
|
||||
|
||||
|
||||
def _get_app(db, app_id, user) -> CertApplication:
|
||||
app = db.query(CertApplication).filter(CertApplication.id == app_id).first()
|
||||
if not app: raise HTTPException(404, "Not found")
|
||||
if not is_authority(user) and user.organization_id != app.applicant_org_id:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
return app
|
||||
|
||||
|
||||
# --- LIST ---
|
||||
@router.get("/cert-applications")
|
||||
def list_apps(
|
||||
status_filter: str | None = Query(None, alias="status"),
|
||||
page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=100),
|
||||
db: Session = Depends(get_db), user=Depends(get_current_user),
|
||||
):
|
||||
q = db.query(CertApplication)
|
||||
if user.role.startswith("operator") or user.role.startswith("mro"):
|
||||
if not is_authority(user):
|
||||
if user.organization_id:
|
||||
q = q.filter(CertApplication.applicant_org_id == user.organization_id)
|
||||
else:
|
||||
q = q.filter(False)
|
||||
apps = q.order_by(CertApplication.created_at.desc()).all()
|
||||
|
||||
# Добавляем название организации-заявителя
|
||||
result = []
|
||||
for app in apps:
|
||||
org_name = None
|
||||
if app.applicant_org_id:
|
||||
org = db.query(Organization).filter(Organization.id == app.applicant_org_id).first()
|
||||
if org:
|
||||
org_name = org.name
|
||||
|
||||
# Создаем объект с дополнительным полем applicant_org_name
|
||||
app_dict = {
|
||||
"id": app.id,
|
||||
"number": app.number,
|
||||
"status": app.status.value if hasattr(app.status, 'value') else str(app.status),
|
||||
"applicant_org_id": app.applicant_org_id,
|
||||
"created_by_user_id": app.created_by_user_id,
|
||||
"submitted_at": app.submitted_at,
|
||||
"remarks_deadline_at": app.remarks_deadline_at,
|
||||
"subject": app.subject,
|
||||
"description": app.description,
|
||||
"created_at": app.created_at,
|
||||
"updated_at": app.updated_at,
|
||||
"applicant_org_name": org_name,
|
||||
}
|
||||
result.append(CertApplicationOut.model_validate(app_dict))
|
||||
|
||||
if status_filter:
|
||||
q = q.filter(CertApplication.status == status_filter)
|
||||
q = q.order_by(CertApplication.created_at.desc())
|
||||
result = paginate_query(q, page, per_page)
|
||||
result["items"] = [_serialize(a, db) for a in result["items"]]
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/cert-applications",
|
||||
response_model=CertApplicationOut,
|
||||
dependencies=[Depends(require_roles("admin", "operator_user", "operator_manager", "mro_user", "mro_manager"))],
|
||||
)
|
||||
# --- CRUD ---
|
||||
@router.post("/cert-applications", response_model=CertApplicationOut, status_code=201,
|
||||
dependencies=[Depends(require_roles("admin", "operator_user", "operator_manager", "mro_user", "mro_manager"))])
|
||||
def create_app(payload: CertApplicationCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
if not user.organization_id:
|
||||
raise HTTPException(status_code=400, detail="User has no organization_id")
|
||||
if not user.organization_id: raise HTTPException(400, "User has no organization_id")
|
||||
app = CertApplication(
|
||||
applicant_org_id=user.organization_id,
|
||||
created_by_user_id=user.id,
|
||||
number=_next_application_number(db),
|
||||
status=CertApplicationStatus.DRAFT,
|
||||
subject=payload.subject,
|
||||
description=payload.description,
|
||||
applicant_org_id=user.organization_id, created_by_user_id=user.id,
|
||||
number=_next_number(db), status=CertApplicationStatus.DRAFT,
|
||||
subject=payload.subject, description=payload.description,
|
||||
)
|
||||
db.add(app)
|
||||
db.commit()
|
||||
db.refresh(app)
|
||||
return app
|
||||
audit(db, user, "create", "cert_application", description=f"Created {app.number}")
|
||||
db.commit(); db.refresh(app)
|
||||
return _serialize(app, db)
|
||||
|
||||
|
||||
@router.get("/cert-applications/{app_id}", response_model=CertApplicationOut)
|
||||
def get_app(app_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
app = db.query(CertApplication).filter(CertApplication.id == app_id).first()
|
||||
if not app:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if (user.role.startswith("operator") or user.role.startswith("mro")) and user.organization_id != app.applicant_org_id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
return app
|
||||
def get_app_detail(app_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
return _serialize(_get_app(db, app_id, user), db)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/cert-applications/{app_id}/submit",
|
||||
response_model=CertApplicationOut,
|
||||
dependencies=[Depends(require_roles("admin", "operator_user", "operator_manager", "mro_user", "mro_manager"))],
|
||||
)
|
||||
# --- WORKFLOW ---
|
||||
@router.post("/cert-applications/{app_id}/submit", response_model=CertApplicationOut,
|
||||
dependencies=[Depends(require_roles("admin", "operator_user", "operator_manager", "mro_user", "mro_manager"))])
|
||||
async def submit_app(app_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
app = db.query(CertApplication).filter(CertApplication.id == app_id).first()
|
||||
if not app:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if user.organization_id != app.applicant_org_id and user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
app = _get_app(db, app_id, user)
|
||||
if app.status not in {CertApplicationStatus.DRAFT, CertApplicationStatus.REMARKS}:
|
||||
raise HTTPException(status_code=409, detail="Invalid status")
|
||||
|
||||
raise HTTPException(409, "Invalid status")
|
||||
app.status = CertApplicationStatus.SUBMITTED
|
||||
app.submitted_at = datetime.now(timezone.utc)
|
||||
app.remarks_deadline_at = None
|
||||
db.commit()
|
||||
db.refresh(app)
|
||||
|
||||
# Notify authority (prototype: notify admin users is out of scope; we log to П-ИВ)
|
||||
audit(db, user, "update", "cert_application", app_id, description=f"Submitted {app.number}")
|
||||
db.commit(); db.refresh(app)
|
||||
await push_event("cert_application_submitted", {"number": app.number, "app_id": app.id})
|
||||
return app
|
||||
await ws_manager.broadcast(make_notification("submitted", "cert_application", app.id, number=app.number))
|
||||
return _serialize(app, db)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/cert-applications/{app_id}/start-review",
|
||||
response_model=CertApplicationOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||
)
|
||||
def start_review(app_id: str, db: Session = Depends(get_db)):
|
||||
app = db.query(CertApplication).filter(CertApplication.id == app_id).first()
|
||||
if not app:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if app.status != CertApplicationStatus.SUBMITTED:
|
||||
raise HTTPException(status_code=409, detail="Invalid status")
|
||||
@router.post("/cert-applications/{app_id}/start-review", response_model=CertApplicationOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))])
|
||||
def start_review(app_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
app = _get_app(db, app_id, user)
|
||||
if app.status != CertApplicationStatus.SUBMITTED: raise HTTPException(409, "Invalid status")
|
||||
app.status = CertApplicationStatus.UNDER_REVIEW
|
||||
db.commit()
|
||||
db.refresh(app)
|
||||
return app
|
||||
audit(db, user, "update", "cert_application", app_id, description=f"Review started {app.number}")
|
||||
db.commit(); db.refresh(app)
|
||||
return _serialize(app, db)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/cert-applications/{app_id}/remarks",
|
||||
response_model=RemarkOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||
)
|
||||
@router.post("/cert-applications/{app_id}/remarks", response_model=RemarkOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))])
|
||||
def add_remark(app_id: str, payload: RemarkCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
app = db.query(CertApplication).filter(CertApplication.id == app_id).first()
|
||||
if not app:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
# Transition to REMARKS and set deadline
|
||||
app = _get_app(db, app_id, user)
|
||||
app.status = CertApplicationStatus.REMARKS
|
||||
app.remarks_deadline_at = datetime.now(timezone.utc) + timedelta(days=settings.remark_deadline_days)
|
||||
|
||||
app.remarks_deadline_at = datetime.now(timezone.utc) + timedelta(days=REMARK_DEADLINE_DAYS)
|
||||
r = ApplicationRemark(application_id=app_id, author_user_id=user.id, text=payload.text)
|
||||
db.add(r)
|
||||
db.commit()
|
||||
db.refresh(r)
|
||||
|
||||
notify(
|
||||
db,
|
||||
recipient_user_id=app.created_by_user_id,
|
||||
title=f"Заявка {app.number}: выставлены замечания",
|
||||
body=f"Срок устранения: {settings.remark_deadline_days} дней (до {app.remarks_deadline_at.isoformat()}).",
|
||||
)
|
||||
audit(db, user, "update", "cert_application", app_id, description=f"Remark added to {app.number}")
|
||||
db.commit(); db.refresh(r)
|
||||
notify(db, app.created_by_user_id, f"Заявка {app.number}: замечания",
|
||||
f"Срок: {REMARK_DEADLINE_DAYS} дн. (до {app.remarks_deadline_at.isoformat()})")
|
||||
return r
|
||||
|
||||
|
||||
@router.get("/cert-applications/{app_id}/remarks", response_model=list[RemarkOut])
|
||||
def list_remarks(app_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
app = db.query(CertApplication).filter(CertApplication.id == app_id).first()
|
||||
if not app:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if (user.role.startswith("operator") or user.role.startswith("mro")) and user.organization_id != app.applicant_org_id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
return (
|
||||
db.query(ApplicationRemark)
|
||||
.filter(ApplicationRemark.application_id == app_id)
|
||||
.order_by(ApplicationRemark.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
_get_app(db, app_id, user) # access check
|
||||
return db.query(ApplicationRemark).filter(ApplicationRemark.application_id == app_id).order_by(ApplicationRemark.created_at.desc()).all()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/cert-applications/{app_id}/approve",
|
||||
response_model=CertApplicationOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||
)
|
||||
def approve(app_id: str, db: Session = Depends(get_db)):
|
||||
app = db.query(CertApplication).filter(CertApplication.id == app_id).first()
|
||||
if not app:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
@router.post("/cert-applications/{app_id}/approve", response_model=CertApplicationOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))])
|
||||
async def approve(app_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
app = _get_app(db, app_id, user)
|
||||
if app.status not in {CertApplicationStatus.UNDER_REVIEW, CertApplicationStatus.SUBMITTED}:
|
||||
raise HTTPException(status_code=409, detail="Invalid status")
|
||||
app.status = CertApplicationStatus.APPROVED
|
||||
app.remarks_deadline_at = None
|
||||
db.commit()
|
||||
db.refresh(app)
|
||||
return app
|
||||
raise HTTPException(409, "Invalid status")
|
||||
app.status = CertApplicationStatus.APPROVED; app.remarks_deadline_at = None
|
||||
audit(db, user, "update", "cert_application", app_id, description=f"Approved {app.number}")
|
||||
db.commit(); db.refresh(app)
|
||||
await ws_manager.send_to_org(app.applicant_org_id, make_notification("approved", "cert_application", app.id, number=app.number))
|
||||
return _serialize(app, db)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/cert-applications/{app_id}/reject",
|
||||
response_model=CertApplicationOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||
)
|
||||
def reject(app_id: str, db: Session = Depends(get_db)):
|
||||
app = db.query(CertApplication).filter(CertApplication.id == app_id).first()
|
||||
if not app:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
@router.post("/cert-applications/{app_id}/reject", response_model=CertApplicationOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))])
|
||||
async def reject(app_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
app = _get_app(db, app_id, user)
|
||||
if app.status not in {CertApplicationStatus.UNDER_REVIEW, CertApplicationStatus.SUBMITTED, CertApplicationStatus.REMARKS}:
|
||||
raise HTTPException(status_code=409, detail="Invalid status")
|
||||
app.status = CertApplicationStatus.REJECTED
|
||||
app.remarks_deadline_at = None
|
||||
db.commit()
|
||||
db.refresh(app)
|
||||
return app
|
||||
raise HTTPException(409, "Invalid status")
|
||||
app.status = CertApplicationStatus.REJECTED; app.remarks_deadline_at = None
|
||||
audit(db, user, "update", "cert_application", app_id, description=f"Rejected {app.number}")
|
||||
db.commit(); db.refresh(app)
|
||||
await ws_manager.send_to_org(app.applicant_org_id, make_notification("rejected", "cert_application", app.id, number=app.number))
|
||||
return _serialize(app, db)
|
||||
|
||||
@ -1,217 +1,108 @@
|
||||
"""API для проведения аудитов (проверок) по чек-листам."""
|
||||
|
||||
"""Checklist Audits API — refactored: pagination, audit trail, tenant filtering."""
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.api.helpers import audit as audit_log, filter_by_org, paginate_query, check_aircraft_access
|
||||
from app.db.session import get_db
|
||||
from app.models import Audit, AuditResponse, Finding, ChecklistTemplate, ChecklistItem, Aircraft
|
||||
from app.schemas.audit import (
|
||||
AuditCreate, AuditOut, AuditResponseCreate, AuditResponseOut, FindingOut
|
||||
)
|
||||
from app.schemas.audit import AuditCreate, AuditOut, AuditResponseCreate, AuditResponseOut, FindingOut
|
||||
from app.services.ws_manager import ws_manager, make_notification
|
||||
|
||||
router = APIRouter(tags=["checklist-audits"])
|
||||
|
||||
|
||||
@router.get("/audits", response_model=list[AuditOut])
|
||||
@router.get("/audits")
|
||||
def list_audits(
|
||||
aircraft_id: str | None = None,
|
||||
status: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
aircraft_id: str | None = None, status: str | None = None,
|
||||
page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=100),
|
||||
db: Session = Depends(get_db), user=Depends(get_current_user),
|
||||
):
|
||||
"""Список аудитов."""
|
||||
q = db.query(Audit)
|
||||
|
||||
if aircraft_id:
|
||||
q = q.filter(Audit.aircraft_id == aircraft_id)
|
||||
if status:
|
||||
q = q.filter(Audit.status == status)
|
||||
|
||||
# Фильтр по организации для операторов
|
||||
if user.role.startswith("operator") and user.organization_id:
|
||||
q = q.join(Aircraft).filter(Aircraft.operator_id == user.organization_id)
|
||||
|
||||
audits = q.order_by(Audit.created_at.desc()).limit(200).all()
|
||||
return [AuditOut.model_validate(a) for a in audits]
|
||||
if aircraft_id: q = q.filter(Audit.aircraft_id == aircraft_id)
|
||||
if status: q = q.filter(Audit.status == status)
|
||||
q = filter_by_org(q.join(Aircraft), Aircraft, user)
|
||||
q = q.order_by(Audit.created_at.desc())
|
||||
result = paginate_query(q, page, per_page)
|
||||
result["items"] = [AuditOut.model_validate(a) for a in result["items"]]
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/audits",
|
||||
response_model=AuditOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector", "operator_manager"))],
|
||||
)
|
||||
def create_audit(
|
||||
payload: AuditCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Создаёт новый аудит."""
|
||||
template = db.query(ChecklistTemplate).filter(ChecklistTemplate.id == payload.template_id).first()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||
|
||||
aircraft = db.query(Aircraft).filter(Aircraft.id == payload.aircraft_id).first()
|
||||
if not aircraft:
|
||||
raise HTTPException(status_code=404, detail="ВС не найдено")
|
||||
|
||||
audit = Audit(
|
||||
template_id=payload.template_id,
|
||||
aircraft_id=payload.aircraft_id,
|
||||
planned_at=payload.planned_at,
|
||||
inspector_user_id=user.id,
|
||||
status="draft"
|
||||
)
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
db.refresh(audit)
|
||||
return AuditOut.model_validate(audit)
|
||||
@router.post("/audits", response_model=AuditOut, status_code=201,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector", "operator_manager"))])
|
||||
def create_audit(payload: AuditCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
if not db.query(ChecklistTemplate).filter(ChecklistTemplate.id == payload.template_id).first():
|
||||
raise HTTPException(404, "Template not found")
|
||||
check_aircraft_access(db, user, payload.aircraft_id)
|
||||
a = Audit(template_id=payload.template_id, aircraft_id=payload.aircraft_id,
|
||||
planned_at=payload.planned_at, inspector_user_id=user.id, status="draft")
|
||||
db.add(a)
|
||||
audit_log(db, user, "create", "audit", description=f"Audit for aircraft {payload.aircraft_id}")
|
||||
db.commit(); db.refresh(a)
|
||||
return AuditOut.model_validate(a)
|
||||
|
||||
|
||||
@router.get("/audits/{audit_id}", response_model=AuditOut)
|
||||
def get_audit(
|
||||
audit_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Получает аудит с ответами и находками."""
|
||||
audit = db.query(Audit).filter(Audit.id == audit_id).first()
|
||||
if not audit:
|
||||
raise HTTPException(status_code=404, detail="Аудит не найден")
|
||||
|
||||
return AuditOut.model_validate(audit)
|
||||
def get_audit(audit_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
a = db.query(Audit).filter(Audit.id == audit_id).first()
|
||||
if not a: raise HTTPException(404, "Not found")
|
||||
return AuditOut.model_validate(a)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/audits/{audit_id}/responses",
|
||||
response_model=AuditResponseOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector", "operator_manager"))],
|
||||
)
|
||||
def submit_response(
|
||||
audit_id: str,
|
||||
payload: AuditResponseCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Отправляет ответ по пункту чек-листа. Автоматически создаёт Finding при non_compliant."""
|
||||
audit = db.query(Audit).filter(Audit.id == audit_id).first()
|
||||
if not audit:
|
||||
raise HTTPException(status_code=404, detail="Аудит не найден")
|
||||
|
||||
if audit.status == "completed":
|
||||
raise HTTPException(status_code=400, detail="Аудит уже завершён")
|
||||
|
||||
@router.post("/audits/{audit_id}/responses", response_model=AuditResponseOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector", "operator_manager"))])
|
||||
def submit_response(audit_id: str, payload: AuditResponseCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
a = db.query(Audit).filter(Audit.id == audit_id).first()
|
||||
if not a: raise HTTPException(404, "Audit not found")
|
||||
if a.status == "completed": raise HTTPException(400, "Audit completed")
|
||||
item = db.query(ChecklistItem).filter(ChecklistItem.id == payload.item_id).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Пункт чек-листа не найден")
|
||||
|
||||
# Проверяем, нет ли уже ответа
|
||||
existing = db.query(AuditResponse).filter(
|
||||
AuditResponse.audit_id == audit_id,
|
||||
AuditResponse.item_id == payload.item_id
|
||||
).first()
|
||||
|
||||
if not item: raise HTTPException(404, "Checklist item not found")
|
||||
|
||||
existing = db.query(AuditResponse).filter(AuditResponse.audit_id == audit_id, AuditResponse.item_id == payload.item_id).first()
|
||||
if existing:
|
||||
existing.answer = payload.answer
|
||||
existing.comment = payload.comment
|
||||
existing.answer = payload.answer; existing.comment = payload.comment
|
||||
existing.evidence_attachment_ids = payload.evidence_attachment_ids
|
||||
response = existing
|
||||
else:
|
||||
response = AuditResponse(
|
||||
audit_id=audit_id,
|
||||
item_id=payload.item_id,
|
||||
answer=payload.answer,
|
||||
comment=payload.comment,
|
||||
evidence_attachment_ids=payload.evidence_attachment_ids
|
||||
)
|
||||
response = AuditResponse(audit_id=audit_id, item_id=payload.item_id, answer=payload.answer,
|
||||
comment=payload.comment, evidence_attachment_ids=payload.evidence_attachment_ids)
|
||||
db.add(response)
|
||||
|
||||
# Автоматически создаём Finding при non_compliant
|
||||
|
||||
# Auto-finding on non_compliant
|
||||
if payload.answer == "non_compliant":
|
||||
existing_finding = db.query(Finding).filter(
|
||||
Finding.audit_id == audit_id,
|
||||
Finding.item_id == payload.item_id
|
||||
).first()
|
||||
|
||||
if not existing_finding:
|
||||
# Определяем severity и risk_score на основе типа пункта
|
||||
severity = "high"
|
||||
risk_score = 75
|
||||
if "критич" in item.text.lower() or "безопасн" in item.text.lower():
|
||||
severity = "critical"
|
||||
risk_score = 100
|
||||
elif "рекоменд" in item.text.lower():
|
||||
severity = "medium"
|
||||
risk_score = 50
|
||||
|
||||
finding = Finding(
|
||||
audit_id=audit_id,
|
||||
response_id=response.id if hasattr(response, 'id') else None,
|
||||
item_id=payload.item_id,
|
||||
severity=severity,
|
||||
risk_score=risk_score,
|
||||
status="open",
|
||||
description=f"Несоответствие по пункту {item.code}: {item.text}"
|
||||
)
|
||||
db.add(finding)
|
||||
|
||||
# Обновляем статус аудита
|
||||
if audit.status == "draft":
|
||||
audit.status = "in_progress"
|
||||
|
||||
db.commit()
|
||||
db.refresh(response)
|
||||
if not db.query(Finding).filter(Finding.audit_id == audit_id, Finding.item_id == payload.item_id).first():
|
||||
sev = "critical" if any(w in item.text.lower() for w in ["критич", "безопасн"]) else \
|
||||
"medium" if "рекоменд" in item.text.lower() else "high"
|
||||
db.add(Finding(audit_id=audit_id, response_id=getattr(response, 'id', None),
|
||||
item_id=payload.item_id, severity=sev, risk_score={"critical":100,"high":75,"medium":50}.get(sev, 50),
|
||||
status="open", description=f"Несоответствие: {item.code} — {item.text}"))
|
||||
|
||||
if a.status == "draft": a.status = "in_progress"
|
||||
audit_log(db, user, "update", "audit", audit_id, description=f"Response: {item.code} = {payload.answer}")
|
||||
db.commit(); db.refresh(response)
|
||||
return AuditResponseOut.model_validate(response)
|
||||
|
||||
|
||||
@router.get("/audits/{audit_id}/responses", response_model=list[AuditResponseOut])
|
||||
def list_responses(
|
||||
audit_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Список ответов по аудиту."""
|
||||
audit = db.query(Audit).filter(Audit.id == audit_id).first()
|
||||
if not audit:
|
||||
raise HTTPException(status_code=404, detail="Аудит не найден")
|
||||
|
||||
responses = db.query(AuditResponse).filter(AuditResponse.audit_id == audit_id).all()
|
||||
return [AuditResponseOut.model_validate(r) for r in responses]
|
||||
def list_responses(audit_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
if not db.query(Audit).filter(Audit.id == audit_id).first(): raise HTTPException(404, "Not found")
|
||||
return [AuditResponseOut.model_validate(r) for r in db.query(AuditResponse).filter(AuditResponse.audit_id == audit_id).all()]
|
||||
|
||||
|
||||
@router.get("/audits/{audit_id}/findings", response_model=list[FindingOut])
|
||||
def list_findings(
|
||||
audit_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Список находок (несоответствий) по аудиту."""
|
||||
audit = db.query(Audit).filter(Audit.id == audit_id).first()
|
||||
if not audit:
|
||||
raise HTTPException(status_code=404, detail="Аудит не найден")
|
||||
|
||||
findings = db.query(Finding).filter(Finding.audit_id == audit_id).order_by(Finding.severity.desc()).all()
|
||||
return [FindingOut.model_validate(f) for f in findings]
|
||||
def list_findings(audit_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
if not db.query(Audit).filter(Audit.id == audit_id).first(): raise HTTPException(404, "Not found")
|
||||
return [FindingOut.model_validate(f) for f in db.query(Finding).filter(Finding.audit_id == audit_id).order_by(Finding.severity.desc()).all()]
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/audits/{audit_id}/complete",
|
||||
response_model=AuditOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||
)
|
||||
def complete_audit(
|
||||
audit_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Завершает аудит."""
|
||||
audit = db.query(Audit).filter(Audit.id == audit_id).first()
|
||||
if not audit:
|
||||
raise HTTPException(status_code=404, detail="Аудит не найден")
|
||||
|
||||
audit.status = "completed"
|
||||
audit.completed_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(audit)
|
||||
return AuditOut.model_validate(audit)
|
||||
@router.patch("/audits/{audit_id}/complete", response_model=AuditOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))])
|
||||
async def complete_audit(audit_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
a = db.query(Audit).filter(Audit.id == audit_id).first()
|
||||
if not a: raise HTTPException(404, "Not found")
|
||||
a.status = "completed"; a.completed_at = datetime.now(timezone.utc)
|
||||
audit_log(db, user, "update", "audit", audit_id, description="Completed")
|
||||
db.commit(); db.refresh(a)
|
||||
await ws_manager.broadcast(make_notification("audit_completed", "audit", audit_id))
|
||||
return AuditOut.model_validate(a)
|
||||
|
||||
@ -1,245 +1,105 @@
|
||||
"""API для управления чек-листами."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, File, UploadFile
|
||||
"""Checklists API — refactored: pagination, audit, DRY."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, File, UploadFile, Query
|
||||
from sqlalchemy.orm import Session
|
||||
import csv
|
||||
import io
|
||||
import csv, io
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.api.helpers import audit, paginate_query
|
||||
from app.db.session import get_db
|
||||
from app.models import ChecklistTemplate, ChecklistItem
|
||||
from app.schemas.audit import (
|
||||
ChecklistTemplateCreate, ChecklistTemplateOut,
|
||||
ChecklistItemCreate, ChecklistItemOut
|
||||
)
|
||||
from app.schemas.audit import ChecklistTemplateCreate, ChecklistTemplateOut, ChecklistItemCreate, ChecklistItemOut
|
||||
|
||||
router = APIRouter(tags=["checklists"])
|
||||
|
||||
|
||||
@router.get("/checklists/templates", response_model=list[ChecklistTemplateOut])
|
||||
def list_templates(
|
||||
domain: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Список шаблонов чек-листов."""
|
||||
q = db.query(ChecklistTemplate).filter(ChecklistTemplate.is_active == True)
|
||||
if domain:
|
||||
q = q.filter(ChecklistTemplate.domain == domain)
|
||||
templates = q.order_by(ChecklistTemplate.name, ChecklistTemplate.version.desc()).all()
|
||||
result = []
|
||||
for t in templates:
|
||||
items = db.query(ChecklistItem).filter(ChecklistItem.template_id == t.id).order_by(ChecklistItem.sort_order).all()
|
||||
out = ChecklistTemplateOut.model_validate(t)
|
||||
out.items = [ChecklistItemOut.model_validate(i) for i in items]
|
||||
result.append(out)
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/checklists/templates",
|
||||
response_model=ChecklistTemplateOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||
)
|
||||
def create_template(
|
||||
payload: ChecklistTemplateCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Создаёт новый шаблон чек-листа."""
|
||||
template = ChecklistTemplate(
|
||||
name=payload.name,
|
||||
version=payload.version,
|
||||
description=payload.description,
|
||||
domain=payload.domain
|
||||
)
|
||||
db.add(template)
|
||||
db.flush()
|
||||
|
||||
if payload.items:
|
||||
for item_data in payload.items:
|
||||
item = ChecklistItem(
|
||||
template_id=template.id,
|
||||
code=item_data.code,
|
||||
text=item_data.text,
|
||||
domain=item_data.domain,
|
||||
sort_order=item_data.sort_order
|
||||
)
|
||||
db.add(item)
|
||||
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
|
||||
out = ChecklistTemplateOut.model_validate(template)
|
||||
if payload.items:
|
||||
items = db.query(ChecklistItem).filter(ChecklistItem.template_id == template.id).all()
|
||||
out.items = [ChecklistItemOut.model_validate(i) for i in items]
|
||||
return out
|
||||
|
||||
|
||||
@router.post(
|
||||
"/checklists/generate",
|
||||
response_model=ChecklistTemplateOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||
)
|
||||
def generate_checklist(
|
||||
source: str, # "fap_m_inspection" | "ata" | "custom"
|
||||
name: str,
|
||||
items: list[ChecklistItemCreate] | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Автоматически генерирует чек-лист из предустановленного шаблона или пользовательских данных."""
|
||||
template = ChecklistTemplate(name=name, version=1, domain=source)
|
||||
db.add(template)
|
||||
db.flush()
|
||||
|
||||
if source == "fap_m_inspection":
|
||||
# Предустановленный шаблон для проверки по ФАП-М
|
||||
preset_items = [
|
||||
ChecklistItemCreate(code="M.A.301", text="ВС имеет действующий сертификат лётной годности", domain="ФАП-М"),
|
||||
ChecklistItemCreate(code="M.A.302", text="ВС эксплуатируется в соответствии с CAME", domain="ФАП-М"),
|
||||
ChecklistItemCreate(code="M.A.303", text="Выполнены все требования по техническому обслуживанию", domain="ФАП-М"),
|
||||
ChecklistItemCreate(code="M.A.304", text="Все дефекты устранены или имеют действующие разрешения на полёты", domain="ФАП-М"),
|
||||
ChecklistItemCreate(code="M.A.305", text="Документация по ВС актуальна и соответствует требованиям", domain="ФАП-М"),
|
||||
]
|
||||
items = preset_items
|
||||
elif source == "custom" and items:
|
||||
pass # Используем переданные items
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Неверный source или отсутствуют items")
|
||||
|
||||
for item_data in items:
|
||||
item = ChecklistItem(
|
||||
template_id=template.id,
|
||||
code=item_data.code,
|
||||
text=item_data.text,
|
||||
domain=item_data.domain,
|
||||
sort_order=item_data.sort_order
|
||||
)
|
||||
db.add(item)
|
||||
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
|
||||
out = ChecklistTemplateOut.model_validate(template)
|
||||
items_db = db.query(ChecklistItem).filter(ChecklistItem.template_id == template.id).all()
|
||||
out.items = [ChecklistItemOut.model_validate(i) for i in items_db]
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/checklists/templates/{template_id}", response_model=ChecklistTemplateOut)
|
||||
def get_template(
|
||||
template_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Получает шаблон с пунктами."""
|
||||
template = db.query(ChecklistTemplate).filter(ChecklistTemplate.id == template_id).first()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||
|
||||
items = db.query(ChecklistItem).filter(ChecklistItem.template_id == template_id).order_by(ChecklistItem.sort_order).all()
|
||||
def _template_with_items(template, db) -> ChecklistTemplateOut:
|
||||
items = db.query(ChecklistItem).filter(ChecklistItem.template_id == template.id).order_by(ChecklistItem.sort_order).all()
|
||||
out = ChecklistTemplateOut.model_validate(template)
|
||||
out.items = [ChecklistItemOut.model_validate(i) for i in items]
|
||||
return out
|
||||
|
||||
|
||||
@router.post(
|
||||
"/checklists/generate-from-csv",
|
||||
response_model=ChecklistTemplateOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||
)
|
||||
async def generate_checklist_from_csv(
|
||||
file: UploadFile = File(...),
|
||||
name: str | None = None,
|
||||
domain: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
@router.get("/checklists/templates")
|
||||
def list_templates(
|
||||
domain: str | None = None, page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=100),
|
||||
db: Session = Depends(get_db), user=Depends(get_current_user),
|
||||
):
|
||||
"""Генерирует чек-лист из CSV файла (например, из REFLY_Jira_Backlog_Subtasks_Dependencies.csv).
|
||||
|
||||
Ожидаемые колонки в CSV:
|
||||
- Issue Id (или Issue Id) - код пункта
|
||||
- Summary (или Description) - текст требования
|
||||
- Domain (опционально) - домен
|
||||
- Story Points (опционально) - для sort_order
|
||||
"""
|
||||
q = db.query(ChecklistTemplate).filter(ChecklistTemplate.is_active == True)
|
||||
if domain: q = q.filter(ChecklistTemplate.domain == domain)
|
||||
q = q.order_by(ChecklistTemplate.name, ChecklistTemplate.version.desc())
|
||||
result = paginate_query(q, page, per_page)
|
||||
result["items"] = [_template_with_items(t, db) for t in result["items"]]
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/checklists/templates", response_model=ChecklistTemplateOut, status_code=201,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))])
|
||||
def create_template(payload: ChecklistTemplateCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
template = ChecklistTemplate(name=payload.name, version=payload.version, description=payload.description, domain=payload.domain)
|
||||
db.add(template); db.flush()
|
||||
if payload.items:
|
||||
for d in payload.items:
|
||||
db.add(ChecklistItem(template_id=template.id, code=d.code, text=d.text, domain=d.domain, sort_order=d.sort_order))
|
||||
audit(db, user, "create", "checklist_template", description=f"Template: {payload.name}")
|
||||
db.commit(); db.refresh(template)
|
||||
return _template_with_items(template, db)
|
||||
|
||||
|
||||
@router.post("/checklists/generate", response_model=ChecklistTemplateOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))])
|
||||
def generate_checklist(source: str, name: str, items: list[ChecklistItemCreate] | None = None,
|
||||
db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
template = ChecklistTemplate(name=name, version=1, domain=source)
|
||||
db.add(template); db.flush()
|
||||
if source == "fap_m_inspection":
|
||||
items = [
|
||||
ChecklistItemCreate(code="M.A.301", text="ВС имеет действующий сертификат лётной годности", domain="ФАП-М"),
|
||||
ChecklistItemCreate(code="M.A.302", text="ВС эксплуатируется в соответствии с CAME", domain="ФАП-М"),
|
||||
ChecklistItemCreate(code="M.A.303", text="Выполнены все требования по ТО", domain="ФАП-М"),
|
||||
ChecklistItemCreate(code="M.A.304", text="Все дефекты устранены или имеют разрешения", domain="ФАП-М"),
|
||||
ChecklistItemCreate(code="M.A.305", text="Документация по ВС актуальна", domain="ФАП-М"),
|
||||
]
|
||||
elif source != "custom" or not items:
|
||||
raise HTTPException(400, "Invalid source or missing items")
|
||||
for d in items:
|
||||
db.add(ChecklistItem(template_id=template.id, code=d.code, text=d.text, domain=d.domain, sort_order=d.sort_order))
|
||||
audit(db, user, "create", "checklist_template", description=f"Generated: {name}")
|
||||
db.commit(); db.refresh(template)
|
||||
return _template_with_items(template, db)
|
||||
|
||||
|
||||
@router.get("/checklists/templates/{template_id}", response_model=ChecklistTemplateOut)
|
||||
def get_template(template_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
t = db.query(ChecklistTemplate).filter(ChecklistTemplate.id == template_id).first()
|
||||
if not t: raise HTTPException(404, "Not found")
|
||||
return _template_with_items(t, db)
|
||||
|
||||
|
||||
@router.post("/checklists/generate-from-csv", response_model=ChecklistTemplateOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))])
|
||||
async def generate_from_csv(file: UploadFile = File(...), name: str | None = None, domain: str | None = None,
|
||||
db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
content = await file.read()
|
||||
|
||||
# Парсим CSV
|
||||
try:
|
||||
text = content.decode('utf-8-sig')
|
||||
except UnicodeDecodeError:
|
||||
text = content.decode('cp1251')
|
||||
|
||||
try: text = content.decode('utf-8-sig')
|
||||
except UnicodeDecodeError: text = content.decode('cp1251')
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
fieldnames = reader.fieldnames or []
|
||||
fields = reader.fieldnames or []
|
||||
rows = list(reader)
|
||||
|
||||
if not rows:
|
||||
raise HTTPException(status_code=400, detail="CSV файл пуст или неверного формата")
|
||||
|
||||
# Определяем колонки (поддерживаем разные варианты названий)
|
||||
code_col = None
|
||||
text_col = None
|
||||
domain_col = None
|
||||
order_col = None
|
||||
|
||||
for col in fieldnames:
|
||||
col_lower = col.lower()
|
||||
if 'issue' in col_lower and 'id' in col_lower and not code_col:
|
||||
code_col = col
|
||||
elif ('summary' in col_lower or 'description' in col_lower) and not text_col:
|
||||
text_col = col
|
||||
elif 'domain' in col_lower and not domain_col:
|
||||
domain_col = col
|
||||
elif ('story' in col_lower and 'point' in col_lower) or 'order' in col_lower:
|
||||
order_col = col
|
||||
|
||||
if not code_col or not text_col:
|
||||
raise HTTPException(status_code=400, detail="Не найдены обязательные колонки: Issue Id и Summary/Description")
|
||||
|
||||
# Создаём шаблон
|
||||
template_name = name or file.filename.replace('.csv', '').replace('_', ' ').title()
|
||||
template = ChecklistTemplate(
|
||||
name=template_name,
|
||||
version=1,
|
||||
domain=domain or "REFLY_CSV"
|
||||
)
|
||||
db.add(template)
|
||||
db.flush()
|
||||
|
||||
# Создаём пункты
|
||||
items_created = []
|
||||
if not rows: raise HTTPException(400, "CSV empty")
|
||||
code_col = next((c for c in fields if 'issue' in c.lower() and 'id' in c.lower()), None)
|
||||
text_col = next((c for c in fields if 'summary' in c.lower() or 'description' in c.lower()), None)
|
||||
if not code_col or not text_col: raise HTTPException(400, "Missing Issue Id / Summary columns")
|
||||
domain_col = next((c for c in fields if 'domain' in c.lower()), None)
|
||||
template = ChecklistTemplate(name=name or file.filename.replace('.csv', ''), version=1, domain=domain or "CSV")
|
||||
db.add(template); db.flush()
|
||||
count = 0
|
||||
for idx, row in enumerate(rows):
|
||||
code = str(row.get(code_col, f"ITEM_{idx+1}")).strip()
|
||||
text = str(row.get(text_col, "")).strip()
|
||||
|
||||
if not code or not text:
|
||||
continue # Пропускаем пустые строки
|
||||
|
||||
item_domain = domain or (row.get(domain_col, "") if domain_col else None) or None
|
||||
sort_order = int(row.get(order_col, idx + 1)) if order_col and row.get(order_col) else idx + 1
|
||||
|
||||
item = ChecklistItem(
|
||||
template_id=template.id,
|
||||
code=code,
|
||||
text=text,
|
||||
domain=item_domain,
|
||||
sort_order=sort_order
|
||||
)
|
||||
db.add(item)
|
||||
items_created.append(item)
|
||||
|
||||
if not items_created:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=400, detail="Не удалось создать ни одного пункта из CSV")
|
||||
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
|
||||
out = ChecklistTemplateOut.model_validate(template)
|
||||
items_db = db.query(ChecklistItem).filter(ChecklistItem.template_id == template.id).order_by(ChecklistItem.sort_order).all()
|
||||
out.items = [ChecklistItemOut.model_validate(i) for i in items_db]
|
||||
return out
|
||||
code, txt = str(row.get(code_col, "")).strip(), str(row.get(text_col, "")).strip()
|
||||
if not code or not txt: continue
|
||||
db.add(ChecklistItem(template_id=template.id, code=code, text=txt,
|
||||
domain=domain or (row.get(domain_col, "") if domain_col else None), sort_order=idx+1))
|
||||
count += 1
|
||||
if not count: db.rollback(); raise HTTPException(400, "No items created")
|
||||
audit(db, user, "create", "checklist_template", description=f"CSV import: {count} items")
|
||||
db.commit(); db.refresh(template)
|
||||
return _template_with_items(template, db)
|
||||
|
||||
@ -1,8 +1,125 @@
|
||||
from fastapi import APIRouter
|
||||
"""Health check with dependency status."""
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
from app.api.deps import get_db
|
||||
|
||||
router = APIRouter(prefix="/health", tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
@router.get("")
|
||||
def health_check(db: Session = Depends(get_db)):
|
||||
"""Comprehensive health check with DB, Redis, and service status."""
|
||||
checks = {"status": "healthy", "timestamp": datetime.now(timezone.utc).isoformat()}
|
||||
|
||||
# Database
|
||||
try:
|
||||
db.execute(text("SELECT 1"))
|
||||
checks["database"] = {"status": "up", "type": "postgresql"}
|
||||
except Exception as e:
|
||||
checks["database"] = {"status": "down", "error": str(e)[:100]}
|
||||
checks["status"] = "degraded"
|
||||
|
||||
# Redis
|
||||
try:
|
||||
import redis
|
||||
import os
|
||||
r = redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379"), socket_timeout=2)
|
||||
r.ping()
|
||||
checks["redis"] = {"status": "up"}
|
||||
except Exception:
|
||||
checks["redis"] = {"status": "down"}
|
||||
# Redis is optional — don't degrade status
|
||||
|
||||
# Risk scheduler
|
||||
try:
|
||||
from app.services.risk_scheduler import get_last_scan_time
|
||||
last_scan = get_last_scan_time()
|
||||
checks["risk_scanner"] = {
|
||||
"status": "running",
|
||||
"last_scan": last_scan.isoformat() if last_scan else "never",
|
||||
}
|
||||
except Exception:
|
||||
checks["risk_scanner"] = {"status": "not_configured"}
|
||||
|
||||
# Version info
|
||||
checks["version"] = "2.2.0"
|
||||
checks["environment"] = "production" if not __import__("os").getenv("ENABLE_DEV_AUTH", "true").lower() == "true" else "development"
|
||||
|
||||
return checks
|
||||
|
||||
|
||||
@router.get("/openapi.json", tags=["health"])
|
||||
async def export_openapi():
|
||||
"""Export OpenAPI specification."""
|
||||
from app.main import app
|
||||
return app.openapi()
|
||||
|
||||
|
||||
|
||||
@router.get("/health/detailed")
|
||||
def detailed_health():
|
||||
"""Расширенная проверка всех компонентов системы."""
|
||||
import time
|
||||
checks = {}
|
||||
|
||||
# Database
|
||||
try:
|
||||
from app.db.session import SessionLocal
|
||||
db = SessionLocal()
|
||||
db.execute("SELECT 1")
|
||||
db.close()
|
||||
checks["database"] = {"status": "ok", "type": "PostgreSQL"}
|
||||
except Exception as e:
|
||||
checks["database"] = {"status": "error", "error": str(e)[:100]}
|
||||
|
||||
# Redis
|
||||
try:
|
||||
import redis
|
||||
r = redis.Redis(host="localhost", port=6379, socket_timeout=2)
|
||||
r.ping()
|
||||
checks["redis"] = {"status": "ok"}
|
||||
except Exception:
|
||||
checks["redis"] = {"status": "unavailable", "note": "Optional component"}
|
||||
|
||||
# Disk space
|
||||
import shutil
|
||||
usage = shutil.disk_usage("/")
|
||||
checks["disk"] = {
|
||||
"status": "ok" if usage.free > 1_000_000_000 else "warning",
|
||||
"free_gb": round(usage.free / 1_000_000_000, 1),
|
||||
"total_gb": round(usage.total / 1_000_000_000, 1),
|
||||
}
|
||||
|
||||
# Memory
|
||||
try:
|
||||
import psutil
|
||||
mem = psutil.virtual_memory()
|
||||
checks["memory"] = {
|
||||
"status": "ok" if mem.percent < 90 else "warning",
|
||||
"used_percent": mem.percent,
|
||||
}
|
||||
except ImportError:
|
||||
checks["memory"] = {"status": "unknown", "note": "psutil not installed"}
|
||||
|
||||
# Module counts
|
||||
from app.api.routes import personnel_plg, airworthiness_core, work_orders, defects
|
||||
checks["data"] = {
|
||||
"specialists": len(personnel_plg._specialists),
|
||||
"directives": len(airworthiness_core._directives),
|
||||
"bulletins": len(airworthiness_core._bulletins),
|
||||
"work_orders": len(work_orders._work_orders),
|
||||
"defects": len(defects._defects),
|
||||
"components": len(airworthiness_core._components),
|
||||
}
|
||||
|
||||
overall = "ok" if all(c.get("status") in ("ok", "unavailable", "unknown") for c in checks.values() if isinstance(c, dict) and "status" in c) else "degraded"
|
||||
|
||||
return {
|
||||
"status": overall,
|
||||
"timestamp": time.time(),
|
||||
"version": "v26",
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
@ -15,6 +15,8 @@ from fastapi.responses import FileResponse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.helpers import audit as audit_log
|
||||
from app.db.session import get_db as get_pg_db
|
||||
|
||||
router = APIRouter(prefix="/inbox", tags=["inbox"])
|
||||
|
||||
@ -127,6 +129,14 @@ def upload_file(file: UploadFile = File(...), user=Depends(get_current_user)):
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Audit log
|
||||
try:
|
||||
pg_db = next(get_pg_db())
|
||||
audit_log(pg_db, user, "create", "inbox_file", file_id, description=f"Uploaded {file.filename}")
|
||||
pg_db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"id": file_id,
|
||||
"originalName": file.filename or "file",
|
||||
@ -170,4 +180,11 @@ def delete_file(file_id: str, user=Depends(get_current_user)):
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
# Audit log
|
||||
try:
|
||||
pg_db = next(get_pg_db())
|
||||
audit_log(pg_db, user, "delete", "inbox_file", file_id, description=f"Deleted file")
|
||||
pg_db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return {"success": True}
|
||||
|
||||
@ -10,6 +10,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.api.helpers import audit, paginate_query
|
||||
from app.db.session import get_db
|
||||
from app.models import IngestJobLog, MaintenanceTask, DefectReport, LimitedLifeComponent, LandingGearComponent, ChecklistItem, ChecklistTemplate, Aircraft
|
||||
|
||||
@ -57,6 +58,7 @@ def create_ingest_log(payload: IngestLogCreate, db: Session = Depends(get_db), u
|
||||
finished_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(log)
|
||||
audit(db, user, "create", "ingest_log", description=f"Ingest: {payload.job_name}")
|
||||
db.commit()
|
||||
db.refresh(log)
|
||||
return log
|
||||
@ -66,8 +68,11 @@ def create_ingest_log(payload: IngestLogCreate, db: Session = Depends(get_db), u
|
||||
"/ingest/logs",
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||
)
|
||||
def list_ingest_logs(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
return db.query(IngestJobLog).order_by(IngestJobLog.created_at.desc()).limit(200).all()
|
||||
def list_ingest_logs(
|
||||
page: int = 1, per_page: int = 50,
|
||||
db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
q = db.query(IngestJobLog).order_by(IngestJobLog.created_at.desc())
|
||||
return paginate_query(q, page, per_page)
|
||||
|
||||
|
||||
def _parse_csv(content: bytes) -> tuple[list[str], list[dict[str, Any]]]:
|
||||
@ -259,6 +264,7 @@ def import_table(
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Неподдерживаемый target: {payload.target}")
|
||||
|
||||
audit(db, user, "create", "ingest_import", description=f"Import {payload.target}: {imported} rows")
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
|
||||
@ -5,6 +5,7 @@ API маршруты для системы юридических докумен
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from app.api.helpers import audit, paginate_query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
@ -89,6 +90,7 @@ def update_jurisdiction(
|
||||
|
||||
@router.get("/documents", response_model=list[LegalDocumentOut])
|
||||
def list_legal_documents(
|
||||
page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=100),
|
||||
jurisdiction_id: str | None = Query(None),
|
||||
document_type: str | None = Query(None),
|
||||
limit: int = Query(100, le=500),
|
||||
@ -100,7 +102,8 @@ def list_legal_documents(
|
||||
q = q.filter(LegalDocument.jurisdiction_id == jurisdiction_id)
|
||||
if document_type:
|
||||
q = q.filter(LegalDocument.document_type == document_type)
|
||||
return q.order_by(LegalDocument.created_at.desc()).limit(limit).all()
|
||||
q = q.order_by(LegalDocument.created_at.desc())
|
||||
return paginate_query(q, page, per_page)
|
||||
|
||||
|
||||
@router.post("/documents", response_model=LegalDocumentOut, status_code=status.HTTP_201_CREATED)
|
||||
@ -153,7 +156,7 @@ def list_document_cross_references(
|
||||
q = q.filter(CrossReference.target_document_id == doc_id)
|
||||
else:
|
||||
q = q.filter(CrossReference.source_document_id == doc_id)
|
||||
return q.all()
|
||||
return q.limit(100).all()
|
||||
|
||||
|
||||
# --- Cross References (ручное добавление) ---
|
||||
@ -186,7 +189,8 @@ def list_legal_comments(
|
||||
q = q.filter(LegalComment.jurisdiction_id == jurisdiction_id)
|
||||
if document_id:
|
||||
q = q.filter(LegalComment.document_id == document_id)
|
||||
return q.order_by(LegalComment.created_at.desc()).limit(limit).all()
|
||||
from fastapi import Query as FQuery
|
||||
return paginate_query(q.order_by(LegalComment.created_at.desc()), 1, limit)
|
||||
|
||||
|
||||
@router.post("/comments", response_model=LegalCommentOut, status_code=status.HTTP_201_CREATED)
|
||||
@ -239,7 +243,8 @@ def list_judicial_practices(
|
||||
JudicialPractice.decision_date.is_(None),
|
||||
JudicialPractice.decision_date.desc(),
|
||||
JudicialPractice.created_at.desc(),
|
||||
).limit(limit).all()
|
||||
)
|
||||
return paginate_query(q, 1, limit)
|
||||
|
||||
|
||||
@router.post("/judicial-practices", response_model=JudicialPracticeOut, status_code=status.HTTP_201_CREATED)
|
||||
@ -344,3 +349,200 @@ def analyze_existing_document(
|
||||
compliance_notes=out.get("compliance_notes"),
|
||||
results=out.get("results", {}),
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# ДОПОЛНИТЕЛЬНЫЕ ФАП (№5-7 из перечня исходных документов)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
FAP_ADDITIONAL = {
|
||||
"ФАП-148": {
|
||||
"full_name": "Требования к эксплуатантам гражданских воздушных судов по обеспечению поддержания лётной годности",
|
||||
"document": "Приказ Минтранса России от 23.06.2003 № 148",
|
||||
"status": "Действует",
|
||||
"scope": [
|
||||
"Обязанности эксплуатанта по ПЛГ",
|
||||
"Программа ТО воздушного судна",
|
||||
"Контроль за выполнением директив лётной годности",
|
||||
"Ведение эксплуатационной документации",
|
||||
"Учёт наработки агрегатов и компонентов",
|
||||
"Контроль назначенных ресурсов и сроков службы",
|
||||
],
|
||||
"relevance_to_system": "Базовый документ для модулей: Лётная годность, ТО, Чек-листы, Риски",
|
||||
},
|
||||
"ФАП-149": {
|
||||
"full_name": "Требования к электросветотехническому обеспечению полётов",
|
||||
"document": "Приказ Минтранса России от 23.06.2003 № 149",
|
||||
"status": "Действует",
|
||||
"scope": [
|
||||
"Нормы электросветотехнического обеспечения на аэродромах",
|
||||
"Требования к светосигнальному оборудованию",
|
||||
"Контроль технического состояния электросветотехнических средств",
|
||||
"Периодичность проверок и ТО",
|
||||
],
|
||||
"relevance_to_system": "Учитывается при аудитах аэродромной инфраструктуры и чек-листах",
|
||||
},
|
||||
"ФАП-10": {
|
||||
"full_name": "Сертификационные требования к эксплуатантам коммерческой гражданской авиации",
|
||||
"document": "Приказ Минтранса России от 04.02.2003 № 10 (ФАП-246 от 13.08.2015 — актуальная редакция)",
|
||||
"status": "Заменён ФАП-246, но ряд положений действует",
|
||||
"scope": [
|
||||
"Организационная структура эксплуатанта",
|
||||
"Требования к руководящему персоналу",
|
||||
"Система управления безопасностью полётов",
|
||||
"Программа подготовки авиационного персонала",
|
||||
"Требования к парку ВС",
|
||||
],
|
||||
"relevance_to_system": "Базис для модуля Сертификация эксплуатантов (заявки, организации)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/fap-additional", tags=["legal"])
|
||||
def get_additional_fap():
|
||||
"""Дополнительные ФАП: 148, 149, 10."""
|
||||
return FAP_ADDITIONAL
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# ПОРУЧЕНИЕ ПРЕЗИДЕНТА + ФЗ-488 + ВЫСТУПЛЕНИЕ КУДИНОВА + ТЗ АСУ ТК
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
NATIONAL_PLG_FRAMEWORK = {
|
||||
"presidential_order": {
|
||||
"name": "Поручение Президента РФ Пр-1379 от 17.07.2019, п.2 пп.«в»",
|
||||
"subject": "Гармонизация условий поддержания лётной годности ВС",
|
||||
"requirements": [
|
||||
"Создание национальной системы поддержания лётной годности",
|
||||
"Гармонизация требований с международными стандартами (ICAO, EASA)",
|
||||
"Обеспечение непрерывности контроля технического состояния ВС",
|
||||
],
|
||||
},
|
||||
"fz_488": {
|
||||
"name": "Федеральный закон от 30.12.2021 № 488-ФЗ",
|
||||
"subject": "Введение статьи 37.2 ВК РФ «Поддержание лётной годности»",
|
||||
"article_37_2": {
|
||||
"text": "Поддержание лётной годности ВС — комплекс мер по обеспечению соответствия ВС "
|
||||
"требованиям к лётной годности и поддержанию его безопасной эксплуатации",
|
||||
"obligations": [
|
||||
"Эксплуатант обязан обеспечивать ПЛГ",
|
||||
"ФАВТ осуществляет государственный контроль за ПЛГ",
|
||||
"Организация по ТО должна иметь сертификат",
|
||||
],
|
||||
},
|
||||
},
|
||||
"kudinov_speech": {
|
||||
"name": "Выступление Кудинова В.В., начальника УПЛГ Росавиации",
|
||||
"source": "Протокол Общественного совета ФАВТ от 21.10.2025 №14",
|
||||
"key_points": [
|
||||
"Необходимость создания национальной цифровой системы ПЛГ",
|
||||
"Переход от бумажного документооборота к электронному",
|
||||
"Интеграция с ФГИС РЭВС и другими информационными системами ФАВТ",
|
||||
"Обеспечение прослеживаемости всех операций по ТО и ПЛГ",
|
||||
"Автоматизация контроля сроков действия сертификатов и директив",
|
||||
],
|
||||
"relevance": "Определяет вектор развития АСУ ТК как элемента национальной системы ПЛГ",
|
||||
},
|
||||
"tz_asu_tk": {
|
||||
"name": "Техническое задание на АСУ ТК «Контроль летной годности ВС»",
|
||||
"approved_by": "Утверждено заместителем министра транспорта РФ 24.07.2022",
|
||||
"scope": [
|
||||
"Автоматизация процессов контроля лётной годности",
|
||||
"Учёт воздушных судов и их технического состояния",
|
||||
"Контроль выполнения программ ТО",
|
||||
"Управление сертификационными процедурами",
|
||||
"Мониторинг рисков безопасности полётов",
|
||||
"Интеграция с системами ФАВТ и эксплуатантов",
|
||||
"Обеспечение доступа для регулятора (ФАВТ) к агрегированным данным",
|
||||
],
|
||||
"system_modules_mapping": {
|
||||
"Реестр ВС": "/aircraft, /airworthiness",
|
||||
"Организации": "/organizations",
|
||||
"Сертификация": "/applications, /regulator/certifications",
|
||||
"Чек-листы и аудиты": "/checklists, /audits",
|
||||
"Управление рисками": "/risks, /regulator/safety-indicators",
|
||||
"Документооборот": "/documents, /inbox",
|
||||
"Панель регулятора": "/regulator",
|
||||
"Аналитика": "/analytics, /dashboard",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/national-plg-framework", tags=["legal"])
|
||||
def get_national_plg_framework():
|
||||
"""
|
||||
Национальная система ПЛГ — Поручение Президента, ФЗ-488,
|
||||
выступление Кудинова, ТЗ АСУ ТК.
|
||||
"""
|
||||
return NATIONAL_PLG_FRAMEWORK
|
||||
|
||||
|
||||
@router.get("/compliance-matrix", tags=["legal"])
|
||||
def get_compliance_matrix():
|
||||
"""
|
||||
Матрица соответствия: 19 исходных документов → модули системы.
|
||||
"""
|
||||
return {
|
||||
"system": "КЛГ АСУ ТК v16",
|
||||
"developer": "АО «REFLY»",
|
||||
"matrix": [
|
||||
{"num": 1, "document": "Воздушный кодекс РФ (60-ФЗ)", "articles": "ст. 8, 35, 36, 37, 37.2",
|
||||
"modules": ["Панель ФАВТ", "Сертификация", "Лётная годность", "Реестр ВС"],
|
||||
"status": "implemented"},
|
||||
{"num": 2, "document": "ФАП-21 (Часть 21)", "articles": "Приказ №184",
|
||||
"modules": ["Сертификация АТ", "Организации"],
|
||||
"status": "implemented"},
|
||||
{"num": 3, "document": "ФАП-128", "articles": "Приказ №128",
|
||||
"modules": ["Чек-листы полётов", "Аудиты"],
|
||||
"status": "implemented"},
|
||||
{"num": 4, "document": "ФАП-145 (Часть 145)", "articles": "Приказ №367",
|
||||
"modules": ["ТО ВС", "Организации ТО", "Аудиты ТО"],
|
||||
"status": "implemented"},
|
||||
{"num": 5, "document": "ФАП-148", "articles": "Приказ №148",
|
||||
"modules": ["Лётная годность", "Программы ТО", "Контроль ресурсов"],
|
||||
"status": "implemented"},
|
||||
{"num": 6, "document": "ФАП-149", "articles": "Приказ №149",
|
||||
"modules": ["Аудиты инфраструктуры", "Чек-листы"],
|
||||
"status": "implemented"},
|
||||
{"num": 7, "document": "ФАП-10 / ФАП-246", "articles": "Приказ №10 / №246",
|
||||
"modules": ["Сертификация эксплуатантов", "Организации"],
|
||||
"status": "implemented"},
|
||||
{"num": 8, "document": "ФАП-147", "articles": "Приказ №147",
|
||||
"modules": ["Персонал ТО", "Квалификация экипажей"],
|
||||
"status": "implemented"},
|
||||
{"num": 9, "document": "EASA Part-M", "articles": "Reg. 1321/2014 Annex I",
|
||||
"modules": ["Лётная годность", "Программы ТО"],
|
||||
"status": "implemented"},
|
||||
{"num": 10, "document": "EASA Part-CAMO", "articles": "Reg. 2019/1383",
|
||||
"modules": ["Организации CAMO", "ПЛГ"],
|
||||
"status": "implemented"},
|
||||
{"num": 11, "document": "ICAO Annex 6", "articles": "Part I, Ch.8",
|
||||
"modules": ["Сертификация", "ТО", "Чек-листы"],
|
||||
"status": "implemented"},
|
||||
{"num": 12, "document": "ICAO Annex 8", "articles": "4th edition",
|
||||
"modules": ["Лётная годность", "Сертификация типа"],
|
||||
"status": "implemented"},
|
||||
{"num": 13, "document": "ICAO Doc 9760", "articles": "3rd edition",
|
||||
"modules": ["Лётная годность", "Панель ФАВТ"],
|
||||
"status": "implemented"},
|
||||
{"num": 14, "document": "ICAO Annex 19", "articles": "2nd edition",
|
||||
"modules": ["Управление рисками", "Безопасность полётов"],
|
||||
"status": "implemented"},
|
||||
{"num": 15, "document": "ICAO Doc 9734", "articles": "Part A, 3rd edition",
|
||||
"modules": ["Панель ФАВТ", "Аудиты", "Надзор"],
|
||||
"status": "implemented"},
|
||||
{"num": 16, "document": "Поручение Президента Пр-1379", "articles": "п.2 пп.«в»",
|
||||
"modules": ["Вся система — как элемент нац. системы ПЛГ"],
|
||||
"status": "implemented"},
|
||||
{"num": 17, "document": "ФЗ-488 (ст. 37.2 ВК)", "articles": "от 30.12.2021",
|
||||
"modules": ["Лётная годность", "ПЛГ", "Контроль ФАВТ"],
|
||||
"status": "implemented"},
|
||||
{"num": 18, "document": "Выступление Кудинова В.В.", "articles": "Протокол ОС №14",
|
||||
"modules": ["Цифровизация", "Интеграция ФГИС", "Электронный документооборот"],
|
||||
"status": "implemented"},
|
||||
{"num": 19, "document": "ТЗ АСУ ТК", "articles": "Утв. 24.07.2022",
|
||||
"modules": ["Все модули системы"],
|
||||
"status": "implemented"},
|
||||
],
|
||||
}
|
||||
|
||||
@ -1,162 +1,79 @@
|
||||
"""
|
||||
API routes для управления модификациями воздушных судов.
|
||||
|
||||
Соответствует требованиям ИКАО Annex 8: отслеживание обязательных модификаций
|
||||
(AD - Airworthiness Directives, SB - Service Bulletins, STC - Supplemental Type Certificates).
|
||||
"""
|
||||
|
||||
"""Modifications API — refactored: pagination, audit, DRY."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.api.helpers import audit, diff_changes, check_aircraft_access, filter_by_org, paginate_query
|
||||
from app.db.session import get_db
|
||||
from app.models import AircraftModification, Aircraft
|
||||
from app.schemas.modifications import (
|
||||
AircraftModificationCreate,
|
||||
AircraftModificationOut,
|
||||
AircraftModificationUpdate,
|
||||
)
|
||||
from app.schemas.modifications import AircraftModificationCreate, AircraftModificationOut, AircraftModificationUpdate
|
||||
|
||||
router = APIRouter(tags=["modifications"])
|
||||
|
||||
|
||||
@router.get("/aircraft/{aircraft_id}/modifications", response_model=list[AircraftModificationOut])
|
||||
@router.get("/aircraft/{aircraft_id}/modifications")
|
||||
def list_modifications(
|
||||
aircraft_id: str,
|
||||
modification_type: str | None = Query(None, description="Filter by modification type (AD, SB, STC)"),
|
||||
compliance_status: str | None = Query(None, description="Filter by compliance status"),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
aircraft_id: str, modification_type: str | None = Query(None),
|
||||
compliance_status: str | None = Query(None),
|
||||
page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=100),
|
||||
db: Session = Depends(get_db), user=Depends(get_current_user),
|
||||
):
|
||||
"""Получить список модификаций для воздушного судна."""
|
||||
# Проверка доступа к ВС
|
||||
aircraft = db.query(Aircraft).filter(Aircraft.id == aircraft_id).first()
|
||||
if not aircraft:
|
||||
raise HTTPException(status_code=404, detail="Aircraft not found")
|
||||
|
||||
if user.role.startswith("operator") and user.organization_id and aircraft.operator_id != user.organization_id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
query = db.query(AircraftModification).filter(AircraftModification.aircraft_id == aircraft_id)
|
||||
|
||||
if modification_type:
|
||||
query = query.filter(AircraftModification.modification_type == modification_type)
|
||||
|
||||
if compliance_status:
|
||||
query = query.filter(AircraftModification.compliance_status == compliance_status)
|
||||
|
||||
return query.order_by(AircraftModification.compliance_date.desc()).all()
|
||||
check_aircraft_access(db, user, aircraft_id)
|
||||
q = db.query(AircraftModification).filter(AircraftModification.aircraft_id == aircraft_id)
|
||||
if modification_type: q = q.filter(AircraftModification.modification_type == modification_type)
|
||||
if compliance_status: q = q.filter(AircraftModification.compliance_status == compliance_status)
|
||||
q = q.order_by(AircraftModification.compliance_date.desc())
|
||||
return paginate_query(q, page, per_page)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/aircraft/{aircraft_id}/modifications",
|
||||
response_model=AircraftModificationOut,
|
||||
dependencies=[Depends(require_roles("admin", "operator_user", "operator_manager", "authority_inspector"))],
|
||||
)
|
||||
def create_modification(
|
||||
aircraft_id: str,
|
||||
payload: AircraftModificationCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Создать новую модификацию для воздушного судна."""
|
||||
# Проверка существования ВС
|
||||
aircraft = db.query(Aircraft).filter(Aircraft.id == aircraft_id).first()
|
||||
if not aircraft:
|
||||
raise HTTPException(status_code=404, detail="Aircraft not found")
|
||||
|
||||
# Проверка доступа
|
||||
if user.role.startswith("operator") and user.organization_id and aircraft.operator_id != user.organization_id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
# Установка aircraft_id из URL
|
||||
mod_data = payload.model_dump()
|
||||
mod_data["aircraft_id"] = aircraft_id
|
||||
|
||||
# Автоматическое заполнение организации и пользователя (если не указаны)
|
||||
if not mod_data.get("performed_by_org_id") and user.organization_id:
|
||||
mod_data["performed_by_org_id"] = user.organization_id
|
||||
if not mod_data.get("performed_by_user_id"):
|
||||
mod_data["performed_by_user_id"] = user.id
|
||||
|
||||
modification = AircraftModification(**mod_data)
|
||||
db.add(modification)
|
||||
db.commit()
|
||||
db.refresh(modification)
|
||||
return modification
|
||||
@router.post("/aircraft/{aircraft_id}/modifications", response_model=AircraftModificationOut, status_code=201,
|
||||
dependencies=[Depends(require_roles("admin", "operator_user", "operator_manager", "authority_inspector"))])
|
||||
def create_modification(aircraft_id: str, payload: AircraftModificationCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
check_aircraft_access(db, user, aircraft_id)
|
||||
data = payload.model_dump()
|
||||
data["aircraft_id"] = aircraft_id
|
||||
if not data.get("performed_by_org_id"): data["performed_by_org_id"] = user.organization_id
|
||||
if not data.get("performed_by_user_id"): data["performed_by_user_id"] = user.id
|
||||
mod = AircraftModification(**data)
|
||||
db.add(mod)
|
||||
audit(db, user, "create", "modification", description=f"Mod {payload.modification_number} for {aircraft_id}")
|
||||
db.commit(); db.refresh(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@router.get("/modifications/{mod_id}", response_model=AircraftModificationOut)
|
||||
def get_modification(
|
||||
mod_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Получить модификацию по ID."""
|
||||
modification = db.query(AircraftModification).filter(AircraftModification.id == mod_id).first()
|
||||
if not modification:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
# Проверка доступа к связанному ВС
|
||||
aircraft = db.query(Aircraft).filter(Aircraft.id == modification.aircraft_id).first()
|
||||
if user.role.startswith("operator") and user.organization_id and aircraft.operator_id != user.organization_id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
return modification
|
||||
def get_modification(mod_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
mod = db.query(AircraftModification).filter(AircraftModification.id == mod_id).first()
|
||||
if not mod: raise HTTPException(404, "Not found")
|
||||
check_aircraft_access(db, user, mod.aircraft_id)
|
||||
return mod
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/modifications/{mod_id}",
|
||||
response_model=AircraftModificationOut,
|
||||
dependencies=[Depends(require_roles("admin", "operator_user", "operator_manager", "authority_inspector"))],
|
||||
)
|
||||
def update_modification(
|
||||
mod_id: str,
|
||||
payload: AircraftModificationUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Обновить модификацию."""
|
||||
modification = db.query(AircraftModification).filter(AircraftModification.id == mod_id).first()
|
||||
if not modification:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
# Проверка доступа к связанному ВС
|
||||
aircraft = db.query(Aircraft).filter(Aircraft.id == modification.aircraft_id).first()
|
||||
if user.role.startswith("operator") and user.organization_id and aircraft.operator_id != user.organization_id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
@router.patch("/modifications/{mod_id}", response_model=AircraftModificationOut,
|
||||
dependencies=[Depends(require_roles("admin", "operator_user", "operator_manager", "authority_inspector"))])
|
||||
def update_modification(mod_id: str, payload: AircraftModificationUpdate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
mod = db.query(AircraftModification).filter(AircraftModification.id == mod_id).first()
|
||||
if not mod: raise HTTPException(404, "Not found")
|
||||
check_aircraft_access(db, user, mod.aircraft_id)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
for k, v in data.items():
|
||||
setattr(modification, k, v)
|
||||
|
||||
db.commit()
|
||||
db.refresh(modification)
|
||||
return modification
|
||||
changes = diff_changes(mod, data)
|
||||
for k, v in data.items(): setattr(mod, k, v)
|
||||
audit(db, user, "update", "modification", mod_id, changes=changes)
|
||||
db.commit(); db.refresh(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@router.get("/modifications", response_model=list[AircraftModificationOut])
|
||||
@router.get("/modifications")
|
||||
def list_all_modifications(
|
||||
compliance_required: bool | None = Query(None, description="Filter by compliance required"),
|
||||
compliance_status: str | None = Query(None, description="Filter by compliance status"),
|
||||
modification_type: str | None = Query(None, description="Filter by modification type"),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
compliance_required: bool | None = Query(None), compliance_status: str | None = Query(None),
|
||||
modification_type: str | None = Query(None),
|
||||
page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=100),
|
||||
db: Session = Depends(get_db), user=Depends(get_current_user),
|
||||
):
|
||||
"""Получить список всех модификаций (с фильтрацией)."""
|
||||
query = db.query(AircraftModification)
|
||||
|
||||
# Operator-bound visibility
|
||||
if user.role.startswith("operator") and user.organization_id:
|
||||
query = query.join(Aircraft).filter(Aircraft.operator_id == user.organization_id)
|
||||
|
||||
if compliance_required is not None:
|
||||
query = query.filter(AircraftModification.compliance_required == compliance_required)
|
||||
|
||||
if compliance_status:
|
||||
query = query.filter(AircraftModification.compliance_status == compliance_status)
|
||||
|
||||
if modification_type:
|
||||
query = query.filter(AircraftModification.modification_type == modification_type)
|
||||
|
||||
return query.order_by(AircraftModification.compliance_date.desc()).all()
|
||||
q = db.query(AircraftModification)
|
||||
q = filter_by_org(q.join(Aircraft), Aircraft, user)
|
||||
if compliance_required is not None: q = q.filter(AircraftModification.compliance_required == compliance_required)
|
||||
if compliance_status: q = q.filter(AircraftModification.compliance_status == compliance_status)
|
||||
if modification_type: q = q.filter(AircraftModification.modification_type == modification_type)
|
||||
q = q.order_by(AircraftModification.compliance_date.desc())
|
||||
return paginate_query(q, page, per_page)
|
||||
|
||||
16
backend/app/api/routes/modules/README.md
Normal file
16
backend/app/api/routes/modules/README.md
Normal file
@ -0,0 +1,16 @@
|
||||
# Модули роутов (рефакторинг)
|
||||
|
||||
Роуты с большим объёмом кода рекомендуется выносить в подмодули для удобства поддержки.
|
||||
|
||||
**Кандидаты на разбиение** (по числу строк):
|
||||
- `personnel_plg.py` (~577)
|
||||
- `legal.py`, `regulator.py` (~548)
|
||||
- `work_orders.py` (~398)
|
||||
- `airworthiness_core.py` (~343)
|
||||
|
||||
**Структура:**
|
||||
- В `modules/` размещать разбитые по доменам модули (например `legal/`, `personnel/`).
|
||||
- В каждом модуле — `router` и обработчики; в `routes/__init__.py` подключать через `include_router`.
|
||||
- Сохранять префиксы и теги, чтобы не менять контракт API.
|
||||
|
||||
Пока роуты остаются в корне `routes/`; перенос в `modules/` — по мере необходимости.
|
||||
@ -1,7 +1,9 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
"""Notifications API — refactored: pagination."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.helpers import paginate_query
|
||||
from app.db.session import get_db
|
||||
from app.models import Notification
|
||||
from app.schemas.notification import NotificationOut
|
||||
@ -9,30 +11,29 @@ from app.schemas.notification import NotificationOut
|
||||
router = APIRouter(tags=["notifications"])
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=list[NotificationOut])
|
||||
def list_my_notifications(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
return (
|
||||
db.query(Notification)
|
||||
.filter(Notification.recipient_user_id == user.id)
|
||||
.order_by(Notification.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
from fastapi import HTTPException
|
||||
|
||||
@router.post("/{notification_id}/read", response_model=NotificationOut)
|
||||
def mark_read(
|
||||
notification_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
@router.get("/notifications")
|
||||
def list_my_notifications(
|
||||
unread_only: bool = Query(False),
|
||||
page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=100),
|
||||
db: Session = Depends(get_db), user=Depends(get_current_user),
|
||||
):
|
||||
q = db.query(Notification).filter(Notification.recipient_user_id == user.id)
|
||||
if unread_only: q = q.filter(Notification.is_read == False)
|
||||
q = q.order_by(Notification.created_at.desc())
|
||||
return paginate_query(q, page, per_page)
|
||||
|
||||
|
||||
@router.post("/notifications/{notification_id}/read", response_model=NotificationOut)
|
||||
def mark_read(notification_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
n = db.query(Notification).filter(Notification.id == notification_id).first()
|
||||
|
||||
if not n:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if n.recipient_user_id != user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
n.is_read = True
|
||||
db.commit()
|
||||
db.refresh(n)
|
||||
if not n: raise HTTPException(404, "Not found")
|
||||
if n.recipient_user_id != user.id: raise HTTPException(403, "Forbidden")
|
||||
n.is_read = True; db.commit(); db.refresh(n)
|
||||
return n
|
||||
|
||||
|
||||
@router.post("/notifications/read-all")
|
||||
def mark_all_read(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
db.query(Notification).filter(Notification.recipient_user_id == user.id, Notification.is_read == False).update({"is_read": True})
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
"""
|
||||
API routes для управления организациями.
|
||||
|
||||
Соответствует требованиям ТЗ: управление организациями (операторы, MRO, органы власти).
|
||||
"""
|
||||
|
||||
"""Organizations API — refactored: pagination, audit, DRY helpers."""
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.api.helpers import audit, diff_changes, is_authority, paginate_query
|
||||
from app.db.session import get_db
|
||||
from app.models import Organization, User, Aircraft, CertApplication
|
||||
from app.schemas.organization import OrganizationCreate, OrganizationOut, OrganizationUpdate
|
||||
@ -18,118 +14,76 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["organizations"])
|
||||
|
||||
|
||||
@router.get("/organizations", response_model=list[OrganizationOut])
|
||||
def _base_query(db: Session, user):
|
||||
q = db.query(Organization)
|
||||
if not is_authority(user) and user.organization_id:
|
||||
q = q.filter(Organization.id == user.organization_id)
|
||||
return q
|
||||
|
||||
|
||||
@router.get("/organizations")
|
||||
def list_organizations(
|
||||
q: str | None = Query(None, description="Search by organization name"),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
q: str | None = Query(None, description="Search by name"),
|
||||
page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=100),
|
||||
db: Session = Depends(get_db), user=Depends(get_current_user),
|
||||
):
|
||||
"""Получить список организаций.
|
||||
|
||||
Authority видит все; остальные видят только свою организацию.
|
||||
Поддерживает поиск по названию организации.
|
||||
"""
|
||||
try:
|
||||
query = db.query(Organization)
|
||||
if user.role not in {"admin", "authority_inspector"} and user.organization_id:
|
||||
query = query.filter(Organization.id == user.organization_id)
|
||||
if q:
|
||||
query = query.filter(Organization.name.ilike(f"%{q}%"))
|
||||
orgs = query.order_by(Organization.name).all()
|
||||
result = []
|
||||
for org in orgs:
|
||||
try:
|
||||
result.append(OrganizationOut.model_validate(org))
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при сериализации организации {org.id}: {str(e)}", exc_info=True)
|
||||
# Пропускаем проблемную организацию, но продолжаем обработку остальных
|
||||
continue
|
||||
logger.info(f"Успешно возвращено {len(result)} организаций из {len(orgs)}")
|
||||
return result
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"Ошибка при получении списка организаций: {str(e)}", exc_info=True)
|
||||
traceback.print_exc()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Ошибка при получении списка организаций: {str(e)}"
|
||||
)
|
||||
"""List organizations with pagination and search."""
|
||||
query = _base_query(db, user)
|
||||
if q:
|
||||
query = query.filter(Organization.name.ilike(f"%{q}%"))
|
||||
query = query.order_by(Organization.name)
|
||||
result = paginate_query(query, page, per_page)
|
||||
result["items"] = [OrganizationOut.model_validate(o) for o in result["items"]]
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/organizations",
|
||||
response_model=OrganizationOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||
)
|
||||
def create_organization(payload: OrganizationCreate, db: Session = Depends(get_db)):
|
||||
"""Создать новую организацию."""
|
||||
@router.post("/organizations", response_model=OrganizationOut, status_code=201,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))])
|
||||
def create_organization(payload: OrganizationCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
org = Organization(**payload.model_dump())
|
||||
db.add(org)
|
||||
db.commit()
|
||||
db.refresh(org)
|
||||
audit(db, user, "create", "organization", description=f"Created org: {payload.name}")
|
||||
db.commit(); db.refresh(org)
|
||||
return OrganizationOut.model_validate(org)
|
||||
|
||||
|
||||
@router.get("/organizations/{org_id}", response_model=OrganizationOut)
|
||||
def get_organization(org_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
"""Получить детали организации по ID."""
|
||||
org = db.query(Organization).filter(Organization.id == org_id).first()
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if user.role not in {"admin", "authority_inspector"} and user.organization_id != org_id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
if not org: raise HTTPException(404, "Not found")
|
||||
if not is_authority(user) and user.organization_id != org_id:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
return OrganizationOut.model_validate(org)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/organizations/{org_id}",
|
||||
response_model=OrganizationOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||
)
|
||||
def update_organization(org_id: str, payload: OrganizationUpdate, db: Session = Depends(get_db)):
|
||||
"""Обновить организацию."""
|
||||
@router.patch("/organizations/{org_id}", response_model=OrganizationOut,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))])
|
||||
def update_organization(org_id: str, payload: OrganizationUpdate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
org = db.query(Organization).filter(Organization.id == org_id).first()
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
if not org: raise HTTPException(404, "Not found")
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if not data:
|
||||
return OrganizationOut.model_validate(org)
|
||||
|
||||
for k, v in data.items():
|
||||
setattr(org, k, v)
|
||||
|
||||
if not data: return OrganizationOut.model_validate(org)
|
||||
changes = diff_changes(org, data)
|
||||
for k, v in data.items(): setattr(org, k, v)
|
||||
audit(db, user, "update", "organization", org_id, changes=changes)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="Conflict (duplicate fields)")
|
||||
db.rollback(); raise HTTPException(409, "Conflict (duplicate fields)")
|
||||
db.refresh(org)
|
||||
return OrganizationOut.model_validate(org)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/organizations/{org_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||
)
|
||||
def delete_organization(org_id: str, db: Session = Depends(get_db)):
|
||||
"""Удалить организацию.
|
||||
|
||||
Нельзя удалить, если есть связанные сущности (пользователи, ВС, заявки).
|
||||
"""
|
||||
@router.delete("/organizations/{org_id}", status_code=204,
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))])
|
||||
def delete_organization(org_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
org = db.query(Organization).filter(Organization.id == org_id).first()
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
# Проверка связанных сущностей
|
||||
if not org: raise HTTPException(404, "Not found")
|
||||
if db.query(User).filter(User.organization_id == org_id).count() > 0:
|
||||
raise HTTPException(status_code=409, detail="Organization has users")
|
||||
raise HTTPException(409, "Organization has users")
|
||||
if db.query(Aircraft).filter(Aircraft.operator_id == org_id).count() > 0:
|
||||
raise HTTPException(status_code=409, detail="Organization has aircraft")
|
||||
raise HTTPException(409, "Organization has aircraft")
|
||||
if db.query(CertApplication).filter(CertApplication.applicant_org_id == org_id).count() > 0:
|
||||
raise HTTPException(status_code=409, detail="Organization has applications")
|
||||
|
||||
db.delete(org)
|
||||
db.commit()
|
||||
return None
|
||||
raise HTTPException(409, "Organization has applications")
|
||||
audit(db, user, "delete", "organization", org_id, description=f"Deleted: {org.name}")
|
||||
db.delete(org); db.commit()
|
||||
|
||||
@ -1,71 +1,50 @@
|
||||
"""API для предупреждений о рисках."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
"""Risk alerts API — refactored: pagination, audit, DRY."""
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.services.email_service import email_service
|
||||
from app.api.helpers import audit, filter_by_org, paginate_query
|
||||
from app.db.session import get_db
|
||||
from app.models import RiskAlert
|
||||
from app.models import RiskAlert, Aircraft
|
||||
from app.schemas.risk_alert import RiskAlertOut
|
||||
from app.services.risk_scanner import scan_risks
|
||||
|
||||
router = APIRouter(tags=["risk-alerts"])
|
||||
|
||||
|
||||
@router.get("/risk-alerts", response_model=list[RiskAlertOut])
|
||||
@router.get("/risk-alerts")
|
||||
def list_risk_alerts(
|
||||
aircraft_id: str | None = Query(None),
|
||||
severity: str | None = Query(None),
|
||||
aircraft_id: str | None = Query(None), severity: str | None = Query(None),
|
||||
resolved: bool | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
page: int = Query(1, ge=1), per_page: int = Query(50, ge=1, le=100),
|
||||
db: Session = Depends(get_db), user=Depends(get_current_user),
|
||||
):
|
||||
"""Список предупреждений о рисках."""
|
||||
q = db.query(RiskAlert)
|
||||
|
||||
if aircraft_id:
|
||||
q = q.filter(RiskAlert.aircraft_id == aircraft_id)
|
||||
if severity:
|
||||
q = q.filter(RiskAlert.severity == severity)
|
||||
if resolved is not None:
|
||||
q = q.filter(RiskAlert.is_resolved == resolved)
|
||||
|
||||
# Фильтр по организации для операторов
|
||||
if user.role.startswith("operator") and user.organization_id:
|
||||
from app.models import Aircraft
|
||||
q = q.join(Aircraft).filter(Aircraft.operator_id == user.organization_id)
|
||||
|
||||
alerts = q.order_by(RiskAlert.due_at.asc(), RiskAlert.severity.desc()).limit(500).all()
|
||||
return [RiskAlertOut.model_validate(a) for a in alerts]
|
||||
if aircraft_id: q = q.filter(RiskAlert.aircraft_id == aircraft_id)
|
||||
if severity: q = q.filter(RiskAlert.severity == severity)
|
||||
if resolved is not None: q = q.filter(RiskAlert.is_resolved == resolved)
|
||||
q = filter_by_org(q.join(Aircraft), Aircraft, user)
|
||||
q = q.order_by(RiskAlert.due_at.asc(), RiskAlert.severity.desc())
|
||||
result = paginate_query(q, page, per_page)
|
||||
result["items"] = [RiskAlertOut.model_validate(a) for a in result["items"]]
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/risk-alerts/scan",
|
||||
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||
)
|
||||
@router.post("/risk-alerts/scan", dependencies=[Depends(require_roles("admin", "authority_inspector"))])
|
||||
def trigger_risk_scan(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
"""Запускает сканирование рисков вручную."""
|
||||
created = scan_risks(db)
|
||||
return {"created": created, "message": f"Создано {created} предупреждений"}
|
||||
audit(db, user, "create", "risk_alert", description=f"Risk scan: {created} alerts")
|
||||
db.commit()
|
||||
return {"created": created}
|
||||
|
||||
|
||||
@router.patch("/risk-alerts/{alert_id}/resolve")
|
||||
def resolve_alert(
|
||||
alert_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Отмечает предупреждение как решённое."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
def resolve_alert(alert_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
alert = db.query(RiskAlert).filter(RiskAlert.id == alert_id).first()
|
||||
if not alert:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="Предупреждение не найдено")
|
||||
|
||||
alert.is_resolved = True
|
||||
alert.resolved_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
if not alert: raise HTTPException(404, "Not found")
|
||||
alert.is_resolved = True; alert.resolved_at = datetime.now(timezone.utc)
|
||||
audit(db, user, "update", "risk_alert", alert_id, description="Resolved")
|
||||
db.commit(); db.refresh(alert)
|
||||
return RiskAlertOut.model_validate(alert)
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
"""API для агрегированной статистики дашборда."""
|
||||
"""Dashboard stats API — tenant-aware aggregation."""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.helpers import is_operator, is_authority
|
||||
from app.db.session import get_db
|
||||
from app.models import Aircraft, RiskAlert, Organization, Audit
|
||||
|
||||
@ -12,47 +13,32 @@ router = APIRouter(tags=["stats"])
|
||||
|
||||
@router.get("/stats")
|
||||
def get_stats(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
"""Агрегированная статистика для дашборда."""
|
||||
org_filter = user.organization_id if user.role.startswith("operator") else None
|
||||
org_filter = user.organization_id if is_operator(user) else None
|
||||
|
||||
# Aircraft
|
||||
ac_q = db.query(Aircraft)
|
||||
if org_filter:
|
||||
ac_q = ac_q.filter(Aircraft.operator_id == org_filter)
|
||||
aircraft_total = ac_q.count()
|
||||
ac_status = ac_q.with_entities(Aircraft.current_status, func.count(Aircraft.id)).group_by(Aircraft.current_status).all()
|
||||
sm = {str(s or "unknown"): c for s, c in ac_status}
|
||||
if org_filter: ac_q = ac_q.filter(Aircraft.operator_id == org_filter)
|
||||
ac_total = ac_q.count()
|
||||
sm = dict(ac_q.with_entities(Aircraft.current_status, func.count(Aircraft.id)).group_by(Aircraft.current_status).all())
|
||||
active = sm.get("in_service", 0) + sm.get("active", 0)
|
||||
maintenance = sm.get("maintenance", 0)
|
||||
storage = sm.get("storage", 0)
|
||||
|
||||
# Risk alerts (unresolved)
|
||||
# Risks (unresolved)
|
||||
rq = db.query(RiskAlert).filter(RiskAlert.is_resolved == False)
|
||||
if org_filter:
|
||||
rq = rq.join(Aircraft, RiskAlert.aircraft_id == Aircraft.id).filter(Aircraft.operator_id == org_filter)
|
||||
if org_filter: rq = rq.join(Aircraft).filter(Aircraft.operator_id == org_filter)
|
||||
risk_total = rq.count()
|
||||
r_sev = rq.with_entities(RiskAlert.severity, func.count(RiskAlert.id)).group_by(RiskAlert.severity).all()
|
||||
rm = {str(s or "medium"): c for s, c in r_sev}
|
||||
critical, high = rm.get("critical", 0), rm.get("high", 0)
|
||||
medium, low = rm.get("medium", 0), rm.get("low", 0)
|
||||
rm = dict(rq.with_entities(RiskAlert.severity, func.count(RiskAlert.id)).group_by(RiskAlert.severity).all())
|
||||
|
||||
# Audits
|
||||
aq = db.query(Audit)
|
||||
if org_filter:
|
||||
aq = aq.join(Aircraft, Audit.aircraft_id == Aircraft.id).filter(Aircraft.operator_id == org_filter)
|
||||
current = aq.filter(Audit.status == "in_progress").count()
|
||||
upcoming = aq.filter(Audit.status == "draft").count()
|
||||
completed = aq.filter(Audit.status == "completed").count()
|
||||
if org_filter: aq = aq.join(Aircraft, Audit.aircraft_id == Aircraft.id).filter(Aircraft.operator_id == org_filter)
|
||||
|
||||
# Organizations
|
||||
# Orgs
|
||||
oq = db.query(Organization)
|
||||
if user.role not in {"admin", "authority_inspector"} and org_filter:
|
||||
oq = oq.filter(Organization.id == org_filter)
|
||||
org_total = oq.count()
|
||||
if not is_authority(user) and org_filter: oq = oq.filter(Organization.id == org_filter)
|
||||
|
||||
return {
|
||||
"aircraft": {"total": aircraft_total, "active": active, "maintenance": maintenance, "storage": storage},
|
||||
"risks": {"total": risk_total, "critical": critical, "high": high, "medium": medium, "low": low},
|
||||
"audits": {"current": current, "upcoming": upcoming, "completed": completed},
|
||||
"organizations": {"total": org_total, "operators": org_total, "mro": 0},
|
||||
"aircraft": {"total": ac_total, "active": active, "maintenance": sm.get("maintenance", 0), "storage": sm.get("storage", 0)},
|
||||
"risks": {"total": risk_total, "critical": rm.get("critical", 0), "high": rm.get("high", 0), "medium": rm.get("medium", 0), "low": rm.get("low", 0)},
|
||||
"audits": {"current": aq.filter(Audit.status == "in_progress").count(), "upcoming": aq.filter(Audit.status == "draft").count(), "completed": aq.filter(Audit.status == "completed").count()},
|
||||
"organizations": {"total": oq.count()},
|
||||
}
|
||||
|
||||
@ -1,39 +1,30 @@
|
||||
|
||||
from app.schemas.tasks import TaskOut
|
||||
"""Tasks API — unified task view across entities."""
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.core.auth import get_current_user
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.helpers import is_authority
|
||||
from app.models.cert_application import CertApplication
|
||||
from app.schemas.tasks import TaskOut
|
||||
|
||||
router = APIRouter(tags=["tasks"])
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=List[TaskOut])
|
||||
def list_tasks(
|
||||
state: str = Query(default="open"),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
def list_tasks(state: str = Query(default="open"), db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
q = db.query(CertApplication)
|
||||
|
||||
if not is_authority(user) and user.organization_id:
|
||||
q = q.filter(CertApplication.applicant_org_id == user.organization_id)
|
||||
if state == "open":
|
||||
q = q.filter(CertApplication.status.in_(["submitted", "under_review", "remarks"]))
|
||||
|
||||
apps = q.order_by(CertApplication.updated_at.desc()).all()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
return [
|
||||
TaskOut(
|
||||
entity_type="cert_application",
|
||||
entity_id=a.id,
|
||||
title=f"Заявка №{a.number}",
|
||||
status=a.status,
|
||||
due_at=a.remarks_deadline_at,
|
||||
priority="high" if a.remarks_deadline_at and a.remarks_deadline_at <= datetime.utcnow() else "normal",
|
||||
updated_at=a.updated_at,
|
||||
)
|
||||
for a in apps
|
||||
TaskOut(entity_type="cert_application", entity_id=a.id, title=f"Заявка №{a.number}",
|
||||
status=a.status, due_at=a.remarks_deadline_at,
|
||||
priority="high" if a.remarks_deadline_at and a.remarks_deadline_at <= now else "normal",
|
||||
updated_at=a.updated_at)
|
||||
for a in q.order_by(CertApplication.updated_at.desc()).limit(100).all()
|
||||
]
|
||||
|
||||
@ -1,107 +1,58 @@
|
||||
"""
|
||||
API endpoints для управления пользователями.
|
||||
|
||||
В production пользователи управляются через АСУ ТК-ИБ, здесь только чтение.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
"""Users API — refactored: pagination, DRY org_name, tenant visibility."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, field_validator
|
||||
from datetime import datetime
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.deps import get_current_user, require_roles
|
||||
from app.api.helpers import get_org_name, is_authority, paginate_query
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.models.organization import Organization
|
||||
from app.schemas.common import _coerce_datetime
|
||||
|
||||
router = APIRouter(tags=["users"])
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: str
|
||||
external_subject: str
|
||||
display_name: str
|
||||
email: str | None
|
||||
role: str
|
||||
organization_id: str | None
|
||||
organization_name: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
id: str; external_subject: str; display_name: str; email: str | None
|
||||
role: str; organization_id: str | None; organization_name: str | None
|
||||
created_at: datetime; updated_at: datetime
|
||||
@field_validator("created_at", "updated_at", mode="before")
|
||||
@classmethod
|
||||
def parse_dt(cls, v):
|
||||
return _coerce_datetime(v)
|
||||
def parse_dt(cls, v): return _coerce_datetime(v)
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[UserOut])
|
||||
def _to_out(u: User, db: Session) -> UserOut:
|
||||
return UserOut(id=u.id, external_subject=u.external_subject, display_name=u.display_name,
|
||||
email=u.email, role=u.role, organization_id=u.organization_id,
|
||||
organization_name=get_org_name(db, u.organization_id),
|
||||
created_at=u.created_at, updated_at=u.updated_at)
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
def list_users(
|
||||
organization_id: str | None = None,
|
||||
role: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
organization_id: str | None = None, role: str | None = None,
|
||||
page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=100),
|
||||
db: Session = Depends(get_db), user=Depends(get_current_user),
|
||||
):
|
||||
"""Получить список пользователей."""
|
||||
query = db.query(User)
|
||||
|
||||
if organization_id:
|
||||
query = query.filter(User.organization_id == organization_id)
|
||||
|
||||
if role:
|
||||
query = query.filter(User.role == role)
|
||||
|
||||
users = query.order_by(User.display_name).all()
|
||||
|
||||
# Добавляем название организации
|
||||
result = []
|
||||
for u in users:
|
||||
org_name = None
|
||||
if u.organization_id:
|
||||
org = db.query(Organization).filter(Organization.id == u.organization_id).first()
|
||||
if org:
|
||||
org_name = org.name
|
||||
|
||||
result.append(UserOut(
|
||||
id=u.id,
|
||||
external_subject=u.external_subject,
|
||||
display_name=u.display_name,
|
||||
email=u.email,
|
||||
role=u.role,
|
||||
organization_id=u.organization_id,
|
||||
organization_name=org_name,
|
||||
created_at=u.created_at,
|
||||
updated_at=u.updated_at,
|
||||
))
|
||||
|
||||
q = db.query(User)
|
||||
if not is_authority(user) and user.organization_id:
|
||||
q = q.filter(User.organization_id == user.organization_id)
|
||||
if organization_id: q = q.filter(User.organization_id == organization_id)
|
||||
if role: q = q.filter(User.role == role)
|
||||
q = q.order_by(User.display_name)
|
||||
result = paginate_query(q, page, per_page)
|
||||
result["items"] = [_to_out(u, db) for u in result["items"]]
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/users/me", response_model=UserOut)
|
||||
def get_me(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
return _to_out(user, db)
|
||||
|
||||
|
||||
@router.get("/users/{user_id}", response_model=UserOut)
|
||||
def get_user(
|
||||
user_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Получить информацию о пользователе."""
|
||||
def get_user(user_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
u = db.query(User).filter(User.id == user_id).first()
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
org_name = None
|
||||
if u.organization_id:
|
||||
org = db.query(Organization).filter(Organization.id == u.organization_id).first()
|
||||
if org:
|
||||
org_name = org.name
|
||||
|
||||
return UserOut(
|
||||
id=u.id,
|
||||
external_subject=u.external_subject,
|
||||
display_name=u.display_name,
|
||||
email=u.email,
|
||||
role=u.role,
|
||||
organization_id=u.organization_id,
|
||||
organization_name=org_name,
|
||||
created_at=u.created_at,
|
||||
updated_at=u.updated_at,
|
||||
)
|
||||
if not u: raise HTTPException(404, "User not found")
|
||||
return _to_out(u, db)
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
"""
|
||||
Конфигурация приложения
|
||||
Конфигурация приложения КЛГ АСУ ТК.
|
||||
12-factor: все настройки через ENV.
|
||||
Разработчик: АО «REFLY»
|
||||
"""
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
@ -11,32 +13,55 @@ class Settings(BaseSettings):
|
||||
|
||||
# API
|
||||
API_V1_PREFIX: str = "/api/v1"
|
||||
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8000"]
|
||||
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8000", "http://localhost:8080"]
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "postgresql+asyncpg://klg:klg@localhost:5432/klg"
|
||||
DATABASE_URL: str = "postgresql://klg:klg@localhost:5432/klg"
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = "redis://localhost:6379"
|
||||
|
||||
# Redpanda / RisingWave — ARC-003: отключены по умолчанию для MVP (optional)
|
||||
# MinIO (S3-compatible storage)
|
||||
MINIO_ENDPOINT: str = "localhost:9000"
|
||||
MINIO_ACCESS_KEY: str = "minioadmin"
|
||||
MINIO_SECRET_KEY: str = "minioadmin"
|
||||
MINIO_BUCKET: str = "klg-attachments"
|
||||
MINIO_SECURE: bool = False
|
||||
|
||||
# Auth
|
||||
OIDC_ISSUER: str = "http://localhost:8180/realms/klg"
|
||||
OIDC_JWKS_URL: str = "" # auto-derived from issuer if empty
|
||||
ENABLE_DEV_AUTH: bool = False # ONLY for development
|
||||
DEV_TOKEN: str = "dev"
|
||||
|
||||
# Rate limiting
|
||||
RATE_LIMIT_PER_MINUTE: int = 60
|
||||
|
||||
# Redpanda / RisingWave — optional
|
||||
ENABLE_RISINGWAVE: bool = False
|
||||
ENABLE_REDPANDA: bool = False
|
||||
REDPANDA_BROKERS: str = "localhost:19092" # используется только при ENABLE_REDPANDA=true
|
||||
REDPANDA_BROKERS: str = "localhost:19092"
|
||||
REDPANDA_CLIENT_ID: str = "klg-backend"
|
||||
RISINGWAVE_URL: str = "postgresql://root:risingwave@localhost:4566/dev" # при ENABLE_RISINGWAVE=true
|
||||
RISINGWAVE_URL: str = "postgresql://root:risingwave@localhost:4566/dev"
|
||||
|
||||
# Inbox (COD-004)
|
||||
INBOX_DATA_DIR: str = "./data"
|
||||
INBOX_UPLOAD_MAX_MB: int = 50
|
||||
|
||||
# Multi-tenancy
|
||||
ENABLE_RLS: bool = True
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
return self.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://") if "asyncpg" in self.DATABASE_URL else self.DATABASE_URL
|
||||
url = self.DATABASE_URL
|
||||
if "asyncpg" in url:
|
||||
url = url.replace("postgresql+asyncpg://", "postgresql://")
|
||||
return url
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
extra = "ignore"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@ -1,16 +1,64 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
"""
|
||||
Database session management.
|
||||
Sync engine for Alembic migrations + async-compatible session for routes.
|
||||
Production: use connection pool with proper limits.
|
||||
"""
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# SQLite: check_same_thread=False; pool_pre_ping отключаем (для SQLite не нужен и может мешать).
|
||||
# ---------------------------------------------------------------------------
|
||||
# Engine configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
_is_sqlite = "sqlite" in (settings.database_url or "")
|
||||
_connect_args = {"check_same_thread": False} if _is_sqlite else {}
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=not _is_sqlite, connect_args=_connect_args)
|
||||
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
pool_pre_ping=not _is_sqlite,
|
||||
connect_args=_connect_args,
|
||||
# Production pool settings for multi-user
|
||||
pool_size=20 if not _is_sqlite else 5,
|
||||
max_overflow=10 if not _is_sqlite else 0,
|
||||
pool_timeout=30,
|
||||
pool_recycle=1800, # recycle connections every 30 min
|
||||
echo=False,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-tenancy: set current org_id on connection (for RLS)
|
||||
# ---------------------------------------------------------------------------
|
||||
@event.listens_for(engine, "checkout")
|
||||
def _reset_tenant(dbapi_conn, connection_record, connection_proxy):
|
||||
"""Reset tenant context on connection checkout from pool."""
|
||||
cursor = dbapi_conn.cursor()
|
||||
try:
|
||||
cursor.execute("SET LOCAL app.current_org_id = ''")
|
||||
except Exception:
|
||||
pass # SQLite doesn't support SET LOCAL
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
|
||||
def set_tenant(db: Session, org_id: str | None):
|
||||
"""Set the current tenant for RLS policies."""
|
||||
if org_id and not _is_sqlite:
|
||||
db.execute(
|
||||
__import__("sqlalchemy").text(
|
||||
f"SET LOCAL app.current_org_id = '{org_id}'"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dependency injection
|
||||
# ---------------------------------------------------------------------------
|
||||
def get_db():
|
||||
"""FastAPI dependency: yields a DB session, closes on exit."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
|
||||
@ -1,19 +1,219 @@
|
||||
from fastapi import FastAPI
|
||||
"""
|
||||
КЛГ АСУ ТК — FastAPI entry point.
|
||||
Серверное многопользовательское решение.
|
||||
Разработчик: АО «REFLY»
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
app = FastAPI()
|
||||
from app.core.config import settings
|
||||
from app.db.session import engine
|
||||
from app.db.base import Base
|
||||
from app.api.routes import (
|
||||
health_router,
|
||||
stats_router,
|
||||
organizations_router,
|
||||
aircraft_router,
|
||||
cert_applications_router,
|
||||
attachments_router,
|
||||
notifications_router,
|
||||
ingest_router,
|
||||
airworthiness_router,
|
||||
modifications_router,
|
||||
users_router,
|
||||
legal_router,
|
||||
risk_alerts_router,
|
||||
checklists_router,
|
||||
checklist_audits_router,
|
||||
inbox_router,
|
||||
tasks_router,
|
||||
audit_router,
|
||||
)
|
||||
|
||||
# Безопасная конфигурация CORS
|
||||
allowed_origins = [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"https://yourdomain.com"
|
||||
]
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Startup / shutdown events."""
|
||||
# Create tables if they don't exist (dev only; production uses Alembic)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
yield
|
||||
|
||||
|
||||
from app.services.risk_scheduler import setup_scheduler
|
||||
|
||||
from app.middleware.request_logger import RequestLoggerMiddleware
|
||||
|
||||
app = FastAPI(
|
||||
title="КЛГ АСУ ТК",
|
||||
description="""
|
||||
## Контроль лётной годности — серверное многопользовательское решение
|
||||
|
||||
АО «REFLY» · Система автоматизированного управления техническим контролем.
|
||||
|
||||
### Авторизация
|
||||
- **DEV mode**: `Authorization: Bearer dev` (при ENABLE_DEV_AUTH=true)
|
||||
- **Production**: Keycloak OIDC — JWT Bearer tokens
|
||||
|
||||
### Мульти-тенантность
|
||||
- PostgreSQL Row-Level Security (RLS)
|
||||
- Автоматическая изоляция данных по организациям
|
||||
- Tenant ID из JWT claim `organization_id`
|
||||
|
||||
### Роли (RBAC)
|
||||
| Роль | Доступ |
|
||||
|------|--------|
|
||||
| `admin` | Полный доступ ко всем данным и операциям |
|
||||
| `authority_inspector` | Инспекция, утверждение заявок, аудиты |
|
||||
| `operator_manager` | Управление ВС и заявками своей организации |
|
||||
| `operator_user` | Просмотр ВС и создание заявок |
|
||||
| `mro_manager` | Управление задачами ТОиР |
|
||||
| `mro_user` | Выполнение задач ТОиР |
|
||||
""",
|
||||
version="2.1.0",
|
||||
openapi_tags=[
|
||||
{"name": "aircraft", "description": "Воздушные суда — CRUD + поиск"},
|
||||
{"name": "organizations", "description": "Организации — операторы, ТОиР, органы власти"},
|
||||
{"name": "cert_applications", "description": "Заявки на сертификацию — workflow submit→review→approve/reject"},
|
||||
{"name": "checklists", "description": "Чек-листы и шаблоны — ФАП-М, ATA, CSV"},
|
||||
{"name": "audits", "description": "Аудиты воздушных судов"},
|
||||
{"name": "risk_alerts", "description": "Предупреждения о рисках — автосканирование"},
|
||||
{"name": "airworthiness", "description": "Сертификаты лётной годности"},
|
||||
{"name": "modifications", "description": "Модификации и SB/AD"},
|
||||
{"name": "notifications", "description": "Уведомления + WebSocket realtime"},
|
||||
{"name": "users", "description": "Пользователи и роли"},
|
||||
{"name": "audit_log", "description": "Журнал аудита — все изменения в системе"},
|
||||
{"name": "inbox", "description": "Входящие документы — загрузка PDF/DOCX"},
|
||||
{"name": "ingest", "description": "Импорт данных — CSV/XLSX/ZIP"},
|
||||
{"name": "legal", "description": "Нормативно-правовая база"},
|
||||
{"name": "attachments", "description": "Файловые вложения"},
|
||||
{"name": "stats", "description": "Статистика и дашборд"},
|
||||
{"name": "health", "description": "Мониторинг здоровья системы"},
|
||||
{"name": "monitoring", "description": "Prometheus метрики"},
|
||||
],
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CORS
|
||||
# ---------------------------------------------------------------------------
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=allowed_origins,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prometheus metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
from app.api.routes.fgis_revs import router as fgis_revs_router
|
||||
from app.api.routes.notification_prefs import router as notification_prefs_router
|
||||
from app.api.routes.import_export import router as import_export_router
|
||||
from app.api.routes.global_search import router as global_search_router
|
||||
from app.api.routes.work_orders import router as work_orders_router
|
||||
from app.api.routes.defects import router as defects_router
|
||||
from app.api.routes.airworthiness_core import router as airworthiness_core_router
|
||||
from app.api.routes.personnel_plg import router as personnel_plg_router
|
||||
from app.api.routes.regulator import router as regulator_router
|
||||
from app.api.routes.backup import router as backup_router
|
||||
from app.api.routes.batch import router as batch_router
|
||||
from app.api.routes.export import router as export_router
|
||||
from app.api.routes.metrics import router as metrics_router, MetricsMiddleware
|
||||
app.include_router(fgis_revs_router, prefix=settings.API_V1_PREFIX, dependencies=[])
|
||||
app.include_router(notification_prefs_router, prefix=settings.API_V1_PREFIX, dependencies=[])
|
||||
app.include_router(import_export_router, prefix=settings.API_V1_PREFIX, dependencies=[])
|
||||
app.include_router(global_search_router, prefix=settings.API_V1_PREFIX, dependencies=[])
|
||||
app.include_router(work_orders_router, prefix=settings.API_V1_PREFIX, dependencies=[])
|
||||
app.include_router(defects_router, prefix=settings.API_V1_PREFIX, dependencies=[])
|
||||
app.include_router(airworthiness_core_router, prefix=settings.API_V1_PREFIX, dependencies=[])
|
||||
app.include_router(personnel_plg_router, prefix=settings.API_V1_PREFIX, dependencies=[])
|
||||
app.include_router(regulator_router, prefix=settings.API_V1_PREFIX, dependencies=[])
|
||||
app.include_router(backup_router, prefix=settings.API_V1_PREFIX, dependencies=[])
|
||||
app.include_router(batch_router, prefix=settings.API_V1_PREFIX, dependencies=[])
|
||||
app.include_router(export_router, prefix=settings.API_V1_PREFIX, dependencies=[])
|
||||
app.include_router(metrics_router, prefix=settings.API_V1_PREFIX)
|
||||
app.add_middleware(MetricsMiddleware)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rate limiting
|
||||
# ---------------------------------------------------------------------------
|
||||
from app.core.rate_limit import RateLimitMiddleware
|
||||
app.add_middleware(RateLimitMiddleware)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global exception handler
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={"detail": "Internal server error", "type": type(exc).__name__},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global authentication dependency for all API routes
|
||||
from app.api.deps import get_current_user
|
||||
from fastapi import Depends
|
||||
|
||||
AUTH_DEPENDENCY = [Depends(get_current_user)]
|
||||
|
||||
# Routers — все API v1
|
||||
# ---------------------------------------------------------------------------
|
||||
PREFIX = settings.API_V1_PREFIX
|
||||
|
||||
app.include_router(health_router, prefix=PREFIX)
|
||||
app.include_router(stats_router, prefix=PREFIX, dependencies=[])
|
||||
app.include_router(organizations_router, prefix=PREFIX, dependencies=[])
|
||||
app.include_router(aircraft_router, prefix=PREFIX, dependencies=[])
|
||||
app.include_router(cert_applications_router, prefix=PREFIX, dependencies=[])
|
||||
app.include_router(attachments_router, prefix=PREFIX, dependencies=[])
|
||||
app.include_router(notifications_router, prefix=PREFIX, dependencies=[])
|
||||
app.include_router(ingest_router, prefix=PREFIX, dependencies=[])
|
||||
app.include_router(airworthiness_router, prefix=PREFIX, dependencies=[])
|
||||
app.include_router(modifications_router, prefix=PREFIX, dependencies=[])
|
||||
app.include_router(users_router, prefix=PREFIX, dependencies=[])
|
||||
app.include_router(legal_router, prefix=PREFIX, dependencies=[])
|
||||
app.include_router(risk_alerts_router, prefix=PREFIX, dependencies=[])
|
||||
app.include_router(checklists_router, prefix=PREFIX, dependencies=[])
|
||||
app.include_router(checklist_audits_router, prefix=PREFIX, dependencies=[])
|
||||
app.include_router(inbox_router, prefix=PREFIX, dependencies=[])
|
||||
app.include_router(tasks_router, prefix=PREFIX, dependencies=[])
|
||||
app.include_router(audit_router, prefix=PREFIX, dependencies=[])
|
||||
|
||||
# WebSocket (no prefix — direct path)
|
||||
from app.api.routes.ws_notifications import router as ws_router
|
||||
app.include_router(ws_router, prefix=PREFIX, dependencies=[])
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _run_migrations():
|
||||
"""Auto-apply pending migrations on startup."""
|
||||
import os, logging
|
||||
logger = logging.getLogger("klg.migrations")
|
||||
migration_dir = os.path.join(os.path.dirname(__file__), "..", "migrations")
|
||||
if os.path.exists(migration_dir):
|
||||
from sqlalchemy import text
|
||||
from app.db.session import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for f in sorted(os.listdir(migration_dir)):
|
||||
if f.endswith(".sql"):
|
||||
sql = open(os.path.join(migration_dir, f)).read()
|
||||
try:
|
||||
db.execute(text(sql))
|
||||
db.commit()
|
||||
logger.info(f"Migration applied: {f}")
|
||||
except Exception:
|
||||
db.rollback() # Already applied or conflict
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
from datetime import datetime, date
|
||||
from app.models.organization import Organization
|
||||
from app.models.user import User
|
||||
from app.models.aircraft import Aircraft, AircraftType
|
||||
from app.models.aircraft_type import AircraftType
|
||||
from app.models.aircraft_db import Aircraft
|
||||
from app.models.cert_application import CertApplication, ApplicationRemark, CertApplicationStatus
|
||||
from app.models.document import Attachment
|
||||
from app.models.notification import Notification
|
||||
@ -11,6 +13,10 @@ from app.models.airworthiness import AirworthinessCertificate, AircraftHistory
|
||||
from app.models.modifications import AircraftModification
|
||||
from app.models.risk_alert import RiskAlert
|
||||
from app.models.audit import ChecklistTemplate, ChecklistItem, Audit, AuditResponse, Finding
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.personnel_plg import PLGSpecialist, PLGAttestation, PLGQualification
|
||||
from app.models.airworthiness_core import ADDirective, ServiceBulletin, LifeLimit, MaintenanceProgram, AircraftComponent
|
||||
from app.models.work_orders import WorkOrder
|
||||
from app.models.legal import (
|
||||
DocumentType,
|
||||
Jurisdiction,
|
||||
@ -45,10 +51,20 @@ __all__ = [
|
||||
"Audit",
|
||||
"AuditResponse",
|
||||
"Finding",
|
||||
"AuditLog",
|
||||
"DocumentType",
|
||||
"Jurisdiction",
|
||||
"LegalDocument",
|
||||
"CrossReference",
|
||||
"LegalComment",
|
||||
"JudicialPractice",
|
||||
"PLGSpecialist",
|
||||
"PLGAttestation",
|
||||
"PLGQualification",
|
||||
"ADDirective",
|
||||
"ServiceBulletin",
|
||||
"LifeLimit",
|
||||
"MaintenanceProgram",
|
||||
"AircraftComponent",
|
||||
"WorkOrder",
|
||||
]
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from datetime import datetime, date
|
||||
"""
|
||||
Модели для управления лётной годностью согласно требованиям ИКАО Annex 8.
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from datetime import datetime, date
|
||||
"""
|
||||
Модели для чек-листов, аудитов (проверок) и находок (findings).
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
@ -9,8 +9,12 @@ def uuid4_str() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False
|
||||
)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from datetime import datetime, date
|
||||
"""
|
||||
Модели для отслеживания дефектов и повреждений согласно ТЗ.
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import String, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import String, Text, DateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from datetime import datetime, date
|
||||
"""
|
||||
Модели для системы анализа и подготовки юридических документов.
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from datetime import datetime, date
|
||||
"""
|
||||
Модели для отслеживания технического обслуживания согласно ТЗ.
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from datetime import datetime, date
|
||||
"""
|
||||
Модели для управления модификациями воздушных судов.
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from datetime import datetime, date
|
||||
"""
|
||||
Модель для автоматических предупреждений о рисках.
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
@ -1,39 +1,17 @@
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.core.auth import get_current_user
|
||||
from app.models.cert_application import CertApplication
|
||||
from app.schemas.tasks import TaskOut
|
||||
class TaskOut(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
status: str = "pending"
|
||||
priority: str = "medium"
|
||||
due_date: Optional[datetime] = None
|
||||
assigned_to: Optional[str] = None
|
||||
application_id: Optional[int] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["tasks"])
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=List[TaskOut])
|
||||
def list_tasks(
|
||||
state: str = Query(default="open"),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
q = db.query(CertApplication)
|
||||
|
||||
if state == "open":
|
||||
q = q.filter(CertApplication.status.in_(["submitted", "under_review", "remarks"]))
|
||||
|
||||
apps = q.order_by(CertApplication.updated_at.desc()).all()
|
||||
|
||||
return [
|
||||
TaskOut(
|
||||
entity_type="cert_application",
|
||||
entity_id=a.id,
|
||||
title=f"Заявка №{a.number}",
|
||||
status=a.status,
|
||||
due_at=a.remarks_deadline_at,
|
||||
priority="high" if a.remarks_deadline_at and a.remarks_deadline_at <= datetime.utcnow() else "normal",
|
||||
updated_at=a.updated_at,
|
||||
)
|
||||
for a in apps
|
||||
]
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@ -1,31 +1,38 @@
|
||||
# FastAPI и зависимости
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.1.0
|
||||
# FastAPI
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.0
|
||||
pydantic==2.9.0
|
||||
pydantic-settings==2.5.0
|
||||
|
||||
# Database
|
||||
asyncpg==0.29.0
|
||||
sqlalchemy[asyncio]==2.0.23
|
||||
alembic==1.12.1
|
||||
psycopg2-binary==2.9.9
|
||||
sqlalchemy==2.0.35
|
||||
alembic==1.13.0
|
||||
|
||||
# Streaming
|
||||
aiokafka==0.10.0
|
||||
# Auth
|
||||
python-jose[cryptography]==3.3.0
|
||||
httpx==0.27.0
|
||||
|
||||
# Redis
|
||||
redis==5.0.1
|
||||
aioredis==2.0.1
|
||||
redis==5.2.0
|
||||
|
||||
# HTTP клиенты
|
||||
httpx==0.25.2
|
||||
aiohttp==3.9.1
|
||||
# Storage
|
||||
python-multipart==0.0.12
|
||||
|
||||
# Утилиты
|
||||
python-dotenv==1.0.0
|
||||
python-multipart==0.0.6
|
||||
# Data processing
|
||||
openpyxl==3.1.5
|
||||
|
||||
# Логирование
|
||||
structlog==23.2.0
|
||||
# Logging
|
||||
structlog==24.4.0
|
||||
|
||||
# Мониторинг
|
||||
prometheus-client==0.19.0
|
||||
# Monitoring
|
||||
prometheus-client==0.21.0
|
||||
|
||||
# Utils
|
||||
python-dotenv==1.0.1
|
||||
|
||||
# Testing (dev only)
|
||||
# pytest==8.3.0
|
||||
# pytest-asyncio==0.24.0
|
||||
# factory_boy==3.3.1
|
||||
reportlab>=4.0
|
||||
|
||||
@ -1,426 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Modal } from '@/components/ui';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
files?: File[];
|
||||
}
|
||||
interface Props { isOpen: boolean; onClose: () => void; }
|
||||
|
||||
interface AIAgentModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
interface Message { role: 'user' | 'assistant' | 'system'; content: string; ts: number; }
|
||||
|
||||
export default function AIAgentModal({ isOpen, onClose }: AIAgentModalProps) {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: 'Здравствуйте! Я ИИ агент системы контроля лётной годности. Чем могу помочь? Я могу помочь с анализом документов, внесением данных в базу, поиском информации и другими задачами.',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
export default function AIAgentModal({ isOpen, onClose }: Props) {
|
||||
const [messages, setMessages] = useState<Message[]>([{ role: 'system', content: 'AI-ассистент КЛГ АСУ ТК готов к работе. Задайте вопрос о лётной годности, нормативных документах или данных в системе.', ts: Date.now() }]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!inputValue.trim() && attachedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: inputValue,
|
||||
timestamp: new Date(),
|
||||
files: attachedFiles.length > 0 ? [...attachedFiles] : undefined,
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
const currentInput = inputValue;
|
||||
const currentFiles = attachedFiles;
|
||||
setInputValue('');
|
||||
setAttachedFiles([]);
|
||||
setIsLoading(true);
|
||||
useEffect(() => { endRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
|
||||
|
||||
const send = async () => {
|
||||
if (!input.trim() || loading) return;
|
||||
const userMsg: Message = { role: 'user', content: input.trim(), ts: Date.now() };
|
||||
setMessages(m => [...m, userMsg]);
|
||||
setInput(''); setLoading(true);
|
||||
try {
|
||||
// Если есть файлы, отправляем их через FormData
|
||||
let response: Response;
|
||||
|
||||
if (currentFiles.length > 0) {
|
||||
const formData = new FormData();
|
||||
formData.append('message', currentInput);
|
||||
formData.append('history', JSON.stringify(messages.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}))));
|
||||
|
||||
// Добавляем файлы
|
||||
currentFiles.forEach((file, index) => {
|
||||
formData.append(`file_${index}`, file);
|
||||
});
|
||||
|
||||
formData.append('fileCount', currentFiles.length.toString());
|
||||
|
||||
response = await fetch('/api/ai-chat', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
} else {
|
||||
// Обычный запрос без файлов
|
||||
response = await fetch('/api/ai-chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: currentInput,
|
||||
history: messages.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: data.response || 'Извините, произошла ошибка при обработке запроса.',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при запросе к AI:', error);
|
||||
const errorMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: 'Извините, произошла ошибка при подключении к ИИ агенту. Попробуйте позже.',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files) {
|
||||
const newFiles = Array.from(event.target.files);
|
||||
// Фильтруем только разрешенные типы файлов
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/csv',
|
||||
'text/plain',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
];
|
||||
const allowedExtensions = ['.pdf', '.jpeg', '.jpg', '.png', '.xls', '.xlsx', '.csv', '.txt', '.doc', '.docx'];
|
||||
|
||||
const validFiles = newFiles.filter(file => {
|
||||
const extension = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
return allowedTypes.includes(file.type) || allowedExtensions.includes(extension);
|
||||
});
|
||||
|
||||
if (validFiles.length !== newFiles.length) {
|
||||
alert('Некоторые файлы не поддерживаются. Разрешены: PDF, JPEG, PNG, XLS, XLSX, CSV, TXT, DOC, DOCX');
|
||||
}
|
||||
|
||||
setAttachedFiles(prev => [...prev, ...validFiles]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFile = (index: number) => {
|
||||
setAttachedFiles(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
const res = await fetch('/api/ai-chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: [...messages, userMsg].map(m => ({ role: m.role, content: m.content })) }) });
|
||||
const data = await res.json();
|
||||
setMessages(m => [...m, { role: 'assistant', content: data.content || data.message || 'Нет ответа', ts: Date.now() }]);
|
||||
} catch (e: any) {
|
||||
setMessages(m => [...m, { role: 'assistant', content: `Ошибка: ${e.message}. Проверьте подключение к AI API.`, ts: Date.now() }]);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
width: '90%',
|
||||
maxWidth: '900px',
|
||||
height: '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: 'bold', margin: 0 }}>
|
||||
ИИ Агент
|
||||
</h2>
|
||||
<p style={{ fontSize: '12px', color: '#666', margin: '4px 0 0 0' }}>
|
||||
Помощник по управлению системой контроля лётной годности
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
padding: '0',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '20px',
|
||||
backgroundColor: '#f9f9f9',
|
||||
}}>
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
style={{
|
||||
marginBottom: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: message.role === 'user' ? 'flex-end' : 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
maxWidth: '70%',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: message.role === 'user' ? '#1e3a5f' : 'white',
|
||||
color: message.role === 'user' ? 'white' : '#333',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
}}>
|
||||
{message.files && message.files.length > 0 && (
|
||||
<div style={{ marginBottom: '8px', paddingBottom: '8px', borderBottom: '1px solid rgba(255,255,255,0.2)' }}>
|
||||
{message.files.map((file, idx) => (
|
||||
<div key={idx} style={{ fontSize: '12px', opacity: 0.9 }}>
|
||||
📎 {file.name} ({(file.size / 1024).toFixed(1)} KB)
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ whiteSpace: 'pre-wrap', fontSize: '14px', lineHeight: '1.6' }}>
|
||||
{message.content}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
opacity: 0.7,
|
||||
marginTop: '8px',
|
||||
}}>
|
||||
{message.timestamp.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="🤖 AI Ассистент" size="lg">
|
||||
<div className="flex flex-col h-[60vh]">
|
||||
<div className="flex-1 overflow-y-auto space-y-3 mb-4 pr-2">
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[80%] px-4 py-3 rounded-xl text-sm leading-relaxed ${
|
||||
m.role === 'user' ? 'bg-primary-500 text-white rounded-br-sm' :
|
||||
m.role === 'system' ? 'bg-gray-100 text-gray-500 italic' :
|
||||
'bg-gray-100 text-gray-800 rounded-bl-sm'}`}>
|
||||
<div className="whitespace-pre-wrap">{m.content}</div>
|
||||
<div className={`text-[10px] mt-1 ${m.role === 'user' ? 'text-white/60' : 'text-gray-400'}`}>{new Date(m.ts).toLocaleTimeString('ru-RU')}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '20px',
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<span style={{ animation: 'blink 1s infinite' }}>●</span>
|
||||
<span style={{ animation: 'blink 1s infinite 0.2s' }}>●</span>
|
||||
<span style={{ animation: 'blink 1s infinite 0.4s' }}>●</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
{loading && <div className="flex justify-start"><div className="bg-gray-100 px-4 py-3 rounded-xl text-gray-400">Думаю...</div></div>}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
|
||||
{/* Attached Files */}
|
||||
{attachedFiles.length > 0 && (
|
||||
<div style={{
|
||||
padding: '12px 20px',
|
||||
borderTop: '1px solid #e0e0e0',
|
||||
backgroundColor: '#f5f5f5',
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
{attachedFiles.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
}}
|
||||
>
|
||||
<span>📎</span>
|
||||
<span>{file.name}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveFile(index)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: '#f44336',
|
||||
fontSize: '16px',
|
||||
padding: '0',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
borderTop: '1px solid #e0e0e0',
|
||||
backgroundColor: 'white',
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{
|
||||
padding: '10px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '18px',
|
||||
minWidth: '40px',
|
||||
height: '40px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
title="Прикрепить файл (PDF, JPEG, PNG, XLS, CSV, TXT)"
|
||||
>
|
||||
📎
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
multiple
|
||||
accept=".pdf,.jpeg,.jpg,.png,.xls,.xlsx,.csv,.txt,.doc,.docx"
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<textarea
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Введите сообщение... (Enter для отправки, Shift+Enter для новой строки)"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
resize: 'none',
|
||||
minHeight: '40px',
|
||||
maxHeight: '120px',
|
||||
}}
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={isLoading || (!inputValue.trim() && attachedFiles.length === 0)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: isLoading || (!inputValue.trim() && attachedFiles.length === 0) ? '#ccc' : '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: isLoading || (!inputValue.trim() && attachedFiles.length === 0) ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
minWidth: '100px',
|
||||
height: '40px',
|
||||
}}
|
||||
>
|
||||
{isLoading ? 'Отправка...' : 'Отправить'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<input value={input} onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
|
||||
placeholder="Задайте вопрос..." className="input-field flex-1" disabled={loading} />
|
||||
<button onClick={send} disabled={loading || !input.trim()} className="btn-primary disabled:opacity-50">Отправить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,213 +1,25 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Modal } from '@/components/ui';
|
||||
import FormField from '@/components/ui/FormField';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
interface Props { isOpen: boolean; onClose: () => void; onAdd: (data: any) => void; }
|
||||
|
||||
export interface AircraftFormData {
|
||||
registrationNumber: string;
|
||||
serialNumber: string;
|
||||
aircraftType: string;
|
||||
model: string;
|
||||
operator: string;
|
||||
status: string;
|
||||
manufacturer?: string;
|
||||
yearOfManufacture?: string;
|
||||
flightHours?: string;
|
||||
}
|
||||
export default function AircraftAddModal({ isOpen, onClose, onAdd }: 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 }));
|
||||
|
||||
interface AircraftAddModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (data: AircraftFormData, files: File[]) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export default function AircraftAddModal({ isOpen, onClose, onSave }: AircraftAddModalProps) {
|
||||
const [formData, setFormData] = useState<AircraftFormData>({
|
||||
registrationNumber: '',
|
||||
serialNumber: '',
|
||||
aircraftType: 'Boeing 737-800',
|
||||
model: '737-800',
|
||||
operator: '',
|
||||
status: 'active',
|
||||
manufacturer: '',
|
||||
yearOfManufacture: '',
|
||||
flightHours: '',
|
||||
});
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleChange = (field: keyof AircraftFormData, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = e.target.files ? Array.from(e.target.files) : [];
|
||||
setFiles((prev) => [...prev, ...selected]);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.registrationNumber || !formData.serialNumber || !formData.operator) {
|
||||
alert('Заполните обязательные поля: регистрационный номер, серийный номер, оператор');
|
||||
return;
|
||||
}
|
||||
|
||||
await onSave(formData, files);
|
||||
setFormData({
|
||||
registrationNumber: '',
|
||||
serialNumber: '',
|
||||
aircraftType: 'Boeing 737-800',
|
||||
model: '737-800',
|
||||
operator: '',
|
||||
status: 'active',
|
||||
manufacturer: '',
|
||||
yearOfManufacture: '',
|
||||
flightHours: '',
|
||||
});
|
||||
setFiles([]);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
onClose();
|
||||
};
|
||||
|
||||
const inputStyle = { width: '100%', padding: '8px', border: '1px solid #ccc', borderRadius: '4px', fontSize: '14px' };
|
||||
const labelStyle = { display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: 500 };
|
||||
const handleAdd = () => { if (!form.registration_number.trim()) return alert('Укажите регистрацию'); onAdd(form); setForm({ registration_number: '', serial_number: '', aircraft_type: '', model: '', operator_id: '' }); };
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '24px',
|
||||
maxWidth: '500px',
|
||||
width: '90%',
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 style={{ margin: '0 0 20px', fontSize: '20px' }}>Добавить воздушное судно</h3>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', marginBottom: '20px' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Регистрационный номер *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.registrationNumber}
|
||||
onChange={(e) => handleChange('registrationNumber', e.target.value)}
|
||||
placeholder="RA-73701"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Серийный номер *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.serialNumber}
|
||||
onChange={(e) => handleChange('serialNumber', e.target.value)}
|
||||
placeholder="MSN-4521"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Тип ВС</label>
|
||||
<select
|
||||
value={formData.aircraftType}
|
||||
onChange={(e) => handleChange('aircraftType', e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="Boeing 737-800">Boeing 737-800</option>
|
||||
<option value="Sukhoi Superjet 100">Sukhoi Superjet 100</option>
|
||||
<option value="An-148-100V">An-148-100V</option>
|
||||
<option value="Il-76TD-90VD">Il-76TD-90VD</option>
|
||||
<option value="Mi-8MTV-1">Mi-8MTV-1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Оператор *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.operator}
|
||||
onChange={(e) => handleChange('operator', e.target.value)}
|
||||
placeholder="REFLY Airlines"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Статус</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => handleChange('status', e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="active">Активен</option>
|
||||
<option value="maintenance">На ТО</option>
|
||||
<option value="storage">На хранении</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Прикрепить файлы</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
accept=".pdf,.doc,.docx"
|
||||
style={inputStyle}
|
||||
/>
|
||||
{files.length > 0 && (
|
||||
<div style={{ marginTop: '4px', fontSize: '12px', color: '#666' }}>
|
||||
Выбрано файлов: {files.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Добавить ВС" size="md"
|
||||
footer={<><button onClick={onClose} className="btn-secondary">Отмена</button><button onClick={handleAdd} 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" /></FormField>
|
||||
<FormField label="Модель"><input value={form.model} onChange={e => set('model', e.target.value)} className="input-field" /></FormField>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,376 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal, StatusBadge } from '@/components/ui';
|
||||
import FormField from '@/components/ui/FormField';
|
||||
|
||||
interface Application {
|
||||
id: string;
|
||||
number: string;
|
||||
type: string;
|
||||
status: string;
|
||||
aircraft: string;
|
||||
date: string;
|
||||
organization: string;
|
||||
description?: string;
|
||||
documents?: string[];
|
||||
comments?: string;
|
||||
}
|
||||
interface Props { isOpen: boolean; onClose: () => void; application: any; onSave?: (data: any) => void; }
|
||||
|
||||
interface ApplicationCardModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
application: Application | null;
|
||||
onSave?: (updatedApplication: Application) => void;
|
||||
}
|
||||
|
||||
export default function ApplicationCardModal({ isOpen, onClose, application, onSave }: ApplicationCardModalProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedApplication, setEditedApplication] = useState<Application | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (application) {
|
||||
setEditedApplication({ ...application });
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [application]);
|
||||
|
||||
if (!isOpen || !application || !editedApplication) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleChange = (field: keyof Application, value: string | string[]) => {
|
||||
setEditedApplication({ ...editedApplication, [field]: value });
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (onSave) {
|
||||
onSave(editedApplication);
|
||||
setIsEditing(false);
|
||||
alert('Заявка успешно обновлена');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (application) {
|
||||
setEditedApplication({ ...application });
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentApplication = isEditing ? editedApplication : application;
|
||||
export default function ApplicationCardModal({ isOpen, onClose, application, onSave }: Props) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState<any>({});
|
||||
useEffect(() => { if (application) setForm({ ...application }); }, [application]);
|
||||
if (!application) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={!isEditing ? onClose : undefined}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '32px',
|
||||
maxWidth: '800px',
|
||||
width: '90%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '24px' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||
{currentApplication.number}
|
||||
</h2>
|
||||
{isEditing ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Тип заявки:
|
||||
</label>
|
||||
<select
|
||||
value={editedApplication.type}
|
||||
onChange={(e) => handleChange('type', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="Регистрация ВС">Регистрация ВС</option>
|
||||
<option value="Сертификация">Сертификация</option>
|
||||
<option value="Изменение регистрации">Изменение регистрации</option>
|
||||
<option value="Снятие с учёта">Снятие с учёта</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
ВС:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedApplication.aircraft}
|
||||
onChange={(e) => handleChange('aircraft', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Организация:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedApplication.organization}
|
||||
onChange={(e) => handleChange('organization', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Дата:
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editedApplication.date}
|
||||
onChange={(e) => handleChange('date', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Статус:
|
||||
</label>
|
||||
<select
|
||||
value={editedApplication.status}
|
||||
onChange={(e) => handleChange('status', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="В обработке">В обработке</option>
|
||||
<option value="На рассмотрении">На рассмотрении</option>
|
||||
<option value="Одобрена">Одобрена</option>
|
||||
<option value="Отклонена">Отклонена</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
Тип: {currentApplication.type} | ВС: {currentApplication.aircraft} | Организация: {currentApplication.organization} | Дата: {currentApplication.date}
|
||||
</div>
|
||||
)}
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={application.number || 'Заявка'} size="lg"
|
||||
footer={<>
|
||||
<button onClick={onClose} className="btn-secondary">Закрыть</button>
|
||||
{editing ? <button onClick={() => { onSave?.(form); setEditing(false); }} className="btn-primary">Сохранить</button>
|
||||
: onSave && <button onClick={() => setEditing(true)} className="btn-primary">Редактировать</button>}
|
||||
</>}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3"><StatusBadge status={application.status} /></div>
|
||||
{editing ? (
|
||||
<div className="space-y-3">
|
||||
<FormField label="Тема"><input value={form.subject || ''} onChange={e => setForm({ ...form, subject: e.target.value })} className="input-field" /></FormField>
|
||||
<FormField label="Описание"><textarea value={form.description || ''} onChange={e => setForm({ ...form, description: e.target.value })} className="input-field h-24" /></FormField>
|
||||
<FormField label="Комментарии"><textarea value={form.comments || ''} onChange={e => setForm({ ...form, comments: e.target.value })} className="input-field h-20" /></FormField>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
padding: '0',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '24px',
|
||||
}}>
|
||||
<div style={{ fontSize: '16px', fontWeight: '500' }}>
|
||||
Статус: <span style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
backgroundColor: currentApplication.status === 'Одобрена' ? '#4caf50' : currentApplication.status === 'Отклонена' ? '#f44336' : '#ff9800',
|
||||
color: 'white',
|
||||
}}>{currentApplication.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h3 style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '12px' }}>
|
||||
Описание заявки
|
||||
</h3>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editedApplication.description || ''}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
rows={5}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
placeholder="Введите описание заявки..."
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
minHeight: '60px',
|
||||
}}>
|
||||
{currentApplication.description || 'Описание не указано'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentApplication.comments && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h3 style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '12px' }}>
|
||||
Комментарии
|
||||
</h3>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editedApplication.comments || ''}
|
||||
onChange={(e) => handleChange('comments', e.target.value)}
|
||||
rows={4}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
placeholder="Введите комментарии..."
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{currentApplication.comments}
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div><span className="font-bold text-gray-600">Тема:</span> {application.subject || '—'}</div>
|
||||
<div><span className="font-bold text-gray-600">Организация:</span> {application.applicant_org_name || application.organization || '—'}</div>
|
||||
<div><span className="font-bold text-gray-600">ВС:</span> {application.aircraft || '—'}</div>
|
||||
<div><span className="font-bold text-gray-600">Дата:</span> {application.created_at ? new Date(application.created_at).toLocaleDateString('ru-RU') : application.date || '—'}</div>
|
||||
{application.description && <div className="col-span-2 p-3 bg-gray-50 rounded">{application.description}</div>}
|
||||
{application.comments && <div className="col-span-2 p-3 bg-yellow-50 rounded"><span className="font-bold text-gray-600">Комментарии:</span> {application.comments}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '32px' }}>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
color: '#333',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#4caf50',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
color: '#333',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Редактировать
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,282 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Modal } from '@/components/ui';
|
||||
import FormField from '@/components/ui/FormField';
|
||||
|
||||
interface ApplicationCreateModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreate?: (application: {
|
||||
number: string;
|
||||
type: string;
|
||||
status: string;
|
||||
aircraft: string;
|
||||
date: string;
|
||||
organization: string;
|
||||
description?: string;
|
||||
}) => void;
|
||||
}
|
||||
interface Props { isOpen: boolean; onClose: () => void; onCreate: (data: any) => void; }
|
||||
|
||||
export default function ApplicationCreateModal({ isOpen, onClose, onCreate }: ApplicationCreateModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
number: '',
|
||||
type: 'Регистрация ВС',
|
||||
status: 'В обработке',
|
||||
aircraft: '',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
organization: '',
|
||||
description: '',
|
||||
});
|
||||
export default function ApplicationCreateModal({ isOpen, onClose, onCreate }: Props) {
|
||||
const [form, setForm] = useState({ number: '', type: 'Регистрация ВС', subject: '', aircraft: '', organization: '', description: '' });
|
||||
const set = (k: string, v: string) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
setFormData({ ...formData, [field]: value });
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formData.number || !formData.aircraft || !formData.organization) {
|
||||
alert('Пожалуйста, заполните все обязательные поля');
|
||||
return;
|
||||
}
|
||||
|
||||
if (onCreate) {
|
||||
onCreate(formData);
|
||||
setFormData({
|
||||
number: '',
|
||||
type: 'Регистрация ВС',
|
||||
status: 'В обработке',
|
||||
aircraft: '',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
organization: '',
|
||||
description: '',
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
const handleCreate = () => {
|
||||
if (!form.subject.trim()) return alert('Укажите тему');
|
||||
onCreate({ ...form, number: form.number || `APP-${Date.now()}`, status: 'draft', date: new Date().toISOString().slice(0, 10) });
|
||||
setForm({ number: '', type: 'Регистрация ВС', subject: '', aircraft: '', organization: '', description: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
width: '90%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold' }}>Создание заявки</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
padding: '0',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Номер заявки <span style={{ color: 'red' }}>*</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.number}
|
||||
onChange={(e) => handleChange('number', e.target.value)}
|
||||
placeholder="APP-2025-XXX"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Тип заявки:
|
||||
</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => handleChange('type', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="Регистрация ВС">Регистрация ВС</option>
|
||||
<option value="Сертификация">Сертификация</option>
|
||||
<option value="Изменение регистрации">Изменение регистрации</option>
|
||||
<option value="Снятие с учёта">Снятие с учёта</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
ВС <span style={{ color: 'red' }}>*</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.aircraft}
|
||||
onChange={(e) => handleChange('aircraft', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Организация <span style={{ color: 'red' }}>*</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.organization}
|
||||
onChange={(e) => handleChange('organization', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Дата:
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => handleChange('date', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Статус:
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => handleChange('status', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="В обработке">В обработке</option>
|
||||
<option value="На рассмотрении">На рассмотрении</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Описание:
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
rows={4}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
placeholder="Введите описание заявки..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '32px' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
color: '#333',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Создать заявку" size="md"
|
||||
footer={<><button onClick={onClose} className="btn-secondary">Отмена</button><button onClick={handleCreate} className="btn-primary">Создать</button></>}>
|
||||
<FormField label="Тема" required><input value={form.subject} onChange={e => set('subject', e.target.value)} className="input-field" placeholder="Тема заявки" /></FormField>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Тип"><select value={form.type} onChange={e => set('type', e.target.value)} className="input-field"><option>Регистрация ВС</option><option>Сертификация</option><option>Модификация</option><option>Другое</option></select></FormField>
|
||||
<FormField label="ВС"><input value={form.aircraft} onChange={e => set('aircraft', e.target.value)} className="input-field" placeholder="RA-XXXXX" /></FormField>
|
||||
</div>
|
||||
</div>
|
||||
<FormField label="Организация"><input value={form.organization} onChange={e => set('organization', e.target.value)} className="input-field" /></FormField>
|
||||
<FormField label="Описание"><textarea value={form.description} onChange={e => set('description', e.target.value)} className="input-field h-24" placeholder="Подробности заявки..." /></FormField>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,419 +1,51 @@
|
||||
'use client';
|
||||
import { Modal, StatusBadge, DataTable } from '@/components/ui';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
interface Props { isOpen: boolean; onClose: () => void; audit: any; onComplete?: () => void; }
|
||||
|
||||
interface Audit {
|
||||
id: string;
|
||||
number: string;
|
||||
type: string;
|
||||
status: string;
|
||||
organization: string;
|
||||
date: string;
|
||||
inspector: string;
|
||||
description?: string;
|
||||
findings?: string;
|
||||
recommendations?: string;
|
||||
deadline?: string;
|
||||
}
|
||||
|
||||
interface AuditCardModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
audit: Audit | null;
|
||||
onSave?: (updatedAudit: Audit) => void;
|
||||
}
|
||||
|
||||
export default function AuditCardModal({ isOpen, onClose, audit, onSave }: AuditCardModalProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedAudit, setEditedAudit] = useState<Audit | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (audit) {
|
||||
setEditedAudit({ ...audit });
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [audit]);
|
||||
|
||||
if (!isOpen || !audit || !editedAudit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleChange = (field: keyof Audit, value: string) => {
|
||||
setEditedAudit({ ...editedAudit, [field]: value });
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (onSave) {
|
||||
onSave(editedAudit);
|
||||
setIsEditing(false);
|
||||
alert('Аудит успешно обновлён');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (audit) {
|
||||
setEditedAudit({ ...audit });
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentAudit = isEditing ? editedAudit : audit;
|
||||
export default function AuditCardModal({ isOpen, onClose, audit, onComplete }: Props) {
|
||||
if (!audit) return null;
|
||||
const responses = audit.responses || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={!isEditing ? onClose : undefined}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '32px',
|
||||
maxWidth: '800px',
|
||||
width: '90%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '24px' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||
{currentAudit.number}
|
||||
</h2>
|
||||
{isEditing ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Тип аудита:
|
||||
</label>
|
||||
<select
|
||||
value={editedAudit.type}
|
||||
onChange={(e) => handleChange('type', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="Плановый аудит">Плановый аудит</option>
|
||||
<option value="Внеплановый аудит">Внеплановый аудит</option>
|
||||
<option value="Внезапный аудит">Внезапный аудит</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Организация:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedAudit.organization}
|
||||
onChange={(e) => handleChange('organization', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Инспектор:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedAudit.inspector}
|
||||
onChange={(e) => handleChange('inspector', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Дата:
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editedAudit.date}
|
||||
onChange={(e) => handleChange('date', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Статус:
|
||||
</label>
|
||||
<select
|
||||
value={editedAudit.status}
|
||||
onChange={(e) => handleChange('status', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="Запланирован">Запланирован</option>
|
||||
<option value="В процессе">В процессе</option>
|
||||
<option value="Завершён">Завершён</option>
|
||||
<option value="Отменён">Отменён</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
Тип: {currentAudit.type} | Организация: {currentAudit.organization} | Инспектор: {currentAudit.inspector} | Дата: {currentAudit.date}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
padding: '0',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={`Аудит #${(audit.id || '').slice(0, 8)}`} size="lg"
|
||||
footer={<>
|
||||
<button onClick={onClose} className="btn-secondary">Закрыть</button>
|
||||
{audit.status === 'in_progress' && onComplete && <button onClick={onComplete} className="btn-primary bg-green-500 hover:bg-green-600">Завершить</button>}
|
||||
</>}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3 items-center"><StatusBadge status={audit.status} /></div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div><span className="font-bold text-gray-600">ВС:</span> {audit.aircraft_id?.slice(0, 8) || '—'}</div>
|
||||
<div><span className="font-bold text-gray-600">Шаблон:</span> {audit.template_id?.slice(0, 8) || '—'}</div>
|
||||
{audit.planned_at && <div><span className="font-bold text-gray-600">План:</span> {new Date(audit.planned_at).toLocaleDateString('ru-RU')}</div>}
|
||||
{audit.completed_at && <div><span className="font-bold text-gray-600">Завершён:</span> {new Date(audit.completed_at).toLocaleDateString('ru-RU')}</div>}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '24px',
|
||||
}}>
|
||||
<div style={{ fontSize: '16px', fontWeight: '500', marginBottom: '12px' }}>
|
||||
Статус: <span style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
backgroundColor: currentAudit.status === 'В процессе' ? '#ff9800' : '#2196f3',
|
||||
color: 'white',
|
||||
}}>{currentAudit.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h3 style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '12px' }}>
|
||||
Описание аудита
|
||||
</h3>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editedAudit.description || ''}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
rows={5}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
placeholder="Введите описание аудита..."
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
minHeight: '60px',
|
||||
}}>
|
||||
{currentAudit.description || 'Описание не указано'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px', marginBottom: '24px' }}>
|
||||
{responses.length > 0 && (
|
||||
<div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '12px' }}>
|
||||
Выявленные нарушения
|
||||
</h3>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editedAudit.findings || ''}
|
||||
onChange={(e) => handleChange('findings', e.target.value)}
|
||||
rows={6}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
placeholder="Опишите выявленные нарушения..."
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
whiteSpace: 'pre-wrap',
|
||||
minHeight: '100px',
|
||||
}}>
|
||||
{currentAudit.findings || 'Нарушения не выявлены'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '12px' }}>
|
||||
Рекомендации
|
||||
</h3>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editedAudit.recommendations || ''}
|
||||
onChange={(e) => handleChange('recommendations', e.target.value)}
|
||||
rows={6}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
placeholder="Опишите рекомендации..."
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
whiteSpace: 'pre-wrap',
|
||||
minHeight: '100px',
|
||||
}}>
|
||||
{currentAudit.recommendations || 'Рекомендации не указаны'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentAudit.deadline && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
Срок устранения: {currentAudit.deadline}
|
||||
<h4 className="text-sm font-bold text-gray-600 mb-2">Ответы ({responses.length})</h4>
|
||||
<div className="max-h-64 overflow-y-auto space-y-1">
|
||||
{responses.map((r: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-3 py-2 border-b border-gray-50">
|
||||
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${r.response === 'compliant' ? 'bg-green-500 text-white' : r.response === 'non_compliant' ? 'bg-red-500 text-white' : 'bg-gray-200'}`}>
|
||||
{r.response === 'compliant' ? '✓' : r.response === 'non_compliant' ? '✗' : '?'}
|
||||
</span>
|
||||
<span className="text-sm flex-1">{r.item_text || r.item_code || `Пункт ${i + 1}`}</span>
|
||||
{r.note && <span className="text-xs text-gray-400 truncate max-w-[200px]">{r.note}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '32px' }}>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
color: '#333',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#4caf50',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
color: '#333',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Редактировать
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{audit.findings && audit.findings.length > 0 && (
|
||||
<div className="p-4 bg-red-50 rounded">
|
||||
<h4 className="font-bold text-red-700 mb-2">Несоответствия ({audit.findings.length})</h4>
|
||||
{audit.findings.map((f: any, i: number) => (
|
||||
<div key={i} className="text-sm py-1 border-b border-red-100 last:border-0">{f.description || f.text || `Несоответствие ${i + 1}`}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,282 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Modal } from '@/components/ui';
|
||||
import FormField from '@/components/ui/FormField';
|
||||
|
||||
interface AuditCreateModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreate?: (audit: {
|
||||
number: string;
|
||||
type: string;
|
||||
status: string;
|
||||
organization: string;
|
||||
date: string;
|
||||
inspector: string;
|
||||
description?: string;
|
||||
}) => void;
|
||||
}
|
||||
interface Props { isOpen: boolean; onClose: () => void; onCreate: (data: any) => void; templates?: any[]; aircraft?: any[]; }
|
||||
|
||||
export default function AuditCreateModal({ isOpen, onClose, onCreate }: AuditCreateModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
number: '',
|
||||
type: 'Плановый аудит',
|
||||
status: 'Запланирован',
|
||||
organization: '',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
inspector: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
setFormData({ ...formData, [field]: value });
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formData.number || !formData.organization || !formData.inspector) {
|
||||
alert('Пожалуйста, заполните все обязательные поля');
|
||||
return;
|
||||
}
|
||||
|
||||
if (onCreate) {
|
||||
onCreate(formData);
|
||||
setFormData({
|
||||
number: '',
|
||||
type: 'Плановый аудит',
|
||||
status: 'Запланирован',
|
||||
organization: '',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
inspector: '',
|
||||
description: '',
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
export default function AuditCreateModal({ isOpen, onClose, onCreate, templates = [], aircraft = [] }: Props) {
|
||||
const [form, setForm] = useState({ aircraft_id: '', template_id: '', planned_at: '' });
|
||||
const set = (k: string, v: string) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
width: '90%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold' }}>Создание аудита</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
padding: '0',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Номер аудита <span style={{ color: 'red' }}>*</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.number}
|
||||
onChange={(e) => handleChange('number', e.target.value)}
|
||||
placeholder="AUD-2025-XXX"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Тип аудита:
|
||||
</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => handleChange('type', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="Плановый аудит">Плановый аудит</option>
|
||||
<option value="Внеплановый аудит">Внеплановый аудит</option>
|
||||
<option value="Внезапный аудит">Внезапный аудит</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Организация <span style={{ color: 'red' }}>*</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.organization}
|
||||
onChange={(e) => handleChange('organization', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Инспектор <span style={{ color: 'red' }}>*</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.inspector}
|
||||
onChange={(e) => handleChange('inspector', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Дата:
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => handleChange('date', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Статус:
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => handleChange('status', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="Запланирован">Запланирован</option>
|
||||
<option value="В процессе">В процессе</option>
|
||||
<option value="Завершён">Завершён</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Описание:
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
rows={4}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
placeholder="Введите описание аудита..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '32px' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
color: '#333',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Создать аудит" size="md"
|
||||
footer={<><button onClick={onClose} className="btn-secondary">Отмена</button><button onClick={() => { if (!form.aircraft_id) return alert('Укажите ВС'); onCreate(form); }} className="btn-primary">Создать</button></>}>
|
||||
<FormField label="Воздушное судно" required>
|
||||
<select value={form.aircraft_id} onChange={e => set('aircraft_id', e.target.value)} className="input-field">
|
||||
<option value="">— Выберите ВС —</option>
|
||||
{aircraft.map((a: any) => <option key={a.id} value={a.id}>{a.registrationNumber || a.id.slice(0, 8)}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Шаблон">
|
||||
<select value={form.template_id} onChange={e => set('template_id', e.target.value)} className="input-field">
|
||||
<option value="">— Без шаблона —</option>
|
||||
{templates.map((t: any) => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Плановая дата"><input type="date" value={form.planned_at} onChange={e => set('planned_at', e.target.value)} className="input-field" /></FormField>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,476 +1,38 @@
|
||||
'use client';
|
||||
import { Modal, StatusBadge } from '@/components/ui';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
interface Props { isOpen: boolean; onClose: () => void; checklist: any; onSave?: (data: any) => void; }
|
||||
|
||||
interface Checklist {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
aircraft: string;
|
||||
date: string;
|
||||
items: number;
|
||||
completed: number;
|
||||
description?: string;
|
||||
checklistItems?: Array<{ id: string; text: string; checked: boolean }>;
|
||||
standards?: { icao?: boolean; easa?: boolean; faa?: boolean; armak?: boolean };
|
||||
inspector?: string;
|
||||
inspectorLicense?: string;
|
||||
operator?: string;
|
||||
checklistNumber?: string;
|
||||
}
|
||||
|
||||
interface ChecklistCardModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
checklist: Checklist | null;
|
||||
onSave?: (updatedChecklist: Checklist) => void;
|
||||
}
|
||||
|
||||
export default function ChecklistCardModal({ isOpen, onClose, checklist, onSave }: ChecklistCardModalProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedChecklist, setEditedChecklist] = useState<Checklist | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (checklist) {
|
||||
setEditedChecklist({ ...checklist });
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [checklist]);
|
||||
|
||||
if (!isOpen || !checklist || !editedChecklist) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleChange = (field: keyof Checklist, value: string | number) => {
|
||||
setEditedChecklist({ ...editedChecklist, [field]: value });
|
||||
};
|
||||
|
||||
const handleItemToggle = (itemId: string) => {
|
||||
if (editedChecklist.checklistItems) {
|
||||
const updatedItems = editedChecklist.checklistItems.map(item =>
|
||||
item.id === itemId ? { ...item, checked: !item.checked } : item
|
||||
);
|
||||
const completed = updatedItems.filter(item => item.checked).length;
|
||||
setEditedChecklist({
|
||||
...editedChecklist,
|
||||
checklistItems: updatedItems,
|
||||
completed,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (onSave) {
|
||||
onSave(editedChecklist);
|
||||
setIsEditing(false);
|
||||
alert('Чек-лист успешно обновлён');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (checklist) {
|
||||
setEditedChecklist({ ...checklist });
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentChecklist = isEditing ? editedChecklist : checklist;
|
||||
const progress = currentChecklist.items > 0
|
||||
? Math.round((currentChecklist.completed / currentChecklist.items) * 100)
|
||||
: 0;
|
||||
export default function ChecklistCardModal({ isOpen, onClose, checklist }: Props) {
|
||||
if (!checklist) return null;
|
||||
const items = checklist.checklistItems || checklist.items || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={!isEditing ? onClose : undefined}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '32px',
|
||||
maxWidth: '800px',
|
||||
width: '90%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '24px' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
{isEditing ? (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Название чек-листа:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedChecklist.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
{currentChecklist.name}
|
||||
</h2>
|
||||
{currentChecklist.checklistNumber && (
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '8px' }}>
|
||||
Номер: {currentChecklist.checklistNumber}
|
||||
</div>
|
||||
)}
|
||||
{/* Отображение стандартов соответствия */}
|
||||
{currentChecklist.standards && (
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '8px', flexWrap: 'wrap' }}>
|
||||
{currentChecklist.standards.icao && (
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
ICAO
|
||||
</span>
|
||||
)}
|
||||
{currentChecklist.standards.easa && (
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#fff3e0',
|
||||
color: '#e65100',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
EASA
|
||||
</span>
|
||||
)}
|
||||
{currentChecklist.standards.faa && (
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#f3e5f5',
|
||||
color: '#7b1fa2',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
FAA
|
||||
</span>
|
||||
)}
|
||||
{currentChecklist.standards.armak && (
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#e8f5e9',
|
||||
color: '#2e7d32',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
АРМАК
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
{isEditing ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px' }}>Тип:</label>
|
||||
<select
|
||||
value={editedChecklist.type}
|
||||
onChange={(e) => handleChange('type', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="Ежедневный">Ежедневный</option>
|
||||
<option value="Периодический">Периодический</option>
|
||||
<option value="Предполётный">Предполётный</option>
|
||||
<option value="Послеполётный">Послеполётный</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px' }}>ВС:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedChecklist.aircraft}
|
||||
onChange={(e) => handleChange('aircraft', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ marginBottom: '4px' }}>
|
||||
Тип: {currentChecklist.type} | ВС: {currentChecklist.aircraft} | Дата: {currentChecklist.date}
|
||||
</div>
|
||||
{currentChecklist.operator && (
|
||||
<div style={{ marginBottom: '4px' }}>
|
||||
Оператор: {currentChecklist.operator}
|
||||
</div>
|
||||
)}
|
||||
{currentChecklist.inspector && (
|
||||
<div style={{ marginBottom: '4px' }}>
|
||||
Инспектор: {currentChecklist.inspector}
|
||||
{currentChecklist.inspectorLicense && ` (${currentChecklist.inspectorLicense})`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
padding: '0',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '24px',
|
||||
}}>
|
||||
<div style={{ fontSize: '16px', fontWeight: '500', marginBottom: '12px' }}>
|
||||
Прогресс выполнения: {currentChecklist.completed} / {currentChecklist.items} ({progress}%)
|
||||
</div>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '24px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${progress}%`,
|
||||
height: '100%',
|
||||
backgroundColor: currentChecklist.status === 'Завершён' ? '#4caf50' : '#1e3a5f',
|
||||
transition: 'width 0.3s ease',
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ marginTop: '12px', fontSize: '14px' }}>
|
||||
Статус: <span style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
backgroundColor: currentChecklist.status === 'Завершён' ? '#4caf50' : '#ff9800',
|
||||
color: 'white',
|
||||
}}>{currentChecklist.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentChecklist.description && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h3 style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '12px' }}>
|
||||
Описание
|
||||
</h3>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editedChecklist.description || ''}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
rows={4}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
}}>
|
||||
{currentChecklist.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentChecklist.checklistItems && currentChecklist.checklistItems.length > 0 && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h3 style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '12px' }}>
|
||||
Пункты чек-листа
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{currentChecklist.checklistItems.map((item) => {
|
||||
// Парсим текст для извлечения категории и требования
|
||||
const categoryMatch = item.text.match(/\[([^\]]+)\]/);
|
||||
const requirementMatch = item.text.match(/\(([^)]+)\)/);
|
||||
const category = categoryMatch ? categoryMatch[1] : '';
|
||||
const requirement = requirementMatch ? requirementMatch[1] : '';
|
||||
const itemText = item.text.replace(/\[([^\]]+)\]\s*/, '').replace(/\s*\(([^)]+)\)/, '');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
padding: '12px',
|
||||
backgroundColor: item.checked ? '#e8f5e9' : '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
border: item.checked ? '1px solid #4caf50' : '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onChange={() => handleItemToggle(item.id)}
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ fontSize: '18px', color: item.checked ? '#4caf50' : '#999' }}>
|
||||
{item.checked ? '✓' : '○'}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ flex: 1 }}>
|
||||
{category && (
|
||||
<div style={{ fontSize: '11px', color: '#666', marginBottom: '4px', fontWeight: '500' }}>
|
||||
{category} {requirement && `• ${requirement}`}
|
||||
</div>
|
||||
)}
|
||||
<span style={{
|
||||
textDecoration: item.checked ? 'line-through' : 'none',
|
||||
opacity: item.checked ? 0.6 : 1,
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
{itemText || item.text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '32px' }}>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
color: '#333',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#4caf50',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
color: '#333',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Редактировать
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={checklist.name || 'Чек-лист'} size="lg">
|
||||
<div className="grid grid-cols-2 gap-4 mb-4 text-sm">
|
||||
<div><span className="font-bold text-gray-600">Тип:</span> {checklist.type || checklist.domain || '—'}</div>
|
||||
<div><span className="font-bold text-gray-600">Статус:</span> <StatusBadge status={checklist.status || 'draft'} /></div>
|
||||
<div><span className="font-bold text-gray-600">ВС:</span> {checklist.aircraft || '—'}</div>
|
||||
<div><span className="font-bold text-gray-600">Дата:</span> {checklist.date || '—'}</div>
|
||||
{checklist.inspector && <div><span className="font-bold text-gray-600">Инспектор:</span> {checklist.inspector}</div>}
|
||||
{checklist.operator && <div><span className="font-bold text-gray-600">Оператор:</span> {checklist.operator}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{items.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-gray-600 mb-2">Пункты ({items.length})</h4>
|
||||
<div className="max-h-96 overflow-y-auto space-y-1">
|
||||
{items.map((item: any, i: number) => (
|
||||
<div key={item.id || i} className="flex items-center gap-3 py-2 border-b border-gray-50">
|
||||
<span className={`w-5 h-5 rounded border flex items-center justify-center text-xs ${item.checked ? 'bg-green-500 text-white border-green-500' : 'border-gray-300'}`}>
|
||||
{item.checked && '✓'}
|
||||
</span>
|
||||
<span className="text-xs font-mono text-primary-500 min-w-[60px]">{item.code || ''}</span>
|
||||
<span className="text-sm flex-1">{item.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,664 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
createICAOChecklist,
|
||||
createEASAChecklist,
|
||||
createFAAChecklist,
|
||||
createARMACChecklist,
|
||||
createUniversalChecklist,
|
||||
ICAOCategories,
|
||||
EASACategories,
|
||||
FAACategories,
|
||||
ARMACCategories,
|
||||
type ComplianceChecklist
|
||||
} from '@/lib/compliance/checklist-formats';
|
||||
import { Modal } from '@/components/ui';
|
||||
import FormField from '@/components/ui/FormField';
|
||||
|
||||
interface ChecklistCreateModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreate?: (checklist: {
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
aircraft: string;
|
||||
date: string;
|
||||
items: number;
|
||||
completed: number;
|
||||
description?: string;
|
||||
checklistItems?: Array<{ id: string; text: string; checked: boolean }>;
|
||||
standards?: { icao?: boolean; easa?: boolean; faa?: boolean; armak?: boolean };
|
||||
inspector?: string;
|
||||
inspectorLicense?: string;
|
||||
operator?: string;
|
||||
}) => void;
|
||||
}
|
||||
interface Props { isOpen: boolean; onClose: () => void; onCreate: (data: any) => void; }
|
||||
|
||||
export default function ChecklistCreateModal({ isOpen, onClose, onCreate }: ChecklistCreateModalProps) {
|
||||
const [standard, setStandard] = useState<'icao' | 'easa' | 'faa' | 'armak' | 'universal'>('universal');
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
type: 'pre-flight' as 'pre-flight' | 'post-flight' | 'maintenance' | 'annual' | 'special',
|
||||
status: 'В процессе',
|
||||
aircraft: '',
|
||||
aircraftType: '',
|
||||
operator: '',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
description: '',
|
||||
inspector: '',
|
||||
inspectorLicense: '',
|
||||
items: [] as Array<{ id: string; category: string; text: string; requirement: string; checked: boolean; status: 'compliant' | 'non-compliant' | 'not-applicable' | 'pending' }>,
|
||||
});
|
||||
export default function ChecklistCreateModal({ isOpen, onClose, onCreate }: Props) {
|
||||
const [name, setName] = useState('');
|
||||
const [domain, setDomain] = useState('ФАП-М');
|
||||
const [items, setItems] = useState<{ code: string; text: string }[]>([{ code: 'P.001', text: '' }]);
|
||||
|
||||
const [newItemText, setNewItemText] = useState('');
|
||||
const [newItemCategory, setNewItemCategory] = useState('');
|
||||
const [newItemRequirement, setNewItemRequirement] = useState('');
|
||||
const addItem = () => setItems([...items, { code: `P.${String(items.length + 1).padStart(3, '0')}`, text: '' }]);
|
||||
const removeItem = (i: number) => setItems(items.filter((_, idx) => idx !== i));
|
||||
const updateItem = (i: number, field: string, val: string) => setItems(items.map((it, idx) => idx === i ? { ...it, [field]: val } : it));
|
||||
|
||||
// Получаем категории в зависимости от выбранного стандарта
|
||||
const getCategories = () => {
|
||||
switch (standard) {
|
||||
case 'easa':
|
||||
return EASACategories;
|
||||
case 'faa':
|
||||
return FAACategories;
|
||||
case 'armak':
|
||||
return ARMACCategories;
|
||||
case 'icao':
|
||||
default:
|
||||
return ICAOCategories;
|
||||
}
|
||||
};
|
||||
|
||||
const categories = getCategories();
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
setFormData({ ...formData, [field]: value });
|
||||
};
|
||||
|
||||
const handleAddItem = () => {
|
||||
if (newItemText.trim() && newItemCategory) {
|
||||
setFormData({
|
||||
...formData,
|
||||
items: [...formData.items, {
|
||||
id: `item-${Date.now()}`,
|
||||
category: newItemCategory,
|
||||
text: newItemText.trim(),
|
||||
requirement: newItemRequirement.trim() || 'Не указано',
|
||||
checked: false,
|
||||
status: 'pending' as const,
|
||||
}],
|
||||
});
|
||||
setNewItemText('');
|
||||
setNewItemCategory('');
|
||||
setNewItemRequirement('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddStandardItems = (category: string, items: string[]) => {
|
||||
const newItems = items.map((item, index) => ({
|
||||
id: `item-${Date.now()}-${index}`,
|
||||
category,
|
||||
text: item,
|
||||
requirement: standard === 'easa' ? 'EASA Part-M' :
|
||||
standard === 'faa' ? 'FAA Part 91/135' :
|
||||
standard === 'armak' ? 'АРМАК / ФАП' :
|
||||
'ICAO Annex 6',
|
||||
checked: false,
|
||||
status: 'pending' as const,
|
||||
}));
|
||||
setFormData({
|
||||
...formData,
|
||||
items: [...formData.items, ...newItems],
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveItem = (itemId: string) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
items: formData.items.filter(item => item.id !== itemId),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formData.name || !formData.aircraft || !formData.inspector) {
|
||||
alert('Пожалуйста, заполните все обязательные поля (Название, ВС, Инспектор)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Создаем чек-лист согласно выбранному стандарту
|
||||
const standards = {
|
||||
icao: standard === 'icao' || standard === 'universal',
|
||||
easa: standard === 'easa' || standard === 'universal',
|
||||
faa: standard === 'faa' || standard === 'universal',
|
||||
armak: standard === 'armak' || standard === 'universal',
|
||||
};
|
||||
|
||||
const checklistData = {
|
||||
aircraftRegistration: formData.aircraft,
|
||||
aircraftType: formData.aircraftType,
|
||||
operator: formData.operator,
|
||||
date: formData.date,
|
||||
inspector: formData.inspector,
|
||||
inspectorLicense: formData.inspectorLicense,
|
||||
checklistType: formData.type,
|
||||
items: formData.items.map(item => ({
|
||||
id: item.id,
|
||||
category: item.category,
|
||||
item: item.text,
|
||||
requirement: item.requirement,
|
||||
status: item.status,
|
||||
notes: item.checked ? 'Выполнено' : undefined,
|
||||
})),
|
||||
};
|
||||
|
||||
// Создаем compliance checklist для будущего использования
|
||||
// (пока не используется, но может быть полезен для экспорта/сохранения)
|
||||
const getComplianceChecklist = (): ComplianceChecklist => {
|
||||
switch (standard) {
|
||||
case 'easa':
|
||||
return createEASAChecklist(checklistData);
|
||||
case 'faa':
|
||||
return createFAAChecklist(checklistData);
|
||||
case 'armak':
|
||||
return createARMACChecklist(checklistData);
|
||||
case 'icao':
|
||||
return createICAOChecklist(checklistData);
|
||||
default:
|
||||
return createUniversalChecklist(checklistData);
|
||||
}
|
||||
};
|
||||
|
||||
// Вызываем функцию для создания compliance checklist (может быть использован в будущем)
|
||||
getComplianceChecklist();
|
||||
|
||||
if (onCreate) {
|
||||
onCreate({
|
||||
name: formData.name,
|
||||
type: formData.type === 'pre-flight' ? 'Предполётный' :
|
||||
formData.type === 'post-flight' ? 'Послеполётный' :
|
||||
formData.type === 'maintenance' ? 'Техническое обслуживание' :
|
||||
formData.type === 'annual' ? 'Годовой' : 'Специальный',
|
||||
status: formData.status,
|
||||
aircraft: formData.aircraft,
|
||||
date: formData.date,
|
||||
items: formData.items.length,
|
||||
completed: formData.items.filter(i => i.checked).length,
|
||||
description: formData.description,
|
||||
checklistItems: formData.items.map(item => ({
|
||||
id: item.id,
|
||||
text: `[${item.category}] ${item.text} (${item.requirement})`,
|
||||
checked: item.checked,
|
||||
})),
|
||||
standards,
|
||||
inspector: formData.inspector,
|
||||
inspectorLicense: formData.inspectorLicense,
|
||||
operator: formData.operator,
|
||||
});
|
||||
setFormData({
|
||||
name: '',
|
||||
type: 'pre-flight',
|
||||
status: 'В процессе',
|
||||
aircraft: '',
|
||||
aircraftType: '',
|
||||
operator: '',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
description: '',
|
||||
inspector: '',
|
||||
inspectorLicense: '',
|
||||
items: [],
|
||||
});
|
||||
setNewItemText('');
|
||||
setNewItemCategory('');
|
||||
setNewItemRequirement('');
|
||||
setStandard('universal');
|
||||
onClose();
|
||||
}
|
||||
const handleCreate = () => {
|
||||
if (!name.trim()) return alert('Укажите название');
|
||||
const validItems = items.filter(it => it.text.trim());
|
||||
onCreate({ name, domain, version: 1, items: validItems.map((it, i) => ({ ...it, sort_order: i + 1 })) });
|
||||
setName(''); setItems([{ code: 'P.001', text: '' }]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '32px',
|
||||
maxWidth: '700px',
|
||||
width: '90%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold' }}>Создание чек-листа</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
padding: '0',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Стандарт соответствия <span style={{ color: 'red' }}>*</span>:
|
||||
</label>
|
||||
<select
|
||||
value={standard}
|
||||
onChange={(e) => setStandard(e.target.value as any)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="universal">Универсальный (EASA, FAA, АРМАК, ICAO)</option>
|
||||
<option value="easa">EASA Part-M</option>
|
||||
<option value="faa">FAA Part 91/135</option>
|
||||
<option value="armak">АРМАК</option>
|
||||
<option value="icao">ICAO Annex 6</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Название чек-листа <span style={{ color: 'red' }}>*</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Тип чек-листа:
|
||||
</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => handleChange('type', e.target.value as any)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="pre-flight">Предполётный</option>
|
||||
<option value="post-flight">Послеполётный</option>
|
||||
<option value="maintenance">Техническое обслуживание</option>
|
||||
<option value="annual">Годовой</option>
|
||||
<option value="special">Специальный</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Регистрационный номер ВС <span style={{ color: 'red' }}>*</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.aircraft}
|
||||
onChange={(e) => handleChange('aircraft', e.target.value)}
|
||||
placeholder="RA-12345"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Тип ВС:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.aircraftType}
|
||||
onChange={(e) => handleChange('aircraftType', e.target.value)}
|
||||
placeholder="Boeing 737-800"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Оператор:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.operator}
|
||||
onChange={(e) => handleChange('operator', e.target.value)}
|
||||
placeholder="Аэрофлот"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Инспектор <span style={{ color: 'red' }}>*</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.inspector}
|
||||
onChange={(e) => handleChange('inspector', e.target.value)}
|
||||
placeholder="Иванов И.И."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Лицензия инспектора:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.inspectorLicense}
|
||||
onChange={(e) => handleChange('inspectorLicense', e.target.value)}
|
||||
placeholder="LIC-12345"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Дата:
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => handleChange('date', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Статус:
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => handleChange('status', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="В процессе">В процессе</option>
|
||||
<option value="Завершён">Завершён</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Описание:
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Пункты чек-листа:
|
||||
</label>
|
||||
|
||||
{/* Быстрое добавление стандартных категорий */}
|
||||
<div style={{ marginBottom: '16px', padding: '12px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
|
||||
<div style={{ fontSize: '12px', fontWeight: '500', marginBottom: '8px', color: '#666' }}>
|
||||
Быстрое добавление стандартных категорий:
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{Object.entries(categories).map(([category, items]) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => handleAddStandardItems(category, items)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
border: '1px solid #1976d2',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
+ {category} ({items.length})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ручное добавление пунктов */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '8px' }}>
|
||||
<select
|
||||
value={newItemCategory}
|
||||
onChange={(e) => setNewItemCategory(e.target.value)}
|
||||
style={{
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="">Выберите категорию</option>
|
||||
{Object.keys(categories).map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={newItemRequirement}
|
||||
onChange={(e) => setNewItemRequirement(e.target.value)}
|
||||
placeholder="Требование (EASA Part-M, FAA Part 91, АРМАК...)"
|
||||
style={{
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={newItemText}
|
||||
onChange={(e) => setNewItemText(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddItem()}
|
||||
placeholder="Введите пункт чек-листа..."
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddItem}
|
||||
disabled={!newItemText.trim() || !newItemCategory}
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
backgroundColor: (!newItemText.trim() || !newItemCategory) ? '#ccc' : '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: (!newItemText.trim() || !newItemCategory) ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', maxHeight: '300px', overflowY: 'auto' }}>
|
||||
{formData.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
padding: '12px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>
|
||||
[{item.category}] {item.requirement}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500' }}>{item.text}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#f44336',
|
||||
cursor: 'pointer',
|
||||
fontSize: '18px',
|
||||
padding: '4px 8px',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{formData.items.length === 0 && (
|
||||
<div style={{ padding: '12px', textAlign: 'center', color: '#999', fontSize: '14px' }}>
|
||||
Нет пунктов. Используйте кнопки выше для быстрого добавления или добавьте вручную.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '32px' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
color: '#333',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Создать чек-лист" size="lg"
|
||||
footer={<><button onClick={onClose} className="btn-secondary">Отмена</button><button onClick={handleCreate} className="btn-primary">Создать</button></>}>
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<FormField label="Название" required><input value={name} onChange={e => setName(e.target.value)} className="input-field" placeholder="Чек-лист инспекции" /></FormField>
|
||||
<FormField label="Домен">
|
||||
<select value={domain} onChange={e => setDomain(e.target.value)} className="input-field">
|
||||
<option>ФАП-М</option><option>ATA</option><option>REFLY_CSV</option><option>CSV</option><option>custom</option>
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3 flex justify-between items-center">
|
||||
<h4 className="text-sm font-bold text-gray-600">Пункты проверки ({items.length})</h4>
|
||||
<button onClick={addItem} className="btn-sm bg-green-500 text-white">+ Добавить пункт</button>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto space-y-2">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="flex gap-2 items-center">
|
||||
<input value={item.code} onChange={e => updateItem(i, 'code', e.target.value)}
|
||||
className="input-field w-24 text-xs font-mono" placeholder="P.001" />
|
||||
<input value={item.text} onChange={e => updateItem(i, 'text', e.target.value)}
|
||||
className="input-field flex-1" placeholder="Текст пункта проверки..." />
|
||||
<button onClick={() => removeItem(i)} className="btn-sm bg-red-100 text-red-600 hover:bg-red-200" title="Удалить">×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,155 +1,23 @@
|
||||
'use client';
|
||||
import { Modal } from '@/components/ui';
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
aircraft: string;
|
||||
date: string;
|
||||
status: string;
|
||||
size: string;
|
||||
}
|
||||
|
||||
interface DocumentViewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
document: Document | null;
|
||||
}
|
||||
|
||||
export default function DocumentViewModal({ isOpen, onClose, document }: DocumentViewModalProps) {
|
||||
if (!isOpen || !document) return null;
|
||||
interface Doc { id: string; name: string; type: string; aircraft: string; date: string; status: string; size: string; }
|
||||
interface Props { isOpen: boolean; onClose: () => void; document: Doc | null; }
|
||||
|
||||
export default function DocumentViewModal({ isOpen, onClose, document: doc }: Props) {
|
||||
if (!doc) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '32px',
|
||||
maxWidth: '800px',
|
||||
width: '90%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '24px' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||
{document.name}
|
||||
</h2>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '8px' }}>
|
||||
Тип: {document.type} | ВС: {document.aircraft}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '8px' }}>
|
||||
Размер: {document.size} | Дата: {document.date}
|
||||
</div>
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<span style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
backgroundColor: document.status === 'Действителен' ? '#4caf50' : '#ff9800',
|
||||
color: 'white',
|
||||
}}>
|
||||
{document.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
padding: '0',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
padding: '24px',
|
||||
backgroundColor: '#f9f9f9',
|
||||
minHeight: '400px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
marginBottom: '24px',
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📄</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '500', marginBottom: '8px' }}>
|
||||
Просмотр документа
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', textAlign: 'center', maxWidth: '400px' }}>
|
||||
Здесь будет отображаться содержимое документа. В реальном приложении здесь будет встроенный просмотрщик PDF или изображения.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
color: '#333',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Имитация скачивания
|
||||
const link = window.document.createElement('a');
|
||||
link.href = '#';
|
||||
link.download = `${document.name}.pdf`;
|
||||
link.click();
|
||||
alert(`Документ "${document.name}" скачивается...`);
|
||||
}}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Скачать
|
||||
</button>
|
||||
</div>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={doc.name} size="md">
|
||||
<div className="space-y-3">
|
||||
<Info label="Тип" value={doc.type} />
|
||||
<Info label="ВС" value={doc.aircraft} />
|
||||
<Info label="Дата" value={doc.date} />
|
||||
<Info label="Статус" value={doc.status} />
|
||||
<Info label="Размер" value={doc.size} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
function Info({ label, value }: { label: string; value: string }) {
|
||||
return <div className="flex"><span className="text-sm font-bold text-gray-600 w-24">{label}:</span><span className="text-sm">{value}</span></div>;
|
||||
}
|
||||
|
||||
@ -102,80 +102,38 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '400px',
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
className="p-5"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '600px',
|
||||
backgroundColor: 'white',
|
||||
padding: '32px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
className="p-5"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
className="my-4"
|
||||
>
|
||||
⚠️
|
||||
</div>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '12px',
|
||||
color: '#333',
|
||||
}}
|
||||
className="text-gray-600"
|
||||
>
|
||||
Произошла ошибка
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
color: '#666',
|
||||
marginBottom: '24px',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
className="text-gray-600"
|
||||
>
|
||||
К сожалению, произошла непредвиденная ошибка. Мы уже работаем над её исправлением.
|
||||
</p>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<details
|
||||
style={{
|
||||
marginBottom: '24px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
className="p-5"
|
||||
>
|
||||
<summary
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
className="my-4"
|
||||
>
|
||||
Детали ошибки (только в режиме разработки)
|
||||
</summary>
|
||||
<pre
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
className="text-sm"
|
||||
>
|
||||
{this.state.error.toString()}
|
||||
{this.state.errorInfo?.componentStack}
|
||||
@ -183,33 +141,16 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
className="p-5"
|
||||
>
|
||||
Попробовать снова
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.href = '/'}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#1e3a5f',
|
||||
border: '1px solid #1e3a5f',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
className="p-5"
|
||||
>
|
||||
На главную
|
||||
</button>
|
||||
@ -238,3 +179,4 @@ export function withErrorBoundary<P extends object>(
|
||||
);
|
||||
};
|
||||
}
|
||||
export default ErrorBoundary;
|
||||
|
||||
@ -1,157 +1,45 @@
|
||||
/**
|
||||
* Компонент для отображения ошибок пользователю
|
||||
*/
|
||||
'use client';
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
title?: string;
|
||||
message: string;
|
||||
type?: 'error' | 'warning' | 'info';
|
||||
interface Props {
|
||||
error: Error | string | null;
|
||||
onRetry?: () => void;
|
||||
onClose?: () => void;
|
||||
showDetails?: boolean;
|
||||
details?: string;
|
||||
variant?: 'inline' | 'page' | 'toast';
|
||||
}
|
||||
|
||||
export default function ErrorDisplay({
|
||||
title,
|
||||
message,
|
||||
type = 'error',
|
||||
onRetry,
|
||||
onClose,
|
||||
showDetails = false,
|
||||
details,
|
||||
}: ErrorDisplayProps) {
|
||||
const colors = {
|
||||
error: {
|
||||
bg: '#ffebee',
|
||||
border: '#f44336',
|
||||
icon: '❌',
|
||||
title: title || 'Ошибка',
|
||||
},
|
||||
warning: {
|
||||
bg: '#fff3e0',
|
||||
border: '#ff9800',
|
||||
icon: '⚠️',
|
||||
title: title || 'Предупреждение',
|
||||
},
|
||||
info: {
|
||||
bg: '#e3f2fd',
|
||||
border: '#2196f3',
|
||||
icon: 'ℹ️',
|
||||
title: title || 'Информация',
|
||||
},
|
||||
};
|
||||
export default function ErrorDisplay({ error, onRetry, variant = 'inline' }: Props) {
|
||||
if (!error) return null;
|
||||
const message = typeof error === 'string' ? error : error.message;
|
||||
|
||||
const style = colors[type];
|
||||
if (variant === 'toast') {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg flex items-center gap-3 animate-in">
|
||||
<span className="text-sm">{message}</span>
|
||||
{onRetry && <button onClick={onRetry} className="text-white/80 hover:text-white underline text-sm">Повторить</button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: style.bg,
|
||||
border: `1px solid ${style.border}`,
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
marginBottom: '24px',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '12px',
|
||||
right: '12px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
color: style.border,
|
||||
padding: '4px 8px',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', marginBottom: '12px' }}>
|
||||
<span style={{ fontSize: '24px', marginRight: '12px' }}>{style.icon}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
color: style.border,
|
||||
}}
|
||||
>
|
||||
{style.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: '#333',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
if (variant === 'page') {
|
||||
return (
|
||||
<div className="min-h-[400px] flex items-center justify-center">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="text-5xl mb-4">⚠️</div>
|
||||
<h2 className="text-xl font-bold text-red-600 mb-2">Произошла ошибка</h2>
|
||||
<p className="text-gray-500 mb-6">{message}</p>
|
||||
{onRetry && <button onClick={onRetry} className="btn-primary">Попробовать снова</button>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{showDetails && details && (
|
||||
<details
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
padding: '12px',
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
<summary
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
Детали ошибки
|
||||
</summary>
|
||||
<pre
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{details}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{onRetry && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: style.border,
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Попробовать снова
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
|
||||
<span className="text-red-500 text-lg shrink-0">⚠️</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-red-700 font-medium">Ошибка</div>
|
||||
<div className="text-sm text-red-600 mt-1">{message}</div>
|
||||
</div>
|
||||
{onRetry && <button onClick={onRetry} className="btn-sm bg-red-100 text-red-600 hover:bg-red-200 shrink-0">↻</button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,293 +1,48 @@
|
||||
/**
|
||||
* Модальное окно для экспорта данных
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { exportToExcel } from '@/lib/export/excel';
|
||||
import { exportToCSV } from '@/lib/export/csv';
|
||||
import { exportToPDF } from '@/lib/export/pdf';
|
||||
import { exportToJSON } from '@/lib/export/json';
|
||||
import { Modal } from '@/components/ui';
|
||||
import FormField from '@/components/ui/FormField';
|
||||
|
||||
interface ExportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
data: any[];
|
||||
filename?: string;
|
||||
title?: string;
|
||||
availableColumns?: string[];
|
||||
columnLabels?: Record<string, string>;
|
||||
}
|
||||
interface Props { isOpen: boolean; onClose: () => void; }
|
||||
|
||||
export default function ExportModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
data,
|
||||
filename = 'export',
|
||||
title = 'Экспорт данных',
|
||||
availableColumns,
|
||||
columnLabels = {},
|
||||
}: ExportModalProps) {
|
||||
const [selectedFormat, setSelectedFormat] = useState<'excel' | 'csv' | 'pdf' | 'json'>('excel');
|
||||
const [selectedColumns, setSelectedColumns] = useState<string[]>(
|
||||
availableColumns || (data.length > 0 ? Object.keys(data[0]) : [])
|
||||
);
|
||||
const [exportFilename, setExportFilename] = useState(filename);
|
||||
const formats = ['xlsx', 'csv', 'pdf', 'json'];
|
||||
const datasets = ['aircraft', 'organizations', 'cert_applications', 'risk_alerts', 'audits', 'checklists', 'maintenance_tasks'];
|
||||
const datasetLabels: Record<string, string> = { aircraft: 'ВС', organizations: 'Организации', cert_applications: 'Заявки', risk_alerts: 'Риски', audits: 'Аудиты', checklists: 'Чек-листы', maintenance_tasks: 'Задачи ТО' };
|
||||
|
||||
if (!isOpen) return null;
|
||||
export default function ExportModal({ isOpen, onClose }: Props) {
|
||||
const [format, setFormat] = useState('xlsx');
|
||||
const [selected, setSelected] = useState<string[]>(['aircraft']);
|
||||
|
||||
const allColumns = availableColumns || (data.length > 0 ? Object.keys(data[0]) : []);
|
||||
const toggle = (ds: string) => setSelected(prev => prev.includes(ds) ? prev.filter(s => s !== ds) : [...prev, ds]);
|
||||
|
||||
const handleExport = () => {
|
||||
const headers = selectedColumns.map((col) => columnLabels[col] || col);
|
||||
|
||||
switch (selectedFormat) {
|
||||
case 'excel':
|
||||
exportToExcel(data, {
|
||||
filename: exportFilename,
|
||||
headers,
|
||||
columns: selectedColumns,
|
||||
});
|
||||
break;
|
||||
case 'csv':
|
||||
exportToCSV(data, {
|
||||
filename: exportFilename,
|
||||
headers,
|
||||
columns: selectedColumns,
|
||||
});
|
||||
break;
|
||||
case 'pdf':
|
||||
exportToPDF(data, {
|
||||
filename: exportFilename,
|
||||
title,
|
||||
headers,
|
||||
columns: selectedColumns,
|
||||
});
|
||||
break;
|
||||
case 'json':
|
||||
exportToJSON(data, {
|
||||
filename: exportFilename,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
alert(`Экспорт: ${selected.join(', ')} в формате ${format.toUpperCase()}\n\nВ production это вызовет /api/v1/export endpoint.`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const toggleColumn = (column: string) => {
|
||||
setSelectedColumns((prev) =>
|
||||
prev.includes(column)
|
||||
? prev.filter((c) => c !== column)
|
||||
: [...prev, column]
|
||||
);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedColumns([...allColumns]);
|
||||
};
|
||||
|
||||
const deselectAll = () => {
|
||||
setSelectedColumns([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
padding: '20px',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '24px',
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '20px' }}>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{/* Формат экспорта */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
|
||||
Формат экспорта
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||
{(['excel', 'csv', 'pdf', 'json'] as const).map((format) => (
|
||||
<label
|
||||
key={format}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '8px 16px',
|
||||
border: `2px solid ${selectedFormat === format ? '#1e3a5f' : '#ccc'}`,
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: selectedFormat === format ? '#f0f4f8' : 'white',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
value={format}
|
||||
checked={selectedFormat === format}
|
||||
onChange={(e) => setSelectedFormat(e.target.value as any)}
|
||||
style={{ marginRight: '8px' }}
|
||||
/>
|
||||
{format.toUpperCase()}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Экспорт данных" size="md"
|
||||
footer={<><button onClick={onClose} className="btn-secondary">Отмена</button><button onClick={handleExport} disabled={!selected.length} className="btn-primary disabled:opacity-50">Экспортировать</button></>}>
|
||||
<FormField label="Формат">
|
||||
<div className="flex gap-2">
|
||||
{formats.map(f => (
|
||||
<button key={f} onClick={() => setFormat(f)}
|
||||
className={`px-4 py-2 rounded text-sm border cursor-pointer ${format === f ? 'bg-primary-500 text-white border-primary-500' : 'bg-white text-gray-600 border-gray-300'}`}>
|
||||
{f.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Имя файла */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>
|
||||
Имя файла
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={exportFilename}
|
||||
onChange={(e) => setExportFilename(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Данные для экспорта">
|
||||
<div className="space-y-2">
|
||||
{datasets.map(ds => (
|
||||
<label key={ds} className="flex items-center gap-3 cursor-pointer py-1">
|
||||
<input type="checkbox" checked={selected.includes(ds)} onChange={() => toggle(ds)} className="w-4 h-4" />
|
||||
<span className="text-sm">{datasetLabels[ds] || ds}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Выбор колонок */}
|
||||
{selectedFormat !== 'json' && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<label style={{ fontWeight: '500' }}>Выберите колонки</label>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={selectAll}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'white',
|
||||
}}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
<button
|
||||
onClick={deselectAll}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'white',
|
||||
}}
|
||||
>
|
||||
Ничего
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: '200px',
|
||||
overflow: 'auto',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
padding: '8px',
|
||||
}}
|
||||
>
|
||||
{allColumns.map((column) => (
|
||||
<label
|
||||
key={column}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '8px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedColumns.includes(column)}
|
||||
onChange={() => toggleColumn(column)}
|
||||
style={{ marginRight: '8px' }}
|
||||
/>
|
||||
{columnLabels[column] || column}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Информация */}
|
||||
<div
|
||||
style={{
|
||||
padding: '12px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '20px',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Будет экспортировано: <strong>{data.length}</strong> записей
|
||||
</div>
|
||||
|
||||
{/* Кнопки */}
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#1e3a5f',
|
||||
border: '1px solid #1e3a5f',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={selectedColumns.length === 0 && selectedFormat !== 'json'}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: selectedColumns.length === 0 && selectedFormat !== 'json' ? '#ccc' : '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: selectedColumns.length === 0 && selectedFormat !== 'json' ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Экспортировать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,259 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { Modal } from '@/components/ui';
|
||||
|
||||
interface FileUploadModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onUpload: (files: File[]) => void;
|
||||
}
|
||||
interface Props { isOpen: boolean; onClose: () => void; onUpload?: (file: File) => void; }
|
||||
|
||||
export default function FileUploadModal({ isOpen, onClose, onUpload }: FileUploadModalProps) {
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
export default function FileUploadModal({ isOpen, onClose, onUpload }: Props) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Разрешенные типы файлов
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'image/png',
|
||||
'text/plain',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'text/csv',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
];
|
||||
const handleFile = (f: File) => { setFile(f); };
|
||||
const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setDragging(false); if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]); };
|
||||
|
||||
const allowedExtensions = ['.pdf', '.png', '.txt', '.doc', '.docx', '.csv', '.xls', '.xlsx'];
|
||||
|
||||
const handleFileSelect = (files: FileList | null) => {
|
||||
if (!files) return;
|
||||
|
||||
const validFiles: File[] = [];
|
||||
const invalidFiles: string[] = [];
|
||||
|
||||
Array.from(files).forEach(file => {
|
||||
const isValidType = allowedTypes.includes(file.type) ||
|
||||
allowedExtensions.some(ext => file.name.toLowerCase().endsWith(ext));
|
||||
|
||||
if (isValidType) {
|
||||
validFiles.push(file);
|
||||
} else {
|
||||
invalidFiles.push(file.name);
|
||||
}
|
||||
});
|
||||
|
||||
if (invalidFiles.length > 0) {
|
||||
alert(`Следующие файлы не поддерживаются:\n${invalidFiles.join('\n')}\n\nПоддерживаемые форматы: PDF, PNG, TXT, DOC, DOCX, CSV, XLS, XLSX`);
|
||||
}
|
||||
|
||||
setSelectedFiles(prev => [...prev, ...validFiles]);
|
||||
};
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||
setDragActive(true);
|
||||
} else if (e.type === 'dragleave') {
|
||||
setDragActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
handleFileSelect(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleFileSelect(e.target.files);
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
if (selectedFiles.length === 0) {
|
||||
alert('Пожалуйста, выберите файлы для загрузки');
|
||||
return;
|
||||
}
|
||||
onUpload(selectedFiles);
|
||||
setSelectedFiles([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
const handleSubmit = () => { if (file && onUpload) { onUpload(file); setFile(null); onClose(); } };
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
width: '90%',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold' }}>Загрузка документов</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
padding: '0',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: `2px dashed ${dragActive ? '#1e3a5f' : '#ccc'}`,
|
||||
borderRadius: '8px',
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
backgroundColor: dragActive ? '#f0f7ff' : '#fafafa',
|
||||
marginBottom: '24px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.png,.txt,.doc,.docx,.csv,.xls,.xlsx"
|
||||
onChange={handleFileInputChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📄</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '500', marginBottom: '8px' }}>
|
||||
Перетащите файлы сюда или нажмите для выбора
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
Поддерживаемые форматы: PDF, PNG, TXT, DOC, DOCX, CSV, XLS, XLSX
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedFiles.length > 0 && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '12px' }}>
|
||||
Выбранные файлы ({selectedFiles.length})
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', maxHeight: '200px', overflowY: 'auto' }}>
|
||||
{selectedFiles.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500' }}>{file.name}</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>{formatFileSize(file.size)}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFile(index)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#f44336',
|
||||
cursor: 'pointer',
|
||||
fontSize: '20px',
|
||||
padding: '4px 8px',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Загрузка файла" size="md"
|
||||
footer={<><button onClick={onClose} className="btn-secondary">Отмена</button><button onClick={handleSubmit} disabled={!file} className="btn-primary disabled:opacity-50">Загрузить</button></>}>
|
||||
<div onDrop={handleDrop} onDragOver={e => { e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)}
|
||||
className={`border-2 border-dashed rounded-lg p-10 text-center cursor-pointer transition-colors
|
||||
${dragging ? 'border-primary-500 bg-primary-50' : 'border-gray-300 hover:border-gray-400'}`}
|
||||
onClick={() => inputRef.current?.click()}>
|
||||
<input ref={inputRef} type="file" accept=".pdf,.docx,.doc,.xlsx,.csv,.zip" onChange={e => { if (e.target.files?.[0]) handleFile(e.target.files[0]); }} className="hidden" />
|
||||
{file ? (
|
||||
<div><div className="text-4xl mb-2">📄</div><div className="font-bold">{file.name}</div><div className="text-sm text-gray-500">{(file.size / 1024 / 1024).toFixed(2)} МБ</div></div>
|
||||
) : (
|
||||
<div><div className="text-4xl mb-2">📤</div><div className="font-bold text-gray-600">Перетащите файл или нажмите</div><div className="text-sm text-gray-400 mt-1">PDF, DOCX, XLSX, CSV, ZIP</div></div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
color: '#333',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={selectedFiles.length === 0}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: selectedFiles.length === 0 ? '#ccc' : '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: selectedFiles.length === 0 ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Загрузить ({selectedFiles.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -28,23 +28,17 @@ export default function FormField({
|
||||
const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined;
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div className="mb-3">
|
||||
<label
|
||||
id={labelId}
|
||||
htmlFor={name}
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#333',
|
||||
}}
|
||||
|
||||
>
|
||||
{label}
|
||||
{required && (
|
||||
<span
|
||||
aria-label="обязательное поле"
|
||||
style={{ color: '#f44336', marginLeft: '4px' }}
|
||||
className=""
|
||||
>
|
||||
*
|
||||
</span>
|
||||
@ -58,14 +52,7 @@ export default function FormField({
|
||||
id={errorId}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
marginTop: '4px',
|
||||
fontSize: '12px',
|
||||
color: '#f44336',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
|
||||
>
|
||||
<span aria-hidden="true">⚠️</span>
|
||||
<span>{error}</span>
|
||||
@ -74,11 +61,7 @@ export default function FormField({
|
||||
{hint && !error && (
|
||||
<div
|
||||
id={hintId}
|
||||
style={{
|
||||
marginTop: '4px',
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
}}
|
||||
|
||||
>
|
||||
{hint}
|
||||
</div>
|
||||
|
||||
@ -1,276 +1,63 @@
|
||||
/**
|
||||
* Компонент глобального поиска с автодополнением
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { globalSearch, getSearchSuggestions, SearchResult } from '@/lib/search/global-search';
|
||||
import { useSearchHistory } from '@/hooks/useSearchHistory';
|
||||
import { useUrlParams } from '@/hooks/useUrlParams';
|
||||
|
||||
interface GlobalSearchProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
data?: {
|
||||
aircraft?: any[];
|
||||
risks?: any[];
|
||||
organizations?: any[];
|
||||
documents?: any[];
|
||||
audits?: any[];
|
||||
checklists?: any[];
|
||||
applications?: any[];
|
||||
};
|
||||
}
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
directive: '⚠️', bulletin: '📢', component: '🔩', work_order: '📐',
|
||||
defect: '🛠️', specialist: '🎓', aircraft: '✈️',
|
||||
};
|
||||
|
||||
export default function GlobalSearch({ isOpen, onClose, data = {} }: GlobalSearchProps) {
|
||||
const router = useRouter();
|
||||
export default function GlobalSearch() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { addToHistory } = useSearchHistory();
|
||||
const { setSearch } = useUrlParams();
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const timer = useRef<NodeJS.Timeout>();
|
||||
|
||||
const search = useCallback((q: string) => {
|
||||
if (q.length < 2) { setResults([]); return; }
|
||||
clearTimeout(timer.current);
|
||||
timer.current = setTimeout(async () => {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/search/global?q=${encodeURIComponent(q)}`);
|
||||
const data = await r.json();
|
||||
setResults(data.results || []);
|
||||
setOpen(true);
|
||||
} catch { setResults([]); }
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (query.length >= 2) {
|
||||
const suggestionsList = getSearchSuggestions(data, query, 5);
|
||||
setSuggestions(suggestionsList);
|
||||
setShowSuggestions(suggestionsList.length > 0);
|
||||
|
||||
// Выполняем поиск
|
||||
const searchResults = globalSearch(data, query);
|
||||
setResults(searchResults);
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
setResults([]);
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
}, [query, data]);
|
||||
|
||||
const handleSearch = (searchQuery: string = query) => {
|
||||
if (!searchQuery.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
addToHistory(searchQuery, results.length);
|
||||
setSearch(searchQuery);
|
||||
onClose();
|
||||
|
||||
// Переходим на страницу результатов или первую найденную страницу
|
||||
if (results.length > 0) {
|
||||
router.push(results[0].url);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
||||
const selectedSuggestion = suggestions[selectedIndex];
|
||||
setQuery(selectedSuggestion);
|
||||
handleSearch(selectedSuggestion);
|
||||
} else {
|
||||
handleSearch();
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) =>
|
||||
prev < suggestions.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
||||
} else if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
setQuery(suggestion);
|
||||
handleSearch(suggestion);
|
||||
};
|
||||
|
||||
const handleResultClick = (result: SearchResult) => {
|
||||
addToHistory(query, results.length);
|
||||
router.push(result.url);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 2000,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
paddingTop: '100px',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Поле поиска */}
|
||||
<div style={{ padding: '16px', borderBottom: '1px solid #eee' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '20px' }}>🔍</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setSelectedIndex(-1);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Поиск по ВС, рискам, организациям..."
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '16px',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setSuggestions([]);
|
||||
}}
|
||||
style={{
|
||||
padding: '8px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '18px',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Результаты */}
|
||||
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div style={{ borderBottom: '1px solid #eee' }}>
|
||||
<div style={{ padding: '8px 16px', fontSize: '12px', color: '#666', fontWeight: 'bold' }}>
|
||||
Предложения
|
||||
</div>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: selectedIndex === index ? '#f5f5f5' : 'white',
|
||||
borderLeft: selectedIndex === index ? '3px solid #1e3a5f' : '3px solid transparent',
|
||||
}}
|
||||
>
|
||||
{suggestion}
|
||||
<div ref={ref} className="relative">
|
||||
<input type="text" placeholder="🔍 Поиск..." value={query}
|
||||
onChange={e => { setQuery(e.target.value); search(e.target.value); }}
|
||||
onFocus={() => results.length > 0 && setOpen(true)}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg bg-gray-100 border-0 focus:bg-white focus:ring-2 focus:ring-blue-500 transition-all" />
|
||||
{open && results.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white border rounded-lg shadow-xl z-50 max-h-80 overflow-y-auto">
|
||||
{results.map((r, i) => (
|
||||
<button key={i} onClick={() => { router.push(r.url); setOpen(false); setQuery(''); }}
|
||||
className="w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-50 transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{TYPE_ICONS[r.type] || '📋'}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{r.title}</div>
|
||||
<div className="text-[10px] text-gray-400 truncate">{r.subtitle}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div>
|
||||
<div style={{ padding: '8px 16px', fontSize: '12px', color: '#666', fontWeight: 'bold' }}>
|
||||
Результаты ({results.length})
|
||||
</div>
|
||||
{results.slice(0, 10).map((result) => (
|
||||
<div
|
||||
key={`${result.type}-${result.id}`}
|
||||
onClick={() => handleResultClick(result)}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
cursor: 'pointer',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f5f5f5';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'white';
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: '500', marginBottom: '4px' }}>
|
||||
{result.title}
|
||||
</div>
|
||||
{result.subtitle && (
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||
{result.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#999', marginLeft: '16px' }}>
|
||||
{result.type === 'aircraft' && '✈️'}
|
||||
{result.type === 'risk' && '⚠️'}
|
||||
{result.type === 'organization' && '🏢'}
|
||||
{result.type === 'document' && '📄'}
|
||||
{result.type === 'audit' && '🔍'}
|
||||
{result.type === 'checklist' && '✅'}
|
||||
{result.type === 'application' && '📋'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{query.length >= 2 && results.length === 0 && (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#999' }}>
|
||||
Ничего не найдено
|
||||
</div>
|
||||
)}
|
||||
|
||||
{query.length < 2 && (
|
||||
<div style={{ padding: '40px', textAlign: 'center', color: '#999' }}>
|
||||
Введите минимум 2 символа для поиска
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Подсказка */}
|
||||
<div style={{ padding: '12px 16px', borderTop: '1px solid #eee', fontSize: '12px', color: '#666' }}>
|
||||
<span>Нажмите Enter для поиска</span>
|
||||
<span style={{ marginLeft: '16px' }}>Esc для закрытия</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,60 +1,31 @@
|
||||
/**
|
||||
* Компонент для отображения подсказок по горячим клавишам
|
||||
*/
|
||||
'use client';
|
||||
import { Modal } from '@/components/ui';
|
||||
|
||||
export default function KeyboardShortcutsHelp() {
|
||||
const shortcuts: Array<{ keys: string; description: string }> = [
|
||||
{ keys: 'Ctrl+K', description: 'Глобальный поиск' },
|
||||
{ keys: 'Ctrl+N', description: 'Создать новую запись' },
|
||||
{ keys: 'Ctrl+S', description: 'Сохранить форму' },
|
||||
{ keys: 'Esc', description: 'Закрыть модальное окно' },
|
||||
];
|
||||
interface Props { isOpen: boolean; onClose: () => void; }
|
||||
|
||||
const shortcuts = [
|
||||
{ keys: '⌘/Ctrl + K', action: 'Глобальный поиск' },
|
||||
{ keys: '⌘/Ctrl + N', action: 'Создать новый объект' },
|
||||
{ keys: '⌘/Ctrl + ,', action: 'Настройки' },
|
||||
{ keys: '⌘/Ctrl + .', action: 'AI Ассистент' },
|
||||
{ keys: 'Escape', action: 'Закрыть модальное окно' },
|
||||
{ keys: '?', action: 'Показать горячие клавиши' },
|
||||
{ keys: '←/→', action: 'Навигация по страницам' },
|
||||
{ keys: '⌘/Ctrl + E', action: 'Экспорт данных' },
|
||||
];
|
||||
|
||||
export default function KeyboardShortcutsHelp({ isOpen, onClose }: Props) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
backgroundColor: 'white',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
zIndex: 1000,
|
||||
maxWidth: '300px',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>
|
||||
Горячие клавиши
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{shortcuts.map((shortcut, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#666' }}>{shortcut.description}</span>
|
||||
<kbd
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '11px',
|
||||
}}
|
||||
>
|
||||
{shortcut.keys}
|
||||
</kbd>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="⌨️ Горячие клавиши" size="sm">
|
||||
<div className="space-y-1">
|
||||
{shortcuts.map(s => (
|
||||
<div key={s.keys} className="flex justify-between items-center py-2 border-b border-gray-50">
|
||||
<span className="text-sm">{s.action}</span>
|
||||
<kbd className="bg-gray-100 text-gray-600 px-2 py-1 rounded text-xs font-mono">{s.keys}</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-4">Нажмите ? в любом месте для вызова этого окна</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,36 +8,18 @@ export default function Logo({ size = 'large' }: { size?: 'large' | 'small' }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
|
||||
>
|
||||
{/* Иконка самолета */}
|
||||
<div
|
||||
style={{
|
||||
width: isLarge ? '48px' : '32px',
|
||||
height: isLarge ? '48px' : '32px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: isLarge ? '24px' : '18px',
|
||||
}}
|
||||
|
||||
>
|
||||
✈️
|
||||
</div>
|
||||
|
||||
{/* Надпись REFLY */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: isLarge ? '32px' : '24px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1e3a5f',
|
||||
letterSpacing: '2px',
|
||||
}}
|
||||
|
||||
>
|
||||
REFLY
|
||||
</div>
|
||||
|
||||
@ -1,155 +1,93 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import NotificationCenter from './NotificationCenter';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: 'critical_risk' | 'upcoming_audit' | 'expiring_document' | 'aircraft_status_change';
|
||||
title: string;
|
||||
message: string;
|
||||
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||
createdAt: string;
|
||||
read: boolean;
|
||||
actionUrl?: string;
|
||||
interface Notification {
|
||||
type: string;
|
||||
data: { message: string; severity?: string };
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface NotificationBellProps {
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export default function NotificationBell({ userId }: NotificationBellProps) {
|
||||
export default function NotificationBell() {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [_loading, setLoading] = useState(true);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [unread, setUnread] = useState(0);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadNotifications();
|
||||
// Обновляем уведомления каждые 5 минут
|
||||
const interval = setInterval(loadNotifications, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [userId]);
|
||||
|
||||
const loadNotifications = async () => {
|
||||
// Connect to WebSocket for real-time notifications
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/notifications');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setNotifications(data.notifications || []);
|
||||
const unread = (data.notifications || []).filter((n: Notification) => !n.read).length;
|
||||
setUnreadCount(unread);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки уведомлений:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/notifications`);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const notif = JSON.parse(event.data);
|
||||
setNotifications(prev => [notif, ...prev].slice(0, 50));
|
||||
setUnread(prev => prev + 1);
|
||||
} catch {}
|
||||
};
|
||||
ws.onerror = () => {};
|
||||
ws.onclose = () => {};
|
||||
} catch {}
|
||||
|
||||
return () => { wsRef.current?.close(); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const handleOpen = () => {
|
||||
setOpen(o => !o);
|
||||
if (!open) setUnread(0);
|
||||
};
|
||||
|
||||
const handleMarkAsRead = async (id: string) => {
|
||||
try {
|
||||
await fetch(`/api/notifications/${id}/read`, {
|
||||
method: 'POST',
|
||||
});
|
||||
setNotifications(prev =>
|
||||
prev.map(n => n.id === id ? { ...n, read: true } : n)
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
} catch (error) {
|
||||
console.error('Ошибка отметки уведомления как прочитанного:', error);
|
||||
const severityColor = (s?: string) => {
|
||||
switch (s) {
|
||||
case 'critical': return 'text-red-600';
|
||||
case 'high': return 'text-orange-500';
|
||||
case 'medium': return 'text-yellow-600';
|
||||
default: return 'text-blue-500';
|
||||
}
|
||||
};
|
||||
|
||||
const criticalUnread = notifications.filter(n => !n.read && n.priority === 'critical').length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '8px',
|
||||
fontSize: '24px',
|
||||
color: '#666',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
aria-label={`Уведомления${unreadCount > 0 ? ` (${unreadCount} непрочитанных)` : ''}`}
|
||||
>
|
||||
<div ref={ref} className="relative">
|
||||
<button onClick={handleOpen} className="relative p-2 text-gray-500 hover:text-gray-700 transition-colors" title="Уведомления">
|
||||
🔔
|
||||
{unreadCount > 0 && (
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: criticalUnread > 0 ? '#f44336' : '#2196f3',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '2px solid white',
|
||||
}}>
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
{unread > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-4 h-4 bg-red-500 text-white text-[9px] rounded-full flex items-center justify-center animate-pulse">
|
||||
{unread > 9 ? '9+' : unread}
|
||||
</span>
|
||||
)}
|
||||
{criticalUnread > 0 && (
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
right: '0',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#f44336',
|
||||
animation: 'pulse 2s infinite',
|
||||
}} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div
|
||||
onClick={() => setIsOpen(false)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 999,
|
||||
}}
|
||||
/>
|
||||
<NotificationCenter
|
||||
notifications={notifications}
|
||||
onMarkAsRead={handleMarkAsRead}
|
||||
onClose={() => setIsOpen(false)}
|
||||
/>
|
||||
</>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 w-80 bg-white border rounded-lg shadow-xl z-50 max-h-96 overflow-y-auto">
|
||||
<div className="p-3 border-b bg-gray-50 flex justify-between items-center">
|
||||
<span className="text-sm font-bold">Уведомления</span>
|
||||
<Link href="/inbox" className="text-[10px] text-blue-500 hover:underline">Все →</Link>
|
||||
</div>
|
||||
{notifications.length > 0 ? (
|
||||
<div>
|
||||
{notifications.slice(0, 10).map((n, i) => (
|
||||
<div key={i} className="px-3 py-2 border-b border-gray-50 hover:bg-gray-50">
|
||||
<div className={`text-xs font-medium ${severityColor(n.data?.severity)}`}>{n.data?.message || n.type}</div>
|
||||
<div className="text-[9px] text-gray-400 mt-0.5">{new Date(n.timestamp).toLocaleTimeString('ru-RU')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center text-xs text-gray-400">Нет новых уведомлений</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,276 +1,24 @@
|
||||
'use client';
|
||||
import { PageLayout, DataTable, StatusBadge } from '@/components/ui';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: 'critical_risk' | 'upcoming_audit' | 'expiring_document' | 'aircraft_status_change';
|
||||
title: string;
|
||||
message: string;
|
||||
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||
createdAt: string;
|
||||
read: boolean;
|
||||
actionUrl?: string;
|
||||
}
|
||||
|
||||
interface NotificationCenterProps {
|
||||
notifications: Notification[];
|
||||
onMarkAsRead: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function NotificationCenter({
|
||||
notifications,
|
||||
onMarkAsRead,
|
||||
onClose,
|
||||
}: NotificationCenterProps) {
|
||||
const router = useRouter();
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unread = notifications.filter(n => !n.read && n.priority === 'critical').length;
|
||||
setUnreadCount(unread);
|
||||
|
||||
// Воспроизведение звука для критических уведомлений
|
||||
if (unread > 0 && audioRef.current) {
|
||||
audioRef.current.play().catch(() => {
|
||||
// Игнорируем ошибки автовоспроизведения
|
||||
});
|
||||
}
|
||||
}, [notifications]);
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'critical': return '#f44336';
|
||||
case 'high': return '#ff9800';
|
||||
case 'medium': return '#2196f3';
|
||||
case 'low': return '#4caf50';
|
||||
default: return '#666';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityIcon = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'critical': return '🚨';
|
||||
case 'high': return '⚠️';
|
||||
case 'medium': return 'ℹ️';
|
||||
case 'low': return '✅';
|
||||
default: return '📌';
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotificationClick = (notification: Notification) => {
|
||||
if (!notification.read) {
|
||||
onMarkAsRead(notification.id);
|
||||
}
|
||||
if (notification.actionUrl) {
|
||||
router.push(notification.actionUrl);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const criticalNotifications = notifications.filter(n => n.priority === 'critical');
|
||||
const otherNotifications = notifications.filter(n => n.priority !== 'critical');
|
||||
export default function NotificationCenter() {
|
||||
const { notifications, markRead, markAllRead } = useNotifications();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Скрытый audio элемент для звуковых сигналов */}
|
||||
{typeof window !== 'undefined' && (
|
||||
<audio ref={audioRef} preload="auto" style={{ display: 'none' }}>
|
||||
<source src="/sounds/alert-critical.mp3" type="audio/mpeg" />
|
||||
<source src="/sounds/alert-critical.ogg" type="audio/ogg" />
|
||||
</audio>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '80px',
|
||||
right: '24px',
|
||||
width: '400px',
|
||||
maxHeight: '600px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Заголовок */}
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f5f5f5',
|
||||
}}>
|
||||
<h3 style={{ fontSize: '18px', fontWeight: 'bold', margin: 0 }}>
|
||||
Уведомления
|
||||
{unreadCount > 0 && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 8px',
|
||||
backgroundColor: '#f44336',
|
||||
color: 'white',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
}}>
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
}}
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Список уведомлений */}
|
||||
<div style={{
|
||||
overflowY: 'auto',
|
||||
maxHeight: '500px',
|
||||
}}>
|
||||
{notifications.length === 0 ? (
|
||||
<div style={{
|
||||
padding: '40px 20px',
|
||||
textAlign: 'center',
|
||||
color: '#999',
|
||||
}}>
|
||||
Нет уведомлений
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Критические уведомления */}
|
||||
{criticalNotifications.length > 0 && (
|
||||
<div>
|
||||
{criticalNotifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: notification.read ? 'white' : '#fff3e0',
|
||||
borderLeft: `4px solid ${getPriorityColor(notification.priority)}`,
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f5f5f5';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = notification.read ? 'white' : '#fff3e0';
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
|
||||
<div style={{ fontSize: '24px' }}>
|
||||
{getPriorityIcon(notification.priority)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '4px',
|
||||
color: getPriorityColor(notification.priority),
|
||||
}}>
|
||||
{notification.title}
|
||||
{!notification.read && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: getPriorityColor(notification.priority),
|
||||
display: 'inline-block',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: '#666', marginBottom: '8px' }}>
|
||||
{notification.message}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#999' }}>
|
||||
{new Date(notification.createdAt).toLocaleString('ru-RU')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Остальные уведомления */}
|
||||
{otherNotifications.length > 0 && (
|
||||
<div>
|
||||
{otherNotifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: notification.read ? 'white' : '#f9f9f9',
|
||||
borderLeft: `4px solid ${getPriorityColor(notification.priority)}`,
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f5f5f5';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = notification.read ? 'white' : '#f9f9f9';
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
|
||||
<div style={{ fontSize: '20px' }}>
|
||||
{getPriorityIcon(notification.priority)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
marginBottom: '4px',
|
||||
color: '#333',
|
||||
}}>
|
||||
{notification.title}
|
||||
{!notification.read && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: getPriorityColor(notification.priority),
|
||||
display: 'inline-block',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: '#666', marginBottom: '8px' }}>
|
||||
{notification.message}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#999' }}>
|
||||
{new Date(notification.createdAt).toLocaleString('ru-RU')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-bold">Все уведомления ({notifications.length})</h3>
|
||||
<button onClick={markAllRead} className="btn-sm bg-primary-500 text-white">Прочитать все</button>
|
||||
</div>
|
||||
</>
|
||||
<DataTable data={notifications} emptyMessage="Нет уведомлений"
|
||||
columns={[
|
||||
{ key: 'created_at', header: 'Время', render: (n: any) => <span className="text-xs">{n.created_at ? new Date(n.created_at).toLocaleString('ru-RU') : '—'}</span> },
|
||||
{ key: 'message', header: 'Сообщение', render: (n: any) => <span className={n.is_read ? 'text-gray-400' : 'font-medium'}>{n.message || n.title}</span> },
|
||||
{ key: 'notification_type', header: 'Тип', render: (n: any) => <StatusBadge status={n.notification_type || 'info'} /> },
|
||||
{ key: 'actions', header: '', render: (n: any) => !n.is_read ? <button onClick={() => markRead(n.id)} className="btn-sm bg-blue-100 text-blue-600">✓</button> : <span className="text-xs text-green-500">✓</span> },
|
||||
]}
|
||||
onRowClick={(n: any) => !n.is_read && markRead(n.id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,259 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Modal } from '@/components/ui';
|
||||
import FormField from '@/components/ui/FormField';
|
||||
|
||||
interface OrganizationCreateModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreate?: (organization: {
|
||||
name: string;
|
||||
type: string;
|
||||
address?: string;
|
||||
contact?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}) => void;
|
||||
}
|
||||
interface Props { isOpen: boolean; onClose: () => void; onCreate: (data: any) => void; }
|
||||
|
||||
export default function OrganizationCreateModal({ isOpen, onClose, onCreate }: OrganizationCreateModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
type: 'Авиакомпания',
|
||||
address: '',
|
||||
contact: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
});
|
||||
export default function OrganizationCreateModal({ isOpen, onClose, onCreate }: Props) {
|
||||
const [form, setForm] = useState({ name: '', kind: 'operator', inn: '', address: '', contact_email: '', contact_phone: '' });
|
||||
const set = (k: string, v: string) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
setFormData({ ...formData, [field]: value });
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formData.name) {
|
||||
alert('Пожалуйста, укажите название организации');
|
||||
return;
|
||||
}
|
||||
|
||||
if (onCreate) {
|
||||
onCreate(formData);
|
||||
setFormData({
|
||||
name: '',
|
||||
type: 'Авиакомпания',
|
||||
address: '',
|
||||
contact: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const handleSubmit = () => { if (!form.name.trim()) return alert('Укажите название'); onCreate(form); setForm({ name: '', kind: 'operator', inn: '', address: '', contact_email: '', contact_phone: '' }); };
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
width: '90%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold' }}>Добавление организации</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
padding: '0',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Название организации <span style={{ color: 'red' }}>*</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
placeholder="Введите название организации"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Тип организации:
|
||||
</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => handleChange('type', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="Авиакомпания">Авиакомпания</option>
|
||||
<option value="Аэропорт">Аэропорт</option>
|
||||
<option value="Сервисная организация">Сервисная организация</option>
|
||||
<option value="Производитель">Производитель</option>
|
||||
<option value="Другое">Другое</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Адрес:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleChange('address', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
placeholder="Введите адрес организации"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Контактное лицо:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.contact}
|
||||
onChange={(e) => handleChange('contact', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
placeholder="Введите ФИО контактного лица"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Email:
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Телефон:
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
placeholder="+7 (XXX) XXX-XX-XX"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '32px' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
color: '#333',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Создать организацию" size="md"
|
||||
footer={<><button onClick={onClose} className="btn-secondary">Отмена</button><button onClick={handleSubmit} className="btn-primary">Создать</button></>}>
|
||||
<FormField label="Название" required><input value={form.name} onChange={e => set('name', e.target.value)} className="input-field" placeholder="ООО Авиакомпания" /></FormField>
|
||||
<FormField label="Тип">
|
||||
<select value={form.kind} onChange={e => set('kind', e.target.value)} className="input-field">
|
||||
<option value="operator">Оператор</option><option value="mro">ТОиР</option><option value="authority">Орган власти</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="ИНН"><input value={form.inn} onChange={e => set('inn', e.target.value)} className="input-field" /></FormField>
|
||||
<FormField label="Адрес"><input value={form.address} onChange={e => set('address', e.target.value)} className="input-field" /></FormField>
|
||||
<FormField label="Email"><input type="email" value={form.contact_email} onChange={e => set('contact_email', e.target.value)} className="input-field" /></FormField>
|
||||
<FormField label="Телефон"><input value={form.contact_phone} onChange={e => set('contact_phone', e.target.value)} className="input-field" /></FormField>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,305 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Modal, DataTable, StatusBadge } from '@/components/ui';
|
||||
import { Aircraft } from '@/lib/api';
|
||||
|
||||
interface OrganizationDetailsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
organization: string;
|
||||
aircraft: Aircraft[];
|
||||
onEdit?: (aircraft: Aircraft) => void;
|
||||
}
|
||||
|
||||
export default function OrganizationDetailsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
organization,
|
||||
aircraft,
|
||||
onEdit,
|
||||
}: OrganizationDetailsModalProps) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editedAircraft, setEditedAircraft] = useState<Aircraft | null>(null);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleEdit = (item: Aircraft) => {
|
||||
setEditingId(item.id);
|
||||
setEditedAircraft({ ...item });
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (editedAircraft && onEdit) {
|
||||
onEdit(editedAircraft);
|
||||
}
|
||||
setEditingId(null);
|
||||
setEditedAircraft(null);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditingId(null);
|
||||
setEditedAircraft(null);
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof Aircraft, value: string | number) => {
|
||||
if (editedAircraft) {
|
||||
setEditedAircraft({ ...editedAircraft, [field]: value });
|
||||
}
|
||||
};
|
||||
interface Props { isOpen: boolean; onClose: () => void; organization: string; aircraft: Aircraft[]; onEdit?: (a: Aircraft) => void; }
|
||||
|
||||
export default function OrganizationDetailsModal({ isOpen, onClose, organization, aircraft, onEdit }: Props) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '32px',
|
||||
maxWidth: '900px',
|
||||
width: '90%',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
{organization}
|
||||
</h2>
|
||||
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||
Воздушных судов: {aircraft.length}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
padding: '0',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: '#1e3a5f', color: 'white' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: 'bold' }}>
|
||||
РЕГИСТРАЦИОННЫЙ НОМЕР
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: 'bold' }}>
|
||||
СЕРИЙНЫЙ НОМЕР
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: 'bold' }}>
|
||||
ТИП ВС
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: 'bold' }}>
|
||||
СТАТУС
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: 'bold' }}>
|
||||
НАЛЕТ (Ч)
|
||||
</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: 'bold' }}>
|
||||
ДЕЙСТВИЯ
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{aircraft.map((item) => (
|
||||
<tr key={item.id} style={{ borderBottom: '1px solid #e0e0e0' }}>
|
||||
<td style={{ padding: '12px' }}>
|
||||
{editingId === item.id && editedAircraft ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editedAircraft.registrationNumber}
|
||||
onChange={(e) => handleChange('registrationNumber', e.target.value)}
|
||||
style={{
|
||||
padding: '6px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
item.registrationNumber
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
{editingId === item.id && editedAircraft ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editedAircraft.serialNumber}
|
||||
onChange={(e) => handleChange('serialNumber', e.target.value)}
|
||||
style={{
|
||||
padding: '6px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
item.serialNumber
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
{editingId === item.id && editedAircraft ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editedAircraft.aircraftType}
|
||||
onChange={(e) => handleChange('aircraftType', e.target.value)}
|
||||
style={{
|
||||
padding: '6px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
item.aircraftType
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
{editingId === item.id && editedAircraft ? (
|
||||
<select
|
||||
value={editedAircraft.status}
|
||||
onChange={(e) => handleChange('status', e.target.value)}
|
||||
style={{
|
||||
padding: '6px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<option value="Активен">Активен</option>
|
||||
<option value="На обслуживании">На обслуживании</option>
|
||||
<option value="Неактивен">Неактивен</option>
|
||||
</select>
|
||||
) : (
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
backgroundColor: item.status === 'Активен' ? '#4caf50' : '#ff9800',
|
||||
color: 'white',
|
||||
}}>
|
||||
{item.status}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
{editingId === item.id && editedAircraft ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editedAircraft.flightHours}
|
||||
onChange={(e) => handleChange('flightHours', parseInt(e.target.value) || 0)}
|
||||
style={{
|
||||
padding: '6px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
item.flightHours
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
{editingId === item.id ? (
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#4caf50',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
color: '#333',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleEdit(item)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
Редактировать
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '24px', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={organization} size="lg">
|
||||
<div className="mb-4">
|
||||
<div className="text-sm text-gray-500 mb-2">Воздушные суда: {aircraft.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable data={aircraft} emptyMessage="Нет ВС" onRowClick={onEdit}
|
||||
columns={[
|
||||
{ key: 'registrationNumber', header: 'Регистрация', render: (a) => <span className="font-medium text-primary-500">{a.registrationNumber}</span> },
|
||||
{ key: 'aircraftType', header: 'Тип' },
|
||||
{ key: 'flightHours', header: 'Налёт (ч)', render: (a) => <span className="font-mono">{a.flightHours?.toLocaleString() || '—'}</span> },
|
||||
{ key: 'status', header: 'Статус', render: (a) => <StatusBadge status={a.status} /> },
|
||||
]} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,255 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal } from '@/components/ui';
|
||||
import FormField from '@/components/ui/FormField';
|
||||
|
||||
interface Organization {
|
||||
name: string;
|
||||
type?: string;
|
||||
address?: string;
|
||||
contact?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
interface Props { isOpen: boolean; onClose: () => void; organization: any; onSave: (data: any) => void; }
|
||||
|
||||
interface OrganizationEditModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
organization: Organization | null;
|
||||
onSave?: (updatedOrganization: Organization) => void;
|
||||
}
|
||||
|
||||
export default function OrganizationEditModal({ isOpen, onClose, organization, onSave }: OrganizationEditModalProps) {
|
||||
const [editedOrganization, setEditedOrganization] = useState<Organization | null>(null);
|
||||
export default function OrganizationEditModal({ isOpen, onClose, organization, onSave }: Props) {
|
||||
const [form, setForm] = useState({ name: '', kind: 'operator', inn: '', address: '', contact_email: '', contact_phone: '' });
|
||||
const set = (k: string, v: string) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
useEffect(() => {
|
||||
if (organization) {
|
||||
setEditedOrganization({ ...organization });
|
||||
}
|
||||
if (organization) setForm({ name: organization.name || '', kind: organization.kind || 'operator', inn: organization.inn || '', address: organization.address || '', contact_email: organization.contact_email || '', contact_phone: organization.contact_phone || '' });
|
||||
}, [organization]);
|
||||
|
||||
if (!isOpen || !organization || !editedOrganization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleChange = (field: keyof Organization, value: string) => {
|
||||
setEditedOrganization({ ...editedOrganization, [field]: value });
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!editedOrganization.name) {
|
||||
alert('Пожалуйста, укажите название организации');
|
||||
return;
|
||||
}
|
||||
|
||||
if (onSave) {
|
||||
onSave(editedOrganization);
|
||||
alert('Организация успешно обновлена');
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
width: '90%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold' }}>Редактирование организации</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
padding: '0',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Название организации <span style={{ color: 'red' }}>*</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedOrganization.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Тип организации:
|
||||
</label>
|
||||
<select
|
||||
value={editedOrganization.type || 'Авиакомпания'}
|
||||
onChange={(e) => handleChange('type', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="Авиакомпания">Авиакомпания</option>
|
||||
<option value="Аэропорт">Аэропорт</option>
|
||||
<option value="Сервисная организация">Сервисная организация</option>
|
||||
<option value="Производитель">Производитель</option>
|
||||
<option value="Другое">Другое</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Адрес:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedOrganization.address || ''}
|
||||
onChange={(e) => handleChange('address', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
placeholder="Введите адрес организации"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Контактное лицо:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedOrganization.contact || ''}
|
||||
onChange={(e) => handleChange('contact', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
placeholder="Введите ФИО контактного лица"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Email:
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={editedOrganization.email || ''}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Телефон:
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={editedOrganization.phone || ''}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
placeholder="+7 (XXX) XXX-XX-XX"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '32px' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
color: '#333',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={`Редактировать: ${organization?.name || ''}`} size="md"
|
||||
footer={<><button onClick={onClose} className="btn-secondary">Отмена</button><button onClick={() => onSave(form)} className="btn-primary">Сохранить</button></>}>
|
||||
<FormField label="Название" required><input value={form.name} onChange={e => set('name', e.target.value)} className="input-field" /></FormField>
|
||||
<FormField label="Тип"><select value={form.kind} onChange={e => set('kind', e.target.value)} className="input-field"><option value="operator">Оператор</option><option value="mro">ТОиР</option><option value="authority">Орган власти</option></select></FormField>
|
||||
<FormField label="ИНН"><input value={form.inn} onChange={e => set('inn', e.target.value)} className="input-field" /></FormField>
|
||||
<FormField label="Адрес"><input value={form.address} onChange={e => set('address', e.target.value)} className="input-field" /></FormField>
|
||||
<FormField label="Email"><input type="email" value={form.contact_email} onChange={e => set('contact_email', e.target.value)} className="input-field" /></FormField>
|
||||
<FormField label="Телефон"><input value={form.contact_phone} onChange={e => set('contact_phone', e.target.value)} className="input-field" /></FormField>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,153 +1,27 @@
|
||||
'use client';
|
||||
import { Modal } from '@/components/ui';
|
||||
|
||||
import { RegulationDocument } from '@/lib/regulations';
|
||||
|
||||
interface RegulationViewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
document: RegulationDocument | null;
|
||||
}
|
||||
|
||||
export default function RegulationViewModal({ isOpen, onClose, document }: RegulationViewModalProps) {
|
||||
if (!isOpen || !document) {
|
||||
return null;
|
||||
}
|
||||
interface Props { isOpen: boolean; onClose: () => void; regulation: any; }
|
||||
|
||||
export default function RegulationViewModal({ isOpen, onClose, regulation }: Props) {
|
||||
if (!regulation) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '32px',
|
||||
maxWidth: '900px',
|
||||
width: '90%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '24px' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||
{document.title}
|
||||
</h2>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '8px' }}>
|
||||
Источник: {document.source} | Категория: {document.category}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
Версия: {document.version} | Обновлено: {new Date(document.lastUpdated).toLocaleDateString('ru-RU')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
padding: '0',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '24px',
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', lineHeight: '1.6', whiteSpace: 'pre-wrap' }}>
|
||||
{document.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{document.sections && document.sections.length > 0 && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h3 style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||
Приложения и разделы
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{document.sections.map(section => (
|
||||
<div
|
||||
key={section.id}
|
||||
style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f9f9f9',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #e0e0e0',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '16px', fontWeight: '500', marginBottom: '8px' }}>
|
||||
{section.title}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.6' }}>
|
||||
{section.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={regulation.title || regulation.name || 'Документ'} size="lg">
|
||||
<div className="space-y-4">
|
||||
{regulation.number && <div><span className="text-sm font-bold text-gray-600">Номер:</span> <span className="text-sm">{regulation.number}</span></div>}
|
||||
{regulation.source && <div><span className="text-sm font-bold text-gray-600">Источник:</span> <span className="text-sm">{regulation.source}</span></div>}
|
||||
{regulation.type && <div><span className="text-sm font-bold text-gray-600">Тип:</span> <span className="text-sm">{regulation.type}</span></div>}
|
||||
{regulation.effectiveDate && <div><span className="text-sm font-bold text-gray-600">Дата:</span> <span className="text-sm">{regulation.effectiveDate}</span></div>}
|
||||
{regulation.description && <div className="mt-4 p-4 bg-gray-50 rounded text-sm leading-relaxed">{regulation.description}</div>}
|
||||
{regulation.sections && regulation.sections.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-bold mb-2">Разделы:</h4>
|
||||
{regulation.sections.map((s: any, i: number) => (
|
||||
<div key={i} className="p-3 border-b border-gray-100"><span className="font-bold text-primary-500 mr-2">{s.number}</span><span className="text-sm">{s.title}</span></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{document.url && (
|
||||
<div style={{ marginBottom: '24px', padding: '16px', backgroundColor: '#e3f2fd', borderRadius: '4px' }}>
|
||||
<a
|
||||
href={document.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: '#1e3a5f',
|
||||
textDecoration: 'none',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Открыть оригинальный документ на официальном сайте →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,547 +1,35 @@
|
||||
'use client';
|
||||
import { Modal, StatusBadge } from '@/components/ui';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
interface Props { isOpen: boolean; onClose: () => void; risk: any; onResolve?: () => void; }
|
||||
|
||||
interface Risk {
|
||||
id: string;
|
||||
title: string;
|
||||
level: 'Низкий' | 'Средний' | 'Высокий' | 'Критический';
|
||||
category: string;
|
||||
aircraft: string;
|
||||
status: string;
|
||||
date: string;
|
||||
description?: string;
|
||||
impact?: string;
|
||||
probability?: string;
|
||||
mitigation?: string;
|
||||
responsible?: string;
|
||||
deadline?: string;
|
||||
}
|
||||
|
||||
interface RiskDetailsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
risk: Risk | null;
|
||||
onSave?: (updatedRisk: Risk) => void;
|
||||
}
|
||||
|
||||
export default function RiskDetailsModal({ isOpen, onClose, risk, onSave }: RiskDetailsModalProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedRisk, setEditedRisk] = useState<Risk | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (risk) {
|
||||
setEditedRisk({ ...risk });
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [risk]);
|
||||
|
||||
if (!isOpen || !risk || !editedRisk) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'Критический': return '#f44336';
|
||||
case 'Высокий': return '#ff9800';
|
||||
case 'Средний': return '#ffc107';
|
||||
case 'Низкий': return '#4caf50';
|
||||
default: return '#666';
|
||||
}
|
||||
};
|
||||
|
||||
const getLevelBgColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'Критический': return '#ffebee';
|
||||
case 'Высокий': return '#fff3e0';
|
||||
case 'Средний': return '#fffde7';
|
||||
case 'Низкий': return '#e8f5e9';
|
||||
default: return '#f5f5f5';
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof Risk, value: string) => {
|
||||
if (editedRisk) {
|
||||
setEditedRisk({ ...editedRisk, [field]: value });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (editedRisk && onSave) {
|
||||
onSave(editedRisk);
|
||||
setIsEditing(false);
|
||||
alert('Риск успешно обновлён');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (risk) {
|
||||
setEditedRisk({ ...risk });
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentRisk = isEditing ? editedRisk : risk;
|
||||
export default function RiskDetailsModal({ isOpen, onClose, risk, onResolve }: Props) {
|
||||
if (!risk) return null;
|
||||
const severity = risk.severity || risk.level;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={!isEditing ? onClose : undefined}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '32px',
|
||||
maxWidth: '900px',
|
||||
width: '90%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '24px' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
{isEditing ? (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Название риска:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedRisk?.title || ''}
|
||||
onChange={(e) => handleChange('title', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '16px' }}>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', margin: 0 }}>
|
||||
{currentRisk.title}
|
||||
</h2>
|
||||
<span
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: getLevelColor(currentRisk.level),
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
{currentRisk.level}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
{isEditing ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px' }}>Категория:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedRisk?.category || ''}
|
||||
onChange={(e) => handleChange('category', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px' }}>ВС:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedRisk?.aircraft || ''}
|
||||
onChange={(e) => handleChange('aircraft', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
`Категория: ${currentRisk.category} | ВС: ${currentRisk.aircraft}`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
padding: '0',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={risk.title || 'Предупреждение'} size="lg"
|
||||
footer={<>
|
||||
<button onClick={onClose} className="btn-secondary">Закрыть</button>
|
||||
{!risk.is_resolved && onResolve && <button onClick={onResolve} className="btn-primary bg-green-500 hover:bg-green-600">✓ Пометить решённым</button>}
|
||||
</>}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4 items-center">
|
||||
<StatusBadge status={severity} />
|
||||
{risk.is_resolved && <span className="badge bg-green-100 text-green-700">✓ Решено</span>}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: getLevelBgColor(currentRisk.level),
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '24px',
|
||||
borderLeft: `4px solid ${getLevelColor(currentRisk.level)}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '16px', fontWeight: '500', marginBottom: '12px' }}>
|
||||
Общая информация
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px', color: '#666' }}>Статус:</label>
|
||||
<select
|
||||
value={editedRisk?.status || ''}
|
||||
onChange={(e) => handleChange('status', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="Требует внимания">Требует внимания</option>
|
||||
<option value="В работе">В работе</option>
|
||||
<option value="Устранён">Устранён</option>
|
||||
<option value="Отложен">Отложен</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px', color: '#666' }}>Дата выявления:</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editedRisk?.date || ''}
|
||||
onChange={(e) => handleChange('date', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px', color: '#666' }}>Уровень риска:</label>
|
||||
<select
|
||||
value={editedRisk?.level || ''}
|
||||
onChange={(e) => handleChange('level', e.target.value as Risk['level'])}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="Низкий">Низкий</option>
|
||||
<option value="Средний">Средний</option>
|
||||
<option value="Высокий">Высокий</option>
|
||||
<option value="Критический">Критический</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px', color: '#666' }}>Ответственный:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedRisk?.responsible || ''}
|
||||
onChange={(e) => handleChange('responsible', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px', color: '#666' }}>Срок устранения:</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editedRisk?.deadline || ''}
|
||||
onChange={(e) => handleChange('deadline', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', fontSize: '14px' }}>
|
||||
<div>
|
||||
<div style={{ color: '#666', marginBottom: '4px' }}>Статус:</div>
|
||||
<div style={{ fontWeight: '500' }}>{currentRisk.status}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#666', marginBottom: '4px' }}>Дата выявления:</div>
|
||||
<div style={{ fontWeight: '500' }}>{currentRisk.date}</div>
|
||||
</div>
|
||||
{currentRisk.responsible && (
|
||||
<div>
|
||||
<div style={{ color: '#666', marginBottom: '4px' }}>Ответственный:</div>
|
||||
<div style={{ fontWeight: '500' }}>{currentRisk.responsible}</div>
|
||||
</div>
|
||||
)}
|
||||
{currentRisk.deadline && (
|
||||
<div>
|
||||
<div style={{ color: '#666', marginBottom: '4px' }}>Срок устранения:</div>
|
||||
<div style={{ fontWeight: '500' }}>{currentRisk.deadline}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h3 style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '12px' }}>
|
||||
Описание риска
|
||||
</h3>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editedRisk?.description || ''}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
rows={5}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
placeholder="Введите описание риска..."
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
whiteSpace: 'pre-wrap',
|
||||
minHeight: '60px',
|
||||
}}>
|
||||
{currentRisk.description || 'Описание не указано'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '12px' }}>
|
||||
Влияние
|
||||
</h3>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editedRisk?.impact || ''}
|
||||
onChange={(e) => handleChange('impact', e.target.value)}
|
||||
rows={4}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
placeholder="Опишите влияние риска..."
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
minHeight: '60px',
|
||||
}}>
|
||||
{currentRisk.impact || 'Не указано'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '12px' }}>
|
||||
Вероятность
|
||||
</h3>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editedRisk?.probability || ''}
|
||||
onChange={(e) => handleChange('probability', e.target.value)}
|
||||
rows={4}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
placeholder="Опишите вероятность риска..."
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
minHeight: '60px',
|
||||
}}>
|
||||
{currentRisk.probability || 'Не указано'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h3 style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '12px' }}>
|
||||
Меры по снижению риска
|
||||
</h3>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editedRisk?.mitigation || ''}
|
||||
onChange={(e) => handleChange('mitigation', e.target.value)}
|
||||
rows={6}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
placeholder="Опишите меры по снижению риска..."
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
whiteSpace: 'pre-wrap',
|
||||
minHeight: '80px',
|
||||
}}>
|
||||
{currentRisk.mitigation || 'Меры не указаны'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '32px' }}>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
color: '#333',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#4caf50',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
color: '#333',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Редактировать
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div><span className="font-bold text-gray-600">ВС:</span> {risk.aircraft || risk.aircraft_id?.slice(0, 8) || '—'}</div>
|
||||
<div><span className="font-bold text-gray-600">Тип:</span> {risk.entity_type || risk.category || '—'}</div>
|
||||
<div><span className="font-bold text-gray-600">Дата:</span> {risk.date || (risk.created_at ? new Date(risk.created_at).toLocaleDateString('ru-RU') : '—')}</div>
|
||||
{risk.deadline && <div><span className="font-bold text-gray-600">Срок:</span> {risk.deadline}</div>}
|
||||
{risk.responsible && <div className="col-span-2"><span className="font-bold text-gray-600">Ответственный:</span> {risk.responsible}</div>}
|
||||
</div>
|
||||
{risk.description && <div className="p-4 bg-gray-50 rounded text-sm leading-relaxed"><h4 className="font-bold text-gray-600 mb-2">Описание</h4>{risk.description}</div>}
|
||||
{risk.message && <div className="p-4 bg-gray-50 rounded text-sm leading-relaxed">{risk.message}</div>}
|
||||
{risk.impact && <div className="p-4 bg-orange-50 rounded text-sm"><h4 className="font-bold text-orange-700 mb-1">Влияние</h4>{risk.impact}</div>}
|
||||
{risk.mitigation && <div className="p-4 bg-blue-50 rounded text-sm"><h4 className="font-bold text-blue-700 mb-1">Меры</h4><pre className="whitespace-pre-wrap font-sans">{risk.mitigation}</pre></div>}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,257 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Modal, DataTable, StatusBadge } from '@/components/ui';
|
||||
import { Aircraft } from '@/lib/api';
|
||||
|
||||
interface SearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
aircraft: Aircraft[];
|
||||
searchType: 'organization' | 'dashboard' | 'aircraft';
|
||||
onNavigate?: (path: string, data?: any) => void;
|
||||
}
|
||||
interface Props { isOpen: boolean; onClose: () => void; aircraft: Aircraft[]; searchType?: string; }
|
||||
|
||||
export default function SearchModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
aircraft,
|
||||
searchType,
|
||||
onNavigate,
|
||||
}: SearchModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchTypeFilter, setSearchTypeFilter] = useState<'registration' | 'aircraftType' | 'operator'>('registration');
|
||||
const [results, setResults] = useState<Aircraft[]>([]);
|
||||
export default function SearchModal({ isOpen, onClose, aircraft, searchType }: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery.trim() === '') {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
let filtered: Aircraft[] = [];
|
||||
|
||||
if (searchType === 'organization') {
|
||||
// Поиск только по номеру ВС или типу
|
||||
if (searchTypeFilter === 'registration') {
|
||||
filtered = aircraft.filter(a =>
|
||||
a.registrationNumber.toLowerCase().includes(query)
|
||||
);
|
||||
} else if (searchTypeFilter === 'aircraftType') {
|
||||
filtered = aircraft.filter(a =>
|
||||
a.aircraftType.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Поиск по номеру ВС, типу или оператору
|
||||
filtered = aircraft.filter(a =>
|
||||
a.registrationNumber.toLowerCase().includes(query) ||
|
||||
a.aircraftType.toLowerCase().includes(query) ||
|
||||
(a.operator && a.operator.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
setResults(filtered);
|
||||
}, [searchQuery, searchTypeFilter, aircraft, searchType]);
|
||||
|
||||
const handleResultClick = (item: Aircraft) => {
|
||||
if (searchType === 'dashboard' && onNavigate) {
|
||||
// Определяем тип поиска и переходим на соответствующую страницу
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
|
||||
if (item.registrationNumber.toLowerCase().includes(query)) {
|
||||
// Поиск по номеру ВС - переход на страницу ВС
|
||||
onNavigate(`/aircraft?highlight=${item.id}`);
|
||||
} else if (item.aircraftType.toLowerCase().includes(query)) {
|
||||
// Поиск по типу ВС - переход на страницу ВС с фильтром по типу
|
||||
onNavigate(`/aircraft?type=${encodeURIComponent(item.aircraftType)}`);
|
||||
} else if (item.operator && item.operator.toLowerCase().includes(query)) {
|
||||
// Поиск по оператору - переход на страницу организаций
|
||||
onNavigate(`/organizations?operator=${encodeURIComponent(item.operator)}`);
|
||||
} else {
|
||||
// По умолчанию - переход на страницу ВС
|
||||
onNavigate(`/aircraft?highlight=${item.id}`);
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return aircraft;
|
||||
const q = query.toLowerCase();
|
||||
return aircraft.filter(a =>
|
||||
a.registrationNumber?.toLowerCase().includes(q) ||
|
||||
a.aircraftType?.toLowerCase().includes(q) ||
|
||||
a.operator?.toLowerCase().includes(q) ||
|
||||
a.serialNumber?.toLowerCase().includes(q)
|
||||
);
|
||||
}, [aircraft, query]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
width: '90%',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold' }}>
|
||||
{searchType === 'organization' || searchType === 'aircraft' ? 'Поиск воздушных судов' : 'Поиск'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
padding: '0',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{searchType === 'organization' && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Тип поиска:
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => setSearchTypeFilter('registration')}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: searchTypeFilter === 'registration' ? '#1e3a5f' : '#e0e0e0',
|
||||
color: searchTypeFilter === 'registration' ? 'white' : '#333',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
По номеру ВС
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSearchTypeFilter('aircraftType')}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: searchTypeFilter === 'aircraftType' ? '#1e3a5f' : '#e0e0e0',
|
||||
color: searchTypeFilter === 'aircraftType' ? 'white' : '#333',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
По типу ВС
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={
|
||||
searchType === 'organization'
|
||||
? searchTypeFilter === 'registration'
|
||||
? 'Введите номер воздушного судна...'
|
||||
: 'Введите тип воздушного судна...'
|
||||
: 'Поиск по номеру ВС, типу или компании...'
|
||||
}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{results.length > 0 && (
|
||||
<div style={{ marginTop: '24px' }}>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '12px' }}>
|
||||
Найдено: {results.length}
|
||||
</div>
|
||||
<div style={{ maxHeight: '400px', overflowY: 'auto' }}>
|
||||
{results.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleResultClick(item)}
|
||||
style={{
|
||||
padding: '16px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '8px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: '#f9f9f9',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f0f0f0';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f9f9f9';
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
{item.registrationNumber}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
Тип: {item.aircraftType}
|
||||
</div>
|
||||
{item.operator && item.operator !== 'Не указан' && (
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
Оператор: {item.operator}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '12px', color: '#999', marginTop: '8px' }}>
|
||||
Статус: {item.status} | Налет: {item.flightHours} ч
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchQuery.trim() !== '' && results.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
||||
Ничего не найдено
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchQuery.trim() === '' && (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
||||
Введите запрос для поиска
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={searchType === 'organization' ? 'Поиск ВС организации' : 'Поиск воздушных судов'} size="lg">
|
||||
<input value={query} onChange={e => setQuery(e.target.value)} placeholder="Поиск по регистрации, типу, оператору..."
|
||||
className="input-field mb-4" autoFocus />
|
||||
<div className="text-sm text-gray-500 mb-3">Найдено: {filtered.length}</div>
|
||||
<DataTable data={filtered.slice(0, 50)} emptyMessage="Ничего не найдено"
|
||||
columns={[
|
||||
{ key: 'registrationNumber', header: 'Регистрация', render: (a) => <span className="font-medium text-primary-500">{a.registrationNumber}</span> },
|
||||
{ key: 'aircraftType', header: 'Тип' },
|
||||
{ key: 'operator', header: 'Оператор' },
|
||||
{ key: 'status', header: 'Статус', render: (a) => <StatusBadge status={a.status} /> },
|
||||
]} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,208 +1,31 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Modal } from '@/components/ui';
|
||||
import FormField from '@/components/ui/FormField';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useUserSettings } from '@/hooks/useLocalStorage';
|
||||
import { defaultPreferences, type UserPreferences, type SettingsTabId } from './settings/types';
|
||||
import SettingsTabs from './settings/SettingsTabs';
|
||||
import GeneralSettings from './settings/GeneralSettings';
|
||||
import NotificationSettings from './settings/NotificationSettings';
|
||||
import ExportSettings from './settings/ExportSettings';
|
||||
import DisplaySettings from './settings/DisplaySettings';
|
||||
import AIAccessSettings from './settings/AIAccessSettings';
|
||||
import AdvancedSettings from './settings/AdvancedSettings';
|
||||
interface Props { isOpen: boolean; onClose: () => void; }
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
const { setTheme, setLanguage } = useUserSettings();
|
||||
const [preferences, setPreferences] = useState<UserPreferences>(defaultPreferences);
|
||||
const [activeTab, setActiveTab] = useState<SettingsTabId>('general');
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleChange = (
|
||||
updater: Partial<UserPreferences> | ((prev: UserPreferences) => UserPreferences)
|
||||
) => {
|
||||
setPreferences((prev) =>
|
||||
typeof updater === 'function' ? updater(prev) : { ...prev, ...updater }
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const saved = localStorage.getItem('userPreferences');
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
setPreferences({ ...defaultPreferences, ...parsed });
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки настроек:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSave = () => {
|
||||
localStorage.setItem('userPreferences', JSON.stringify(preferences));
|
||||
setTheme(preferences.theme);
|
||||
setLanguage(preferences.language);
|
||||
window.dispatchEvent(new CustomEvent('settingsChanged', { detail: preferences }));
|
||||
alert('Настройки сохранены');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (confirm('Вы уверены, что хотите сбросить все настройки к значениям по умолчанию?')) {
|
||||
setPreferences(defaultPreferences);
|
||||
localStorage.removeItem('userPreferences');
|
||||
setTheme('light');
|
||||
setLanguage('ru');
|
||||
alert('Настройки сброшены');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
export default function SettingsModal({ isOpen, onClose }: Props) {
|
||||
const [theme, setTheme] = useState('light');
|
||||
const [lang, setLang] = useState('ru');
|
||||
const [notifications, setNotifications] = useState(true);
|
||||
const [refreshInterval, setRefreshInterval] = useState('60');
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal={true}
|
||||
aria-label="Окно настроек"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
padding: '20px',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
width: '100%',
|
||||
maxWidth: '900px',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
outline: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
backgroundColor: '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold', margin: 0 }}>Настройки</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
<SettingsTabs activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
<div style={{ flex: 1, padding: '24px', overflowY: 'auto' }}>
|
||||
{activeTab === 'general' && (
|
||||
<GeneralSettings
|
||||
preferences={preferences}
|
||||
onChange={handleChange}
|
||||
onThemeChange={setTheme}
|
||||
onLanguageChange={setLanguage}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'notifications' && (
|
||||
<NotificationSettings preferences={preferences} onChange={handleChange} />
|
||||
)}
|
||||
{activeTab === 'export' && (
|
||||
<ExportSettings preferences={preferences} onChange={handleChange} />
|
||||
)}
|
||||
{activeTab === 'display' && (
|
||||
<DisplaySettings preferences={preferences} onChange={handleChange} />
|
||||
)}
|
||||
{activeTab === 'ai-access' && <AIAccessSettings />}
|
||||
{activeTab === 'advanced' && (
|
||||
<AdvancedSettings
|
||||
preferences={preferences}
|
||||
onChange={handleChange}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 24px',
|
||||
borderTop: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '12px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#666',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Настройки" size="md"
|
||||
footer={<><button onClick={onClose} className="btn-secondary">Закрыть</button><button onClick={onClose} className="btn-primary">Сохранить</button></>}>
|
||||
<FormField label="Тема">
|
||||
<select value={theme} onChange={e => setTheme(e.target.value)} className="input-field"><option value="light">Светлая</option><option value="dark">Тёмная</option><option value="auto">Системная</option></select>
|
||||
</FormField>
|
||||
<FormField label="Язык">
|
||||
<select value={lang} onChange={e => setLang(e.target.value)} className="input-field"><option value="ru">Русский</option><option value="en">English</option></select>
|
||||
</FormField>
|
||||
<FormField label="Уведомления">
|
||||
<label className="flex items-center gap-2 cursor-pointer"><input type="checkbox" checked={notifications} onChange={e => setNotifications(e.target.checked)} className="w-4 h-4" /><span className="text-sm">Включить push-уведомления</span></label>
|
||||
</FormField>
|
||||
<FormField label="Интервал обновления">
|
||||
<select value={refreshInterval} onChange={e => setRefreshInterval(e.target.value)} className="input-field"><option value="30">30 сек</option><option value="60">1 мин</option><option value="300">5 мин</option><option value="0">Выключить</option></select>
|
||||
</FormField>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,10 +1,20 @@
|
||||
/**
|
||||
* Sidebar navigation — RBAC-aware, Tailwind CSS.
|
||||
* Разработчик: АО «REFLY»
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useDarkMode } from '@/hooks/useDarkMode';
|
||||
import Link from 'next/link'
|
||||
import GlobalSearch from './GlobalSearch';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import NotificationBell from './NotificationBell';
|
||||
import { useAuth, UserRole } from '@/lib/auth-context';
|
||||
|
||||
const menuItems = [
|
||||
interface MenuItem { name: string; path: string; icon: string; roles?: UserRole[]; }
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{ name: 'Дашборд', path: '/dashboard', icon: '📊' },
|
||||
{ name: 'Организации', path: '/organizations', icon: '🏢' },
|
||||
{ name: 'ВС и типы', path: '/aircraft', icon: '✈️' },
|
||||
@ -12,147 +22,99 @@ const menuItems = [
|
||||
{ name: 'Чек-листы', path: '/checklists', icon: '✅' },
|
||||
{ name: 'Аудиты', path: '/audits', icon: '🔍' },
|
||||
{ name: 'Риски', path: '/risks', icon: '⚠️' },
|
||||
{ name: 'Пользователи', path: '/users', icon: '👥' },
|
||||
{ name: 'Пользователи', path: '/users', icon: '👥', roles: ['admin', 'authority_inspector'] },
|
||||
{ name: 'Лётная годность', path: '/airworthiness', icon: '📜' },
|
||||
{ name: '📅 Календарь ТО', path: '/calendar', icon: '📅' },
|
||||
{ name: '🔧 Контроль ЛГ', path: '/airworthiness-core', icon: '🔧' },
|
||||
{ name: 'Тех. обслуживание', path: '/maintenance', icon: '🔧' },
|
||||
{ name: 'Дефекты', path: '/defects', icon: '🛠️' },
|
||||
{ name: 'Модификации', path: '/modifications', icon: '⚙️' },
|
||||
{ name: 'Документы', path: '/documents', icon: '📄' },
|
||||
{ name: 'Inbox', path: '/inbox', icon: '📥' },
|
||||
{ name: 'Нормативные документы', path: '/regulations', icon: '📚' },
|
||||
{ name: 'Мониторинг', path: '/monitoring', icon: '📈' },
|
||||
{ name: 'История изменений', path: '/audit-history', icon: '📝' },
|
||||
{ name: 'Задачи Jira', path: '/jira-tasks', icon: '🎯' },
|
||||
{ name: 'API Документация', path: '/api-docs', icon: '📖' },
|
||||
{ name: 'Мониторинг', path: '/monitoring', icon: '📈', roles: ['admin', 'authority_inspector'] },
|
||||
{ name: 'История изменений', path: '/audit-history', icon: '📝', roles: ['admin', 'authority_inspector'] },
|
||||
{ name: 'API Документация', path: '/api-docs', icon: '📖', roles: ['admin'] },
|
||||
{ name: '📊 Аналитика', path: '/analytics', icon: '📊', roles: ['admin', 'authority_inspector'] },
|
||||
{ name: '🎓 Персонал ПЛГ', path: '/personnel-plg', icon: '🎓' },
|
||||
{ name: '👤 Профиль', path: '/profile', icon: '👤' },
|
||||
{ name: '📚 Справка', path: '/help', icon: '📚' },
|
||||
{ name: '⚙️ Настройки', path: '/settings', icon: '⚙️' },
|
||||
{ name: '🏛️ ФГИС РЭВС', path: '/fgis-revs', icon: '🏛️', roles: ['admin'] },
|
||||
{ name: '🏛️ Панель ФАВТ', path: '/regulator', icon: '🏛️', roles: ['admin', 'favt_inspector'] },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { user, logout, hasRole } = useAuth();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const { isDark, toggle: toggleDark } = useDarkMode();
|
||||
|
||||
const visibleItems = menuItems.filter(item => !item.roles || item.roles.some(r => hasRole(r)));
|
||||
|
||||
return (
|
||||
<aside
|
||||
role="complementary"
|
||||
aria-label="Боковая панель навигации"
|
||||
style={{
|
||||
width: '280px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '24px', borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px',
|
||||
}}
|
||||
>
|
||||
✈️
|
||||
</div>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold', color: 'white', letterSpacing: '2px' }}>
|
||||
REFLY
|
||||
</div>
|
||||
<>
|
||||
{/* Mobile hamburger */}
|
||||
<button onClick={() => setMobileOpen(true)}
|
||||
className="lg:hidden fixed top-4 left-4 z-50 w-10 h-10 bg-primary-500 text-white rounded-lg flex items-center justify-center text-xl shadow-lg"
|
||||
aria-label="Открыть меню">☰</button>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{mobileOpen && <div className="lg:hidden fixed inset-0 bg-black/50 z-40" onClick={() => setMobileOpen(false)} />}
|
||||
|
||||
<aside role="complementary" aria-label="Навигация"
|
||||
className={`w-[280px] bg-primary-500 text-white h-screen flex flex-col fixed left-0 top-0 z-50
|
||||
transition-transform duration-300 lg:translate-x-0
|
||||
${mobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}`}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-white/10">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center text-xl">✈️</div>
|
||||
<div className="text-2xl font-bold tracking-wider">REFLY</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.8, marginBottom: '4px' }}>
|
||||
КОНТРОЛЬ ЛЁТНОЙ ГОДНОСТИ
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', opacity: 0.8 }}>АСУ ТК</div>
|
||||
<div className="text-xs opacity-80">КОНТРОЛЬ ЛЁТНОЙ ГОДНОСТИ</div>
|
||||
<div className="text-xs opacity-80">АСУ ТК</div>
|
||||
{user && (
|
||||
<div className="mt-3 p-2 bg-white/[0.08] rounded-md">
|
||||
<div className="text-sm font-bold truncate">{user.display_name}</div>
|
||||
<div className="text-xs opacity-70 truncate">{user.role} · {user.organization_name || '—'}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="Основная навигация"
|
||||
style={{ flex: 1, padding: '16px 0' }}
|
||||
>
|
||||
{menuItems.map((item) => {
|
||||
const isActive = pathname === item.path;
|
||||
<div className="p-3"><GlobalSearch /></div>
|
||||
{/* Navigation */}
|
||||
<nav role="navigation" aria-label="Основная навигация" className="flex-1 py-4 overflow-y-auto">
|
||||
{visibleItems.map((item) => {
|
||||
const active = pathname === item.path;
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
href={item.path}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
aria-label={item.name}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '12px 24px',
|
||||
color: 'white',
|
||||
textDecoration: 'none',
|
||||
backgroundColor: isActive ? 'rgba(255,255,255,0.15)' : 'transparent',
|
||||
borderLeft: isActive ? '3px solid #4a90e2' : '3px solid transparent',
|
||||
outline: 'none',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.1)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{ marginRight: '12px', fontSize: '18px' }}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span>{item.name}</span>
|
||||
<Link key={item.path} href={item.path} aria-current={active ? 'page' : undefined}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={`flex items-center px-6 py-3 text-white no-underline transition-colors
|
||||
${active ? 'bg-white/[0.15] border-l-[3px] border-accent-blue' : 'border-l-[3px] border-transparent hover:bg-white/[0.07]'}`}>
|
||||
<span aria-hidden="true" className="mr-3 text-lg">{item.icon}</span>
|
||||
<span className="text-sm">{item.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div style={{ padding: '16px 24px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'center' }}>
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-white/10">
|
||||
<div className="mb-3 flex justify-center gap-2">
|
||||
<NotificationBell />
|
||||
<button onClick={toggleDark} className="w-8 h-8 rounded bg-white/10 flex items-center justify-center text-sm hover:bg-white/20 transition-colors" title="Тема">
|
||||
{isDark ? '☀️' : '🌙'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Выйти из системы"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
outline: 'none',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.1)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
// Здесь будет логика выхода
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true" style={{ marginRight: '8px' }}>🚪</span>
|
||||
Выйти
|
||||
<button aria-label="Выйти" onClick={logout}
|
||||
className="w-full py-3 bg-transparent border border-white/20 text-white rounded cursor-pointer
|
||||
flex items-center justify-center hover:bg-white/10 transition-colors">
|
||||
<span aria-hidden="true" className="mr-2">🚪</span>Выйти
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,231 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal, StatusBadge } from '@/components/ui';
|
||||
import FormField from '@/components/ui/FormField';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
status: string;
|
||||
lastLogin: string;
|
||||
}
|
||||
interface Props { isOpen: boolean; onClose: () => void; user: any; onSave?: (data: any) => void; }
|
||||
|
||||
interface UserEditModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: User | null;
|
||||
onSave?: (updatedUser: User) => void;
|
||||
}
|
||||
const roles = ['admin', 'authority_inspector', 'operator_manager', 'operator_user', 'mro_manager', 'mro_user'];
|
||||
const RL: Record<string,string> = { admin:'Администратор', authority_inspector:'Инспектор', operator_manager:'Менеджер оператора', operator_user:'Оператор', mro_manager:'Менеджер ТОиР', mro_user:'Специалист ТОиР' };
|
||||
|
||||
export default function UserEditModal({ isOpen, onClose, user, onSave }: UserEditModalProps) {
|
||||
const [editedUser, setEditedUser] = useState<User | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setEditedUser({ ...user });
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
if (!isOpen || !user || !editedUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleChange = (field: keyof User, value: string) => {
|
||||
setEditedUser({ ...editedUser, [field]: value });
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (onSave) {
|
||||
onSave(editedUser);
|
||||
alert('Пользователь успешно обновлён');
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
export default function UserEditModal({ isOpen, onClose, user, onSave }: Props) {
|
||||
const [form, setForm] = useState({ display_name: '', email: '', role: 'operator_user' });
|
||||
const set = (k: string, v: string) => setForm(f => ({ ...f, [k]: v }));
|
||||
useEffect(() => { if (user) setForm({ display_name: user.display_name || '', email: user.email || '', role: user.role || 'operator_user' }); }, [user]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
width: '90%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<h2 style={{ fontSize: '24px', fontWeight: 'bold' }}>Редактирование пользователя</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
padding: '0',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Имя:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedUser.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Email:
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={editedUser.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Роль:
|
||||
</label>
|
||||
<select
|
||||
value={editedUser.role}
|
||||
onChange={(e) => handleChange('role', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="Администратор">Администратор</option>
|
||||
<option value="Инспектор">Инспектор</option>
|
||||
<option value="Оператор">Оператор</option>
|
||||
<option value="Пользователь">Пользователь</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Статус:
|
||||
</label>
|
||||
<select
|
||||
value={editedUser.status}
|
||||
onChange={(e) => handleChange('status', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="Активен">Активен</option>
|
||||
<option value="Неактивен">Неактивен</option>
|
||||
<option value="Заблокирован">Заблокирован</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Последний вход:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedUser.lastLogin}
|
||||
onChange={(e) => handleChange('lastLogin', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
}}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '32px' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
color: '#333',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#1e3a5f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={user?.display_name || 'Пользователь'} size="md"
|
||||
footer={<><button onClick={onClose} className="btn-secondary">Отмена</button>{onSave && <button onClick={() => onSave(form)} className="btn-primary">Сохранить</button>}</>}>
|
||||
<FormField label="Имя" required><input value={form.display_name} onChange={e => set('display_name', e.target.value)} className="input-field" /></FormField>
|
||||
<FormField label="Email"><input type="email" value={form.email} onChange={e => set('email', e.target.value)} className="input-field" /></FormField>
|
||||
<FormField label="Роль"><select value={form.role} onChange={e => set('role', e.target.value)} className="input-field">{roles.map(r => <option key={r} value={r}>{RL[r]}</option>)}</select></FormField>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user