lct-10: карточка КИО — форма, слияние правок, read-only

Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.

Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.

Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.

Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
This commit is contained in:
Ivan Gerasimov 2026-09-17 21:16:51 +03:00
commit 48c936ea6e
8 changed files with 454 additions and 3 deletions

View file

@ -0,0 +1,147 @@
// Поля карточки информационного обмена. Это не дизайн формы, это норматив:
// docs/spec/NORMATIVES.md#карточка-информационного-обмена-кио
//
// Описание полей отдельно от отрисовки: по нему же рисуется карточка на внешнем
// мониторе, на пульте и на АРМ ДДС — там она read-only.
import type { DDSCode, IncidentType } from "@/shared/types/generated";
export type FieldKind = "text" | "number" | "bool" | "select";
export interface Field {
/** Путь в карточке: «floor», «fire.floors» — так же, как в `kio.patch`. */
path: string;
label: string;
kind: FieldKind;
options?: { value: string; label: string }[];
/** Заполняется системой: номер определяется автоматически, время — сервером. */
readOnly?: boolean;
}
export interface FieldGroup {
title: string;
fields: Field[];
/** Группа показывается только для этой службы. */
dds?: DDSCode;
}
const INCIDENT: { value: IncidentType; label: string }[] = [
{ value: "fire", label: "Пожар" },
{ value: "medical", label: "Медицинский вызов" },
{ value: "police", label: "Правонарушение" },
{ value: "gkh", label: "Авария ЖКХ" },
{ value: "era_glonass", label: "ДТП, автовызов" },
{ value: "other", label: "Прочее" },
];
const DDS: { value: DDSCode; label: string }[] = [
{ value: "01", label: "01 — МЧС" },
{ value: "02", label: "02 — полиция" },
{ value: "03", label: "03 — скорая помощь" },
{ value: "04", label: "04 — прочие службы" },
{ value: "gkh", label: "ЖКХ" },
];
export const GROUPS: FieldGroup[] = [
{
title: "Служебное",
fields: [
{ path: "registered_at", label: "Время регистрации", kind: "text", readOnly: true },
{ path: "response_status", label: "Статус реагирования", kind: "text", readOnly: true },
],
},
{
title: "Звонящий",
fields: [
{ path: "caller_number", label: "Номер телефона", kind: "text", readOnly: true },
{ path: "caller_name", label: "ФИО", kind: "text" },
{ path: "caller_contact", label: "Контактный телефон", kind: "text" },
{ path: "language", label: "Язык общения", kind: "text" },
],
},
{
title: "Место",
fields: [
{ path: "okato", label: "ОКАТО", kind: "text" },
{ path: "address", label: "Адрес", kind: "text" },
{ path: "street", label: "Улица", kind: "text" },
{ path: "building", label: "Дом", kind: "text" },
{ path: "entrance", label: "Подъезд", kind: "text" },
{ path: "floor", label: "Этаж", kind: "text" },
{ path: "intercom_code", label: "Код домофона", kind: "text" },
],
},
{
title: "Происшествие",
fields: [
{ path: "incident_type", label: "Тип происшествия", kind: "select", options: INCIDENT },
{ path: "description", label: "Описание", kind: "text" },
{ path: "victims_count", label: "Пострадавших", kind: "number" },
{ path: "is_emergency", label: "Признак ЧС", kind: "bool" },
{ path: "life_threat", label: "Угроза жизни", kind: "bool" },
{ path: "evacuation_needed", label: "Нужна эвакуация", kind: "bool" },
],
},
{
title: "Дежурно-диспетчерская служба",
fields: [
{ path: "dds", label: "Служба", kind: "select", options: DDS },
{ path: "dispatch_order_at", label: "Приказ на выезд", kind: "text", readOnly: true },
{ path: "arrival_at", label: "Прибытие", kind: "text", readOnly: true },
],
},
{
title: "Уточнения: пожар",
dds: "01",
fields: [
{ path: "fire.fire_nature", label: "Характер пожара", kind: "text" },
{ path: "fire.object_kind", label: "Объект", kind: "text" },
{ path: "fire.floors", label: "Этажность", kind: "number" },
{ path: "fire.gasified", label: "Газификация", kind: "bool" },
{ path: "fire.people_inside", label: "Люди в помещении", kind: "bool" },
{ path: "fire.smoke_spread", label: "Куда идёт дым", kind: "text" },
],
},
{
title: "Уточнения: полиция",
dds: "02",
fields: [
{ path: "police.offence_kind", label: "Вид правонарушения", kind: "text" },
{ path: "police.suspects", label: "Подозреваемые, приметы", kind: "text" },
{ path: "police.suspect_fled", label: "Нарушитель скрылся", kind: "bool" },
{ path: "police.vehicle", label: "Транспортное средство", kind: "text" },
],
},
{
title: "Уточнения: медицина",
dds: "03",
fields: [
{ path: "medical.reason", label: "Повод к вызову", kind: "text" },
{ path: "medical.conscious", label: "В сознании", kind: "bool" },
{ path: "medical.breathing", label: "Дышит", kind: "bool" },
{ path: "medical.can_move", label: "Может передвигаться", kind: "bool" },
{ path: "medical.age", label: "Возраст", kind: "number" },
],
},
{
title: "Уточнения: ЖКХ и прочие",
dds: "gkh",
fields: [
{ path: "utility.failure_kind", label: "Тип аварии", kind: "text" },
{ path: "utility.scale", label: "Масштаб", kind: "text" },
{ path: "utility.threat_to_residents", label: "Угроза жильцам", kind: "bool" },
],
},
];
/** Группы, которые показываются при выбранной службе. */
export function visibleGroups(dds: string | null | undefined): FieldGroup[] {
return GROUPS.filter((group) => !group.dds || group.dds === dds);
}
export function readValue(card: Record<string, unknown>, path: string): unknown {
return path.split(".").reduce<unknown>(
(value, part) => (value && typeof value === "object" ? (value as Record<string, unknown>)[part] : undefined),
card,
);
}