lct-10: карточка КИО — форма, слияние правок, read-only
Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.
Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.
Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.
Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
|
|
|
|
// Карточка КИО. Один и тот же компонент рисуется у курсанта, на пульте,
|
|
|
|
|
|
// на внешнем мониторе и на АРМ ДДС — там в режиме `readOnly`.
|
|
|
|
|
|
|
feat: классификатор ЕКП — признаки вместо выбора службы (lct-32)
Оператор системы-112 службу не выбирает: он проставляет формализованные
признаки происшествия, комбинация признаков даёт код ЕКП, а коду соответствует
список оповещения, который система собирает сама (docs/spec/DATASET.md).
Прежняя модель с полем dds из пяти значений оценивала действие, которого
в боевой работе нет.
- scripts/import_ekp.py (make ekp): книга заказчика → app/domain/ekp.json,
1283 кода, 61 служба, 23 группы. Разовый импорт, результат под гитом:
читать xlsx в рантайме — лишняя зависимость и полсекунды на старте.
- domain/ekp.py: справочник с ленивой загрузкой, каскад значений признаков,
список оповещения с модификаторами (нет доступа, угроза людям, пострадавшие,
газификация и ещё десяток).
- КИО: поля signs, incident_code, notify. Последние два только на чтение
и пересчитываются при каждой правке признаков; добавленная вручную служба
не теряется, удалить службу нельзя — как в боевом АРМ.
- GET /api/ekp/signs отдаёт один уровень признаков, а не дерево на полмегабайта.
- Карточка на фронте: три каскадных селектора вместо выбора службы.
- Оценка: метрика incident_signs (E2, маршрутизация). Для размеченных сценариев
dds_choice больше не считается — список оповещения производен от признаков,
и штрафовать за него отдельно значит наказать дважды за одну ошибку.
Разбор книги оказался основной работой: имя службы лежит то в первой строке
заголовка, то во второй, то склеено с модификатором; «Классификатор МЧС» —
заголовок группы колонок, а не служба. Правила разбора в докстринге импорта.
113 тестов зелёных (14 новых в tests/test_ekp.py), make typecheck чистый.
2026-09-19 20:11:50 +03:00
|
|
|
|
import { useSigns } from "@/shared/api/http";
|
lct-10: карточка КИО — форма, слияние правок, read-only
Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.
Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.
Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.
Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
|
|
|
|
import { type CardState, display } from "./merge";
|
|
|
|
|
|
import { type Field, visibleGroups } from "./fields";
|
|
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
|
state: CardState;
|
|
|
|
|
|
/** Обязательные поля приходят сценарием в `call.incoming`. */
|
|
|
|
|
|
required?: string[];
|
|
|
|
|
|
readOnly?: boolean;
|
|
|
|
|
|
onChange?: (path: string, value: unknown) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function KioCard({ state, required = [], readOnly = false, onChange }: Props) {
|
|
|
|
|
|
const dds = display(state, "dds") as string | null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="kio">
|
|
|
|
|
|
{visibleGroups(dds).map((group) => (
|
|
|
|
|
|
<table className="grid" key={group.title}>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th colSpan={2}>{group.title}</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{group.fields.map((field) => (
|
|
|
|
|
|
<Row
|
|
|
|
|
|
key={field.path}
|
|
|
|
|
|
field={field}
|
|
|
|
|
|
value={display(state, field.path)}
|
|
|
|
|
|
pending={field.path in state.pending}
|
|
|
|
|
|
required={required.includes(field.path)}
|
|
|
|
|
|
readOnly={readOnly || field.readOnly}
|
|
|
|
|
|
onChange={onChange}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function Row({
|
|
|
|
|
|
field, value, pending, required, readOnly, onChange,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
field: Field;
|
|
|
|
|
|
value: unknown;
|
|
|
|
|
|
pending: boolean;
|
|
|
|
|
|
required: boolean;
|
|
|
|
|
|
readOnly?: boolean;
|
|
|
|
|
|
onChange?: (path: string, value: unknown) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const empty = value === null || value === undefined || value === "";
|
|
|
|
|
|
return (
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>
|
|
|
|
|
|
{field.label}
|
|
|
|
|
|
{required && <span title="обязательное поле" className="required"> ·</span>}
|
|
|
|
|
|
</th>
|
|
|
|
|
|
<td className={required && empty ? "warn" : pending ? "pending" : ""}>
|
|
|
|
|
|
{readOnly ? (
|
|
|
|
|
|
<span>{formatValue(value)}</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Input field={field} value={value} onChange={onChange} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function Input({
|
|
|
|
|
|
field, value, onChange,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
field: Field;
|
|
|
|
|
|
value: unknown;
|
|
|
|
|
|
onChange?: (path: string, value: unknown) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const emit = (next: unknown) => onChange?.(field.path, next);
|
|
|
|
|
|
|
feat: классификатор ЕКП — признаки вместо выбора службы (lct-32)
Оператор системы-112 службу не выбирает: он проставляет формализованные
признаки происшествия, комбинация признаков даёт код ЕКП, а коду соответствует
список оповещения, который система собирает сама (docs/spec/DATASET.md).
Прежняя модель с полем dds из пяти значений оценивала действие, которого
в боевой работе нет.
- scripts/import_ekp.py (make ekp): книга заказчика → app/domain/ekp.json,
1283 кода, 61 служба, 23 группы. Разовый импорт, результат под гитом:
читать xlsx в рантайме — лишняя зависимость и полсекунды на старте.
- domain/ekp.py: справочник с ленивой загрузкой, каскад значений признаков,
список оповещения с модификаторами (нет доступа, угроза людям, пострадавшие,
газификация и ещё десяток).
- КИО: поля signs, incident_code, notify. Последние два только на чтение
и пересчитываются при каждой правке признаков; добавленная вручную служба
не теряется, удалить службу нельзя — как в боевом АРМ.
- GET /api/ekp/signs отдаёт один уровень признаков, а не дерево на полмегабайта.
- Карточка на фронте: три каскадных селектора вместо выбора службы.
- Оценка: метрика incident_signs (E2, маршрутизация). Для размеченных сценариев
dds_choice больше не считается — список оповещения производен от признаков,
и штрафовать за него отдельно значит наказать дважды за одну ошибку.
Разбор книги оказался основной работой: имя службы лежит то в первой строке
заголовка, то во второй, то склеено с модификатором; «Классификатор МЧС» —
заголовок группы колонок, а не служба. Правила разбора в докстринге импорта.
113 тестов зелёных (14 новых в tests/test_ekp.py), make typecheck чистый.
2026-09-19 20:11:50 +03:00
|
|
|
|
if (field.kind === "list") {
|
|
|
|
|
|
return <span>{formatValue(value)}</span>;
|
|
|
|
|
|
}
|
lct-10: карточка КИО — форма, слияние правок, read-only
Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.
Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.
Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.
Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
|
|
|
|
if (field.kind === "bool") {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<input type="checkbox" checked={value === true} onChange={(e) => emit(e.target.checked)} />
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (field.kind === "select") {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<select value={(value as string) ?? ""} onChange={(e) => emit(e.target.value || null)}>
|
|
|
|
|
|
<option value="">—</option>
|
|
|
|
|
|
{field.options?.map((option) => (
|
|
|
|
|
|
<option key={option.value} value={option.value}>{option.label}</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
feat: классификатор ЕКП — признаки вместо выбора службы (lct-32)
Оператор системы-112 службу не выбирает: он проставляет формализованные
признаки происшествия, комбинация признаков даёт код ЕКП, а коду соответствует
список оповещения, который система собирает сама (docs/spec/DATASET.md).
Прежняя модель с полем dds из пяти значений оценивала действие, которого
в боевой работе нет.
- scripts/import_ekp.py (make ekp): книга заказчика → app/domain/ekp.json,
1283 кода, 61 служба, 23 группы. Разовый импорт, результат под гитом:
читать xlsx в рантайме — лишняя зависимость и полсекунды на старте.
- domain/ekp.py: справочник с ленивой загрузкой, каскад значений признаков,
список оповещения с модификаторами (нет доступа, угроза людям, пострадавшие,
газификация и ещё десяток).
- КИО: поля signs, incident_code, notify. Последние два только на чтение
и пересчитываются при каждой правке признаков; добавленная вручную служба
не теряется, удалить службу нельзя — как в боевом АРМ.
- GET /api/ekp/signs отдаёт один уровень признаков, а не дерево на полмегабайта.
- Карточка на фронте: три каскадных селектора вместо выбора службы.
- Оценка: метрика incident_signs (E2, маршрутизация). Для размеченных сценариев
dds_choice больше не считается — список оповещения производен от признаков,
и штрафовать за него отдельно значит наказать дважды за одну ошибку.
Разбор книги оказался основной работой: имя службы лежит то в первой строке
заголовка, то во второй, то склеено с модификатором; «Классификатор МЧС» —
заголовок группы колонок, а не служба. Правила разбора в докстринге импорта.
113 тестов зелёных (14 новых в tests/test_ekp.py), make typecheck чистый.
2026-09-19 20:11:50 +03:00
|
|
|
|
if (field.kind === "signs") {
|
|
|
|
|
|
return <SignsPicker value={(value as string[]) ?? []} onChange={(next) => emit(next)} />;
|
|
|
|
|
|
}
|
lct-10: карточка КИО — форма, слияние правок, read-only
Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.
Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.
Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.
Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
|
|
|
|
if (field.kind === "number") {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min={0}
|
|
|
|
|
|
value={value === null || value === undefined ? "" : String(value)}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
const text = e.target.value;
|
|
|
|
|
|
if (text === "") return emit(null);
|
|
|
|
|
|
const parsed = Number(text);
|
|
|
|
|
|
// Нечисловое значение не уходит на сервер: карточка — норматив,
|
|
|
|
|
|
// а не свободный текст.
|
|
|
|
|
|
if (Number.isFinite(parsed) && parsed >= 0) emit(parsed);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<input type="text" value={(value as string) ?? ""} onChange={(e) => emit(e.target.value)} />
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: классификатор ЕКП — признаки вместо выбора службы (lct-32)
Оператор системы-112 службу не выбирает: он проставляет формализованные
признаки происшествия, комбинация признаков даёт код ЕКП, а коду соответствует
список оповещения, который система собирает сама (docs/spec/DATASET.md).
Прежняя модель с полем dds из пяти значений оценивала действие, которого
в боевой работе нет.
- scripts/import_ekp.py (make ekp): книга заказчика → app/domain/ekp.json,
1283 кода, 61 служба, 23 группы. Разовый импорт, результат под гитом:
читать xlsx в рантайме — лишняя зависимость и полсекунды на старте.
- domain/ekp.py: справочник с ленивой загрузкой, каскад значений признаков,
список оповещения с модификаторами (нет доступа, угроза людям, пострадавшие,
газификация и ещё десяток).
- КИО: поля signs, incident_code, notify. Последние два только на чтение
и пересчитываются при каждой правке признаков; добавленная вручную служба
не теряется, удалить службу нельзя — как в боевом АРМ.
- GET /api/ekp/signs отдаёт один уровень признаков, а не дерево на полмегабайта.
- Карточка на фронте: три каскадных селектора вместо выбора службы.
- Оценка: метрика incident_signs (E2, маршрутизация). Для размеченных сценариев
dds_choice больше не считается — список оповещения производен от признаков,
и штрафовать за него отдельно значит наказать дважды за одну ошибку.
Разбор книги оказался основной работой: имя службы лежит то в первой строке
заголовка, то во второй, то склеено с модификатором; «Классификатор МЧС» —
заголовок группы колонок, а не служба. Правила разбора в докстринге импорта.
113 тестов зелёных (14 новых в tests/test_ekp.py), make typecheck чистый.
2026-09-19 20:11:50 +03:00
|
|
|
|
/** Три признака происшествия каскадом: выбор верхнего сужает нижний.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Значения приходят с сервера, а не лежат на фронте: классификатор — 1283 строки,
|
|
|
|
|
|
* и он обновляется файлом от заказчика (docs/spec/DATASET.md). Служба из выбора
|
|
|
|
|
|
* не следует — её считает сервер, поэтому здесь её нет. */
|
|
|
|
|
|
function SignsPicker({
|
|
|
|
|
|
value, onChange,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
value: string[];
|
|
|
|
|
|
onChange: (next: string[]) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const levels = [0, 1, 2];
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="signs">
|
|
|
|
|
|
{levels.map((level) => (
|
|
|
|
|
|
<SignSelect
|
|
|
|
|
|
key={level}
|
|
|
|
|
|
prefix={value.slice(0, level)}
|
|
|
|
|
|
chosen={value[level] ?? ""}
|
|
|
|
|
|
onPick={(sign) => {
|
|
|
|
|
|
// Смена верхнего признака обнуляет нижние: они относились
|
|
|
|
|
|
// к другой ветке классификатора.
|
|
|
|
|
|
const next = value.slice(0, level);
|
|
|
|
|
|
if (sign) next.push(sign);
|
|
|
|
|
|
onChange(next);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function SignSelect({
|
|
|
|
|
|
prefix, chosen, onPick,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
prefix: string[];
|
|
|
|
|
|
chosen: string;
|
|
|
|
|
|
onPick: (sign: string) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const page = useSigns(prefix);
|
|
|
|
|
|
const values = page.data?.values ?? [];
|
|
|
|
|
|
// Уровня может не быть: часть происшествий классифицируется двумя признаками.
|
|
|
|
|
|
if (!page.isLoading && values.length === 0) return null;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<select value={chosen} onChange={(e) => onPick(e.target.value)} disabled={page.isLoading}>
|
|
|
|
|
|
<option value="">—</option>
|
|
|
|
|
|
{values.map((sign) => (
|
|
|
|
|
|
<option key={sign} value={sign}>{sign}</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
lct-10: карточка КИО — форма, слияние правок, read-only
Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.
Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.
Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.
Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
|
|
|
|
function formatValue(value: unknown): string {
|
|
|
|
|
|
if (value === true) return "да";
|
|
|
|
|
|
if (value === false) return "нет";
|
feat: классификатор ЕКП — признаки вместо выбора службы (lct-32)
Оператор системы-112 службу не выбирает: он проставляет формализованные
признаки происшествия, комбинация признаков даёт код ЕКП, а коду соответствует
список оповещения, который система собирает сама (docs/spec/DATASET.md).
Прежняя модель с полем dds из пяти значений оценивала действие, которого
в боевой работе нет.
- scripts/import_ekp.py (make ekp): книга заказчика → app/domain/ekp.json,
1283 кода, 61 служба, 23 группы. Разовый импорт, результат под гитом:
читать xlsx в рантайме — лишняя зависимость и полсекунды на старте.
- domain/ekp.py: справочник с ленивой загрузкой, каскад значений признаков,
список оповещения с модификаторами (нет доступа, угроза людям, пострадавшие,
газификация и ещё десяток).
- КИО: поля signs, incident_code, notify. Последние два только на чтение
и пересчитываются при каждой правке признаков; добавленная вручную служба
не теряется, удалить службу нельзя — как в боевом АРМ.
- GET /api/ekp/signs отдаёт один уровень признаков, а не дерево на полмегабайта.
- Карточка на фронте: три каскадных селектора вместо выбора службы.
- Оценка: метрика incident_signs (E2, маршрутизация). Для размеченных сценариев
dds_choice больше не считается — список оповещения производен от признаков,
и штрафовать за него отдельно значит наказать дважды за одну ошибку.
Разбор книги оказался основной работой: имя службы лежит то в первой строке
заголовка, то во второй, то склеено с модификатором; «Классификатор МЧС» —
заголовок группы колонок, а не служба. Правила разбора в докстринге импорта.
113 тестов зелёных (14 новых в tests/test_ekp.py), make typecheck чистый.
2026-09-19 20:11:50 +03:00
|
|
|
|
if (Array.isArray(value)) return value.length ? value.join(", ") : "—";
|
lct-10: карточка КИО — форма, слияние правок, read-only
Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.
Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.
Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.
Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
|
|
|
|
if (value === null || value === undefined || value === "") return "—";
|
|
|
|
|
|
return String(value);
|
|
|
|
|
|
}
|