feat: 8 шаблонов чек-листов аудита ВС (РФ/ИКАО/EASA/FAA) + редактирование

- seed_checklists.py: 8 шаблонов (~120 пунктов) — ФАП-148, ФАП-145, ФАП-147,
  ICAO Annex 8, ICAO Annex 19 SMS, EASA Part-M, EASA Part-CAMO, FAA Part 43/91
- checklists.py: PATCH template, PATCH/POST/DELETE items
- ChecklistEditModal.tsx: модальное окно с inline-edit пунктов
- api-client.ts: updateTemplate, addItem, updateItem, deleteItem
- FilterBar: фильтры по стандартам (ФАП/ИКАО/EASA/FAA)
- Автозагрузка шаблонов при первом запуске (lifespan + demo seed)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yuriy 2026-02-15 15:37:55 +03:00
parent 646401299c
commit 62958239ac
9 changed files with 816 additions and 3 deletions

View File

@ -1,5 +1,6 @@
'use client';
import ChecklistCardModal from '@/components/ChecklistCardModal';
import ChecklistEditModal from '@/components/ChecklistEditModal';
import { useState } from 'react';
import ChecklistCreateModal from '@/components/ChecklistCreateModal';
import { PageLayout, FilterBar, EmptyState } from '@/components/ui';
@ -12,6 +13,7 @@ export default function ChecklistsPage() {
const { data, isLoading, mutate } = useChecklistsData({ domain });
const templates = data?.items || [];
const [exp, setExp] = useState<string | null>(null);
const [editTemplate, setEditTemplate] = useState<any | null>(null);
const gen = async (src: string) => { const n = prompt('Название:'); if (!n) return; await checklistsApi.generate(src, n); mutate(); };
@ -22,14 +24,28 @@ export default function ChecklistsPage() {
<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' }]} />
options={[
{ value: undefined, label: 'Все' },
{ value: 'ФАП-148', label: '🇷🇺 ФАП-148' },
{ value: 'ФАП-145', label: '🇷🇺 ФАП-145' },
{ value: 'ФАП-147', label: '🇷🇺 ФАП-147' },
{ value: 'ICAO', label: '🌐 ИКАО' },
{ value: 'EASA', label: '🇪🇺 EASA' },
{ value: 'FAA', label: '🇺🇸 FAA' },
{ 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 className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<RequireRole roles={['admin', 'authority_inspector']}>
<button type="button" onClick={() => setEditTemplate(t)} className="rounded px-2 py-1 text-sm hover:bg-gray-100" title="Редактировать"> Редактировать</button>
</RequireRole>
<span className="text-lg">{exp === t.id ? '▼' : '▶'}</span>
</div>
</div>
{exp === t.id && t.items?.length > 0 && (
<div className="border-t border-gray-100 px-5 pb-4">
@ -45,6 +61,13 @@ export default function ChecklistsPage() {
))}
</div>
) : !isLoading ? <EmptyState message="Нет шаблонов. Создайте через кнопку выше." /> : null}
{editTemplate && (
<ChecklistEditModal
template={editTemplate}
onClose={() => setEditTemplate(null)}
onSaved={() => mutate()}
/>
)}
</PageLayout>
);
}

View File

@ -7,7 +7,14 @@ from app.api.deps import get_current_user, require_roles
from app.api.helpers import audit, paginate_query
from app.api.deps 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,
ChecklistTemplateUpdate,
ChecklistItemCreate,
ChecklistItemOut,
ChecklistItemUpdate,
)
router = APIRouter(tags=["checklists"])
@ -75,6 +82,93 @@ def get_template(template_id: str, db: Session = Depends(get_db), user=Depends(g
return _template_with_items(t, db)
@router.patch("/checklists/templates/{template_id}", response_model=ChecklistTemplateOut,
dependencies=[Depends(require_roles("admin", "authority_inspector"))])
def update_template(
template_id: str,
payload: ChecklistTemplateUpdate,
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, "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.domain is not None:
t.domain = payload.domain
audit(db, user, "update", "checklist_template", entity_id=template_id)
db.commit()
db.refresh(t)
return _template_with_items(t, db)
@router.patch("/checklists/items/{item_id}", response_model=ChecklistItemOut,
dependencies=[Depends(require_roles("admin", "authority_inspector"))])
def update_item(
item_id: str,
payload: ChecklistItemUpdate,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
item = db.query(ChecklistItem).filter(ChecklistItem.id == item_id).first()
if not item:
raise HTTPException(404, "Item not found")
if payload.code is not None:
item.code = payload.code
if payload.text is not None:
item.text = payload.text
if payload.sort_order is not None:
item.sort_order = payload.sort_order
audit(db, user, "update", "checklist_item", entity_id=item_id)
db.commit()
db.refresh(item)
return ChecklistItemOut.model_validate(item)
@router.post("/checklists/templates/{template_id}/items", response_model=ChecklistItemOut, status_code=201,
dependencies=[Depends(require_roles("admin", "authority_inspector"))])
def add_item(
template_id: str,
payload: ChecklistItemCreate,
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, "Template not found")
max_order = db.query(ChecklistItem).filter(ChecklistItem.template_id == template_id).count()
item = ChecklistItem(
template_id=template_id,
code=payload.code,
text=payload.text,
domain=payload.domain,
sort_order=payload.sort_order if payload.sort_order else (max_order + 1),
)
db.add(item)
audit(db, user, "create", "checklist_item")
db.commit()
db.refresh(item)
return ChecklistItemOut.model_validate(item)
@router.delete("/checklists/items/{item_id}", status_code=204,
dependencies=[Depends(require_roles("admin", "authority_inspector"))])
def delete_item(
item_id: str,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
item = db.query(ChecklistItem).filter(ChecklistItem.id == item_id).first()
if not item:
raise HTTPException(404, "Item not found")
audit(db, user, "delete", "checklist_item", entity_id=item_id)
db.delete(item)
db.commit()
@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,

View File

@ -0,0 +1 @@
# Demo: seed и утилиты для demo-развёртывания

237
backend/app/demo/seed.py Normal file
View File

@ -0,0 +1,237 @@
"""
Наполнение БД demo-данными: организации, типы ВС, воздушные суда, заявки, пользователи.
Запуск: python -m app.demo.seed
"""
import logging
import sys
from datetime import datetime, timezone, timedelta
from app.db.session import SessionLocal, engine
from app.db.base import Base
from app.models import (
Organization,
Aircraft,
AircraftType,
CertApplication,
CertApplicationStatus,
Notification,
User,
)
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)
def seed():
logger.info("Creating tables...")
Base.metadata.create_all(bind=engine)
db = SessionLocal()
try:
# Проверка: если данные уже есть — пропускаем
if db.query(Organization).first():
logger.info("Demo data already exists, skipping.")
return
now = datetime.now(timezone.utc)
# ── 5 тестовых пользователей (для JWT и created_by) ──
demo_users = [
User(
id="demo-admin-001",
external_subject="demo-admin-001",
display_name="Админ Системы",
email="admin@demo.klg.refly.ru",
role="admin",
organization_id=None,
),
User(
id="demo-inspector-001",
external_subject="demo-inspector-001",
display_name="Иванов И.П. (Инспектор ФАВТ)",
email="inspector@demo.klg.refly.ru",
role="authority_inspector",
organization_id=None,
),
User(
id="demo-operator-mgr-001",
external_subject="demo-operator-mgr-001",
display_name="Петров А.С. (Оператор — руководитель)",
email="operator-mgr@demo.klg.refly.ru",
role="operator_manager",
organization_id="org-airline-001",
),
User(
id="demo-mro-mgr-001",
external_subject="demo-mro-mgr-001",
display_name="Сидоров В.Н. (ТОиР — руководитель)",
email="mro-mgr@demo.klg.refly.ru",
role="mro_manager",
organization_id="org-mro-001",
),
User(
id="demo-operator-user-001",
external_subject="demo-operator-user-001",
display_name="Козлова Е.А. (Оператор — специалист)",
email="operator@demo.klg.refly.ru",
role="operator_user",
organization_id="org-airline-001",
),
]
db.add_all(demo_users)
logger.info("Added 5 demo users")
# ── Организации (kind, не type) ───────────────────────
orgs = [
Organization(
id="org-airline-001",
name="АК «КалинингрАвиа»",
kind="operator",
inn="3901234567",
ogrn="1023901234567",
address="г. Калининград, ул. Авиационная, д. 1",
),
Organization(
id="org-mro-001",
name="ТОиР «Балтик Техник»",
kind="mro",
inn="3907654321",
ogrn="1023907654321",
address="г. Калининград, аэропорт Храброво, ангар 3",
),
Organization(
id="org-authority-001",
name="МТУ ФАВТ Северо-Запад",
kind="authority",
inn="7801234567",
ogrn="1027801234567",
address="г. Санкт-Петербург, Московский пр., д. 10",
),
]
db.add_all(orgs)
logger.info("Added 3 organizations")
# ── Типы ВС (model, manufacturer, icao_code) ───────────
types = [
AircraftType(
id="type-ssj100",
model="Sukhoi Superjet 100",
manufacturer="ПАО «Яковлев»",
icao_code="SU95",
),
AircraftType(
id="type-mc21",
model="МС-21-300",
manufacturer="ПАО «Яковлев»",
icao_code="M321",
),
AircraftType(
id="type-l410",
model="L-410 UVP-E20",
manufacturer="Aircraft Industries",
icao_code="L410",
),
]
db.add_all(types)
logger.info("Added 3 aircraft types")
# ── Воздушные суда (без flight_hours — нет в модели aircraft_db) ──
aircraft = [
Aircraft(
id="ac-001",
registration_number="RA-89001",
serial_number="95001",
aircraft_type_id="type-ssj100",
operator_id="org-airline-001",
status="active",
year_of_manufacture=2020,
),
Aircraft(
id="ac-002",
registration_number="RA-89002",
serial_number="95002",
aircraft_type_id="type-ssj100",
operator_id="org-airline-001",
status="active",
year_of_manufacture=2021,
),
Aircraft(
id="ac-003",
registration_number="RA-73001",
serial_number="21001",
aircraft_type_id="type-mc21",
operator_id="org-airline-001",
status="maintenance",
year_of_manufacture=2024,
),
Aircraft(
id="ac-004",
registration_number="RA-67001",
serial_number="3001",
aircraft_type_id="type-l410",
operator_id="org-airline-001",
status="active",
year_of_manufacture=2019,
),
]
db.add_all(aircraft)
logger.info("Added 4 aircraft")
# ── Заявки на сертификацию ─────────────────────────────
today_str = now.strftime("%Y%m%d")
apps = [
CertApplication(
id="app-001",
number=f"KLG-{today_str}-0001",
applicant_org_id="org-mro-001",
created_by_user_id="demo-mro-mgr-001",
status=CertApplicationStatus.SUBMITTED,
subject="Сертификация ТОиР «Балтик Техник» по ФАП-145",
description="Первичная сертификация организации по техническому обслуживанию. "
"Scope: SSJ100, L-410. Категории: A (base), C (engine).",
submitted_at=now - timedelta(days=5),
),
CertApplication(
id="app-002",
number=f"KLG-{today_str}-0002",
applicant_org_id="org-airline-001",
created_by_user_id="demo-operator-mgr-001",
status=CertApplicationStatus.DRAFT,
subject="Продление сертификата эксплуатанта АК «КалинингрАвиа»",
description="Продление СЭ. 4 ВС в парке: 2x SSJ100, 1x МС-21, 1x L-410.",
),
]
db.add_all(apps)
logger.info("Added 2 cert applications")
# ── Уведомления ───────────────────────────────────────
notifs = [
Notification(
recipient_user_id="demo-inspector-001",
title="Новая заявка: ТОиР «Балтик Техник»",
body="Заявка KLG-...-0001 подана на рассмотрение.",
),
Notification(
recipient_user_id="demo-operator-mgr-001",
title="Приближается срок инспекции RA-89001",
body="Плановая инспекция через 14 дней.",
),
]
db.add_all(notifs)
db.commit()
logger.info("✅ Demo seed complete: 5 users, 3 orgs, 3 types, 4 aircraft, 2 applications")
from app.demo.seed_checklists import seed_checklists
seed_checklists()
except Exception as e:
db.rollback()
logger.error("Seed failed: %s", e)
raise
finally:
db.close()
if __name__ == "__main__":
seed()

View File

@ -0,0 +1,178 @@
"""
Seed 8 шаблонов чек-листов аудита ВС (РФ/ИКАО/EASA/FAA).
Проверка на дубликаты по имени перед созданием.
"""
import logging
from app.db.session import SessionLocal
from app.models import ChecklistTemplate, ChecklistItem
logger = logging.getLogger(__name__)
def _ensure_template(db, name: str, description: str | None, domain: str, items: list[tuple[str, str]]) -> None:
if db.query(ChecklistTemplate).filter(ChecklistTemplate.name == name).first():
return
t = ChecklistTemplate(name=name, version=1, description=description, domain=domain, is_active=True)
db.add(t)
db.flush()
for sort_order, (code, text) in enumerate(items, start=1):
db.add(ChecklistItem(template_id=t.id, code=code, text=text, domain=domain, sort_order=sort_order))
logger.info("Created template: %s (%s items)", name, len(items))
def seed_checklists():
db = SessionLocal()
try:
# 1. ФАП-148 — Поддержание лётной годности ВС (15 пунктов)
_ensure_template(db, "ФАП-148 — Поддержание лётной годности ВС", None, "ФАП-148", [
("148.1", "Наличие действующего сертификата лётной годности (СЛГ)"),
("148.2", "СЛГ соответствует типу и модификации ВС"),
("148.3", "Наличие утверждённой программы ТО (ПрТО)"),
("148.4", "ПрТО соответствует данным разработчика и требованиям ГосЦЕНТР"),
("148.5", "Все директивы лётной годности (ДЛГ) выполнены в установленные сроки"),
("148.6", "Информация о выполнении ДЛГ внесена в формуляр ВС"),
("148.7", "Бюллетени (SB) категории «обязательные» выполнены"),
("148.8", "Наработка ВС (часы, циклы) соответствует записям в формуляре"),
("148.9", "Агрегаты с ограниченным ресурсом не превышают назначенных пределов"),
("148.10", "Весовая ведомость и центровка актуальны"),
("148.11", "Записи о последнем ТО формы A-check / C-check имеются"),
("148.12", "Все открытые дефекты оформлены по MEL/CDL или устранены"),
("148.13", "Бортовая документация укомплектована и актуальна"),
("148.14", "Свидетельство о регистрации действительно"),
("148.15", "Страховой полис ОСАГО действителен"),
])
# 2. ФАП-145 — Аудит организации ТОиР (18 пунктов)
_ensure_template(db, "ФАП-145 — Аудит организации ТОиР", None, "ФАП-145", [
("145.A.25", "Наличие одобрения / сертификата ТОиР"),
("145.A.30a", "Требования к персоналу: ответственный руководитель назначен"),
("145.A.30b", "Система качества: менеджер по качеству назначен, независим"),
("145.A.35", "Сертифицирующий персонал: квалификация подтверждена"),
("145.A.40a", "Оборудование и инструмент: перечень актуален"),
("145.A.40b", "Калибровка инструмента выполнена в срок"),
("145.A.42", "Компоненты: входной контроль, прослеживаемость, маркировка"),
("145.A.45a", "Техническая документация: актуальность и доступность"),
("145.A.45b", "Данные производителя (AMM, IPC, SRM) в актуальной ревизии"),
("145.A.47", "Планирование ТО: ресурсы, сроки, приоритеты"),
("145.A.50a", "Выполнение ТО: соответствие наряду и карте ТО"),
("145.A.50b", "CRS (сертификат допуска к эксплуатации) оформлен правильно"),
("145.A.55", "Записи о ТО: хранение не менее 3 лет"),
("145.A.60", "Происшествия/инциденты: сообщения поданы"),
("145.A.65a", "Политика безопасности: SMS внедрена"),
("145.A.65b", "Система управления рисками функционирует"),
("145.A.70", "Руководство MRO (MOE) актуально и одобрено"),
("145.A.75", "Привилегии: работы выполняются в рамках области одобрения"),
])
# 3. ФАП-147 — Аудит учебной организации (9 пунктов)
_ensure_template(db, "ФАП-147 — Аудит учебной организации", None, "ФАП-147", [
("147.A.05", "Область одобрения"),
("147.A.10", "Персонал"),
("147.A.15", "Помещения"),
("147.A.20", "Оборудование"),
("147.A.25", "Программы"),
("147.A.30", "Экзамены"),
("147.A.35", "Записи"),
("147.A.40", "MTOE"),
("147.A.45", "Привилегии"),
])
# 4. ICAO Annex 8 — Лётная годность ВС (12 пунктов)
_ensure_template(db, "ICAO Annex 8 — Лётная годность ВС", None, "ICAO", [
("ICAO-8.3.1", "Сертификаты типа"),
("ICAO-8.3.2", "Сертификаты лётной годности"),
("ICAO-8.3.3", "Надзор за ЛГ"),
("ICAO-8.3.4", "Надзор (продолжение)"),
("ICAO-8.4.1", "Программа ТО"),
("ICAO-8.4.2", "Директивы (AD)"),
("ICAO-8.4.3", "Модификации"),
("ICAO-8.4.4", "Ремонты"),
("ICAO-8.5.1", "Документация"),
("ICAO-8.5.2", "Весовая ведомость"),
("ICAO-8.6.1", "Годность после ТО"),
("ICAO-8.6.2", "CRS"),
])
# 5. ICAO Annex 19 — SMS (12 пунктов)
_ensure_template(db, "ICAO Annex 19 — SMS", None, "ICAO", [
("SMS-1.1", "Политика безопасности — утверждена"),
("SMS-1.2", "Политика — доведена до персонала"),
("SMS-1.3", "Политика — пересмотр"),
("SMS-2.1", "Управление рисками — идентификация"),
("SMS-2.2", "Управление рисками — оценка"),
("SMS-2.3", "Управление рисками — митигация"),
("SMS-3.1", "Обеспечение безопасности — обеспечение"),
("SMS-3.2", "Обеспечение безопасности — мониторинг"),
("SMS-3.3", "Обеспечение безопасности — улучшение"),
("SMS-4.1", "Содействие — обучение"),
("SMS-4.2", "Содействие — коммуникация"),
("SMS-4.3", "Содействие — отчетность"),
])
# 6. EASA Part-M — Поддержание ЛГ (20 пунктов)
_ensure_template(db, "EASA Part-M — Поддержание ЛГ", None, "EASA", [
("M.A.201", "Ответственность эксплуатанта"),
("M.A.301a", "ТО — соответствие"),
("M.A.301b", "ТО — AD выполнены"),
("M.A.301c", "Дефекты — устранены или разрешены"),
("M.A.302", "Программа ТО"),
("M.A.303", "Директивы"),
("M.A.304", "Модификации"),
("M.A.305", "Документация"),
("M.A.306", "Записи ТО"),
("M.A.307", "Хранение записей"),
("M.A.401", "Выполнение ТО"),
("M.A.402", "Дефекты при ТО"),
("M.A.403", "Компоненты — пригодность"),
("M.A.501", "Компоненты — установка"),
("M.A.502", "Компоненты — прослеживаемость"),
("M.A.801", "CRS"),
("M.A.901", "ARC — выдача"),
("M.A.902", "ARC — хранение"),
])
# 7. EASA Part-CAMO — Аудит CAMO (12 пунктов)
_ensure_template(db, "EASA Part-CAMO — Аудит CAMO", None, "EASA", [
("CAMO.A.200", "Одобрение CAMO"),
("CAMO.A.205", "Управление воздушным судном (AM)"),
("CAMO.A.210", "Персонал"),
("CAMO.A.215", "Помещения"),
("CAMO.A.300", "Программа ТО"),
("CAMO.A.305", "AD — выполнение"),
("CAMO.A.310", "Компоненты"),
("CAMO.A.315", "Дефекты"),
("CAMO.A.320", "ARC"),
("CAMO.A.325", "Записи ARC"),
("CAMO.A.405", "Качество"),
("CAMO.A.410", "SMS"),
])
# 8. FAA Part 43/91 — Annual/100-hour Inspection (17 пунктов)
_ensure_template(db, "FAA Part 43/91 — Annual/100-hour Inspection", None, "FAA", [
("43.D-1", "Fuselage and cabin"),
("43.D-2", "Cabin — emergency equipment"),
("43.D-3", "Engine and nacelle"),
("43.D-4", "Engine — compressor"),
("43.D-5", "Propeller"),
("43.D-6", "Landing gear"),
("43.D-7", "Wing and center section"),
("43.D-8", "Empennage"),
("43.D-9", "Control cables"),
("43.D-10", "Instruments"),
("43.D-11", "Avionics"),
("43.D-12", "Fuel system"),
("43.D-13", "Hydraulic system"),
("43.D-14", "Miscellaneous"),
("43.D-15", "Engine — final check"),
("91.409", "Inspection status"),
("91.417", "Records"),
])
db.commit()
logger.info("Checklist seed complete: 8 templates")
except Exception as e:
db.rollback()
logger.exception("Checklist seed failed: %s", e)
raise
finally:
db.close()

View File

@ -40,6 +40,12 @@ 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)
try:
from app.demo.seed_checklists import seed_checklists
seed_checklists()
except Exception as e:
import logging
logging.getLogger(__name__).warning("Checklist seed skipped: %s", e)
# Планировщик рисков (передаём app для shutdown hook)
setup_scheduler(app)
yield

View File

@ -47,6 +47,18 @@ class ChecklistTemplateOut(BaseModel):
from_attributes = True
class ChecklistTemplateUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
domain: Optional[str] = None
class ChecklistItemUpdate(BaseModel):
code: Optional[str] = None
text: Optional[str] = None
sort_order: Optional[int] = None
# --- Audit ---
class AuditCreate(BaseModel):
template_id: str

View File

@ -0,0 +1,254 @@
'use client';
import { useState } from 'react';
import { checklistsApi } from '@/lib/api/api-client';
export interface ChecklistEditModalTemplate {
id: string;
name: string;
description?: string | null;
domain?: string | null;
version: number;
items: { id: string; code: string; text: string; sort_order: number }[];
}
interface Props {
template: ChecklistEditModalTemplate;
onClose: () => void;
onSaved: () => void;
}
export default function ChecklistEditModal({ template, onClose, onSaved }: Props) {
const [name, setName] = useState(template.name);
const [description, setDescription] = useState(template.description ?? '');
const [savingHeader, setSavingHeader] = useState(false);
const [items, setItems] = useState(template.items);
const [editingId, setEditingId] = useState<string | null>(null);
const [editCode, setEditCode] = useState('');
const [editText, setEditText] = useState('');
const [newCode, setNewCode] = useState('');
const [newText, setNewText] = useState('');
const [adding, setAdding] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const saveHeader = async () => {
setSavingHeader(true);
try {
await checklistsApi.updateTemplate(template.id, {
name: name.trim() || template.name,
description: description.trim() || null,
});
onSaved();
} finally {
setSavingHeader(false);
}
};
const startEdit = (item: { id: string; code: string; text: string }) => {
setEditingId(item.id);
setEditCode(item.code);
setEditText(item.text);
};
const cancelEdit = () => {
setEditingId(null);
};
const saveEdit = async () => {
if (!editingId) return;
try {
await checklistsApi.updateItem(editingId, { code: editCode.trim(), text: editText.trim() });
setItems((prev) =>
prev.map((i) => (i.id === editingId ? { ...i, code: editCode.trim(), text: editText.trim() } : i))
);
setEditingId(null);
onSaved();
} catch (e) {
console.error(e);
}
};
const deleteItem = async (itemId: string) => {
if (!confirm('Удалить пункт?')) return;
setDeletingId(itemId);
try {
await checklistsApi.deleteItem(itemId);
setItems((prev) => prev.filter((i) => i.id !== itemId));
onSaved();
} catch (e) {
console.error(e);
} finally {
setDeletingId(null);
}
};
const addItem = async () => {
const code = newCode.trim();
const text = newText.trim();
if (!code || !text) return;
setAdding(true);
try {
const created = await checklistsApi.addItem(template.id, {
code,
text,
domain: template.domain ?? undefined,
sort_order: items.length + 1,
});
setItems((prev) => [...prev, created]);
setNewCode('');
setNewText('');
onSaved();
} catch (e) {
console.error(e);
} finally {
setAdding(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div
className="flex max-h-[90vh] w-full max-w-4xl flex-col rounded-lg bg-white shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="border-b border-gray-200 p-4">
<div className="mb-2">
<label className="text-xs text-gray-500">Название</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div className="mb-2">
<label className="text-xs text-gray-500">Описание</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="w-full rounded border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<button
type="button"
onClick={saveHeader}
disabled={savingHeader}
className="rounded bg-primary-500 px-3 py-1.5 text-sm text-white hover:bg-primary-600 disabled:opacity-50"
>
{savingHeader ? 'Сохранение…' : 'Сохранить заголовок'}
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-2">
{items.map((item, idx) => (
<div
key={item.id}
className="group flex items-start gap-2 rounded border border-gray-100 bg-gray-50/50 p-2"
>
<span className="w-6 shrink-0 text-xs text-gray-400">{idx + 1}</span>
{editingId === item.id ? (
<>
<input
value={editCode}
onChange={(e) => setEditCode(e.target.value)}
placeholder="Код"
className="w-24 rounded border border-gray-300 px-2 py-1 text-sm"
/>
<input
value={editText}
onChange={(e) => setEditText(e.target.value)}
placeholder="Текст"
className="min-w-0 flex-1 rounded border border-gray-300 px-2 py-1 text-sm"
/>
<button
type="button"
onClick={saveEdit}
className="rounded bg-green-600 px-2 py-1 text-xs text-white hover:bg-green-700"
>
Сохранить
</button>
<button
type="button"
onClick={cancelEdit}
className="rounded bg-gray-400 px-2 py-1 text-xs text-white hover:bg-gray-500"
>
Отмена
</button>
</>
) : (
<>
<span className="min-w-[80px] shrink-0 text-xs font-medium text-primary-600">{item.code}</span>
<span className="min-w-0 flex-1 text-sm text-gray-800">{item.text}</span>
<div className="flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
onClick={() => startEdit(item)}
className="rounded p-1 hover:bg-gray-200"
title="Редактировать"
>
</button>
<button
type="button"
onClick={() => deleteItem(item.id)}
disabled={deletingId === item.id}
className="rounded p-1 hover:bg-red-100 disabled:opacity-50"
title="Удалить"
>
🗑
</button>
</div>
</>
)}
</div>
))}
</div>
<div className="mt-4 flex flex-wrap items-end gap-2 rounded border border-dashed border-gray-300 bg-gray-50/50 p-3">
<div>
<label className="text-xs text-gray-500">Код</label>
<input
value={newCode}
onChange={(e) => setNewCode(e.target.value)}
placeholder="Напр. 148.16"
className="ml-1 w-28 rounded border border-gray-300 px-2 py-1.5 text-sm"
/>
</div>
<div className="min-w-0 flex-1">
<label className="text-xs text-gray-500">Текст</label>
<input
value={newText}
onChange={(e) => setNewText(e.target.value)}
placeholder="Текст пункта"
className="ml-1 w-full rounded border border-gray-300 px-2 py-1.5 text-sm"
/>
</div>
<button
type="button"
onClick={addItem}
disabled={adding || !newCode.trim() || !newText.trim()}
className="rounded bg-primary-500 px-3 py-1.5 text-sm text-white hover:bg-primary-600 disabled:opacity-50"
>
{adding ? '…' : 'Добавить'}
</button>
</div>
</div>
<div className="flex items-center justify-between border-t border-gray-200 px-4 py-3 text-sm text-gray-500">
<span>
{template.domain ?? '—'} · v{template.version} · {items.length} пунктов
</span>
<button
type="button"
onClick={onClose}
className="rounded bg-gray-200 px-3 py-1.5 hover:bg-gray-300"
>
Закрыть
</button>
</div>
</div>
</div>
);
}

View File

@ -191,6 +191,14 @@ export const checklistsApi = {
getTemplate: (id: string) => apiFetch(`/checklists/templates/${id}`),
createTemplate: (data: any) => apiFetch('/checklists/templates', { method: 'POST', body: JSON.stringify(data) }),
generate: (source: string, name: string, items?: any[]) => apiFetch(`/checklists/generate?source=${source}&name=${name}`, { method: 'POST', body: items ? JSON.stringify(items) : undefined }),
updateTemplate: (id: string, data: any) =>
apiFetch(`/checklists/templates/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
addItem: (templateId: string, data: any) =>
apiFetch(`/checklists/templates/${templateId}/items`, { method: 'POST', body: JSON.stringify(data) }),
updateItem: (itemId: string, data: any) =>
apiFetch(`/checklists/items/${itemId}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteItem: (itemId: string) =>
apiFetch(`/checklists/items/${itemId}`, { method: 'DELETE' }),
};
// Notifications