feat: библиотека шаблонов документов (25 шт.) в стиле REFLY

- DocumentTemplate: модель, CRUD API, seed 25 шаблонов
- Категории: заявки, сертификаты, акты, письма, формы, отчёты, приказы
- Стандарты: РФ (ФАП-145/146/148/246), ICAO, EASA, FAA
- Бренд-бук REFLY: шапка, подвал, цвета #1e3a5f, печатный формат A4
- Поля contenteditable для заполнения в браузере
- DocumentPreviewModal: просмотр, заполнение, печать
- Страница /templates с фильтрами по категории и стандарту
- Alembic миграция 0002_document_templates

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yuriy 2026-02-15 16:37:09 +03:00
parent 44b14cc4fd
commit 0cf1cfdaec
13 changed files with 939 additions and 0 deletions

View File

@ -9,6 +9,7 @@ export default function DocumentsPage() {
{ title: 'Сертификаты', desc: 'Сертификаты ЛГ', href: '/airworthiness', icon: '📜' }, { title: 'Сертификаты', desc: 'Сертификаты ЛГ', href: '/airworthiness', icon: '📜' },
{ title: 'Нормативные документы', desc: 'ФАП, ICAO, EASA', href: '/regulations', icon: '📚' }, { title: 'Нормативные документы', desc: 'ФАП, ICAO, EASA', href: '/regulations', icon: '📚' },
{ title: 'Чек-листы', desc: 'Шаблоны проверок', href: '/checklists', icon: '✅' }, { title: 'Чек-листы', desc: 'Шаблоны проверок', href: '/checklists', icon: '✅' },
{ title: 'Шаблоны документов', desc: 'Заявки, акты, письма, формы', href: '/templates', icon: '📋' },
]; ];
return ( return (
<PageLayout title="Документы" subtitle="Просмотр документов, прикреплённых к ВС, аудитам и заявкам"> <PageLayout title="Документы" subtitle="Просмотр документов, прикреплённых к ВС, аудитам и заявкам">

105
app/templates/page.tsx Normal file
View File

@ -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<string, string> = {
application: '📝',
certificate: '📜',
act: '📋',
letter: '✉️',
form: '📄',
report: '📊',
order: '📌',
};
export default function TemplatesPage() {
const [category, setCategory] = useState<string | undefined>();
const [standard, setStandard] = useState<string | undefined>();
const [preview, setPreview] = useState<any>(null);
const params: Record<string, string | undefined> = {};
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 (
<PageLayout
title="Шаблоны документов"
subtitle={isLoading ? 'Загрузка...' : `${data?.total || 0} шаблонов`}
>
<div className="mb-4 flex flex-wrap gap-2">
<FilterBar value={category} onChange={setCategory} options={CATEGORIES} className="mb-0" />
<FilterBar value={standard} onChange={setStandard} options={STANDARDS} className="mb-0" />
</div>
{!isLoading && templates.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{templates.map((t: any) => (
<div
key={t.id}
onClick={() => 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"
>
<div className="flex items-start gap-3">
<span className="text-2xl">{CATEGORY_ICONS[t.category] || '📄'}</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-bold text-primary-500 group-hover:underline">{t.name}</div>
<div className="mt-1 text-xs text-gray-500">
{t.code} · v{t.version}
</div>
{t.description && (
<div className="mt-2 line-clamp-2 text-xs text-gray-400">{t.description}</div>
)}
<div className="mt-2 flex gap-2">
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">{t.standard}</span>
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600">{t.category}</span>
</div>
</div>
</div>
</div>
))}
</div>
) : !isLoading ? (
<EmptyState message="Нет шаблонов по выбранным фильтрам." />
) : null}
{preview && (
<DocumentPreviewModal
template={preview}
onClose={() => setPreview(null)}
onSaved={() => mutate()}
/>
)}
</PageLayout>
);
}

View File

@ -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')

View File

@ -17,6 +17,7 @@ from .inbox import router as inbox_router
from .tasks import router as tasks_router from .tasks import router as tasks_router
from .audit import router as audit_router from .audit import router as audit_router
from .ai import router as ai_router from .ai import router as ai_router
from .document_templates import router as document_templates_router
__all__ = [ __all__ = [
"health_router", "health_router",
@ -38,5 +39,6 @@ __all__ = [
"tasks_router", "tasks_router",
"audit_router", "audit_router",
"ai_router", "ai_router",
"document_templates_router",
] ]

View File

@ -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

View File

@ -224,6 +224,8 @@ def seed():
from app.demo.seed_checklists import seed_checklists from app.demo.seed_checklists import seed_checklists
seed_checklists() seed_checklists()
from app.demo.seed_document_templates import seed_document_templates
seed_document_templates()
except Exception as e: except Exception as e:
db.rollback() db.rollback()

View File

@ -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 = """
<style>
.doc { font-family: 'Inter', -apple-system, sans-serif; max-width: 210mm; margin: 0 auto; padding: 20mm; color: #333; font-size: 12pt; line-height: 1.5; }
.header { text-align: center; border-bottom: 3px solid #1e3a5f; padding-bottom: 12px; margin-bottom: 20px; }
.logo { font-size: 28pt; font-weight: 900; color: #1e3a5f; letter-spacing: 3px; }
.org-name { font-size: 11pt; color: #1e3a5f; font-weight: 600; margin-top: 4px; }
.org-details { font-size: 8pt; color: #888; margin-top: 2px; }
.doc-title { text-align: center; font-size: 16pt; font-weight: 700; margin: 24px 0 8px; text-transform: uppercase; }
.doc-subtitle { text-align: center; font-size: 10pt; color: #666; margin-bottom: 20px; }
.doc-number { text-align: right; font-size: 10pt; color: #666; margin-bottom: 16px; }
.field { background: #f0f4ff; border-bottom: 1px solid #1e3a5f; padding: 2px 8px; min-width: 120px; display: inline-block; cursor: text; }
.field:focus { background: #e0e8ff; outline: 2px solid #4a90e2; }
.section-title { font-size: 12pt; font-weight: 700; color: #1e3a5f; margin: 16px 0 8px; border-bottom: 1px solid #ddd; padding-bottom: 4px; }
table.doc-table { width: 100%; border-collapse: collapse; margin: 12px 0; }
table.doc-table th { background: #1e3a5f; color: white; padding: 8px 12px; text-align: left; font-size: 10pt; }
table.doc-table td { padding: 8px 12px; border: 1px solid #ddd; font-size: 10pt; }
table.doc-table tr:nth-child(even) { background: #f8fafc; }
.signatures { display: flex; justify-content: space-between; margin-top: 40px; }
.sig-block { width: 45%; }
.sig-line { border-bottom: 1px solid #333; margin-top: 40px; padding-top: 4px; font-size: 10pt; }
.sig-label { font-size: 8pt; color: #888; }
.footer { text-align: center; font-size: 8pt; color: #aaa; margin-top: 40px; padding-top: 12px; border-top: 1px solid #ddd; }
.stamp-area { border: 1px dashed #ccc; width: 100px; height: 100px; display: inline-block; text-align: center; line-height: 100px; font-size: 8pt; color: #ccc; }
@media print { .doc { padding: 15mm; } .field { background: transparent; border-bottom: 1px solid #333; } }
</style>
"""
_HEADER = """
<div class="header">
<div class="logo">REFLY</div>
<div class="org-name">АСУ ТК КЛГ Контроль лётной годности</div>
<div class="org-details">АО «REFLY» · г. Калининград · тел. +7 (4012) ХХХ-ХХ-ХХ · info@refly.ru</div>
</div>
"""
_FOOTER = """
<div class="footer">АО «REFLY» · АСУ ТК КЛГ · Стр. ___ из ___</div>
"""
def _wrap(title: str, subtitle: str | None, content: str) -> str:
sub = f'<div class="doc-subtitle">{subtitle}</div>' if subtitle else ""
return (
_STYLE
+ '<div class="doc">'
+ _HEADER
+ f'<div class="doc-title">{title}</div>'
+ sub
+ '<div class="doc-number">№ <span class="field" contenteditable="true">[___________]</span> от <span class="field" contenteditable="true">[дата]</span></div>'
+ content
+ _FOOTER
+ "</div>"
)
def _templates_data() -> list[dict]:
return [
{
"code": "APP-246",
"name": "Заявка на получение сертификата эксплуатанта (ФАП-246)",
"category": "application",
"standard": "RF",
"description": "Заявка на получение сертификата эксплуатанта ВК РФ",
"sort_order": 1,
"html_content": _wrap(
"Заявка на получение сертификата эксплуатанта",
"ФАП-246",
"""
<p>Наименование организации: <span class="field" contenteditable="true">[___________]</span></p>
<p>ИНН: <span class="field" contenteditable="true">[___]</span> ОГРН: <span class="field" contenteditable="true">[___]</span></p>
<p>Адрес: <span class="field" contenteditable="true">[___________]</span></p>
<p>Типы ВС: <span class="field" contenteditable="true">[___________]</span></p>
<p>Виды авиаработ: <span class="field" contenteditable="true">[___________]</span></p>
<p>Ответственное лицо: <span class="field" contenteditable="true">[___________]</span></p>
<div class="signatures"><div class="sig-block"><div class="sig-line">Подпись руководителя</div><div class="sig-label">ФИО</div></div><div class="stamp-area">М.П.</div></div>
""",
),
},
{
"code": "APP-145",
"name": "Заявка на одобрение организации ТОиР (ФАП-145)",
"category": "application",
"standard": "RF",
"description": "Заявка на одобрение организации по техническому обслуживанию",
"sort_order": 2,
"html_content": _wrap(
"Заявка на одобрение организации ТОиР",
"ФАП-145",
"""
<p>Наименование: <span class="field" contenteditable="true">[___________]</span></p>
<p>Область одобрения: <span class="field" contenteditable="true">[___________]</span></p>
<p>Типы ВС: <span class="field" contenteditable="true">[___________]</span></p>
<p>Категории работ: <span class="field" contenteditable="true">[___________]</span></p>
<div class="signatures"><div class="sig-block"><div class="sig-line">Подпись</div></div></div>
""",
),
},
{
"code": "APP-SLG",
"name": "Заявка на выдачу/продление СЛГ",
"category": "application",
"standard": "RF",
"description": "Заявка на выдачу или продление сертификата лётной годности",
"sort_order": 3,
"html_content": _wrap(
"Заявка на выдачу/продление сертификата лётной годности",
None,
"""
<p>Рег. номер ВС: <span class="field" contenteditable="true">[___]</span> Тип: <span class="field" contenteditable="true">[___]</span></p>
<p>Серийный : <span class="field" contenteditable="true">[___]</span> Собственник: <span class="field" contenteditable="true">[___________]</span></p>
<p>Дата последнего ТО: <span class="field" contenteditable="true">[дата]</span></p>
<div class="signatures"><div class="sig-block"><div class="sig-line">Подпись</div></div></div>
""",
),
},
{
"code": "APP-MOD",
"name": "Заявка на одобрение модификации ВС",
"category": "application",
"standard": "RF",
"description": "Заявка на одобрение модификации воздушного судна",
"sort_order": 4,
"html_content": _wrap(
"Заявка на одобрение модификации ВС",
None,
"""
<p>Описание модификации: <span class="field" contenteditable="true">[___________]</span></p>
<p>STC: <span class="field" contenteditable="true">[___]</span> Основание: <span class="field" contenteditable="true">[___]</span></p>
<p>ВС (рег. ): <span class="field" contenteditable="true">[___]</span></p>
<div class="signatures"><div class="sig-block"><div class="sig-line">Подпись</div></div></div>
""",
),
},
{
"code": "CERT-SLG",
"name": "Сертификат лётной годности (СЛГ) — бланк",
"category": "certificate",
"standard": "RF",
"description": "Бланк сертификата лётной годности",
"sort_order": 5,
"html_content": _wrap(
"Сертификат лётной годности",
None,
"""
<p>Государство регистрации: <span class="field" contenteditable="true">[___]</span> Рег. знак: <span class="field" contenteditable="true">[___]</span></p>
<p>Тип: <span class="field" contenteditable="true">[___]</span> Серийный : <span class="field" contenteditable="true">[___]</span></p>
<p>Категория: <span class="field" contenteditable="true">[___]</span></p>
<p>Дата выдачи: <span class="field" contenteditable="true">[дата]</span> Срок действия: <span class="field" contenteditable="true">[дата]</span></p>
<p>Орган выдачи: <span class="field" contenteditable="true">[___________]</span></p>
<div class="stamp-area">Печать</div>
""",
),
},
{
"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",
"""
<p>Организация ТОиР: <span class="field" contenteditable="true">[___________]</span></p>
<p>ВС: <span class="field" contenteditable="true">[рег. ]</span> Тип: <span class="field" contenteditable="true">[___]</span></p>
<p>Выполненные работы: <span class="field" contenteditable="true">[___________]</span></p>
<p>Наряд : <span class="field" contenteditable="true">[___]</span> Дата: <span class="field" contenteditable="true">[дата]</span></p>
<p>Подпись уполномоченного лица: _____________________</p>
<div class="stamp-area">М.П.</div>
""",
),
},
{
"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",
"""
<table class="doc-table">
<tr><th>Part Number</th><th>Description</th><th>Quantity</th><th>Serial No</th><th>Status</th><th>Remarks</th></tr>
<tr><td><span class="field" contenteditable="true">[___]</span></td><td><span class="field" contenteditable="true">[___]</span></td><td><span class="field" contenteditable="true">[___]</span></td><td><span class="field" contenteditable="true">[___]</span></td><td><span class="field" contenteditable="true">[___]</span></td><td><span class="field" contenteditable="true">[___]</span></td></tr>
</table>
<p>Authorized Signature: <span class="field" contenteditable="true">[___________]</span></p>
""",
),
},
{
"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",
"""
<p>Part No: <span class="field" contenteditable="true">[___]</span> Serial No: <span class="field" contenteditable="true">[___]</span></p>
<p>Description: <span class="field" contenteditable="true">[___________]</span></p>
<p>Status: <span class="field" contenteditable="true">[___]</span> Remarks: <span class="field" contenteditable="true">[___]</span></p>
<p>Approved by: <span class="field" contenteditable="true">[___________]</span></p>
""",
),
},
{
"code": "ACT-INSP",
"name": "Акт инспекционной проверки ВС",
"category": "act",
"standard": "RF",
"description": "Акт инспекционной проверки воздушного судна",
"sort_order": 9,
"html_content": _wrap(
"Акт инспекционной проверки ВС",
None,
"""
<p>Дата: <span class="field" contenteditable="true">[дата]</span> Место: <span class="field" contenteditable="true">[___________]</span></p>
<p>Борт: <span class="field" contenteditable="true">[рег. ]</span> Инспектор: <span class="field" contenteditable="true">[___________]</span></p>
<p>Результат: <span class="field" contenteditable="true">[годен / не годен]</span></p>
<p>Замечания: <span class="field" contenteditable="true">[___________]</span></p>
<div class="signatures"><div class="sig-block"><div class="sig-line">Инспектор</div></div><div class="sig-block"><div class="sig-line">Представитель организации</div></div></div>
""",
),
},
{
"code": "ACT-AUDIT",
"name": "Акт аудита организации",
"category": "act",
"standard": "RF",
"description": "Акт проведения аудита организации",
"sort_order": 10,
"html_content": _wrap(
"Акт аудита организации",
None,
"""
<p>Организация: <span class="field" contenteditable="true">[___________]</span></p>
<p>Дата: <span class="field" contenteditable="true">[дата]</span> Аудитор: <span class="field" contenteditable="true">[___________]</span></p>
<p>Область проверки: <span class="field" contenteditable="true">[___________]</span></p>
<p>Несоответствия (level 1/2): <span class="field" contenteditable="true">[___________]</span></p>
<p>Корректирующие действия, срок: <span class="field" contenteditable="true">[___________]</span></p>
<div class="signatures"><div class="sig-block"><div class="sig-line">Аудитор</div></div><div class="sig-block"><div class="sig-line">Представитель организации</div></div></div>
""",
),
},
{
"code": "ACT-DEFECT",
"name": "Акт дефектации",
"category": "act",
"standard": "RF",
"description": "Акт дефектации компонента/ВС",
"sort_order": 11,
"html_content": _wrap(
"Акт дефектации",
None,
"""
<p>ВС: <span class="field" contenteditable="true">[рег. ]</span> ATA chapter: <span class="field" contenteditable="true">[___]</span></p>
<p>Описание дефекта: <span class="field" contenteditable="true">[___________]</span></p>
<p>MEL категория: <span class="field" contenteditable="true">[___]</span> Решение: <span class="field" contenteditable="true">[___________]</span></p>
<div class="sig-block"><div class="sig-line">Подпись</div></div>
""",
),
},
{
"code": "ACT-ACCEPT",
"name": "Акт приёмки ВС после ТО",
"category": "act",
"standard": "RF",
"description": "Акт приёмки воздушного судна после технического обслуживания",
"sort_order": 12,
"html_content": _wrap(
"Акт приёмки ВС после ТО",
None,
"""
<p>Наряд : <span class="field" contenteditable="true">[___]</span> ВС: <span class="field" contenteditable="true">[рег. ]</span></p>
<p>Выполненные работы: <span class="field" contenteditable="true">[___________]</span></p>
<p>Замечания: <span class="field" contenteditable="true">[___________]</span></p>
<p>CRS: <span class="field" contenteditable="true">[___]</span></p>
<div class="signatures"><div class="sig-block"><div class="sig-line">Заказчик</div></div><div class="sig-block"><div class="sig-line">Исполнитель</div></div></div>
""",
),
},
{
"code": "LTR-FAVT",
"name": "Сопроводительное письмо в ФАВТ (Росавиация)",
"category": "letter",
"standard": "RF",
"description": "Сопроводительное письмо в Федеральное агентство воздушного транспорта",
"sort_order": 13,
"html_content": _wrap(
"Сопроводительное письмо",
"в ФАВТ (Росавиация)",
"""
<p>Исх. <span class="field" contenteditable="true">[___]</span> от <span class="field" contenteditable="true">[дата]</span></p>
<p>Кому: <span class="field" contenteditable="true">[___________]</span></p>
<p>От кого: <span class="field" contenteditable="true">[___________]</span></p>
<p>Тема: <span class="field" contenteditable="true">[___________]</span></p>
<p>Текст: <span class="field" contenteditable="true">[___________]</span></p>
<p>Приложения: <span class="field" contenteditable="true">[___________]</span></p>
<div class="sig-block"><div class="sig-line">Подпись</div></div>
""",
),
},
{
"code": "LTR-MRO",
"name": "Письмо-заказ в организацию ТОиР",
"category": "letter",
"standard": "RF",
"description": "Письмо-заказ на выполнение работ организацией ТОиР",
"sort_order": 14,
"html_content": _wrap(
"Письмо-заказ в организацию ТОиР",
None,
"""
<p>Наименование ТОиР: <span class="field" contenteditable="true">[___________]</span></p>
<p>Запрос на выполнение работ: <span class="field" contenteditable="true">[___________]</span></p>
<p>ВС: <span class="field" contenteditable="true">[рег. ]</span> Сроки: <span class="field" contenteditable="true">[___________]</span></p>
<p>Контакт: <span class="field" contenteditable="true">[___________]</span></p>
<div class="sig-block"><div class="sig-line">Подпись</div></div>
""",
),
},
{
"code": "LTR-SB",
"name": "Уведомление о выполнении сервисного бюллетеня (SB)",
"category": "letter",
"standard": "RF",
"description": "Уведомление о выполнении SB",
"sort_order": 15,
"html_content": _wrap(
"Уведомление о выполнении SB",
None,
"""
<p> SB: <span class="field" contenteditable="true">[___]</span> ВС: <span class="field" contenteditable="true">[рег. ]</span></p>
<p>Дата выполнения: <span class="field" contenteditable="true">[дата]</span></p>
<p>Ссылка на наряд: <span class="field" contenteditable="true">[___]</span></p>
<div class="sig-block"><div class="sig-line">Подпись</div></div>
""",
),
},
{
"code": "LTR-AD",
"name": "Отчёт о выполнении директивы ЛГ (AD/ДЛГ)",
"category": "letter",
"standard": "RF",
"description": "Отчёт о выполнении воздушной директивы",
"sort_order": 16,
"html_content": _wrap(
"Отчёт о выполнении директивы ЛГ",
"AD / ДЛГ",
"""
<p> AD: <span class="field" contenteditable="true">[___]</span> ВС: <span class="field" contenteditable="true">[рег. ]</span></p>
<p>Метод выполнения: <span class="field" contenteditable="true">[___________]</span></p>
<p>Дата: <span class="field" contenteditable="true">[дата]</span></p>
<p>Подтверждение: <span class="field" contenteditable="true">[___________]</span></p>
<div class="sig-block"><div class="sig-line">Подпись</div></div>
""",
),
},
{
"code": "FORM-TECHLOG",
"name": "Technical Log / Бортовой журнал",
"category": "form",
"standard": "ICAO",
"description": "Бортовой журнал (ICAO)",
"sort_order": 17,
"html_content": _wrap(
"Technical Log / Бортовой журнал",
"ICAO",
"""
<table class="doc-table">
<tr><th>Flight No</th><th>Date</th><th>Departure</th><th>Arrival</th><th>Block Time</th><th>Defects</th><th>Actions</th><th>CRS</th></tr>
<tr><td><span class="field" contenteditable="true">[___]</span></td><td><span class="field" contenteditable="true">[___]</span></td><td><span class="field" contenteditable="true">[___]</span></td><td><span class="field" contenteditable="true">[___]</span></td><td><span class="field" contenteditable="true">[___]</span></td><td><span class="field" contenteditable="true">[___]</span></td><td><span class="field" contenteditable="true">[___]</span></td><td><span class="field" contenteditable="true">[___]</span></td></tr>
</table>
""",
),
},
{
"code": "FORM-MEL",
"name": "MEL Deferral Form / Форма отложенного дефекта",
"category": "form",
"standard": "ICAO",
"description": "Форма отложенного дефекта по MEL",
"sort_order": 18,
"html_content": _wrap(
"MEL Deferral Form",
None,
"""
<p>ВС: <span class="field" contenteditable="true">[___]</span> ATA: <span class="field" contenteditable="true">[___]</span></p>
<p>Item: <span class="field" contenteditable="true">[___]</span> Category (A/B/C/D): <span class="field" contenteditable="true">[___]</span></p>
<p>Deferred By: <span class="field" contenteditable="true">[___]</span> Expiry Date: <span class="field" contenteditable="true">[дата]</span></p>
<p>Rectified By: <span class="field" contenteditable="true">[___]</span></p>
""",
),
},
{
"code": "FORM-WEIGHT",
"name": "Weight & Balance Sheet / Весовая ведомость",
"category": "form",
"standard": "ICAO",
"description": "Весовая ведомость ВС",
"sort_order": 19,
"html_content": _wrap(
"Weight & Balance Sheet",
"Весовая ведомость",
"""
<p>ВС: <span class="field" contenteditable="true">[___]</span></p>
<p>Empty Weight: <span class="field" contenteditable="true">[___]</span> CG: <span class="field" contenteditable="true">[___]</span></p>
<p>Fuel: <span class="field" contenteditable="true">[___]</span> Payload: <span class="field" contenteditable="true">[___]</span></p>
<p>Max TOW: <span class="field" contenteditable="true">[___]</span> Actual TOW: <span class="field" contenteditable="true">[___]</span></p>
""",
),
},
{
"code": "FORM-WO",
"name": "Work Order / Наряд-задание на ТО",
"category": "form",
"standard": "RF",
"description": "Наряд-задание на техническое обслуживание",
"sort_order": 20,
"html_content": _wrap(
"Work Order / Наряд-задание на ТО",
None,
"""
<p>WO : <span class="field" contenteditable="true">[___]</span> ВС: <span class="field" contenteditable="true">[рег. ]</span></p>
<p>Тип работ: <span class="field" contenteditable="true">[___________]</span></p>
<p>Плановые ч/ч: <span class="field" contenteditable="true">[___]</span> Исполнитель: <span class="field" contenteditable="true">[___________]</span></p>
<p>Инструмент, запчасти: <span class="field" contenteditable="true">[___________]</span></p>
<p>CRS: <span class="field" contenteditable="true">[___]</span></p>
<div class="sig-block"><div class="sig-line">Подпись</div></div>
""",
),
},
{
"code": "RPT-ANNUAL",
"name": "Годовой отчёт о состоянии парка ВС",
"category": "report",
"standard": "RF",
"description": "Годовой отчёт по парку воздушных судов",
"sort_order": 21,
"html_content": _wrap(
"Годовой отчёт о состоянии парка ВС",
None,
"""
<div class="section-title">Парк ВС</div><p><span class="field" contenteditable="true">[___________]</span></p>
<div class="section-title">Наработка, ТО</div><p><span class="field" contenteditable="true">[___________]</span></p>
<div class="section-title">Инциденты, AD/SB, риски</div><p><span class="field" contenteditable="true">[___________]</span></p>
<div class="sig-block"><div class="sig-line">Руководитель</div></div>
""",
),
},
{
"code": "RPT-SMS",
"name": "Отчёт по SMS (Safety Management System)",
"category": "report",
"standard": "ICAO",
"description": "Отчёт по системе управления безопасностью",
"sort_order": 22,
"html_content": _wrap(
"Отчёт по SMS",
"Safety Management System",
"""
<div class="section-title">Показатели безопасности (SPI)</div><p><span class="field" contenteditable="true">[___________]</span></p>
<div class="section-title">Происшествия, риски</div><p><span class="field" contenteditable="true">[___________]</span></p>
<div class="section-title">Корректирующие действия</div><p><span class="field" contenteditable="true">[___________]</span></p>
<div class="sig-block"><div class="sig-line">Подпись</div></div>
""",
),
},
{
"code": "RPT-RISK",
"name": "Отчёт об оценке риска",
"category": "report",
"standard": "RF",
"description": "Отчёт об оценке риска",
"sort_order": 23,
"html_content": _wrap(
"Отчёт об оценке риска",
None,
"""
<p>Опасность: <span class="field" contenteditable="true">[___________]</span></p>
<p>Вероятность: <span class="field" contenteditable="true">[___]</span> Серьёзность: <span class="field" contenteditable="true">[___]</span></p>
<p>Матрица риска: <span class="field" contenteditable="true">[___]</span></p>
<p>Меры: <span class="field" contenteditable="true">[___________]</span></p>
<p>Остаточный риск: <span class="field" contenteditable="true">[___]</span></p>
<div class="sig-block"><div class="sig-line">Подпись</div></div>
""",
),
},
{
"code": "ORD-PILOT",
"name": "Приказ о допуске экипажа к полётам",
"category": "order",
"standard": "RF",
"description": "Приказ о допуске пилота/экипажа к полётам",
"sort_order": 24,
"html_content": _wrap(
"Приказ о допуске экипажа к полётам",
None,
"""
<p>ФИО: <span class="field" contenteditable="true">[___________]</span> Должность: <span class="field" contenteditable="true">[___]</span></p>
<p>Свидетельство : <span class="field" contenteditable="true">[___]</span></p>
<p>Типы ВС: <span class="field" contenteditable="true">[___________]</span></p>
<p>Дата: <span class="field" contenteditable="true">[дата]</span></p>
<div class="sig-block"><div class="sig-line">Руководитель</div></div>
""",
),
},
{
"code": "ORD-INSPECT",
"name": "Распоряжение о проведении инспекции",
"category": "order",
"standard": "RF",
"description": "Распоряжение о проведении инспекционной проверки",
"sort_order": 25,
"html_content": _wrap(
"Распоряжение о проведении инспекции",
None,
"""
<p>Основание: <span class="field" contenteditable="true">[___________]</span></p>
<p>Объект: <span class="field" contenteditable="true">[___________]</span></p>
<p>Дата: <span class="field" contenteditable="true">[дата]</span> Инспектор: <span class="field" contenteditable="true">[___________]</span></p>
<p>Задачи: <span class="field" contenteditable="true">[___________]</span></p>
<div class="sig-block"><div class="sig-line">Подпись</div></div>
""",
),
},
]
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()

View File

@ -33,6 +33,7 @@ from app.api.routes import (
tasks_router, tasks_router,
audit_router, audit_router,
ai_router, ai_router,
document_templates_router,
) )
@ -47,6 +48,12 @@ async def lifespan(app: FastAPI):
except Exception as e: except Exception as e:
import logging import logging
logging.getLogger(__name__).warning("Checklist seed skipped: %s", e) 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) # Планировщик рисков (передаём app для shutdown hook)
setup_scheduler(app) setup_scheduler(app)
yield 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(tasks_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(audit_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(ai_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
app.include_router(document_templates_router, prefix=PREFIX, dependencies=AUTH_DEPENDENCY)
# WebSocket (no prefix — direct path) # WebSocket (no prefix — direct path)
from app.api.routes.ws_notifications import router as ws_router from app.api.routes.ws_notifications import router as ws_router

View File

@ -17,6 +17,7 @@ from app.models.audit_log import AuditLog
from app.models.personnel_plg import PLGSpecialist, PLGAttestation, PLGQualification from app.models.personnel_plg import PLGSpecialist, PLGAttestation, PLGQualification
from app.models.airworthiness_core import ADDirective, ServiceBulletin, LifeLimit, MaintenanceProgram, AircraftComponent from app.models.airworthiness_core import ADDirective, ServiceBulletin, LifeLimit, MaintenanceProgram, AircraftComponent
from app.models.work_orders import WorkOrder from app.models.work_orders import WorkOrder
from app.models.document_template import DocumentTemplate
from app.models.legal import ( from app.models.legal import (
DocumentType, DocumentType,
Jurisdiction, Jurisdiction,
@ -67,4 +68,5 @@ __all__ = [
"MaintenanceProgram", "MaintenanceProgram",
"AircraftComponent", "AircraftComponent",
"WorkOrder", "WorkOrder",
"DocumentTemplate",
] ]

View File

@ -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)

View File

@ -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<HTMLDivElement>(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 (
<>
<style>{`
@media print {
body * { visibility: hidden; }
.document-preview-modal-print, .document-preview-modal-print * { visibility: visible; }
.document-preview-modal-print { position: absolute; left: 0; top: 0; width: 100%; background: white; }
.document-preview-modal-print .document-print-header { display: none; }
}
`}</style>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 document-preview-modal-print" onClick={onClose}>
<div
className="flex max-h-[95vh] w-full max-w-5xl flex-col rounded-lg bg-white shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="document-print-header flex items-center justify-between border-b border-gray-200 px-4 py-3">
<div>
<span className="font-bold text-gray-800">{template.name}</span>
<span className="ml-2 text-sm text-gray-500">{template.code}</span>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={handlePrint}
className="rounded bg-primary-500 px-3 py-1.5 text-sm text-white hover:bg-primary-600"
>
🖨 Печать
</button>
{canEdit && (
<button
type="button"
onClick={handleSave}
disabled={saving}
className="rounded bg-green-600 px-3 py-1.5 text-sm text-white hover:bg-green-700 disabled:opacity-50"
>
{saving ? 'Сохранение…' : '💾 Сохранить изменения'}
</button>
)}
<button
type="button"
onClick={onClose}
className="rounded bg-gray-200 px-3 py-1.5 text-sm hover:bg-gray-300"
>
Закрыть
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 print:overflow-visible">
<div
ref={contentRef}
className="document-content mx-auto max-w-[210mm]"
dangerouslySetInnerHTML={{ __html: template.html_content }}
/>
</div>
</div>
</div>
</>
);
}

View File

@ -30,6 +30,7 @@ const menuItems: MenuItem[] = [
{ name: 'Дефекты', path: '/defects', icon: '🛠️' }, { name: 'Дефекты', path: '/defects', icon: '🛠️' },
{ name: 'Модификации', path: '/modifications', icon: '⚙️' }, { name: 'Модификации', path: '/modifications', icon: '⚙️' },
{ name: 'Документы', path: '/documents', icon: '📄' }, { name: 'Документы', path: '/documents', icon: '📄' },
{ name: 'Шаблоны', path: '/templates', icon: '📋' },
{ name: 'Inbox', path: '/inbox', icon: '📥' }, { name: 'Inbox', path: '/inbox', icon: '📥' },
{ name: 'Нормативные документы', path: '/regulations', icon: '📚' }, { name: 'Нормативные документы', path: '/regulations', icon: '📚' },
{ name: 'Мониторинг', path: '/monitoring', icon: '📈', roles: ['admin', 'authority_inspector'] }, { name: 'Мониторинг', path: '/monitoring', icon: '📈', roles: ['admin', 'authority_inspector'] },

View File

@ -243,6 +243,15 @@ export const attachmentsApi = {
downloadUrl: (id: string) => `${API_BASE}/attachments/${id}/download`, downloadUrl: (id: string) => `${API_BASE}/attachments/${id}/download`,
}; };
// Document Templates (библиотека шаблонов REFLY)
export const documentTemplatesApi = {
list: (params?: QueryParams) =>
apiFetch<PaginatedResponse<any>>(`/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 // Health
export const healthApi = { export const healthApi = {
check: () => apiFetch('/health'), check: () => apiFetch('/health'),