lct-38: каталог 209 служб из «СЛУЖБЫ 112» для ручного добавления в карточку

This commit is contained in:
Ivan Gerasimov 2026-09-27 16:52:11 +03:00
commit 3ce3e768e1
13 changed files with 1715 additions and 6 deletions

View file

@ -1,7 +1,9 @@
// Карточка КИО. Один и тот же компонент рисуется у курсанта, на пульте,
// на внешнем мониторе и на АРМ ДДС — там в режиме `readOnly`.
import { useIncidentGroups, useSigns } from "@/shared/api/http";
import { useState } from "react";
import { useIncidentGroups, useServiceCatalog, useSigns } from "@/shared/api/http";
import { type CardState, display } from "./merge";
import { type Field, type FieldGroup, visibleGroups } from "./fields";
@ -141,6 +143,9 @@ function Input({
</select>
);
}
if (field.kind === "services") {
return <ServicesPicker value={(value as string[]) ?? []} onChange={(next) => emit(next)} />;
}
if (field.kind === "signs") {
return <SignsPicker value={(value as string[]) ?? []} group={incidentGroup} onChange={(next) => emit(next)} />;
}
@ -170,6 +175,36 @@ function Input({
);
}
/** Ручное добавление служб из каталога — окно «Добавьте службы» боевого АРМ.
* Список оповещения по признакам остаётся как есть: здесь только добавки. */
function ServicesPicker({ value, onChange }: { value: string[]; onChange: (next: string[]) => void }) {
const catalog = useServiceCatalog();
const [query, setQuery] = useState("");
const needle = query.trim().toLowerCase();
const found = needle
? (catalog.data?.services ?? [])
.filter((item) => !value.includes(item.name) && item.name.toLowerCase().includes(needle))
.slice(0, 8)
: [];
return (
<div className="kio-services">
{value.length > 0 && <div className="signs-selected" aria-label="Добавленные службы">
{value.map((name) => <span key={name}>
{name} <button type="button" aria-label={`Убрать ${name}`}
onClick={() => onChange(value.filter((item) => item !== name))}>×</button>
</span>)}
</div>}
<input type="search" aria-label="Поиск службы в каталоге" placeholder="Поиск службы…"
value={query} onChange={(event) => setQuery(event.target.value)} />
{found.length > 0 && <ul className="kio-services-found">
{found.map((item) => <li key={item.id}>
<button type="button" onClick={() => { onChange([...value, item.name]); setQuery(""); }}>{item.name}</button>
</li>)}
</ul>}
</div>
);
}
function IncidentGroupPicker({ value, onChange }: { value: number | null; onChange: (next: number | null) => void }) {
const page = useIncidentGroups();
return <select aria-label="Категория происшествия" value={value ?? ""}

View file

@ -6,7 +6,7 @@
import type { DDSCode, IncidentType } from "@/shared/types/generated";
export type FieldKind = "text" | "textarea" | "number" | "bool" | "select" | "signs" | "group" | "list";
export type FieldKind = "text" | "textarea" | "number" | "bool" | "select" | "signs" | "group" | "list" | "services";
export interface Field {
/** Путь в карточке: «floor», «fire.floors» — так же, как в `kio.patch`. */
@ -107,6 +107,8 @@ export const GROUPS: FieldGroup[] = [
fields: [
// Список оповещения оператор не выбирает: он производен от признаков.
{ path: "notify", label: "Список оповещения", kind: "list", readOnly: true },
// Как «Службы: +» в боевом АРМ: добавить можно, убрать автоматическую — нет.
{ path: "notify_extra", label: "Дополнительно оповестить", kind: "services" },
{ path: "dds", label: "Служба (устаревшее)", kind: "select", options: DDS },
{ path: "dispatch_order_at", label: "Приказ на выезд", kind: "text", readOnly: true },
{ path: "arrival_at", label: "Прибытие", kind: "text", readOnly: true },

View file

@ -548,6 +548,7 @@ export function Dds() {
<div><b>{field(card, "description")}</b></div>
<div>Класс: <b>{field(card, "incident_type")}</b></div>
<div>Получатели карточки: <b>{station?.recipient_services?.join(", ") || field(card, "notify")}</b></div>
{field(card, "notify_extra") !== "—" && <div>Добавлены оператором: <b>{field(card, "notify_extra")}</b></div>}
<div>Ваша ДДС: <b>{station?.managed_service || "служба не назначена"}</b></div></div>
</div>
<div className="dds-case-stage">

View file

@ -230,6 +230,17 @@ export const useIncidentGroups = () => useQuery({
queryFn: () => request<IncidentGroupsPage>("/api/ekp/groups"),
});
export interface ServiceCatalogPage {
source: string;
services: { id: string; name: string; organization: string | null; service_level: string | null; source_reference: string }[];
}
/** Каталог окна «Добавьте службы»: 209 служб, включая районные ДДС. */
export const useServiceCatalog = () => useQuery({
queryKey: ["ekp-services"], staleTime: Infinity,
queryFn: () => request<ServiceCatalogPage>("/api/ekp/services"),
});
export function useSigns(prefix: string[], group: number | null = null) {
const params = new URLSearchParams();
if (group !== null) params.set("group", String(group));

View file

@ -346,6 +346,8 @@ export interface KIO {
evacuation_needed?: boolean;
/** Список оповещения */
notify?: Array<string>;
/** Дополнительно оповестить */
notify_extra?: Array<string>;
/** Устаревшее: одна служба. Заменено списком оповещения */
dds?: DDSCode | null;
dispatch_order_at?: string | null;

View file

@ -783,6 +783,11 @@ button:disabled { opacity: 0.5; cursor: default; }
.kio-arm-incident .signs select { flex: 1 1 180px; width: auto; max-width: 100%; padding: 7px 8px; border: 1px solid #acb5bc; background: white; }
.kio-arm-ref .signs-selected { display: flex; flex: 1 1 100%; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
.kio-arm-ref .signs-selected span { padding: 7px 10px; background: #1876b4; color: white; font-size: 12px; font-weight: 700; }
.kio-services { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.kio-services .signs-selected { display: flex; flex-wrap: wrap; gap: 5px; }
.kio-services .signs-selected button { border: 0; background: transparent; color: inherit; cursor: pointer; padding: 0 0 0 4px; }
.kio-services-found { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); max-height: 220px; overflow-y: auto; }
.kio-services-found button { width: 100%; text-align: left; border: 0; border-bottom: 1px solid var(--line); background: var(--panel); padding: 6px 8px; cursor: pointer; }
.kio-arm-choice { display: flex; flex-wrap: wrap; gap: 6px; padding: 5px 0; }
.kio-arm-choice button { min-width: 49px; padding: 6px 11px; border: 1px solid #aeb6bd; background: white; font-size: 13px; font-weight: 700; }
.kio-arm-choice .kio-arm-selected { border-color: #1979bb; background: #1979bb; color: white; }