diff --git a/app/documents/page.tsx b/app/documents/page.tsx index b7aa046..13d33f9 100644 --- a/app/documents/page.tsx +++ b/app/documents/page.tsx @@ -9,6 +9,7 @@ export default function DocumentsPage() { { title: 'Сертификаты', desc: 'Сертификаты ЛГ', href: '/airworthiness', icon: '📜' }, { title: 'Нормативные документы', desc: 'ФАП, ICAO, EASA', href: '/regulations', icon: '📚' }, { title: 'Чек-листы', desc: 'Шаблоны проверок', href: '/checklists', icon: '✅' }, + { title: 'Шаблоны документов', desc: 'Заявки, акты, письма, формы', href: '/templates', icon: '📋' }, ]; return ( diff --git a/app/templates/page.tsx b/app/templates/page.tsx new file mode 100644 index 0000000..9da1d44 --- /dev/null +++ b/app/templates/page.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { useState } from 'react'; +import { PageLayout, FilterBar, EmptyState } from '@/components/ui'; +import { documentTemplatesApi } from '@/lib/api/api-client'; +import useSWR from 'swr'; +import DocumentPreviewModal from '@/components/DocumentPreviewModal'; + +const CATEGORIES = [ + { value: undefined, label: 'Все' }, + { value: 'application', label: '📝 Заявки' }, + { value: 'certificate', label: '📜 Сертификаты' }, + { value: 'act', label: '📋 Акты' }, + { value: 'letter', label: '✉️ Письма' }, + { value: 'form', label: '📄 Формы' }, + { value: 'report', label: '📊 Отчёты' }, + { value: 'order', label: '📌 Приказы' }, +]; + +const STANDARDS = [ + { value: undefined, label: 'Все' }, + { value: 'RF', label: '🇷🇺 РФ' }, + { value: 'ICAO', label: '🌐 ИКАО' }, + { value: 'EASA', label: '🇪🇺 EASA' }, + { value: 'FAA', label: '🇺🇸 FAA' }, + { value: 'INTERNAL', label: '🏢 Внутренние' }, +]; + +const CATEGORY_ICONS: Record = { + application: '📝', + certificate: '📜', + act: '📋', + letter: '✉️', + form: '📄', + report: '📊', + order: '📌', +}; + +export default function TemplatesPage() { + const [category, setCategory] = useState(); + const [standard, setStandard] = useState(); + const [preview, setPreview] = useState(null); + + const params: Record = {}; + if (category) params.category = category; + if (standard) params.standard = standard; + + const { data, isLoading, mutate } = useSWR( + ['doc-templates', category, standard], + () => documentTemplatesApi.list(params) + ); + + const templates = data?.items || []; + + return ( + +
+ + +
+ + {!isLoading && templates.length > 0 ? ( +
+ {templates.map((t: any) => ( +
setPreview(t)} + className="group cursor-pointer rounded-lg border border-gray-200 bg-white p-5 shadow-sm transition-all hover:border-primary-300 hover:shadow-md" + > +
+ {CATEGORY_ICONS[t.category] || '📄'} +
+
{t.name}
+
+ {t.code} · v{t.version} +
+ {t.description && ( +
{t.description}
+ )} +
+ {t.standard} + {t.category} +
+
+
+
+ ))} +
+ ) : !isLoading ? ( + + ) : null} + + {preview && ( + setPreview(null)} + onSaved={() => mutate()} + /> + )} +
+ ); +} diff --git a/backend/alembic/versions/0002_document_templates.py b/backend/alembic/versions/0002_document_templates.py new file mode 100644 index 0000000..07f64bd --- /dev/null +++ b/backend/alembic/versions/0002_document_templates.py @@ -0,0 +1,39 @@ +"""Document templates table + +Revision ID: 0002 +Revises: 0001 +Create Date: 2026-02-16 + +""" +from alembic import op +import sqlalchemy as sa + +revision = '0002' +down_revision = '0001' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'document_templates', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('code', sa.String(32), nullable=False), + sa.Column('name', sa.String(255), nullable=False), + sa.Column('category', sa.String(64), nullable=False), + sa.Column('standard', sa.String(32), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('html_content', sa.Text(), nullable=False), + sa.Column('version', sa.Integer(), nullable=False, server_default='1'), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index('idx_document_templates_code', 'document_templates', ['code'], unique=True) + op.create_index('idx_document_templates_category', 'document_templates', ['category']) + op.create_index('idx_document_templates_standard', 'document_templates', ['standard']) + + +def downgrade() -> None: + op.drop_table('document_templates') diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py index c532d11..0709620 100644 --- a/backend/app/api/routes/__init__.py +++ b/backend/app/api/routes/__init__.py @@ -17,6 +17,7 @@ from .inbox import router as inbox_router from .tasks import router as tasks_router from .audit import router as audit_router from .ai import router as ai_router +from .document_templates import router as document_templates_router __all__ = [ "health_router", @@ -38,5 +39,6 @@ __all__ = [ "tasks_router", "audit_router", "ai_router", + "document_templates_router", ] diff --git a/backend/app/api/routes/document_templates.py b/backend/app/api/routes/document_templates.py new file mode 100644 index 0000000..42decd7 --- /dev/null +++ b/backend/app/api/routes/document_templates.py @@ -0,0 +1,78 @@ +"""Шаблоны документов — CRUD + предпросмотр.""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from pydantic import BaseModel + +from app.api.deps import get_current_user, require_roles, get_db +from app.api.helpers import paginate_query +from app.models.document_template import DocumentTemplate + +router = APIRouter(tags=["document_templates"]) + + +class TemplateOut(BaseModel): + id: str + code: str + name: str + category: str + standard: str + description: str | None + html_content: str + version: int + + class Config: + from_attributes = True + + +class TemplateUpdate(BaseModel): + name: str | None = None + description: str | None = None + html_content: str | None = None + + +@router.get("/document-templates") +def list_templates( + category: str | None = None, + standard: str | None = 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), +): + q = db.query(DocumentTemplate).filter(DocumentTemplate.is_active == True) + if category: + q = q.filter(DocumentTemplate.category == category) + if standard: + q = q.filter(DocumentTemplate.standard == standard) + q = q.order_by(DocumentTemplate.sort_order, DocumentTemplate.name) + return paginate_query(q, page, per_page) + + +@router.get("/document-templates/{template_id}", response_model=TemplateOut) +def get_template(template_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + t = db.query(DocumentTemplate).filter(DocumentTemplate.id == template_id).first() + if not t: + raise HTTPException(404, "Template not found") + return t + + +@router.patch("/document-templates/{template_id}", response_model=TemplateOut, + dependencies=[Depends(require_roles("admin", "authority_inspector"))]) +def update_template( + template_id: str, + payload: TemplateUpdate, + db: Session = Depends(get_db), + user=Depends(get_current_user), +): + t = db.query(DocumentTemplate).filter(DocumentTemplate.id == template_id).first() + if not t: + raise HTTPException(404, "Template not found") + if payload.name is not None: + t.name = payload.name + if payload.description is not None: + t.description = payload.description + if payload.html_content is not None: + t.html_content = payload.html_content + db.commit() + db.refresh(t) + return t diff --git a/backend/app/demo/seed.py b/backend/app/demo/seed.py index d57e76e..6ea79fd 100644 --- a/backend/app/demo/seed.py +++ b/backend/app/demo/seed.py @@ -224,6 +224,8 @@ def seed(): from app.demo.seed_checklists import seed_checklists seed_checklists() + from app.demo.seed_document_templates import seed_document_templates + seed_document_templates() except Exception as e: db.rollback() diff --git a/backend/app/demo/seed_document_templates.py b/backend/app/demo/seed_document_templates.py new file mode 100644 index 0000000..27c71fa --- /dev/null +++ b/backend/app/demo/seed_document_templates.py @@ -0,0 +1,570 @@ +""" +Seed 25 шаблонов документов авиационной отрасли в стиле REFLY. +""" +import logging +from app.db.session import SessionLocal +from app.models.document_template import DocumentTemplate + +logger = logging.getLogger(__name__) + +_STYLE = """ + +""" + +_HEADER = """ +
+ +
АСУ ТК КЛГ — Контроль лётной годности
+
АО «REFLY» · г. Калининград · тел. +7 (4012) ХХХ-ХХ-ХХ · info@refly.ru
+
+""" + +_FOOTER = """ + +""" + + +def _wrap(title: str, subtitle: str | None, content: str) -> str: + sub = f'
{subtitle}
' if subtitle else "" + return ( + _STYLE + + '
' + + _HEADER + + f'
{title}
' + + sub + + '
[___________] от [дата]
' + + content + + _FOOTER + + "
" + ) + + +def _templates_data() -> list[dict]: + return [ + { + "code": "APP-246", + "name": "Заявка на получение сертификата эксплуатанта (ФАП-246)", + "category": "application", + "standard": "RF", + "description": "Заявка на получение сертификата эксплуатанта ВК РФ", + "sort_order": 1, + "html_content": _wrap( + "Заявка на получение сертификата эксплуатанта", + "ФАП-246", + """ +

Наименование организации: [___________]

+

ИНН: [___] ОГРН: [___]

+

Адрес: [___________]

+

Типы ВС: [___________]

+

Виды авиаработ: [___________]

+

Ответственное лицо: [___________]

+
Подпись руководителя
ФИО
М.П.
+ """, + ), + }, + { + "code": "APP-145", + "name": "Заявка на одобрение организации ТОиР (ФАП-145)", + "category": "application", + "standard": "RF", + "description": "Заявка на одобрение организации по техническому обслуживанию", + "sort_order": 2, + "html_content": _wrap( + "Заявка на одобрение организации ТОиР", + "ФАП-145", + """ +

Наименование: [___________]

+

Область одобрения: [___________]

+

Типы ВС: [___________]

+

Категории работ: [___________]

+
Подпись
+ """, + ), + }, + { + "code": "APP-SLG", + "name": "Заявка на выдачу/продление СЛГ", + "category": "application", + "standard": "RF", + "description": "Заявка на выдачу или продление сертификата лётной годности", + "sort_order": 3, + "html_content": _wrap( + "Заявка на выдачу/продление сертификата лётной годности", + None, + """ +

Рег. номер ВС: [___] Тип: [___]

+

Серийный №: [___] Собственник: [___________]

+

Дата последнего ТО: [дата]

+
Подпись
+ """, + ), + }, + { + "code": "APP-MOD", + "name": "Заявка на одобрение модификации ВС", + "category": "application", + "standard": "RF", + "description": "Заявка на одобрение модификации воздушного судна", + "sort_order": 4, + "html_content": _wrap( + "Заявка на одобрение модификации ВС", + None, + """ +

Описание модификации: [___________]

+

STC: [___] Основание: [___]

+

ВС (рег. №): [___]

+
Подпись
+ """, + ), + }, + { + "code": "CERT-SLG", + "name": "Сертификат лётной годности (СЛГ) — бланк", + "category": "certificate", + "standard": "RF", + "description": "Бланк сертификата лётной годности", + "sort_order": 5, + "html_content": _wrap( + "Сертификат лётной годности", + None, + """ +

Государство регистрации: [___] Рег. знак: [___]

+

Тип: [___] Серийный №: [___]

+

Категория: [___]

+

Дата выдачи: [дата] Срок действия: [дата]

+

Орган выдачи: [___________]

+
Печать
+ """, + ), + }, + { + "code": "CERT-CRS", + "name": "Свидетельство о допуске к эксплуатации (CRS)", + "category": "certificate", + "standard": "RF", + "description": "ФАП-145 / EASA Part-145.A.50 — Certificate of Release to Service", + "sort_order": 6, + "html_content": _wrap( + "Свидетельство о допуске к эксплуатации", + "ФАП-145 / EASA Part-145.A.50", + """ +

Организация ТОиР: [___________]

+

ВС: [рег. №] Тип: [___]

+

Выполненные работы: [___________]

+

Наряд №: [___] Дата: [дата]

+

Подпись уполномоченного лица: _____________________

+
М.П.
+ """, + ), + }, + { + "code": "CERT-EASA1", + "name": "EASA Form 1 — Authorized Release Certificate", + "category": "certificate", + "standard": "EASA", + "description": "Форма 1 EASA — сертификат авторизованного выпуска", + "sort_order": 7, + "html_content": _wrap( + "EASA Form 1 — Authorized Release Certificate", + "EASA", + """ + + + +
Part NumberDescriptionQuantitySerial NoStatusRemarks
[___][___][___][___][___][___]
+

Authorized Signature: [___________]

+ """, + ), + }, + { + "code": "CERT-8130", + "name": "FAA Form 8130-3 — Airworthiness Approval Tag", + "category": "certificate", + "standard": "FAA", + "description": "Форма 8130-3 FAA — ярлык одобрения лётной годности", + "sort_order": 8, + "html_content": _wrap( + "FAA Form 8130-3 — Airworthiness Approval Tag", + "FAA", + """ +

Part No: [___] Serial No: [___]

+

Description: [___________]

+

Status: [___] Remarks: [___]

+

Approved by: [___________]

+ """, + ), + }, + { + "code": "ACT-INSP", + "name": "Акт инспекционной проверки ВС", + "category": "act", + "standard": "RF", + "description": "Акт инспекционной проверки воздушного судна", + "sort_order": 9, + "html_content": _wrap( + "Акт инспекционной проверки ВС", + None, + """ +

Дата: [дата] Место: [___________]

+

Борт: [рег. №] Инспектор: [___________]

+

Результат: [годен / не годен]

+

Замечания: [___________]

+
Инспектор
Представитель организации
+ """, + ), + }, + { + "code": "ACT-AUDIT", + "name": "Акт аудита организации", + "category": "act", + "standard": "RF", + "description": "Акт проведения аудита организации", + "sort_order": 10, + "html_content": _wrap( + "Акт аудита организации", + None, + """ +

Организация: [___________]

+

Дата: [дата] Аудитор: [___________]

+

Область проверки: [___________]

+

Несоответствия (level 1/2): [___________]

+

Корректирующие действия, срок: [___________]

+
Аудитор
Представитель организации
+ """, + ), + }, + { + "code": "ACT-DEFECT", + "name": "Акт дефектации", + "category": "act", + "standard": "RF", + "description": "Акт дефектации компонента/ВС", + "sort_order": 11, + "html_content": _wrap( + "Акт дефектации", + None, + """ +

ВС: [рег. №] ATA chapter: [___]

+

Описание дефекта: [___________]

+

MEL категория: [___] Решение: [___________]

+
Подпись
+ """, + ), + }, + { + "code": "ACT-ACCEPT", + "name": "Акт приёмки ВС после ТО", + "category": "act", + "standard": "RF", + "description": "Акт приёмки воздушного судна после технического обслуживания", + "sort_order": 12, + "html_content": _wrap( + "Акт приёмки ВС после ТО", + None, + """ +

Наряд №: [___] ВС: [рег. №]

+

Выполненные работы: [___________]

+

Замечания: [___________]

+

CRS: [___]

+
Заказчик
Исполнитель
+ """, + ), + }, + { + "code": "LTR-FAVT", + "name": "Сопроводительное письмо в ФАВТ (Росавиация)", + "category": "letter", + "standard": "RF", + "description": "Сопроводительное письмо в Федеральное агентство воздушного транспорта", + "sort_order": 13, + "html_content": _wrap( + "Сопроводительное письмо", + "в ФАВТ (Росавиация)", + """ +

Исх. № [___] от [дата]

+

Кому: [___________]

+

От кого: [___________]

+

Тема: [___________]

+

Текст: [___________]

+

Приложения: [___________]

+
Подпись
+ """, + ), + }, + { + "code": "LTR-MRO", + "name": "Письмо-заказ в организацию ТОиР", + "category": "letter", + "standard": "RF", + "description": "Письмо-заказ на выполнение работ организацией ТОиР", + "sort_order": 14, + "html_content": _wrap( + "Письмо-заказ в организацию ТОиР", + None, + """ +

Наименование ТОиР: [___________]

+

Запрос на выполнение работ: [___________]

+

ВС: [рег. №] Сроки: [___________]

+

Контакт: [___________]

+
Подпись
+ """, + ), + }, + { + "code": "LTR-SB", + "name": "Уведомление о выполнении сервисного бюллетеня (SB)", + "category": "letter", + "standard": "RF", + "description": "Уведомление о выполнении SB", + "sort_order": 15, + "html_content": _wrap( + "Уведомление о выполнении SB", + None, + """ +

№ SB: [___] ВС: [рег. №]

+

Дата выполнения: [дата]

+

Ссылка на наряд: [___]

+
Подпись
+ """, + ), + }, + { + "code": "LTR-AD", + "name": "Отчёт о выполнении директивы ЛГ (AD/ДЛГ)", + "category": "letter", + "standard": "RF", + "description": "Отчёт о выполнении воздушной директивы", + "sort_order": 16, + "html_content": _wrap( + "Отчёт о выполнении директивы ЛГ", + "AD / ДЛГ", + """ +

№ AD: [___] ВС: [рег. №]

+

Метод выполнения: [___________]

+

Дата: [дата]

+

Подтверждение: [___________]

+
Подпись
+ """, + ), + }, + { + "code": "FORM-TECHLOG", + "name": "Technical Log / Бортовой журнал", + "category": "form", + "standard": "ICAO", + "description": "Бортовой журнал (ICAO)", + "sort_order": 17, + "html_content": _wrap( + "Technical Log / Бортовой журнал", + "ICAO", + """ + + + +
Flight NoDateDepartureArrivalBlock TimeDefectsActionsCRS
[___][___][___][___][___][___][___][___]
+ """, + ), + }, + { + "code": "FORM-MEL", + "name": "MEL Deferral Form / Форма отложенного дефекта", + "category": "form", + "standard": "ICAO", + "description": "Форма отложенного дефекта по MEL", + "sort_order": 18, + "html_content": _wrap( + "MEL Deferral Form", + None, + """ +

ВС: [___] ATA: [___]

+

Item: [___] Category (A/B/C/D): [___]

+

Deferred By: [___] Expiry Date: [дата]

+

Rectified By: [___]

+ """, + ), + }, + { + "code": "FORM-WEIGHT", + "name": "Weight & Balance Sheet / Весовая ведомость", + "category": "form", + "standard": "ICAO", + "description": "Весовая ведомость ВС", + "sort_order": 19, + "html_content": _wrap( + "Weight & Balance Sheet", + "Весовая ведомость", + """ +

ВС: [___]

+

Empty Weight: [___] CG: [___]

+

Fuel: [___] Payload: [___]

+

Max TOW: [___] Actual TOW: [___]

+ """, + ), + }, + { + "code": "FORM-WO", + "name": "Work Order / Наряд-задание на ТО", + "category": "form", + "standard": "RF", + "description": "Наряд-задание на техническое обслуживание", + "sort_order": 20, + "html_content": _wrap( + "Work Order / Наряд-задание на ТО", + None, + """ +

WO №: [___] ВС: [рег. №]

+

Тип работ: [___________]

+

Плановые ч/ч: [___] Исполнитель: [___________]

+

Инструмент, запчасти: [___________]

+

CRS: [___]

+
Подпись
+ """, + ), + }, + { + "code": "RPT-ANNUAL", + "name": "Годовой отчёт о состоянии парка ВС", + "category": "report", + "standard": "RF", + "description": "Годовой отчёт по парку воздушных судов", + "sort_order": 21, + "html_content": _wrap( + "Годовой отчёт о состоянии парка ВС", + None, + """ +
Парк ВС

[___________]

+
Наработка, ТО

[___________]

+
Инциденты, AD/SB, риски

[___________]

+
Руководитель
+ """, + ), + }, + { + "code": "RPT-SMS", + "name": "Отчёт по SMS (Safety Management System)", + "category": "report", + "standard": "ICAO", + "description": "Отчёт по системе управления безопасностью", + "sort_order": 22, + "html_content": _wrap( + "Отчёт по SMS", + "Safety Management System", + """ +
Показатели безопасности (SPI)

[___________]

+
Происшествия, риски

[___________]

+
Корректирующие действия

[___________]

+
Подпись
+ """, + ), + }, + { + "code": "RPT-RISK", + "name": "Отчёт об оценке риска", + "category": "report", + "standard": "RF", + "description": "Отчёт об оценке риска", + "sort_order": 23, + "html_content": _wrap( + "Отчёт об оценке риска", + None, + """ +

Опасность: [___________]

+

Вероятность: [___] Серьёзность: [___]

+

Матрица риска: [___]

+

Меры: [___________]

+

Остаточный риск: [___]

+
Подпись
+ """, + ), + }, + { + "code": "ORD-PILOT", + "name": "Приказ о допуске экипажа к полётам", + "category": "order", + "standard": "RF", + "description": "Приказ о допуске пилота/экипажа к полётам", + "sort_order": 24, + "html_content": _wrap( + "Приказ о допуске экипажа к полётам", + None, + """ +

ФИО: [___________] Должность: [___]

+

Свидетельство №: [___]

+

Типы ВС: [___________]

+

Дата: [дата]

+
Руководитель
+ """, + ), + }, + { + "code": "ORD-INSPECT", + "name": "Распоряжение о проведении инспекции", + "category": "order", + "standard": "RF", + "description": "Распоряжение о проведении инспекционной проверки", + "sort_order": 25, + "html_content": _wrap( + "Распоряжение о проведении инспекции", + None, + """ +

Основание: [___________]

+

Объект: [___________]

+

Дата: [дата] Инспектор: [___________]

+

Задачи: [___________]

+
Подпись
+ """, + ), + }, + ] + + +def seed_document_templates(): + db = SessionLocal() + try: + for d in _templates_data(): + if db.query(DocumentTemplate).filter(DocumentTemplate.code == d["code"]).first(): + continue + db.add( + DocumentTemplate( + code=d["code"], + name=d["name"], + category=d["category"], + standard=d["standard"], + description=d.get("description"), + html_content=d["html_content"], + version=1, + is_active=True, + sort_order=d["sort_order"], + ) + ) + db.commit() + logger.info("Document templates seed complete: up to 25 templates") + except Exception as e: + db.rollback() + logger.exception("Document templates seed failed: %s", e) + raise + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py index fd205dd..272e32f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -33,6 +33,7 @@ from app.api.routes import ( tasks_router, audit_router, ai_router, + document_templates_router, ) @@ -47,6 +48,12 @@ async def lifespan(app: FastAPI): except Exception as e: import logging logging.getLogger(__name__).warning("Checklist seed skipped: %s", e) + try: + from app.demo.seed_document_templates import seed_document_templates + seed_document_templates() + except Exception as e: + import logging + logging.getLogger(__name__).warning("Document templates seed skipped: %s", e) # Планировщик рисков (передаём app для shutdown hook) setup_scheduler(app) yield @@ -213,6 +220,7 @@ app.include_router(inbox_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY) app.include_router(tasks_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY) app.include_router(audit_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY) app.include_router(ai_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY) +app.include_router(document_templates_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY) # WebSocket (no prefix — direct path) from app.api.routes.ws_notifications import router as ws_router diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index de4bebd..04e656c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -17,6 +17,7 @@ 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.document_template import DocumentTemplate from app.models.legal import ( DocumentType, Jurisdiction, @@ -67,4 +68,5 @@ __all__ = [ "MaintenanceProgram", "AircraftComponent", "WorkOrder", + "DocumentTemplate", ] diff --git a/backend/app/models/document_template.py b/backend/app/models/document_template.py new file mode 100644 index 0000000..ddfc462 --- /dev/null +++ b/backend/app/models/document_template.py @@ -0,0 +1,27 @@ +"""Шаблоны документов авиационной отрасли.""" +from sqlalchemy import String, Text, Integer, Boolean +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base +from app.models.common import TimestampMixin, uuid4_str + + +class DocumentTemplate(Base, TimestampMixin): + __tablename__ = "document_templates" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str) + code: Mapped[str] = mapped_column(String(32), unique=True, nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + category: Mapped[str] = mapped_column( + String(64), nullable=False, + doc="checklist|letter|application|certificate|report|act|order|form" + ) + standard: Mapped[str] = mapped_column( + String(32), nullable=False, + doc="RF|ICAO|EASA|FAA|INTERNAL" + ) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + html_content: Mapped[str] = mapped_column(Text, nullable=False) + version: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) diff --git a/components/DocumentPreviewModal.tsx b/components/DocumentPreviewModal.tsx new file mode 100644 index 0000000..2e86f92 --- /dev/null +++ b/components/DocumentPreviewModal.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { documentTemplatesApi } from '@/lib/api/api-client'; +import { useAuth } from '@/lib/auth-context'; + +interface Props { + template: { id: string; name: string; code: string; html_content: string }; + onClose: () => void; + onSaved?: () => void; +} + +export default function DocumentPreviewModal({ template, onClose, onSaved }: Props) { + const contentRef = useRef(null); + const [saving, setSaving] = useState(false); + const { hasRole } = useAuth(); + const canEdit = hasRole('admin') || hasRole('authority_inspector'); + + const handlePrint = () => { + window.print(); + }; + + const handleSave = async () => { + if (!contentRef.current || !canEdit) return; + setSaving(true); + try { + const html = contentRef.current.innerHTML; + await documentTemplatesApi.update(template.id, { html_content: html }); + onSaved?.(); + } catch (e) { + console.error(e); + } finally { + setSaving(false); + } + }; + + return ( + <> + +
+
e.stopPropagation()} + > +
+
+ {template.name} + {template.code} +
+
+ + {canEdit && ( + + )} + +
+
+
+
+
+
+
+ + ); +} diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 9452db5..a5fa05f 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -30,6 +30,7 @@ const menuItems: MenuItem[] = [ { name: 'Дефекты', path: '/defects', icon: '🛠️' }, { name: 'Модификации', path: '/modifications', icon: '⚙️' }, { name: 'Документы', path: '/documents', icon: '📄' }, + { name: 'Шаблоны', path: '/templates', icon: '📋' }, { name: 'Inbox', path: '/inbox', icon: '📥' }, { name: 'Нормативные документы', path: '/regulations', icon: '📚' }, { name: 'Мониторинг', path: '/monitoring', icon: '📈', roles: ['admin', 'authority_inspector'] }, diff --git a/lib/api/api-client.ts b/lib/api/api-client.ts index 5b64f31..018a703 100644 --- a/lib/api/api-client.ts +++ b/lib/api/api-client.ts @@ -243,6 +243,15 @@ export const attachmentsApi = { downloadUrl: (id: string) => `${API_BASE}/attachments/${id}/download`, }; +// Document Templates (библиотека шаблонов REFLY) +export const documentTemplatesApi = { + list: (params?: QueryParams) => + apiFetch>(`/document-templates${buildQuery(params)}`), + get: (id: string) => apiFetch(`/document-templates/${id}`), + update: (id: string, data: any) => + apiFetch(`/document-templates/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), +}; + // Health export const healthApi = { check: () => apiFetch('/health'),