feat(tgu-ui): вкладка «Слоты» — расчёт покрытия и бронирование слотов заявки

- новая вкладка: заявки интервала на карте, выбор заявки кликом/инспектором,
  расчёт слотов покрытия, бронирование и снятие брони
- выбор заявки без выпадающего списка (не масштабируется на ~10к): все заявки
  на карте, панель справа с кнопкой «Выбрать», имя выбранной заявки в тулбаре,
  кнопка «Сброс», фильтр заявок по интервалу
- бронь отмечаем по ответу POST booking/request (by-request отдаёт слот в чужом
  cycle-фрейме — ключ не совпадал, бейдж «бронь» не появлялся)
- подсветка и автоскролл активной строки по клику на слот (ручной scrollBy)
- меню выбора при плотном наложении слотов (SlotPickMenu); контур заявки
  pickable:false, чтобы не «съедать» меню; панель не закрывается при выборе
- фильтр отображения по суткам (SlotDaySelector)
- gateway: маршрут slots (/api/pcp-slots/**)
- карточка полосы обзора (swath): КА и виток в инспекторе
This commit is contained in:
Дмитрий Соловьев
2026-06-11 12:20:28 +03:00
parent 802c966347
commit 6689351ef0
18 changed files with 1735 additions and 47 deletions
@@ -0,0 +1,115 @@
import type {
BookedSlotDTO,
RequestSlotsParams,
SlotBookingRequest,
SlotDTO,
} from '../model/slotTypes'
import { apiFetch } from './apiFetch'
// slots-service через gateway: /api/pcp-slots/** → StripPrefix=2 → slots-service /api/slots/**
const BASE = '/api/pcp-slots/api/slots'
async function readErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const text = await response.text()
if (!text) return `${fallback}: ${response.status}`
try {
const body = JSON.parse(text) as { error?: string; message?: string }
return body.error || body.message || text
} catch {
return text
}
} catch {
return `${fallback}: ${response.status}`
}
}
/**
* Рассчитывает слоты покрытия заявки (slots-service: GET /api/slots/request-cover).
* @param params Заявка, интервал и фильтры (ветвь, признак покрытия, КА, угол Солнца).
*
* @returns Список слотов-кандидатов с контурами для карты.
*/
export async function fetchRequestSlots(params: RequestSlotsParams): Promise<SlotDTO[]> {
const query = new URLSearchParams()
query.set('requestId', params.requestId)
query.set('timeStart', params.timeStart)
query.set('timeStop', params.timeStop)
if (params.revSign) query.set('revSign', params.revSign)
if (params.cov != null) query.set('cov', String(params.cov))
if (params.sun != null) query.set('sun', String(params.sun))
params.satellites?.forEach((id) => query.append('satellites', String(id)))
const response = await apiFetch(`${BASE}/request-cover?${query}`, {
headers: { Accept: 'application/json' },
})
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Не удалось рассчитать слоты'))
}
return response.json() as Promise<SlotDTO[]>
}
/**
* Бронирует выбранные слоты под заявку (slots-service: POST /api/slots/booking/request).
* @param requestId Идентификатор заявки.
* @param slots Список слотов (КА, номер слота, цикл).
*
* @returns Забронированные слоты.
*/
export async function bookRequestSlots(
requestId: string,
slots: SlotBookingRequest[],
): Promise<BookedSlotDTO[]> {
const response = await apiFetch(`${BASE}/booking/request`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ requestId, slots }),
})
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Не удалось забронировать слоты'))
}
return response.json() as Promise<BookedSlotDTO[]>
}
/**
* Снимает все брони заявки (slots-service: DELETE /api/slots/booking/request).
* @param requestId Идентификатор заявки.
*
* @returns Сколько броней снято.
*/
export async function cancelRequestBooking(requestId: string): Promise<number> {
const response = await apiFetch(
`${BASE}/booking/request?requestId=${encodeURIComponent(requestId)}`,
{ method: 'DELETE', headers: { Accept: 'application/json' } },
)
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Не удалось снять бронь'))
}
const value = Number(await response.text())
return Number.isFinite(value) ? value : 0
}
/**
* Загружает уже забронированные слоты заявки (slots-service: GET /api/slots/booking/by-request/{id}).
* @param requestId Идентификатор заявки.
*
* @returns Список забронированных слотов с контурами.
*/
export async function fetchBookedSlotsByRequest(requestId: string): Promise<SlotDTO[]> {
const response = await apiFetch(
`${BASE}/booking/by-request/${encodeURIComponent(requestId)}`,
{ headers: { Accept: 'application/json' } },
)
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Не удалось загрузить брони заявки'))
}
return response.json() as Promise<SlotDTO[]>
}