Merge branch 'feat/service-catalog'
lct-38: каталог 209 служб из «СЛУЖБЫ 112» для ручного добавления в карточку
This commit is contained in:
commit
fdc5484646
15 changed files with 1853 additions and 16 deletions
|
|
@ -74,6 +74,18 @@ socket.receive({ type: "kio.patch", fields: {
|
|||
}, source: "operator" });
|
||||
socket = reconnect(socket);
|
||||
assert.deepEqual(socket.sent, [], "matching checkpoint echo clears the KIO patch");
|
||||
const refused = { type: "kio.patch", fields: { address: "ул. Новая, 3", notify_extra: ["Нет в каталоге"] } };
|
||||
call.send(refused);
|
||||
socket.receive({ type: "kio.patch", fields: { address: patch.fields.address, notify_extra: [] }, source: "auto" });
|
||||
socket.sent = [];
|
||||
call.send({ type: "kio.patch", fields: { floor: "2" } });
|
||||
assert.deepEqual(socket.sent.at(-1).fields, { floor: "2" },
|
||||
"server values after a refused patch acknowledge it: refused fields are not merged into the next edit");
|
||||
socket.receive({ type: "kio.patch", fields: { floor: "2" }, source: "operator" });
|
||||
socket = reconnect(socket);
|
||||
assert.deepEqual(socket.sent, [], "nothing left to replay after refusal and the next echo");
|
||||
const refusedCard = applyPatch(edit(empty, "notify_extra", ["Нет в каталоге"]), { notify_extra: [] }, "auto");
|
||||
assert.deepEqual(refusedCard.pending, {}, "server value after refusal drops the pending edit");
|
||||
const edited = edit(empty, "coords", { latitude: 55.7, longitude: 37.6 });
|
||||
assert.deepEqual(applyPatch(edited, { coords: { longitude: 37.6, latitude: 55.7 } }, "operator").pending, {});
|
||||
assert.deepEqual(applyState(edited, { coords: { longitude: 37.6, latitude: 55.7 } }).pending, {});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
@ -17,6 +19,7 @@ interface Props {
|
|||
export function KioCard({ state, required = [], readOnly = false, referenceArm = false, onChange }: Props) {
|
||||
const dds = display(state, "dds") as string | null;
|
||||
const incidentGroup = display(state, "incident_group") as number | null;
|
||||
const notify = (display(state, "notify") as string[] | null) ?? [];
|
||||
const groups = visibleGroups(dds);
|
||||
const section = (group: FieldGroup) => (
|
||||
<section className={`kio-arm-section kio-arm-${group.area}`} key={group.title}>
|
||||
|
|
@ -36,6 +39,7 @@ export function KioCard({ state, required = [], readOnly = false, referenceArm =
|
|||
readOnly={readOnly || field.readOnly}
|
||||
referenceArm={referenceArm}
|
||||
incidentGroup={incidentGroup}
|
||||
notify={notify}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -58,7 +62,7 @@ export function KioCard({ state, required = [], readOnly = false, referenceArm =
|
|||
}
|
||||
|
||||
function Row({
|
||||
field, value, pending, required, readOnly, referenceArm, incidentGroup, onChange,
|
||||
field, value, pending, required, readOnly, referenceArm, incidentGroup, notify, onChange,
|
||||
}: {
|
||||
field: Field;
|
||||
value: unknown;
|
||||
|
|
@ -67,6 +71,7 @@ function Row({
|
|||
readOnly?: boolean;
|
||||
referenceArm: boolean;
|
||||
incidentGroup: number | null;
|
||||
notify: string[];
|
||||
onChange?: (path: string, value: unknown) => void;
|
||||
}) {
|
||||
const empty = value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0);
|
||||
|
|
@ -85,7 +90,7 @@ function Row({
|
|||
{readOnly ? (
|
||||
<span className="kio-arm-readonly">{formatValue(value, field)}</span>
|
||||
) : (
|
||||
<Input field={field} value={value} referenceArm={referenceArm} incidentGroup={incidentGroup} onChange={onChange} />
|
||||
<Input field={field} value={value} referenceArm={referenceArm} incidentGroup={incidentGroup} notify={notify} onChange={onChange} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -93,12 +98,13 @@ function Row({
|
|||
}
|
||||
|
||||
function Input({
|
||||
field, value, referenceArm, incidentGroup, onChange,
|
||||
field, value, referenceArm, incidentGroup, notify, onChange,
|
||||
}: {
|
||||
field: Field;
|
||||
value: unknown;
|
||||
referenceArm: boolean;
|
||||
incidentGroup: number | null;
|
||||
notify: string[];
|
||||
onChange?: (path: string, value: unknown) => void;
|
||||
}) {
|
||||
const emit = (next: unknown) => onChange?.(field.path, next);
|
||||
|
|
@ -141,6 +147,9 @@ function Input({
|
|||
</select>
|
||||
);
|
||||
}
|
||||
if (field.kind === "services") {
|
||||
return <ServicesPicker value={(value as string[]) ?? []} notify={notify} onChange={(next) => emit(next)} />;
|
||||
}
|
||||
if (field.kind === "signs") {
|
||||
return <SignsPicker value={(value as string[]) ?? []} group={incidentGroup} onChange={(next) => emit(next)} />;
|
||||
}
|
||||
|
|
@ -170,6 +179,46 @@ function Input({
|
|||
);
|
||||
}
|
||||
|
||||
/** Как `max_length` у `KIO.notify_extra`. */
|
||||
const SERVICES_LIMIT = 20;
|
||||
|
||||
/** Ручное добавление служб из каталога — окно «Добавьте службы» боевого АРМ.
|
||||
* Список оповещения по признакам остаётся как есть: здесь только добавки. */
|
||||
function ServicesPicker({ value, notify, onChange }: {
|
||||
value: string[]; notify: string[]; onChange: (next: string[]) => void;
|
||||
}) {
|
||||
const catalog = useServiceCatalog();
|
||||
const [query, setQuery] = useState("");
|
||||
const needle = query.trim().toLowerCase();
|
||||
// Лимит и службы из списка оповещения — те же правила, что на сервере:
|
||||
// лишняя служба отклонила бы весь пакет правок.
|
||||
const full = value.length >= SERVICES_LIMIT;
|
||||
const found = needle && !full
|
||||
? (catalog.data?.services ?? [])
|
||||
.filter((item) => !value.includes(item.name) && !notify.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={full ? `Не больше ${SERVICES_LIMIT} служб` : "Поиск службы…"} disabled={full}
|
||||
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 ?? ""}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -47,6 +47,11 @@ function field(card: CardState, path: string): string {
|
|||
const value = readPath(card.server, path);
|
||||
return value === null || value === undefined || value === "" ? "—" : String(value);
|
||||
}
|
||||
/** Список из карточки: `field()` не годится — `String([])` пустая строка, а не «—». */
|
||||
function names(card: CardState, path: string): string[] {
|
||||
const value = readPath(card.server, path);
|
||||
return Array.isArray(value) ? value.map(String) : [];
|
||||
}
|
||||
function time(value: string): string {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleTimeString("ru-RU");
|
||||
|
|
@ -548,6 +553,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>
|
||||
{names(card, "notify_extra").length > 0 && <div>Добавлены оператором: <b>{names(card, "notify_extra").join(", ")}</b></div>}
|
||||
<div>Ваша ДДС: <b>{station?.managed_service || "служба не назначена"}</b></div></div>
|
||||
</div>
|
||||
<div className="dds-case-stage">
|
||||
|
|
|
|||
|
|
@ -240,6 +240,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));
|
||||
|
|
|
|||
|
|
@ -164,8 +164,17 @@ export const callChannel = (
|
|||
...options,
|
||||
queueKey: (event) => event.type === "kio.patch" ? "kio.patch" : null,
|
||||
isAcknowledged: (incoming, pending) => {
|
||||
if (pending.type !== "kio.patch" || incoming.type !== "kio.patch"
|
||||
|| incoming.source !== "operator") return false;
|
||||
if (pending.type !== "kio.patch" || incoming.type !== "kio.patch") return false;
|
||||
// `auto` по всем полям команды с другим значением — сервер её переписал
|
||||
// (так он отвечает и на отклонённый пакет), а merge.ts уже снял правку из
|
||||
// pending. Повтор затёр бы серверную истину, а слияние с новыми правками
|
||||
// отклоняло бы и их. Совпадающий `auto` ничего не доказывает: команда
|
||||
// могла не дойти, повтор безвреден.
|
||||
if (incoming.source === "auto") {
|
||||
const keys = Object.keys(pending.fields);
|
||||
return keys.every((key) => key in incoming.fields)
|
||||
&& keys.some((key) => !sameJsonValue(pending.fields[key], incoming.fields[key]));
|
||||
}
|
||||
return Object.entries(pending.fields).every(([key, value]) =>
|
||||
sameJsonValue(value, incoming.fields[key]),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue