2026-09-24 01:10:49 +03:00
// Учебное А Р М ДДС. Проверяемый текстовый протокол дополняется настоящим
// локальным SIP/WebRTC-телефоном; о б а контура явно подписаны в интерфейсе.
2026-09-26 18:12:27 +03:00
import { lazy , Suspense , useEffect , useRef , useState } from "react" ;
2026-09-17 21:45:47 +03:00
import { KioCard } from "@/features/kio-card/KioCard" ;
2026-09-26 18:12:27 +03:00
import { Timers } from "@/features/timers/Timers" ;
2026-09-21 17:40:54 +03:00
import { applyState , empty as emptyCard , readPath , type CardState } from "@/features/kio-card/merge" ;
2026-09-26 19:53:48 +00:00
import { ackDeadline } from "./ack.mjs" ;
2026-09-26 18:12:27 +03:00
import { filterSortHistory } from "./history.mjs" ;
2026-09-26 20:59:50 +00:00
import { useSessionReport } from "@/shared/api/http" ;
2026-09-17 21:45:47 +03:00
import { sessionIdFromUrl } from "@/shared/api/session" ;
2026-09-21 17:40:54 +03:00
import { stationChannel , type ChannelStatus } from "@/shared/api/ws" ;
2026-09-26 20:59:50 +00:00
import type { DdsQueueCard , ServiceStatus , StationSnapshot , StationToServer , TimerSnapshot } from "@/shared/types/generated" ;
2026-09-21 17:40:54 +03:00
import { ALARMING , CARD_STATUS_LABELS , SERVICE_STATUS_LABELS } from "./statuses" ;
2026-09-17 21:45:47 +03:00
2026-09-26 18:12:27 +03:00
const SipPhone = lazy ( ( ) = > import ( "@/features/sip/SipPhone" ) . then ( ( { SipPhone } ) = > ( { default : SipPhone } ) ) ) ;
2026-09-21 17:40:54 +03:00
type ReportPhase = "dispatched" | "arrived" | "working" | "completed" ;
interface PhoneReport { service : string ; crew : string ; phase : ReportPhase ; text : string ; at : string }
2026-09-24 01:10:49 +03:00
interface PhoneLine { service : string ; crew : string ; speaker : "dispatcher" | "crew" ; text : string ; at : string }
interface PhonePending { service : string ; crew : string ; phase : ReportPhase }
2026-09-26 18:12:27 +03:00
interface CompletedCard {
card_id : string ; scenario_id : string ; score_auto : number ; score_final? : number ;
session_id? : string ; ended_at? : string ; title? : string | null ; address? : string | null ;
description? : string | null ; incident_type? : string | null ; victims_count? : number | null ;
received_at? : string | null ; managed_service? : string | null ; recipient_services? : string [ ] ;
}
2026-09-21 17:40:54 +03:00
interface DdsSnapshot extends StationSnapshot {
2026-09-24 01:10:49 +03:00
recipient_services? : string [ ] ;
managed_service? : string | null ;
2026-09-21 17:40:54 +03:00
crew_options? : string [ ] ;
crew_selected? : string | null ;
phone_reports? : PhoneReport [ ] ;
2026-09-24 01:10:49 +03:00
phone_lines? : PhoneLine [ ] ;
phone_pending? : PhonePending | null ;
2026-09-21 19:56:13 +03:00
card_id? : string | null ;
card_index? : number ;
card_total? : number ;
reply_text? : string ;
completed_cards? : CompletedCard [ ] ;
2026-09-24 01:10:49 +03:00
queue_cards? : DdsQueueCard [ ] ;
2026-09-21 17:40:54 +03:00
}
const REPORT_LABELS : Record < ReportPhase , string > = {
dispatched : "Доклад о выезде" , arrived : "Доклад о прибытии" ,
working : "Доклад о ходе работ" , completed : "Доклад о завершении работ" ,
} ;
function field ( card : CardState , path : string ) : string {
const value = readPath ( card . server , path ) ;
return value === null || value === undefined || value === "" ? "—" : String ( value ) ;
}
function time ( value : string ) : string {
const parsed = new Date ( value ) ;
return Number . isNaN ( parsed . getTime ( ) ) ? value : parsed.toLocaleTimeString ( "ru-RU" ) ;
}
2026-09-21 19:56:13 +03:00
function serviceTitle ( service : string ) : string {
2026-09-26 18:12:27 +03:00
return /^\d{2,3}$/ . test ( service ) ? ` Служба ${ service } ` : service ;
}
function useDdsHistory() {
const [ cards , setCards ] = useState < CompletedCard [ ] > ( [ ] ) ;
const [ error , setError ] = useState ( "" ) ;
useEffect ( ( ) = > {
const controller = new AbortController ( ) ;
void fetch ( "/api/sessions/dds-history?limit=200" , {
credentials : "same-origin" , signal : controller.signal ,
} ) . then ( async ( response ) = > {
if ( ! response . ok ) throw new Error ( ` Н е удалось загрузить историю ДДС (HTTP ${ response . status } ). ` ) ;
setCards ( await response . json ( ) as CompletedCard [ ] ) ;
setError ( "" ) ;
} ) . catch ( ( cause : unknown ) = > {
if ( controller . signal . aborted ) return ;
setError ( cause instanceof Error ? cause . message : "Н е удалось загрузить историю ДДС." ) ;
} ) ;
return ( ) = > controller . abort ( ) ;
} , [ ] ) ;
return { cards , error } ;
}
function DdsHistoryOnly ( { cards , error } : { cards : CompletedCard [ ] ; error : string } ) {
const [ search , setSearch ] = useState ( "" ) ;
const [ searchOpen , setSearchOpen ] = useState ( false ) ;
const searchInput = useRef < HTMLInputElement > ( null ) ;
const [ advanced , setAdvanced ] = useState ( false ) ;
const [ service , setService ] = useState ( "all" ) ;
const [ notification , setNotification ] = useState ( "all" ) ;
const [ page , setPage ] = useState ( 1 ) ;
const [ pageSize , setPageSize ] = useState ( 10 ) ;
const [ descending , setDescending ] = useState ( true ) ;
const [ now , setNow ] = useState ( ( ) = > new Date ( ) ) ;
useEffect ( ( ) = > {
const timer = setInterval ( ( ) = > setNow ( new Date ( ) ) , 1000 ) ;
return ( ) = > clearInterval ( timer ) ;
} , [ ] ) ;
useEffect ( ( ) = > {
if ( searchOpen ) searchInput . current ? . focus ( ) ;
} , [ searchOpen ] ) ;
const query = search . trim ( ) . toLocaleLowerCase ( "ru-RU" ) ;
const filtered = filterSortHistory ( cards , { query , service , notification , descending } ) ;
const pages = Math . max ( 1 , Math . ceil ( filtered . length / pageSize ) ) ;
const currentPage = Math . min ( page , pages ) ;
const offset = ( currentPage - 1 ) * pageSize ;
const visible = filtered . slice ( offset , offset + pageSize ) ;
const services = [ . . . new Set ( cards . map ( ( item ) = > item . managed_service ) . filter ( ( value ) : value is string = > Boolean ( value ) ) ) ] ;
const notifications = [ . . . new Set ( cards . flatMap ( ( item ) = > item . recipient_services ? ? [ ] ) ) ] ;
return < main className = "page dds-workspace" >
< section className = "dds-search" >
< div className = "dds-search-main" >
< h1 > П о и с к п р о и с ш е с т в и й < / h1 >
< button type = "button" className = "dds-search-icon" aria-label = { searchOpen ? "Свернуть поиск" : "Открыть поиск" }
aria - expanded = { searchOpen } aria - controls = "dds-history-search-line"
title = { searchOpen ? "Свернуть поиск" : "Открыть поиск" }
onClick = { ( ) = > {
if ( search . trim ( ) ) searchInput . current ? . focus ( ) ;
else setSearchOpen ( ( open ) = > ! open ) ;
} } > ⌕ < / button >
< div id = "dds-history-search-line" className = { ` dds-search-line ${ searchOpen ? " is-open" : "" } ` } >
{ searchOpen && < >
< label htmlFor = "dds-history-search" > П о и с к п о н о м е р у , а д р е с у и л и о п и с а н и ю < / label >
< input ref = { searchInput } id = "dds-history-search" type = "search" value = { search }
onChange = { ( event ) = > { setSearch ( event . target . value ) ; setPage ( 1 ) ; } }
placeholder = "Введите номер или адрес" / >
< / > }
< button type = "button" onClick = { ( ) = > {
setSearch ( "" ) ; setService ( "all" ) ; setNotification ( "all" ) ; setPage ( 1 ) ;
} } > с б р о с и т ь < / button >
< button type = "button" className = "dds-advanced-toggle" aria-expanded = { advanced }
onClick = { ( ) = > setAdvanced ( ( open ) = > ! open ) } >
р а с ш и р е н н ы й п о и с к { advanced ? "⌃" : "⌄" }
< / button >
< / div >
{ advanced && < div className = "dds-advanced-search" >
< label > С л у ж б а < select aria-label = "Фильтр службы" value = { service }
onChange = { ( event ) = > { setService ( event . target . value ) ; setPage ( 1 ) ; } } >
< option value = "all" > В с е с л у ж б ы < / option >
{ services . map ( ( value ) = > < option key = { value } value = { value } > { serviceTitle ( value ) } < / option > ) }
< / select > < / label >
< / div > }
< / div >
< div className = "dds-clock" >
< small > { now . toLocaleDateString ( "ru-RU" , { weekday : "long" , day : "numeric" , month : "long" , year : "numeric" } ) } < / small >
< strong > { now . toLocaleTimeString ( "ru-RU" ) } < / strong >
< span > У ч е б н ы й к о н т у р · а р х и в н ы е к а р т о ч к и < / span >
< / div >
< / section >
< section className = "dds-panel dds-register-panel" >
< div className = "dds-panel-title dds-history-title" > < h2 > С п и с о к п р о и с ш е с т в и й ⌃ < / h2 >
< label className = "dds-history-notifications" > ⓘ у в е д о м л е н и я < select aria-label = "Фильтр уведомлений" value = { notification }
onChange = { ( event ) = > { setNotification ( event . target . value ) ; setPage ( 1 ) ; } } >
< option value = "all" > в ы б е р и т е ч т о п о к а з а т ь < / option >
{ notifications . map ( ( value ) = > < option key = { value } value = { value } > { serviceTitle ( value ) } < / option > ) }
< / select > < / label >
< / div >
{ error && < p className = "dds-alert" role = "alert" > { error } < / p > }
< div className = "dds-table-wrap" > < table className = "dds-registry dds-history-registry" > < colgroup >
< col style = { { width : "8.5%" } } / > < col style = { { width : "3%" } } / > < col style = { { width : "3%" } } / >
< col style = { { width : "3%" } } / > < col style = { { width : "4%" } } / > < col style = { { width : "4%" } } / >
< col style = { { width : "4%" } } / > < col style = { { width : "21%" } } / > < col style = { { width : "4%" } } / >
< col style = { { width : "34.5%" } } / > < col style = { { width : "11%" } } / >
< / colgroup > < thead > < tr >
< th > С в я з и < / th > < th > Ч С < / th > < th > О п е р . < / th > < th > А Р М < / th > < th > Н о м е р < / th >
< th > < button type = "button" className = "dds-row-link" onClick = { ( ) = > setDescending ( ( value ) = > ! value ) } >
Д а т а { descending ? "↓" : "↑" } < / button > < / th > < th > В р е м я < / th > < th > Т и п п р о и с ш е с т в и я < / th >
< th > П о с т р . < / th > < th > А д р е с < / th > < th > С т а т у с с л у ж б ы < / th >
< / tr > < / thead > < tbody >
{ visible . map ( ( item ) = > {
const occurred = item . received_at ? new Date ( item . received_at ) :
item . ended_at ? new Date ( item . ended_at ) : null ;
return < >
< tr key = { ` ${ item . session_id ? ? "" } - ${ item . card_id } ` } >
< td data-label = "Связи" > < button type = "button" className = "dds-row-open" aria-label = "Сведения о карточке" > ▾ < / button > < / td >
< td data-label = "ЧС" > — < / td > < td data-label = "Опер." > 0 < / td > < td data-label = "А Р М " > Д Д С < / td >
< td data-label = "Номер" > { item . card_id . slice ( 0 , 8 ) . toUpperCase ( ) } < / td >
< td data-label = "Дата" > { occurred ? occurred . toLocaleDateString ( "ru-RU" ) : "—" } < / td >
< td data-label = "Время" > { occurred ? occurred . toLocaleTimeString ( "ru-RU" ) : "—" } < / td >
< td data-label = "Тип происшествия" > { item . title || item . incident_type || ` Сценарий ${ item . scenario_id } ` } < / td >
< td data-label = "Постр." > { item . victims_count ? ? "—" } < / td > < td data-label = "Адрес" > { item . address || "—" } < / td >
< td data-label = "Статус службы" > О т р а б о т а н а · { ( item . score_final ? ? item . score_auto ) . toFixed ( 0 ) } / 100 < / td >
< / tr >
{ item . description && < tr key = { ` ${ item . card_id } -description ` } className = "dds-history-description" >
< td colSpan = { 8 } data-label = "Описание" > О п и с а н и е : { occurred ? ` ${ occurred . toLocaleDateString ( "ru-RU" ) } ${ occurred . toLocaleTimeString ( "ru-RU" ) } · ` : "" } { item . description } < / td > < td colSpan = { 3 } > < / td >
< / tr > }
< / > ;
} ) }
{ ! visible . length && < tr > < td colSpan = { 11 } className = "dds-empty" >
{ error ? "История временно недоступна." : query ? "По запросу ничего не найдено." : "Сохранённых карточек пока нет." }
< / td > < / tr > }
< / tbody > < / table > < / div >
< div className = "dds-pagination dds-history-pagination" aria-label = "Страницы исторического реестра" >
< label > С т р а н и ц а < select aria-label = "Страница реестра" value = { currentPage }
onChange = { ( event ) = > setPage ( Number ( event . target . value ) ) } >
{ Array . from ( { length : pages } , ( _ , index ) = > index + 1 ) . map ( ( value ) = >
< option key = { value } value = { value } > { value } < / option > ) }
< / select > < / label >
< label > З а п и с е й н а с т р а н и ц е < select aria-label = "Количество записей на странице" value = { pageSize }
onChange = { ( event ) = > { setPageSize ( Number ( event . target . value ) ) ; setPage ( 1 ) ; } } >
{ [ 10 , 25 , 50 ] . map ( ( value ) = > < option key = { value } value = { value } > { value } < / option > ) }
< / select > < / label >
< span > { filtered . length ? ` ${ offset + 1 } – ${ Math . min ( offset + pageSize , filtered . length ) } из ${ filtered . length } ` : "0 из 0" } < / span >
< button type = "button" aria-label = "Предыдущая страница" disabled = { currentPage <= 1 }
onClick = { ( ) = > setPage ( currentPage - 1 ) } > ‹ < / button >
< button type = "button" aria-label = "Следующая страница" disabled = { currentPage >= pages }
onClick = { ( ) = > setPage ( currentPage + 1 ) } > › < / button >
< / div >
< / section >
< / main > ;
2026-09-21 19:56:13 +03:00
}
2026-09-17 21:45:47 +03:00
export function Dds() {
const sessionId = sessionIdFromUrl ( ) ;
const role = new URLSearchParams ( location . search ) . get ( "role" ) ? ? "dds_01" ;
2026-09-26 18:12:27 +03:00
const ddsHistory = useDdsHistory ( ) ;
2026-09-17 21:45:47 +03:00
const [ status , setStatus ] = useState < ChannelStatus > ( "connecting" ) ;
2026-09-21 19:56:13 +03:00
const [ view , setView ] = useState < "registry" | "card" > ( "registry" ) ;
const [ pane , setPane ] = useState < "status" | "phone" | "reply" | "full" | "journal" | "more" | null > ( null ) ;
const [ activeService , setActiveService ] = useState < string | null > ( null ) ;
const [ search , setSearch ] = useState ( "" ) ;
2026-09-26 18:12:27 +03:00
const [ advancedSearchOpen , setAdvancedSearchOpen ] = useState ( false ) ;
const [ serviceFilter , setServiceFilter ] = useState ( "all" ) ;
const [ statusFilter , setStatusFilter ] = useState < "all" | "pending" | "completed" > ( "all" ) ;
const [ page , setPage ] = useState ( 1 ) ;
const [ pageSize , setPageSize ] = useState ( 10 ) ;
2026-09-21 19:56:13 +03:00
const [ clockNow , setClockNow ] = useState ( ( ) = > new Date ( ) ) ;
2026-09-17 21:45:47 +03:00
const [ card , setCard ] = useState < CardState | null > ( null ) ;
2026-09-21 17:40:54 +03:00
const [ from , setFrom ] = useState ( "" ) ;
2026-09-17 21:45:47 +03:00
const [ receivedAt , setReceivedAt ] = useState < number | null > ( null ) ;
2026-09-21 17:40:54 +03:00
const [ station , setStation ] = useState < DdsSnapshot | null > ( null ) ;
2026-09-26 18:12:27 +03:00
const [ timers , setTimers ] = useState < TimerSnapshot [ ] > ( [ ] ) ;
2026-09-21 17:40:54 +03:00
const [ lastReport , setLastReport ] = useState < PhoneReport | null > ( null ) ;
const [ dialing , setDialing ] = useState ( false ) ;
2026-09-24 01:10:49 +03:00
const [ brief , setBrief ] = useState ( { address : "" , incident : "" , request : "" } ) ;
const [ checkText , setCheckText ] = useState ( "" ) ;
2026-09-21 17:40:54 +03:00
const [ error , setError ] = useState ( "" ) ;
const [ ended , setEnded ] = useState ( false ) ;
2026-09-26 20:59:50 +00:00
// Разбор просим только после `score.ready`; новая карточка снимает флаг.
const [ scoreReady , setScoreReady ] = useState ( false ) ;
const { report , loading : reportLoading , error : reportError , refetch : refetchReport } =
useSessionReport ( sessionId , scoreReady ) ;
2026-09-17 21:45:47 +03:00
const [ zone , setZone ] = useState < boolean | null > ( null ) ;
2026-09-21 19:56:13 +03:00
const [ reply , setReply ] = useState ( { text : "" , dirty : false } ) ;
const [ advancePending , setAdvancePending ] = useState ( false ) ;
2026-09-17 21:45:47 +03:00
const channel = useRef < ReturnType < typeof stationChannel > | null > ( null ) ;
2026-09-21 19:56:13 +03:00
const activeCardId = useRef < string | null > ( null ) ;
2026-09-24 01:10:49 +03:00
const openCardAfterSwitch = useRef ( false ) ;
2026-09-17 21:45:47 +03:00
useEffect ( ( ) = > {
if ( ! sessionId ) return ;
const ch = stationChannel ( sessionId , role , {
2026-09-21 17:40:54 +03:00
onStatus : ( next ) = > { setStatus ( next ) ; if ( next !== "open" ) setDialing ( false ) ; } ,
2026-09-17 21:45:47 +03:00
onEvent : ( event ) = > {
2026-09-21 19:56:13 +03:00
if ( event . type === "station.state" ) {
const snapshot = event . snapshot as DdsSnapshot ;
setStation ( snapshot ) ;
2026-09-26 18:12:27 +03:00
setZone ( snapshot . zone_decision ? ? null ) ;
if ( snapshot . card_id == null && ( snapshot . pending_cards_count ? ? 0 ) > 0 ) {
2026-09-24 01:10:49 +03:00
activeCardId . current = null ;
setCard ( null ) ;
setView ( "registry" ) ;
setPane ( null ) ;
setAdvancePending ( false ) ;
}
2026-09-21 19:56:13 +03:00
setActiveService ( ( previous ) = > previous && snapshot . services . includes ( previous )
? previous : snapshot.services [ 0 ] ? ? null ) ;
setReply ( ( previous ) = > {
const saved = snapshot . reply_text ? ? "" ;
if ( previous . text === saved ) return { text : saved , dirty : false } ;
return previous . dirty ? previous : { text : saved , dirty : false } ;
} ) ;
setError ( "" ) ;
}
2026-09-21 17:40:54 +03:00
else if ( event . type === "card.received" ) {
2026-09-21 19:56:13 +03:00
const nextId = String ( event . card . card_id ) ;
if ( activeCardId . current !== nextId ) {
activeCardId . current = nextId ;
setStation ( null ) ;
setReply ( { text : "" , dirty : false } ) ;
setLastReport ( null ) ;
setDialing ( false ) ;
2026-09-24 01:10:49 +03:00
setBrief ( { address : "" , incident : "" , request : "" } ) ;
setCheckText ( "" ) ;
2026-09-21 19:56:13 +03:00
setZone ( null ) ;
2026-09-26 20:59:50 +00:00
setScoreReady ( false ) ;
2026-09-24 01:10:49 +03:00
setView ( openCardAfterSwitch . current ? "card" : "registry" ) ;
openCardAfterSwitch . current = false ;
2026-09-21 19:56:13 +03:00
setPane ( null ) ;
setActiveService ( null ) ;
2026-09-26 19:53:48 +00:00
setTimers ( [ ] ) ;
2026-09-21 19:56:13 +03:00
}
setCard ( applyState ( emptyCard , { . . . event . card } ) ) ;
2026-09-17 21:45:47 +03:00
setFrom ( event . from_operator ) ;
2026-09-21 17:40:54 +03:00
// Норматив идёт с передачи карточки сервером, в том числе если
// диспетчер открыл А Р М уже после начала занятия.
const transmittedAt = new Date ( event . at ) . getTime ( ) ;
setReceivedAt ( Number . isNaN ( transmittedAt ) ? Date . now ( ) : transmittedAt ) ;
setEnded ( false ) ;
2026-09-21 19:56:13 +03:00
setAdvancePending ( false ) ;
2026-09-26 18:12:27 +03:00
} else if ( event . type === "timer.tick" ) setTimers ( event . timers ) ;
else if ( event . type === "error" ) { setError ( event . message ) ; setDialing ( false ) ; setAdvancePending ( false ) ; }
2026-09-21 19:56:13 +03:00
else if ( event . type === "session.ended" ) { setEnded ( true ) ; setDialing ( false ) ; setAdvancePending ( false ) ; setPane ( null ) ; setView ( "registry" ) ; }
2026-09-26 20:59:50 +00:00
else if ( event . type === "score.ready" ) setScoreReady ( true ) ;
2026-09-21 19:56:13 +03:00
else if ( event . type === "phone.report" ) {
setLastReport ( event ) ;
2026-09-21 17:40:54 +03:00
setDialing ( false ) ;
2026-09-24 01:10:49 +03:00
setBrief ( { address : "" , incident : "" , request : "" } ) ;
setCheckText ( "" ) ;
2026-09-21 17:40:54 +03:00
setError ( "" ) ;
2026-09-17 21:45:47 +03:00
}
2026-09-24 01:10:49 +03:00
else if ( event . type === "phone.line" ) setDialing ( false ) ;
2026-09-17 21:45:47 +03:00
} ,
} ) . connect ( ) ;
channel . current = ch ;
2026-09-26 20:59:50 +00:00
return ( ) = > { ch . close ( ) ; channel . current = null ; } ;
2026-09-17 21:45:47 +03:00
} , [ sessionId , role ] ) ;
2026-09-21 19:56:13 +03:00
useEffect ( ( ) = > {
const timer = setInterval ( ( ) = > setClockNow ( new Date ( ) ) , 1000 ) ;
return ( ) = > clearInterval ( timer ) ;
} , [ ] ) ;
const send = ( command : StationToServer ) : boolean = > channel . current ? . send ( command ) ? ? false ;
2026-09-26 18:12:27 +03:00
if ( ! sessionId ) return < DdsHistoryOnly cards = { ddsHistory . cards } error = { ddsHistory . error } / > ;
2026-09-17 21:45:47 +03:00
2026-09-21 17:40:54 +03:00
const reports = station ? . phone_reports ? ? [ ] ;
const selectedCrew = station ? . crew_selected ? ? "" ;
2026-09-24 01:10:49 +03:00
const latest = ( lastReport ? . crew === selectedCrew ? lastReport : null ) ? ?
reports . filter ( ( item ) = > item . crew === selectedCrew ) . at ( - 1 ) ? ? null ;
const phoneCall = station ? . phone_pending ? ? null ;
const phoneLines = ( station ? . phone_lines ? ? [ ] ) . filter ( ( line ) = > line . crew === selectedCrew ) ;
2026-09-21 17:40:54 +03:00
const isConnected = status === "open" ;
2026-09-21 19:56:13 +03:00
const cardId = card ? String ( readPath ( card . server , "card_id" ) ? ? "" ) : "" ;
const cardIndex = station ? . card_index ? ? 1 ;
const cardTotal = station ? . card_total ? ? 1 ;
2026-09-26 18:12:27 +03:00
const completedCards = [ . . . ( station ? . completed_cards ? ? [ ] ) ] ;
for ( const archived of ddsHistory . cards ) {
if ( ! completedCards . some ( ( item ) = > item . card_id === archived . card_id ) ) completedCards . push ( archived ) ;
}
2026-09-24 01:10:49 +03:00
const queueCards = station ? . queue_cards ? ? [ ] ;
2026-09-26 19:53:48 +00:00
const ack = ackDeadline ( timers , queueCards ) ;
2026-09-21 19:56:13 +03:00
const query = search . trim ( ) . toLocaleLowerCase ( "ru-RU" ) ;
2026-09-26 18:12:27 +03:00
const filteredCompletedCards = statusFilter === "pending" ? [ ] : completedCards . filter ( ( item ) = > {
const serviceMatches = serviceFilter === "all" || item . managed_service === serviceFilter ;
const textMatches = ! query || [ item . card_id , item . scenario_id , item . title ? ? "" , item . address ? ? "" ,
item . description ? ? "" , item . incident_type ? ? "" ] . some ( ( value ) = > value . toLocaleLowerCase ( "ru-RU" ) . includes ( query ) ) ;
return serviceMatches && textMatches ;
} ) ;
const filteredQueueCards = statusFilter === "completed" ? [ ] : queueCards . filter ( ( item ) = > {
const textMatches = ! query ||
[ item . card_id , item . address ? ? "" , item . description ? ? "" , item . incident_type ? ? "" , item . title ]
. some ( ( value ) = > value . toLocaleLowerCase ( "ru-RU" ) . includes ( query ) ) ;
const serviceMatches = serviceFilter === "all" || item . managed_service === serviceFilter ;
return textMatches && serviceMatches ;
} ) ;
const filteredRecordCount = filteredCompletedCards . length + filteredQueueCards . length ;
const pageCount = Math . max ( 1 , Math . ceil ( filteredRecordCount / pageSize ) ) ;
const currentPage = Math . min ( page , pageCount ) ;
const pageOffset = ( currentPage - 1 ) * pageSize ;
const visibleCompletedCards = filteredCompletedCards . slice ( pageOffset , pageOffset + pageSize ) ;
const queuePageStart = Math . max ( 0 , pageOffset - filteredCompletedCards . length ) ;
const queuePageEnd = Math . max ( 0 , pageOffset + pageSize - filteredCompletedCards . length ) ;
const visibleQueueCards = filteredQueueCards . slice ( queuePageStart , queuePageEnd ) ;
2026-09-21 17:40:54 +03:00
const crewAccepted = Boolean ( station ? . services . some ( ( service ) = >
selectedCrew . startsWith ( ` ${ service } — ` ) &&
[ "accepted" , "responding" , "arrived" , "working" ] . includes ( station . statuses [ service ] ) ) ) ;
2026-09-17 21:45:47 +03:00
2026-09-21 17:40:54 +03:00
return < main className = "page dds-workspace" >
2026-09-26 18:12:27 +03:00
< span className = "dds-connection-live" role = "status" aria-live = "polite" >
А Р М { role } : { isConnected ? "на связи" : status }
< / span >
2026-09-21 17:40:54 +03:00
{ error && < div className = "dds-alert" role = "alert" > Д е й с т в и е н е п р и н я т о : { error } < / div > }
{ ended && < div className = "dds-alert dds-done" > З а н я т и е з а в е р ш е н о . О ц е н к а п о я в и т с я п о с л е р а с ч ё т а . < / div > }
2026-09-26 20:59:50 +00:00
{ ( ended || scoreReady ) && < section className = "dds-panel" aria-live = "polite" >
2026-09-21 17:40:54 +03:00
< div className = "dds-panel-title" > < h2 > И т о г у п р а ж н е н и я < / h2 > < / div >
{ report ? < div style = { { padding : 12 } } >
< p > < strong > И т о г о в а я о ц е н к а : { report . score_final . toFixed ( 0 ) } и з 100 < / strong >
{ report . score_final !== report . score_auto && ` · автооценка ${ report . score_auto . toFixed ( 0 ) } ` } < / p >
< p className = "ref" > С к а ч а т ь о т ч ё т : < a href = { ` /api/sessions/ ${ sessionId } /report.csv ` } > CSV < / a >
{ " · " } < a href = { ` /api/sessions/ ${ sessionId } /report.pdf ` } > PDF < / a > < / p >
2026-09-21 19:56:13 +03:00
{ Boolean ( report . card_results ? . length ) && < >
< h3 > Р е з у л ь т а т ы п о к а р т о ч к а м < / h3 >
{ report . card_results ? . map ( ( item , index ) = > < details className = "dds-details" key = { item . card_id } >
< summary > К а р т о ч к а { index + 1 } · { item . scenario_id } · { item . score_auto . toFixed ( 0 ) } / 100 < / summary >
< p > < b > О т в е т д и с п е т ч е р а : < / b > { item . reply_text || "не внесён" } < / p >
2026-09-26 18:12:27 +03:00
{ item . metrics . filter ( ( metric ) = > ! metric . passed || ( metric . credit != null && metric . credit < 1 ) ) . map ( ( metric ) = >
< p key = { metric . key } className = { metric . passed ? "ref" : "state-violated" } >
{ metric . title } : { metric . fact } · н о р м а т и в { metric . norm }
{ metric . credit != null && ` · оценочный вклад ${ Math . round ( metric . credit * 100 ) } % ` } < / p > ) }
2026-09-21 19:56:13 +03:00
{ item . findings . map ( ( finding , findingIndex ) = >
< p key = { ` ${ finding . code } - ${ findingIndex } ` } > { finding . code } : { finding . summary } < / p > ) }
< / details > ) }
< / > }
2026-09-26 18:12:27 +03:00
< h3 > Н о р м а т и в ы и о ц е н о ч н ы й в к л а д < / h3 >
2026-09-21 17:40:54 +03:00
< table className = "dds-registry" > < tbody >
2026-09-26 18:12:27 +03:00
{ report . metrics . filter ( ( metric ) = > ! metric . passed || ( metric . credit != null && metric . credit < 1 ) ) . map ( ( metric ) = > < tr key = { metric . key } >
< th > { metric . title } < / th > < td > { metric . fact } · н о р м а т и в : { metric . norm }
{ metric . credit != null && ` · оценочный вклад ${ Math . round ( metric . credit * 100 ) } % ` } < / td > < / tr > ) }
{ report . metrics . every ( ( metric ) = > metric . passed && ( metric . credit == null || metric . credit === 1 ) ) &&
< tr > < td > В с е п р о в е р е н н ы е н о р м а т и в ы в ы п о л н е н ы , в р е м е н н ы е м е т р и к и д а л и п о л н ы й в к л а д . < / td > < / tr > }
2026-09-21 17:40:54 +03:00
< / tbody > < / table >
< h3 > З а м е ч а н и я < / h3 >
< table className = "dds-registry" > < tbody >
{ report . findings . map ( ( finding , index ) = > < tr key = { ` ${ finding . code } - ${ index } ` } >
< th > { finding . code } < / th > < td > { finding . summary } < div className = "ref" > { finding . fact } { finding . norm && ` · ${ finding . norm } ` } < / div > < / td > < / tr > ) }
{ report . findings . length === 0 && < tr > < td > З а м е ч а н и й н е т . < / td > < / tr > }
< / tbody > < / table >
< / div > : < div style = { { padding : 12 } } >
2026-09-26 20:59:50 +00:00
{ reportLoading ? "Подготавливаем разбор…" : reportError || "Ожидаем итоговую оценку…" }
{ reportError && < button type = "button" onClick = { ( ) = > void refetchReport ( ) } > П о в т о р и т ь з а п р о с < / button > }
2026-09-21 17:40:54 +03:00
< / div > }
< / section > }
2026-09-17 21:45:47 +03:00
2026-09-21 19:56:13 +03:00
{ view === "registry" && < >
< section className = "dds-search" >
< div className = "dds-search-main" >
< h1 > П о и с к п р о и с ш е с т в и й < / h1 >
< div className = "dds-search-line" > < label htmlFor = "dds-search-input" > П о и с к п о н о м е р у , а д р е с у и л и о п и с а н и ю < / label >
2026-09-26 18:12:27 +03:00
< input id = "dds-search-input" type = "search" value = { search } onChange = { ( event ) = > { setSearch ( event . target . value ) ; setPage ( 1 ) ; } }
2026-09-21 19:56:13 +03:00
placeholder = "Введите номер или адрес" / >
2026-09-26 18:12:27 +03:00
< button type = "button" onClick = { ( ) = > { setSearch ( "" ) ; setServiceFilter ( "all" ) ; setStatusFilter ( "all" ) ; setPage ( 1 ) ; } } > с б р о с и т ь < / button >
< button type = "button" className = "dds-advanced-toggle" aria-expanded = { advancedSearchOpen }
onClick = { ( ) = > setAdvancedSearchOpen ( ( open ) = > ! open ) } >
р а с ш и р е н н ы й п о и с к { advancedSearchOpen ? "⌃" : "⌄" }
< / button > < / div >
{ advancedSearchOpen && < div className = "dds-advanced-search" >
< label > С т а т у с < select aria-label = "Фильтр статуса происшествий" value = { statusFilter }
onChange = { ( event ) = > { setStatusFilter ( event . target . value as typeof statusFilter ) ; setPage ( 1 ) ; } } >
< option value = "all" > В с е п р о и с ш е с т в и я < / option > < option value = "pending" > В о ч е р е д и < / option >
< option value = "completed" > О т р а б о т а н н ы е < / option > < / select > < / label >
< label > С л у ж б а < select aria-label = "Фильтр службы" value = { serviceFilter }
onChange = { ( event ) = > { setServiceFilter ( event . target . value ) ; setPage ( 1 ) ; } } >
< option value = "all" > В с е с л у ж б ы < / option >
{ [ . . . new Set ( queueCards . map ( ( item ) = > item . managed_service ) . filter ( ( value ) : value is string = > Boolean ( value ) ) ) ] . map ( ( service ) = >
< option key = { service } value = { service } > { serviceTitle ( service ) } < / option > ) }
< / select > < / label >
< / div > }
2026-09-21 19:56:13 +03:00
< / div >
< div className = "dds-clock" > < small > { clockNow . toLocaleDateString ( "ru-RU" , { weekday : "long" , day : "numeric" , month : "long" , year : "numeric" } ) } < / small >
2026-09-26 18:12:27 +03:00
< strong > { clockNow . toLocaleTimeString ( "ru-RU" ) } < / strong >
< span > У ч е б н ы й к о н т у р · { isConnected ? "● Н а связи" : ` ● ${ status } ` } < / span > < / div >
2026-09-21 19:56:13 +03:00
< / section >
< section className = "dds-panel dds-register-panel" > < div className = "dds-panel-title" > < h2 > С п и с о к п р о и с ш е с т в и й ⌃ < / h2 >
< span > К а р т о ч к а { cardIndex } и з { cardTotal } · о б р а б о т а н о { completedCards . length } < / span > < / div >
2026-09-21 17:40:54 +03:00
< div className = "dds-table-wrap" > < table className = "dds-registry" > < thead > < tr >
2026-09-21 19:56:13 +03:00
< th > С в я з и < / th > < th > Ч С < / th > < th > О п е р . < / th > < th > А Р М < / th > < th > Н о м е р < / th > < th > Д а т а < / th > < th > В р е м я < / th > < th > Т и п п р о и с ш е с т в и я < / th > < th > П о с т р . < / th > < th > А д р е с < / th > < th > С т а т у с с л у ж б ы < / th >
< / tr > < / thead > < tbody >
{ visibleCompletedCards . map ( ( item ) = > < tr key = { item . card_id } >
< td data-label = "Связи" > ▾ < / td > < td data-label = "ЧС" > — < / td > < td data-label = "Опер." > 0 < / td > < td data-label = "А Р М " > Д Д С < / td >
2026-09-26 18:12:27 +03:00
< td data-label = "Номер" > { item . card_id . slice ( 0 , 8 ) . toUpperCase ( ) } < / td >
< td data-label = "Дата" > { item . received_at ? new Date ( item . received_at ) . toLocaleDateString ( "ru-RU" ) :
item . ended_at ? new Date ( item . ended_at ) . toLocaleDateString ( "ru-RU" ) : "—" } < / td >
< td data-label = "Время" > { item . received_at ? new Date ( item . received_at ) . toLocaleTimeString ( "ru-RU" ) :
item . ended_at ? new Date ( item . ended_at ) . toLocaleTimeString ( "ru-RU" ) : "—" } < / td >
< td data-label = "Тип происшествия" > { item . title || item . incident_type || ` Сценарий ${ item . scenario_id } ` } < / td >
< td data-label = "Постр." > { item . victims_count ? ? "—" } < / td > < td data-label = "Адрес" > { item . address || "—" } < / td >
< td data-label = "Статус службы" > О т р а б о т а н а · { ( item . score_final ? ? item . score_auto ) . toFixed ( 0 ) } / 100 < / td >
2026-09-21 19:56:13 +03:00
< / tr > ) }
2026-09-24 01:10:49 +03:00
{ visibleQueueCards . map ( ( item ) = > {
const received = new Date ( item . received_at ) ;
const openCard = ( ) = > {
if ( item . active ) {
2026-09-26 18:12:27 +03:00
send ( { type : "card.open" , card_id : item.card_id } ) ;
2026-09-24 01:10:49 +03:00
setView ( "card" ) ;
return ;
}
openCardAfterSwitch . current = true ;
if ( ! send ( { type : "card.open" , card_id : item.card_id } ) ) {
openCardAfterSwitch . current = false ;
}
} ;
return < tr key = { item . card_id } className = { item . active ? "dds-selected" : "" }
aria - current = { item . active ? "true" : undefined } >
< td data-label = "Связи" > < button type = "button" className = "dds-row-open" onClick = { openCard } aria-label = "Открыть карточку" > ▾ < / button > < / td >
< td data-label = "ЧС" > — < / td >
2026-09-21 19:56:13 +03:00
< td data-label = "Опер." > 0 < / td > < td data-label = "А Р М " > Д Д С < / td >
2026-09-24 01:10:49 +03:00
< td data-label = "Номер" > < button type = "button" className = "dds-row-link" onClick = { openCard } > { item . card_id . slice ( 0 , 8 ) . toUpperCase ( ) } < / button > < / td >
< td data-label = "Дата" > { received . toLocaleDateString ( "ru-RU" ) } < / td >
< td data-label = "Время" > { received . toLocaleTimeString ( "ru-RU" ) } < / td >
< td data-label = "Тип происшествия" > < span className = "dds-cell-value" >
< span > { item . title } · { item . incident_type || "тип не указан" } < / span >
< small > О п и с а н и е : { item . description || "—" } < / small >
< / span > < / td >
< td data-label = "Постр." > { item . victims_count ? ? "—" } < / td >
< td data-label = "Адрес" > { item . address || "—" } < / td >
< td data-label = "Статус службы" > < span className = "dds-cell-value" >
< span > { item . managed_service || "ДДС" } : { SERVICE_STATUS_LABELS [ item . service_status ] } < / span >
2026-09-26 19:53:48 +00:00
< small className = { ! item . timer_stopped && item . elapsed_ms > item . limit_ms ? "state-violated" : "" } >
{ item . timer_stopped ? "решение за" : "до первичного решения" } { Math . ceil ( item . elapsed_ms / 1000 ) } с
2026-09-24 01:10:49 +03:00
< / small >
< / span > < / td >
< / tr > } ) }
{ ! visibleQueueCards . length && ! visibleCompletedCards . length && < tr > < td colSpan = { 11 } className = "dds-empty" >
2026-09-21 19:56:13 +03:00
{ query ? "По запросу ничего не найдено." : "Ожидание готовой карточки от преподавателя или оператора 112…" } < / td > < / tr > }
< / tbody > < / table > < / div >
2026-09-26 18:12:27 +03:00
< div className = "dds-pagination" aria-label = "Страницы реестра происшествий" >
< label > С т р а н и ц а < select aria-label = "Страница реестра" value = { currentPage }
onChange = { ( event ) = > setPage ( Number ( event . target . value ) ) } >
{ Array . from ( { length : pageCount } , ( _ , index ) = > index + 1 ) . map ( ( value ) = >
< option key = { value } value = { value } > { value } < / option > ) }
< / select > < / label >
< label > З а п и с е й н а с т р а н и ц е < select aria-label = "Количество записей на странице" value = { pageSize }
onChange = { ( event ) = > { setPageSize ( Number ( event . target . value ) ) ; setPage ( 1 ) ; } } >
{ [ 10 , 25 , 50 ] . map ( ( value ) = > < option key = { value } value = { value } > { value } < / option > ) }
< / select > < / label >
< span > { filteredRecordCount === 0 ? "0 из 0" : ` ${ pageOffset + 1 } – ${ Math . min ( pageOffset + pageSize , filteredRecordCount ) } из ${ filteredRecordCount } ` } < / span >
< button type = "button" aria-label = "Предыдущая страница" disabled = { currentPage <= 1 }
onClick = { ( ) = > setPage ( currentPage - 1 ) } > ‹ < / button >
< button type = "button" aria-label = "Следующая страница" disabled = { currentPage >= pageCount }
onClick = { ( ) = > setPage ( currentPage + 1 ) } > › < / button >
< / div >
2026-09-24 01:10:49 +03:00
{ queueCards . length > 1 && < p className = "dds-hint" > О д н о в р е м е н н о в р а б о т е { queueCards . length } к а р т о ч е к . Т а й м е р к а ж д о й и д ё т н е з а в и с и м о о т т о г о , к а к а я с т р о к а о т к р ы т а . < / p > }
2026-09-26 18:12:27 +03:00
{ station && ( station . pending_cards_count ? ? 0 ) > 0 && < p className = "dds-hint" role = "status" aria-live = "polite" >
{ station . next_arrival_in_seconds === null || station . next_arrival_in_seconds === undefined
? ` Осталось карточек: ${ station . pending_cards_count ? ? 0 } . В с е карточки уже поступили; необработанные находятся в реестре. `
2026-09-24 01:10:49 +03:00
: station . next_arrival_in_seconds > 0
2026-09-26 18:12:27 +03:00
? ` Осталось карточек: ${ station . pending_cards_count ? ? 0 } . Следующее поступление примерно через ${ station . next_arrival_in_seconds } с . `
: queueCards . filter ( ( item ) = > ! item . active ) . length >= ( station . max_waiting_cards ? ? 3 )
? ` Осталось карточек: ${ station . pending_cards_count ? ? 0 } . Очередь ожидания заполнена ( ${ station . max_waiting_cards ? ? 3 } ); новые поступят, когда освободится место. `
: ` Осталось карточек: ${ station . pending_cards_count ? ? 0 } . Ожидается следующее поступление. ` }
2026-09-24 01:10:49 +03:00
< / p > }
2026-09-21 17:40:54 +03:00
< / section >
2026-09-21 19:56:13 +03:00
< / > }
2026-09-17 21:45:47 +03:00
2026-09-21 19:56:13 +03:00
{ card && view === "card" && < div className = "dds-case-layout" >
< div className = "dds-call-strip" >
< div className = "dds-call-off" > < span className = "dds-call-icon" > ☎ < / span > < strong > О т к л ю ч е н и е < / strong > < small > з а п и с и з в о н к о в · с п и с о к SMS < / small > < / div >
< div > < span className = "dds-call-icon" > ☎ < / span > < small > А О Н < / small > < strong > { field ( card , "caller_number" ) } < / strong > < / div >
< div > < span className = "dds-call-icon" > ☎ < / span > < small > п р е д о с т а в л е н н ы й < / small > < strong > { field ( card , "caller_contact" ) } < / strong > < / div >
2026-09-24 01:10:49 +03:00
< div > < span className = "dds-call-icon" > ☎ < / span > < small > т е л е ф о н н а м е с т о < / small > < strong > { field ( card , "phone_on_scene" ) } < / strong > < / div >
2026-09-26 18:12:27 +03:00
< div className = "dds-call-incident" > < div className = "dds-call-incident-title" >
< button type = "button" className = "dds-card-back" aria-label = "К списку происшествий"
onClick = { ( ) = > { setView ( "registry" ) ; setPane ( null ) ; } } > ← < / button >
< strong > П р о и с ш е с т в и е { cardId . slice ( 0 , 8 ) . toUpperCase ( ) } < / strong >
< / div >
2026-09-21 19:56:13 +03:00
< small > С о х р . { receivedAt ? new Date ( receivedAt ) . toLocaleString ( "ru-RU" ) : "—" } < / small >
2026-09-26 18:12:27 +03:00
< small className = { ` dds-connection ${ isConnected ? "state-ok" : "state-warn" } ` } >
О п е р . , А Р М Д Д С · { isConnected ? "● Н а связи" : ` ● ${ status } ` }
< / small > < / div >
2026-09-21 19:56:13 +03:00
< div className = "dds-case-top-buttons" > < button type = "button" onClick = { ( ) = > setPane ( "full" ) } > п р о с м о т р < / button >
< button type = "button" onClick = { ( ) = > setPane ( "more" ) } > д о п о л н е н и е < / button > < / div >
< / div >
< div className = "dds-case-meta" > < span > Ф И О з а я в и т е л я : < b > { field ( card , "caller_name" ) } < / b > < / span >
< span > П о с т р а д а в ш и е : { field ( card , "victims_count" ) } & nbsp ; Ч С : { field ( card , "is_emergency" ) === "true" ? "да" : "нет" } < / span >
< span > ✎ < / span > < / div >
< div className = "dds-case-summary" >
< div className = "dds-case-address" > < strong > { field ( card , "address" ) } < / strong > < span > { field ( card , "street" ) } { field ( card , "building" ) } < / span >
< div > < b > { receivedAt ? new Date ( receivedAt ) . toLocaleString ( "ru-RU" ) : "—" } < / b > & nbsp ; { field ( card , "description" ) } < / div > < / div >
< div className = "dds-case-classification" > < strong > П р о и с ш е с т в и е { field ( card , "incident_code" ) } < / strong >
< div > < b > { field ( card , "description" ) } < / b > < / div >
< div > К л а с с : < b > { field ( card , "incident_type" ) } < / b > < / div >
2026-09-24 01:10:49 +03:00
< div > П о л у ч а т е л и к а р т о ч к и : < b > { station ? . recipient_services ? . join ( ", " ) || field ( card , "notify" ) } < / b > < / div >
< div > В а ш а Д Д С : < b > { station ? . managed_service || "служба не назначена" } < / b > < / div > < / div >
2026-09-21 19:56:13 +03:00
< / div >
< div className = "dds-case-stage" >
{ pane && < div className = { ` dds-case-drawer dds-case-drawer- ${ pane } ` } >
< header className = "dds-drawer-title" > < strong > { ( { status : "Статусы службы" , phone : "IP-телефон" , reply : "Ответ диспетчера" ,
full : "Карточка информационного обмена" , journal : "Журнал реагирования" , more : "Дополнение" } ) [ pane ] } < / strong >
< button type = "button" onClick = { ( ) = > setPane ( null ) } aria-label = "Закрыть панель" > × < / button > < / header >
< div className = "dds-main" > < div className = "dds-left" >
{ pane === "full" && < section className = "dds-panel" > < div className = "dds-panel-title" > < h2 > К а р т о ч к а о б р а щ е н и я < / h2 >
2026-09-21 17:40:54 +03:00
< span > И с т о ч н и к : { from || "112" } < / span > < / div >
< div className = "dds-summary" >
< div > < small > А д р е с < / small > < strong > { field ( card , "address" ) } < / strong > < / div >
< div > < small > З а я в и т е л ь < / small > < strong > { field ( card , "caller_name" ) } < / strong > < / div >
< div > < small > Т е л е ф о н < / small > < strong > { field ( card , "caller_contact" ) } < / strong > < / div >
< div > < small > П р о и с ш е с т в и е < / small > < strong > { field ( card , "description" ) } < / strong > < / div >
< / div > < details className = "dds-details" > < summary > П о л н а я к а р т о ч к а и н ф о р м а ц и о н н о г о о б м е н а < / summary > < KioCard state = { card } readOnly / > < / details >
2026-09-21 19:56:13 +03:00
< / section > }
{ pane === "status" && < section className = "dds-panel" > < div className = "dds-panel-title" > < h2 > Р е а г и р о в а н и е с л у ж б < / h2 >
2026-09-21 17:40:54 +03:00
< span className = { station && ALARMING . includes ( station . card ) ? "state-violated" : "state-ok" } >
{ station ? CARD_STATUS_LABELS [ station . card ] : "—" } < / span > < / div >
2026-09-26 19:53:48 +00:00
{ ack && < div className = { ` dds-deadline ${ ack . violated ? "state-violated" : "state-ok" } ` } >
П е р в и ч н о е р е ш е н и е « П р и н я т а » / « Н е п р и н я т а » : { Math . ceil ( ack . elapsedMs / 1000 ) } и з { Math . ceil ( ack . limitMs / 1000 ) } с е к у н д < / div > }
2026-09-26 18:12:27 +03:00
< Timers timers = { timers . filter ( ( timer ) = > timer . code === "dds_ack" || timer . code === "dds_work" ) } / >
2026-09-21 19:56:13 +03:00
{ station && station . services . length > 0 ? < >
< div className = "dds-status-history" > < strong > С л у ж б а { activeService ? ? station . services [ 0 ] } < / strong >
< button type = "button" className = "dds-status-close" onClick = { ( ) = > setPane ( null ) } aria-label = "Закрыть статусы" > × < / button >
{ station . log . filter ( ( entry ) = > entry . service === ( activeService ? ? station . services [ 0 ] ) ) . map ( ( entry , index ) = >
< div key = { ` ${ entry . at } - ${ index } ` } > < span > › { time ( entry . at ) } { SERVICE_STATUS_LABELS [ entry . status ] } < / span >
< span > { entry . comment || "—" } < / span > < / div > ) }
{ ! station . log . some ( ( entry ) = > entry . service === ( activeService ? ? station . services [ 0 ] ) ) &&
< div > < span > › К а р т о ч к а п о л у ч е н а с л у ж б о й < / span > < span > О ж и д а е т с я п е р в и ч н о е р е ш е н и е < / span > < / div > }
< / div >
{ station . services . filter ( ( service ) = > service === ( activeService ? ? station . services [ 0 ] ) ) . map ( ( service ) = >
< StatusRow key = { ` ${ cardId } : ${ service } ` } service = { service }
2026-09-21 17:40:54 +03:00
current = { station . statuses [ service ] } available = { station . available [ service ] ? ? [ ] }
2026-09-24 01:10:49 +03:00
disabled = { ! isConnected || ended }
2026-09-21 19:56:13 +03:00
crewOptions = { station . crew_options ? . filter ( ( crew ) = > crew . startsWith ( ` ${ service } — ` ) ) ? ? [ ] }
selectedCrew = { selectedCrew . startsWith ( ` ${ service } — ` ) ? selectedCrew : "" }
onCrew = { ( crew ) = > send ( { type : "crew.select" , crew } ) }
2026-09-21 17:40:54 +03:00
onSet = { ( next , comment ) = > channel . current ? . send ( { type : "card.status" , service , status : next , comment } ) } / > ) }
2026-09-21 19:56:13 +03:00
< / > : < p className = "dds-empty" > О ж и д а е м с о с т а в с л у ж б п о к а р т о ч к е . < / p > }
2026-09-24 01:10:49 +03:00
< p className = "dds-hint" > С н а ч а л а п р и м и т е к а р т о ч к у и л и у к а ж и т е п р и ч и н у о т к а з а . П о с л е д у ю щ и е с т а т у с ы с т а в я т с я п о ф а к т и ч е с к и п о л у ч е н н о й и н ф о р м а ц и и ; т е л е ф о н — о д и н и з в о з м о ж н ы х к а н а л о в . < / p >
2026-09-21 19:56:13 +03:00
< / section > }
{ pane === "journal" && station && station . log . length > 0 && < section className = "dds-panel" > < div className = "dds-panel-title" > < h2 > Ж у р н а л р е а г и р о в а н и я < / h2 > < / div >
2026-09-21 17:40:54 +03:00
< div className = "dds-table-wrap" > < table className = "dds-registry" > < thead > < tr > < th > В р е м я < / th > < th > С л у ж б а < / th > < th > С т а т у с < / th > < th > К о м м е н т а р и й < / th > < / tr > < / thead >
< tbody > { station . log . map ( ( entry , index ) = > < tr key = { ` ${ entry . at } - ${ index } ` } > < td > { time ( entry . at ) } < / td > < td > { entry . service } < / td >
< td > { SERVICE_STATUS_LABELS [ entry . status ] } < / td > < td > { entry . comment || "—" } < / td > < / tr > ) } < / tbody > < / table > < / div > < / section > }
< / div > < aside className = "dds-right" >
2026-09-24 01:10:49 +03:00
{ pane === "phone" && < section className = "dds-panel dds-phone" > < div className = "dds-panel-title" > < h2 > IP - т е л е ф о н < / h2 > < span > SIP / WebRTC + у ч е б н ы й п р о т о к о л < / span > < / div >
2026-09-26 18:12:27 +03:00
< Suspense fallback = { < p className = "dds-hint" > З а г р у ж а е м IP - т е л е ф о н … < / p > } >
< SipPhone defaultExtension = "6101" defaultTarget = "6102" title = "Реальный локальный канал ДДС" / >
< / Suspense >
2026-09-24 01:10:49 +03:00
< p className = "dds-hint" > Н а в т о р о м р а б о ч е м м е с т е о т к р о й т е < a href = "/phone" target = "_blank" rel = "noreferrer" > т е л е ф о н с т а р ш е г о г р у п п ы < / a > и з а р е г и с т р и р у й т е 6102 . П а р о л и 6101 / 6102 в ы д а ё т л о к а л ь н ы й а д м и н и с т р а т о р к о м а н д о й < code > make sip - credentials < / code > . < / p >
< h3 className = "dds-protocol-title" > У ч е б н ы й п р о т о к о л д о к л а д а < / h3 >
< p className = "dds-hint" > С в я з ь и с п о л ь з у е т с я п р и н е о б х о д и м о с т и . Р а з г о в о р и у ч е б н ы й д о к л а д с о х р а н я ю т с я в ж у р н а л е , н о н е б л о к и р у ю т и з м е н е н и е с т а т у с а . < / p >
2026-09-21 17:40:54 +03:00
< label htmlFor = "dds-crew" > Д е ж у р н а я б р и г а д а < / label >
< select id = "dds-crew" value = { selectedCrew } disabled = { ! isConnected || ended || ! station ? . crew_options ? . length }
onChange = { ( event ) = > send ( { type : "crew.select" , crew : event.target.value } ) } >
< option value = "" > В ы б е р и т е б р и г а д у < / option >
{ station ? . crew_options ? . map ( ( crew ) = > < option key = { crew } value = { crew } > { crew } < / option > ) }
< / select >
2026-09-24 01:10:49 +03:00
< button type = "button" className = "dds-dial" disabled = { ! crewAccepted || ! isConnected || ended || dialing || Boolean ( phoneCall ) }
2026-09-21 17:40:54 +03:00
onClick = { ( ) = > { if ( send ( { type : "phone.dial" } ) ) setDialing ( true ) ; } } >
☎ { dialing ? "Соединение…" : "Позвонить старшему группы" } < / button >
{ selectedCrew && ! crewAccepted && < p className = "dds-hint state-warn" > П е р е д з в о н к о м п о с т а в ь т е с т а т у с « П р и н я т а » д л я с л у ж б ы э т о й б р и г а д ы . < / p > }
2026-09-24 01:10:49 +03:00
{ phoneCall && < div className = "dds-phone-active" aria-live = "polite" >
< strong > ● С о е д и н е н и е у с т а н о в л е н о · { phoneCall . crew } < / strong >
{ phoneCall . phase === "dispatched" ? < div className = "dds-phone-form" >
< p > П е р е д а й т е с в е д е н и я и з к а р т о ч к и и з а д а ч у б р и г а д е . Д о к л а д о в ы е з д е п о я в и т с я т о л ь к о п о с л е п е р е д а ч и . < / p >
< label htmlFor = "dds-phone-address" > П о л н ы й а д р е с и з к а р т о ч к и < / label >
< input id = "dds-phone-address" value = { brief . address } onChange = { ( event ) = > setBrief ( { . . . brief , address : event.target.value } ) } placeholder = "Улица, дом, при наличии квартира и этаж" / >
< label htmlFor = "dds-phone-incident" > Х а р а к т е р п р о и с ш е с т в и я < / label >
< textarea id = "dds-phone-incident" rows = { 2 } value = { brief . incident } onChange = { ( event ) = > setBrief ( { . . . brief , incident : event.target.value } ) } placeholder = "Что случилось и важные обстоятельства" / >
< label htmlFor = "dds-phone-request" > Ч т о т р е б у е т с я о т б р и г а д ы < / label >
< input id = "dds-phone-request" value = { brief . request } onChange = { ( event ) = > setBrief ( { . . . brief , request : event.target.value } ) } placeholder = "Подтвердите выезд и доложите о прибытии" / >
< button type = "button" disabled = { ! isConnected || ended || ! brief . address . trim ( ) || ! brief . incident . trim ( ) || ! brief . request . trim ( ) }
onClick = { ( ) = > send ( { type : "phone.brief" , . . . brief } ) } > П е р е д а т ь с о о б щ е н и е < / button >
< / div > : < div className = "dds-phone-form" >
< p > У т о ч н и т е о б с т а н о в к у у с т а р ш е г о г р у п п ы . О т в е т с т а н е т о с н о в а н и е м д л я с л е д у ю щ е г о с т а т у с а . < / p >
< label htmlFor = "dds-phone-check" > В а ш в о п р о с с т а р ш е м у < / label >
< textarea id = "dds-phone-check" rows = { 2 } value = { checkText } onChange = { ( event ) = > setCheckText ( event . target . value ) } placeholder = "Сообщите, пожалуйста, текущее состояние работ по карточке" / >
< button type = "button" disabled = { ! isConnected || ended || checkText . trim ( ) . length < 8 }
onClick = { ( ) = > send ( { type : "phone.check" , text : checkText } ) } > С п р о с и т ь о б с т а н о в к у < / button >
< / div > }
< button type = "button" className = "dds-phone-hangup" onClick = { ( ) = > send ( { type : "phone.hangup" } ) } > З а в е р ш и т ь б е з д о к л а д а < / button >
< / div > }
{ phoneLines . length > 0 && < div className = "dds-phone-transcript" aria-live = "polite" > < strong > У ч е б н ы й р а з г о в о р < / strong >
{ phoneLines . map ( ( line , index ) = > < p key = { ` ${ line . at } - ${ index } ` } > < small > { time ( line . at ) } · { line . speaker === "crew" ? "Старший группы" : "Диспетчер" } < / small > < br / > { line . text } < / p > ) }
< / div > }
2026-09-21 17:40:54 +03:00
{ latest ? < div className = "dds-report" aria-live = "polite" > < small > { REPORT_LABELS [ latest . phase ] } · { time ( latest . at ) } < / small >
< strong > { latest . crew } < / strong > < p > { latest . text } < / p >
< span > П р о с л у ш а й т е д о к л а д и п о с т а в ь т е с о о т в е т с т в у ю щ и й с т а т у с р е а г и р о в а н и я . < / span > < / div >
2026-09-24 01:10:49 +03:00
: < div className = "dds-phone-idle" > Д о к л а д п о я в и т с я п о с л е п е р е д а ч и с в е д е н и й и о т в е т а с т а р ш е г о г р у п п ы . < / div > }
2026-09-21 17:40:54 +03:00
{ reports . length > 0 && < details className = "dds-details" > < summary > И с т о р и я д о к л а д о в ( { reports . length } ) < / summary >
< ol className = "dds-reports" > { reports . map ( ( report , index ) = > < li key = { ` ${ report . at } - ${ index } ` } > < b > { REPORT_LABELS [ report . phase ] } < / b > · { time ( report . at ) } < br / > { report . text } < / li > ) } < / ol >
< / details > }
2026-09-21 19:56:13 +03:00
< / section > }
{ pane === "reply" && < section className = "dds-panel dds-reply" >
2026-09-24 01:10:49 +03:00
< div className = "dds-panel-title" > < h2 > Р а б о ч а я з а м е т к а д и с п е т ч е р а < / h2 >
2026-09-21 19:56:13 +03:00
< span > { reply . dirty ? "Н е сохранено" : "Сохранено" } < / span > < / div >
2026-09-24 01:10:49 +03:00
< label htmlFor = "dds-reply-text" > Н е о б я з а т е л ь н а я з а м е т к а п о т е к у щ е й к а р т о ч к е < / label >
2026-09-21 19:56:13 +03:00
< textarea id = "dds-reply-text" rows = { 7 } value = { reply . text }
disabled = { ! isConnected || ended || advancePending }
placeholder = "Что сообщено старшему группы, какие сведения получены и почему принято решение"
onChange = { ( event ) = > setReply ( { text : event.target.value , dirty : true } ) } / >
2026-09-24 01:10:49 +03:00
< p className = "dds-hint" > З а м е т к а с о х р а н я е т с я в ж у р н а л е , н о н е в л и я е т н а о ц е н к у . О б я з а т е л ь н ы й к о м м е н т а р и й в в о д и т с я н е п о с р е д с т в е н н о п р и с т а т у с е о т к а з а . < / p >
2026-09-21 19:56:13 +03:00
< button type = "button" disabled = { ! cardId || ! reply . dirty || ! isConnected || ended || advancePending }
onClick = { ( ) = > { if ( send ( { type : "card.reply" , card_id : cardId , text : reply.text } ) ) setError ( "" ) ; } } >
С о х р а н и т ь о т в е т < / button >
< / section > }
{ pane === "more" && < section className = "dds-panel dds-secondary" > < div className = "dds-panel-title" > < h2 > Д о п о л н и т е л ь н ы е д е й с т в и я < / h2 > < / div >
2026-09-21 17:40:54 +03:00
< div className = "dds-zone" > З о н а о т в е т с т в е н н о с т и : < button type = "button" disabled = { zone !== null || ! isConnected || ended }
2026-09-26 18:12:27 +03:00
onClick = { ( ) = > { channel . current ? . send ( { type : "zone.decision" , in_zone : true } ) ; } } > Н а ш а < / button >
2026-09-21 17:40:54 +03:00
< button type = "button" disabled = { zone !== null || ! isConnected || ended }
2026-09-26 18:12:27 +03:00
onClick = { ( ) = > { channel . current ? . send ( { type : "zone.decision" , in_zone : false } ) ; } } > Н е н а ш а < / button > < / div >
2026-09-24 01:10:49 +03:00
< p className = "dds-hint" > Д Д С н е и с п р а в л я е т и н е в о з в р а щ а е т з а п о л н е н н у ю карточку : её к а ч е с т в о к о н т р о л и р у ю т о п е р а т о р и с л у ж б а к о н т р о л я 112 . < / p >
2026-09-21 19:56:13 +03:00
< button type = "button" className = "dds-finish" disabled = { ! cardId || ! isConnected || ended || advancePending }
onClick = { ( ) = > {
if ( reply . dirty && ! send ( { type : "card.reply" , card_id : cardId , text : reply.text } ) ) return ;
if ( send ( { type : "card.next" , card_id : cardId } ) ) setAdvancePending ( true ) ;
} } >
2026-09-24 01:10:49 +03:00
{ queueCards . length > 1 ? "Закрыть карточку" : "Закрыть карточку и занятие" }
2026-09-21 19:56:13 +03:00
< / button >
{ cardTotal > 1 && < button type = "button" disabled = { ! isConnected || ended || advancePending }
onClick = { ( ) = > { if ( window . confirm ( "Завершить занятие досрочно? Остальные карточки не будут пройдены." ) ) send ( { type : "station.finish" } ) ; } } >
Д о с р о ч н о з а в е р ш и т ь з а н я т и е < / button > }
< / section > }
< / aside > < / div > < / div > }
< / div >
< nav className = "dds-service-dock" aria-label = "Действия с карточкой" >
< span className = "dds-dock-label" > С л у ж б ы : < / span >
2026-09-26 18:12:27 +03:00
< div className = "dds-service-recipient-list" role = "group" aria-label = "Службы-получатели" >
{ ( station ? . recipient_services ? . length ? station.recipient_services : station?.services ? ? [ ] ) . map ( ( service ) = > {
const canManage = station ? . services . includes ( service ) ? ? false ;
const serviceStatus = station ? . statuses [ service ] ? ? "added" ;
return < button type = "button" className = { ` dds-service-recipient ${ pane === "status" && activeService === service ? "active" : "" } ` }
key = { service } disabled = { ! canManage }
title = { canManage ? ` Обновить статус: ${ service } ` : ` Уведомление направлено ${ service } ; статус меняет назначенная ДДС ` }
onClick = { ( ) = > { if ( canManage ) { setActiveService ( service ) ; setPane ( "status" ) ; } } } >
< b > { serviceTitle ( service ) } < / b > < small > { SERVICE_STATUS_LABELS [ serviceStatus ] } < / small >
< / button > ;
} ) }
< / div >
< div className = "dds-service-tools" role = "group" aria-label = "Действия диспетчера" >
< button type = "button" onClick = { ( ) = > setPane ( "phone" ) } > < b > ☎ С в я з ь < / b > < small > Б р и г а д а < / small > < / button >
< button type = "button" onClick = { ( ) = > setPane ( "reply" ) } > < b > ✎ О т в е т < / b > < small > { reply . dirty ? "Н е сохранён" : "Комментарий" } < / small > < / button >
< button type = "button" onClick = { ( ) = > setPane ( "journal" ) } > < b > Ж у р н а л < / b > < small > С т а т у с ы < / small > < / button >
< button type = "button" disabled = { ! cardId || ! isConnected || ended || advancePending }
onClick = { ( ) = > {
if ( reply . dirty && ! send ( { type : "card.reply" , card_id : cardId , text : reply.text } ) ) return ;
if ( send ( { type : "card.next" , card_id : cardId } ) ) setAdvancePending ( true ) ;
} } > < b > ✓ З а в е р ш и т ь < / b > < small > { queueCards . length > 1 ? "Закрыть карточку" : "Занятие" } < / small > < / button >
< / div >
2026-09-21 19:56:13 +03:00
< / nav >
< / div > }
2026-09-21 17:40:54 +03:00
< / main > ;
2026-09-17 21:45:47 +03:00
}
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
2026-09-24 01:10:49 +03:00
function StatusRow ( { service , current , available , disabled , crewOptions , selectedCrew , onCrew , onSet } : {
2026-09-21 17:40:54 +03:00
service : string ; current : ServiceStatus ; available : ServiceStatus [ ] ; disabled : boolean ;
2026-09-24 01:10:49 +03:00
crewOptions : string [ ] ; selectedCrew : string ;
2026-09-21 19:56:13 +03:00
onCrew : ( crew : string ) = > void ; onSet : ( status : ServiceStatus , comment : string ) = > void ;
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
} ) {
const [ chosen , setChosen ] = useState < ServiceStatus | "" > ( "" ) ;
2026-09-26 18:12:27 +03:00
const [ basis , setBasis ] = useState ( "" ) ;
const [ information , setInformation ] = useState ( "" ) ;
const [ recipient , setRecipient ] = useState ( "" ) ;
2026-09-21 19:56:13 +03:00
if ( ! available . length ) return < p className = "dds-hint" > { serviceTitle ( service ) } : { SERVICE_STATUS_LABELS [ current ] } . Д а л ь н е й ш и е с т а т у с ы н е д о с т у п н ы . < / p > ;
return < div className = "dds-status-editor" >
< label > С т а т у с
2026-09-21 17:40:54 +03:00
< select value = { chosen } disabled = { disabled } aria-label = { ` Следующий статус ${ service } ` }
onChange = { ( event ) = > setChosen ( event . target . value as ServiceStatus ) } >
2026-09-21 19:56:13 +03:00
< option value = "" > { SERVICE_STATUS_LABELS [ current ] } → < / option >
2026-09-21 17:40:54 +03:00
{ available . map ( ( next ) = > < option key = { next } value = { next } > { SERVICE_STATUS_LABELS [ next ] } < / option > ) }
< / select >
2026-09-21 19:56:13 +03:00
< / label >
< label > Н о м е р н а р я д а
< select value = { selectedCrew } disabled = { disabled || ! crewOptions . length } aria-label = { ` Наряд ${ service } ` }
onChange = { ( event ) = > { if ( event . target . value ) onCrew ( event . target . value ) ; } } >
< option value = "" > — < / option >
{ crewOptions . map ( ( crew ) = > < option value = { crew } key = { crew } > { crew } < / option > ) }
< / select >
< / label >
2026-09-26 18:12:27 +03:00
< label > О с н о в а н и е с т а т у с а
< input type = "text" value = { basis } disabled = { disabled } aria-label = { ` Основание статуса ${ service } ` }
placeholder = { chosen === "declined" || chosen === "refused" ? "Причина отказа" : "Например: доклад о выезде" }
onChange = { ( event ) = > setBasis ( event . target . value ) } / >
< / label >
< label > П о л у ч е н н ы е с в е д е н и я
< input type = "text" value = { information } disabled = { disabled } aria-label = { ` Полученные сведения ${ service } ` }
placeholder = "Что сообщено о ходе работ"
onChange = { ( event ) = > setInformation ( event . target . value ) } / >
< / label >
< label > К о м у п е р е д а н ы с в е д е н и я · н е о б я з а т е л ь н о
< input type = "text" value = { recipient } disabled = { disabled } aria-label = { ` Получатель сведений ${ service } ` }
placeholder = "Например: бригаде № 12"
onChange = { ( event ) = > setRecipient ( event . target . value ) } / >
2026-09-21 19:56:13 +03:00
< / label >
< button type = "button" className = "dds-status-save" aria-label = "Сохранить статус"
2026-09-26 18:12:27 +03:00
disabled = { disabled || ! chosen || ! basis . trim ( ) || ! information . trim ( ) }
onClick = { ( ) = > { if ( chosen ) {
const lines = [ ` Основание: ${ basis . trim ( ) } ` , ` Сведения: ${ information . trim ( ) } ` ] ;
if ( recipient . trim ( ) ) lines . push ( ` Передано: ${ recipient . trim ( ) } ` ) ;
onSet ( chosen , lines . join ( "\n" ) ) ; setChosen ( "" ) ; setBasis ( "" ) ; setInformation ( "" ) ; setRecipient ( "" ) ;
} } } > ✓ < / button >
2026-09-21 19:56:13 +03:00
< button type = "button" className = "dds-status-cancel" aria-label = "Отменить изменение статуса"
2026-09-26 18:12:27 +03:00
onClick = { ( ) = > { setChosen ( "" ) ; setBasis ( "" ) ; setInformation ( "" ) ; setRecipient ( "" ) ; } } > × < / button >
2026-09-21 19:56:13 +03:00
< / div > ;
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
}