diff --git a/services/pcp-tgu-ui-service/src/api/slotsApi.test.ts b/services/pcp-tgu-ui-service/src/api/slotsApi.test.ts index b078be1..8f509e5 100644 --- a/services/pcp-tgu-ui-service/src/api/slotsApi.test.ts +++ b/services/pcp-tgu-ui-service/src/api/slotsApi.test.ts @@ -36,6 +36,18 @@ describe("fetchRequestSlots", () => { expect(String(fetchMock.mock.calls[0][0])).not.toContain("coverageStrategy"); }); + it("прокидывает all=true в query (показать и занятые бронью)", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([])); + await fetchRequestSlots({ ...baseParams, all: true }); + expect(String(fetchMock.mock.calls[0][0])).toContain("all=true"); + }); + + it("не добавляет all, если не задан", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([])); + await fetchRequestSlots(baseParams); + expect(String(fetchMock.mock.calls[0][0])).not.toContain("all="); + }); + it("пробрасывает AbortSignal в fetch", async () => { const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([])); const ctrl = new AbortController(); diff --git a/services/pcp-tgu-ui-service/src/api/slotsApi.ts b/services/pcp-tgu-ui-service/src/api/slotsApi.ts index 0d92996..cd6df86 100644 --- a/services/pcp-tgu-ui-service/src/api/slotsApi.ts +++ b/services/pcp-tgu-ui-service/src/api/slotsApi.ts @@ -46,6 +46,7 @@ export async function fetchRequestSlots( if (params.cov != null) query.set('cov', String(params.cov)) if (params.sun != null) query.set('sun', String(params.sun)) if (params.coverageStrategy) query.set('coverageStrategy', params.coverageStrategy) + if (params.all != null) query.set('all', String(params.all)) params.satellites?.forEach((id) => query.append('satellites', String(id))) const response = await apiFetch(`${BASE}/request-cover?${query}`, { diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotFilterBar.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotFilterBar.tsx new file mode 100644 index 0000000..a56e27b --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotFilterBar.tsx @@ -0,0 +1,109 @@ +import type { RevolutionSign } from "../../model/slotTypes"; +import type { SlotStatusKey } from "./slotFilters"; + +type SlotFilterBarProps = { + /** Счётчики статусов по окну суток (до статус-фильтра) — стабильны при переключении чипов. */ + statusCounts: Record; + statusFilter: ReadonlySet; + onToggleStatus: (key: SlotStatusKey) => void; + /** Список КА в наборе (отсортирован). Чип КА не показываем, если спутник один. */ + satellites: number[]; + satFilter: ReadonlySet; + onToggleSat: (id: number) => void; + branchFilter: ReadonlySet; + onToggleBranch: (branch: RevolutionSign) => void; + /** Фильтры в дефолте (всё видно) — тогда «Сбросить» не показываем. */ + isDefault: boolean; + onReset: () => void; +}; + +const STATUS_META: { key: SlotStatusKey; label: string; variant: string }[] = [ + { key: "free", label: "свободен", variant: "" }, + { key: "booked", label: "бронь", variant: " is-booked" }, + { key: "occupied", label: "недоступен", variant: " is-occupied" }, +]; + +/** + * Панель фильтров отображения слотов (видимость). Сужает И таблицу, И карту через единый + * filteredSlots. Стоит над строкой выбора: сначала сужаешь, потом отбираешь. Чипы — + * настоящие кнопки aria-pressed; цвет статус-чипа совпадает с бейджем статуса в таблице, + * на каждом текст-метка (не только цвет — дальтоники). + */ +export function SlotFilterBar({ + statusCounts, + statusFilter, + onToggleStatus, + satellites, + satFilter, + onToggleSat, + branchFilter, + onToggleBranch, + isDefault, + onReset, +}: SlotFilterBarProps) { + return ( +
+
+ Статус + {STATUS_META.map(({ key, label, variant }) => { + const active = statusFilter.has(key); + return ( + + ); + })} +
+ + {satellites.length > 1 && ( +
+ КА + {satellites.map((id) => { + const active = satFilter.has(id); + return ( + + ); + })} +
+ )} + +
+ Ветвь + {(["ASC", "DESC"] as RevolutionSign[]).map((branch) => { + const active = branchFilter.has(branch); + return ( + + ); + })} +
+ + {!isDefault && ( + + )} +
+ ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx index c3c3f7c..71ad41c 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { fetchRequestsForMap } from "../../api/requestApi"; import { @@ -26,6 +26,8 @@ import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { formatLocalDateTimeSeconds, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; import { SlotDaySelector, type SlotDay } from "./SlotDaySelector"; import { SlotPickMenu, type SlotPick } from "./SlotPickMenu"; +import { SlotFilterBar } from "./SlotFilterBar"; +import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters"; import { selectionAfterCheck } from "./slotSelection"; import { runChunked } from "./runChunked"; import { ChunkProgress } from "./ChunkProgress"; @@ -36,8 +38,6 @@ const DAY_MS = 24 * 60 * 60 * 1000; const BOOK_CHUNK = 200; /** Выше этого числа слотов бронь требует подтверждения. */ const BOOK_CONFIRM_THRESHOLD = 500; -/** Выше этого числа полигонов в окне суток показываем подсказку «сузьте сутки». */ -const MAP_HINT_THRESHOLD = 800; /** Оценка высоты строки таблицы (px) для виртуализатора. */ const ROW_HEIGHT = 34; @@ -94,6 +94,16 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { const [dayFrom, setDayFrom] = useState(1); const [dayTo, setDayTo] = useState(1); + // Фильтры видимости (статус/КА/ветвь). Единая модель «selected = visible»: множество = что + // видно, пусто = ничего. Дефолт — всё видно; satFilter инициализируется всеми КА при расчёте. + const [statusFilter, setStatusFilter] = useState>( + () => new Set(["free", "booked", "occupied"]) + ); + const [satFilter, setSatFilter] = useState>(() => new Set()); + const [branchFilter, setBranchFilter] = useState>( + () => new Set(["ASC", "DESC"]) + ); + const [calculating, setCalculating] = useState(false); const [autoFilling, setAutoFilling] = useState(false); const [booking, setBooking] = useState(false); @@ -191,9 +201,12 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { }, [selectedKeys, bookedByKey]); const selectedBookableCount = useMemo(() => { let n = 0; - for (const key of selectedKeys) if (!bookedKeys.has(key)) n += 1; + for (const key of selectedKeys) { + const slot = slotByKey.get(key); + if (!bookedKeys.has(key) && !(slot && slotIsBusy(slot))) n += 1; + } return n; - }, [selectedKeys, bookedKeys]); + }, [selectedKeys, bookedKeys, slotByKey]); // Сутки, по которым раскладываются строки таблицы (для фильтра отображения). const days = useMemo(() => { @@ -218,8 +231,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { const effDayFrom = Math.max(1, Math.min(dayFrom, days.length || 1)); const effDayTo = Math.max(effDayFrom, Math.min(dayTo, days.length || 1)); - // Слоты выбранного диапазона суток — то, что видно и в таблице, и на карте (кандидаты + брони). - const filteredSlots = useMemo(() => { + // Слоты в окне суток (до фильтров видимости) — база для счётчиков статусов и фильтрации. + const dayWindowed = useMemo(() => { if (days.length === 0) return displaySlots; const fromMs = days[effDayFrom - 1].dayMs; const toMsExcl = days[effDayTo - 1].dayMs + DAY_MS; @@ -229,6 +242,40 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { }); }, [displaySlots, days, effDayFrom, effDayTo]); + // Счётчики статусов по окну суток (до статус-фильтра) — стабильны при переключении чипов. + const statusCounts = useMemo>(() => { + const counts: Record = { free: 0, booked: 0, occupied: 0 }; + for (const slot of dayWindowed) counts[slotStatusKey(slot, bookedKeys.has(slotKey(slot)))] += 1; + return counts; + }, [dayWindowed, bookedKeys]); + + // КА в наборе (для чипов фильтра); сигнатура — чтобы инициализировать satFilter только при + // смене набора КА (новый расчёт), а не при каждой брони. + const satellites = useMemo(() => { + const ids = new Set(); + for (const slot of displaySlots) ids.add(slot.satelliteId); + return Array.from(ids).sort((a, b) => a - b); + }, [displaySlots]); + const satSignature = satellites.join(","); + + // Новый набор КА → показываем все КА (бронирование набор КА не меняет, поэтому не сбрасываем). + // useLayoutEffect — чтобы satFilter раскрылся ДО отрисовки и не мигнул пустой таблицей. + useLayoutEffect(() => { + setSatFilter(new Set(satellites)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [satSignature]); + + // Слоты, видимые в таблице и на карте: окно суток + фильтры видимости (статус/КА/ветвь). + const filteredSlots = useMemo( + () => + applySlotFilters( + dayWindowed, + { status: statusFilter, satellites: satFilter, branches: branchFilter }, + (slot) => bookedKeys.has(slotKey(slot)), + ), + [dayWindowed, statusFilter, satFilter, branchFilter, bookedKeys], + ); + // Виртуализация таблицы: в DOM держим только видимое окно строк (+overscan), а не все слоты — // иначе на тысячах строк рендер встаёт. Скролл-контейнер — секция списка (listRef). const rowVirtualizer = useVirtualizer({ @@ -320,6 +367,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { setNotice(undefined); setError(undefined); setActiveSlotId(undefined); + // Смена заявки — фильтры видимости в дефолт (satFilter раскроется через satSignature-эффект). + setStatusFilter(new Set(["free", "booked", "occupied"])); + setBranchFilter(new Set(["ASC", "DESC"])); if (selectedRequestId) void loadBooked(selectedRequestId); }, [selectedRequestId, loadBooked]); @@ -367,8 +417,10 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { timeStop: toLocalIso(parsedRange.toMs), revSign: revSign || undefined, // cov=true (режим покрытия) ровно когда задана стратегия — это путь «Подобрать - // покрытие». «Показать все слоты» идёт без стратегии и получает полный набор кандидатов. + // покрытие». «Показать все слоты» идёт без стратегии и получает полный набор кандидатов, + // включая занятые бронью (all=true) — занятые показываем для обзора, бронировать нельзя. cov: auto, + all: !auto, coverageStrategy }, ctrl.signal @@ -391,9 +443,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { : "Для выбранного интервала слотов покрытия не найдено." ); } - if (result.length > 0) { - focusOn({ lat: result[0].latitude, lon: result[0].longitude }); - } + // Карту НЕ перецентрируем: result[0] — первый по времени/витку, географически произвольный + // угол, а зум до 4.5 спрятал бы остальной набор. Оставляем общий обзор заявки на месте; + // прицельный фокус — по клику на строку таблицы (focusOn в onRowClick). } catch (caught) { // Отмена (повтор/смена заявки) — это не ошибка, молча выходим. if (ctrl.signal.aborted || (caught instanceof DOMException && caught.name === "AbortError")) return; @@ -456,7 +508,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { // Выбранные кандидаты под бронь (без уже забронированных — их бронировать незачем). const collectSelected = (): SlotBookingRequest[] => slots - .filter((slot) => selectedKeys.has(slotKey(slot)) && !bookedKeys.has(slotKey(slot))) + .filter( + (slot) => selectedKeys.has(slotKey(slot)) && !bookedKeys.has(slotKey(slot)) && !slotIsBusy(slot), + ) .map((slot) => ({ satelliteId: slot.satelliteId, slotNum: slot.slotNumber, cycle: slot.cycle })); const book = () => { @@ -546,25 +600,47 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { anchorIndexRef.current = index; }; - // Чекбокс шапки — выбрать/снять все ВИДИМЫЕ (по окну суток) слоты. + // Чекбокс шапки — выбрать/снять все ВИДИМЫЕ (по окну суток) выбираемые слоты. BUSY (занятые + // бронью) бронировать нельзя — в массовый выбор не берём. + const selectableVisible = useMemo(() => filteredSlots.filter((slot) => !slotIsBusy(slot)), [filteredSlots]); const allVisibleSelected = - filteredSlots.length > 0 && filteredSlots.every((slot) => selectedKeys.has(slotKey(slot))); + selectableVisible.length > 0 && selectableVisible.every((slot) => selectedKeys.has(slotKey(slot))); const toggleAllVisible = () => { setSelectedKeys((current) => { const next = new Set(current); - if (allVisibleSelected) for (const slot of filteredSlots) next.delete(slotKey(slot)); - else for (const slot of filteredSlots) next.add(slotKey(slot)); + if (allVisibleSelected) for (const slot of selectableVisible) next.delete(slotKey(slot)); + else for (const slot of selectableVisible) next.add(slotKey(slot)); return next; }); anchorIndexRef.current = null; }; - // Выбрать все слоты расчёта (по всем суткам), снять весь выбор. - const selectAllDays = () => setSelectedKeys(new Set(displaySlots.map(slotKey))); + // Выбрать все слоты расчёта (по всем суткам, кроме занятых бронью), снять весь выбор. + const selectAllDays = () => + setSelectedKeys(new Set(displaySlots.filter((slot) => !slotIsBusy(slot)).map(slotKey))); const clearSelection = () => { setSelectedKeys(new Set()); anchorIndexRef.current = null; }; + // Тогглы фильтров видимости (selected = visible: клик добавляет/убирает значение из множества). + const toggleInSet = (set: ReadonlySet, value: T): Set => { + const next = new Set(set); + if (next.has(value)) next.delete(value); + else next.add(value); + return next; + }; + const toggleStatus = (key: SlotStatusKey) => setStatusFilter((prev) => toggleInSet(prev, key)); + const toggleSat = (id: number) => setSatFilter((prev) => toggleInSet(prev, id)); + const toggleBranch = (branch: RevolutionSign) => setBranchFilter((prev) => toggleInSet(prev, branch)); + // Фильтры в дефолте — всё видно (на всех трёх осях выбраны все значения). + const filtersDefault = + statusFilter.size === 3 && branchFilter.size === 2 && satFilter.size === satellites.length; + const resetFilters = () => { + setStatusFilter(new Set(["free", "booked", "occupied"])); + setBranchFilter(new Set(["ASC", "DESC"])); + setSatFilter(new Set(satellites)); + }; + // Сколько выбрано из видимых суток и сколько — на других сутках (для прозрачности охвата). const visibleSelectedCount = useMemo( () => filteredSlots.reduce((n, slot) => (selectedKeys.has(slotKey(slot)) ? n + 1 : n), 0), @@ -635,6 +711,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { }); } } + // Рисуем все отфильтрованные слоты без потолка: общую картину не режем, объём сужает + // оператор фильтрами (статус/КА/ветвь/сутки). Таблица виртуализирована, карта — canvas. for (const slot of filteredSlots) { const key = slotKey(slot); for (const ring of ringsFor(key, slot.contour)) { @@ -776,8 +854,6 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { const virtualTotal = rowVirtualizer.getTotalSize(); const padTop = virtualRows.length > 0 ? virtualRows[0].start : 0; const padBottom = virtualRows.length > 0 ? virtualTotal - virtualRows[virtualRows.length - 1].end : 0; - // Слишком много полигонов в окне суток — подсказываем сузить (карту не блокируем). - const mapOverloaded = filteredSlots.length > MAP_HINT_THRESHOLD; return (
@@ -873,6 +949,20 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
{hasRequest ? ( <> + {displaySlots.length > 0 && ( + + )}
Выбрано: {selectedKeys.size} @@ -906,7 +996,20 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
{filteredSlots.length === 0 ? (
- {displaySlots.length > 0 ? "На выбранные сутки слотов нет." : "Слоты не рассчитаны."} + {displaySlots.length === 0 ? ( + "Слоты не рассчитаны." + ) : dayWindowed.length === 0 ? ( + "На выбранные сутки слотов нет." + ) : ( + <> + Слоты скрыты фильтром — включите статус/КА или сбросьте фильтры. +
+ +
+ + )}
) : ( @@ -954,6 +1057,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { checked={selectedKeys.has(key)} onChange={() => {}} onClick={(event) => handleSlotCheck(vrow.index, event.shiftKey)} + disabled={slotIsBusy(slot)} + title={slotIsBusy(slot) ? "Слот недоступен — конфликт с бронью, бронировать нельзя" : undefined} aria-label="Выбрать слот" /> @@ -987,7 +1092,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { ) : ( )} - {hasRequest && mapOverloaded && ( -
- Много слотов в окне ({filteredSlots.length}) — сузьте сутки для удобной карты. -
- )} {hasRequest && days.length > 0 && ( ): boolean { + return slot.state === "BUSY"; +} + function slotStateLabel(state: string): string { switch (state) { case "AVAILABLE": return "свободен"; case "BOOKED": return "бронь"; - case "OCCUPIED": - return "занят"; + case "BUSY": + return "недоступен"; default: return state || "—"; } diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/slotFilters.test.ts b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotFilters.test.ts new file mode 100644 index 0000000..a01ee22 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotFilters.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import type { SlotDTO } from "../../model/slotTypes"; +import { applySlotFilters, slotStatusKey, type SlotFilterState } from "./slotFilters"; + +function slot(over: Partial): SlotDTO { + return { + cycle: 1, + satelliteId: 2, + tn: "2026-06-15T10:00:00", + tk: "2026-06-15T10:00:10", + roll: 30, + contour: "POLYGON EMPTY", + revolution: 281, + revolutionSign: "ASC", + slotNumber: 1, + state: "AVAILABLE", + latitude: 55, + longitude: 37, + ...over, + }; +} + +const allStatuses = new Set(["free", "booked", "occupied"] as const); +const allBranches = new Set(["ASC", "DESC"] as const); + +describe("slotStatusKey", () => { + it("бронь важнее state (booked, даже если state=AVAILABLE)", () => { + expect(slotStatusKey({ state: "AVAILABLE" }, true)).toBe("booked"); + }); + it("BUSY → occupied, когда не бронь", () => { + expect(slotStatusKey({ state: "BUSY" }, false)).toBe("occupied"); + }); + it("AVAILABLE → free", () => { + expect(slotStatusKey({ state: "AVAILABLE" }, false)).toBe("free"); + }); +}); + +describe("applySlotFilters", () => { + const rows = [ + slot({ slotNumber: 1, satelliteId: 2, revolutionSign: "ASC", state: "AVAILABLE" }), + slot({ slotNumber: 2, satelliteId: 2, revolutionSign: "DESC", state: "BUSY" }), + slot({ slotNumber: 3, satelliteId: 5, revolutionSign: "ASC", state: "AVAILABLE" }), + ]; + const booked = new Set([1]); // слот №1 — бронь + const isBooked = (s: SlotDTO) => booked.has(s.slotNumber); + const base: SlotFilterState = { + status: allStatuses, + satellites: new Set([2, 5]), + branches: allBranches, + }; + + it("дефолт (все оси) пропускает всё", () => { + expect(applySlotFilters(rows, base, isBooked)).toHaveLength(3); + }); + it("статус free убирает бронь и занятый", () => { + const out = applySlotFilters(rows, { ...base, status: new Set(["free"]) }, isBooked); + expect(out.map((s) => s.slotNumber)).toEqual([3]); + }); + it("фильтр по КА оставляет только выбранный спутник", () => { + const out = applySlotFilters(rows, { ...base, satellites: new Set([5]) }, isBooked); + expect(out.map((s) => s.slotNumber)).toEqual([3]); + }); + it("ветвь ASC оставляет только восходящие", () => { + const out = applySlotFilters(rows, { ...base, branches: new Set(["ASC"]) }, isBooked); + expect(out.map((s) => s.slotNumber)).toEqual([1, 3]); + }); + it("снятие последней галочки оси (пустое множество) → ничего не видно", () => { + expect(applySlotFilters(rows, { ...base, status: new Set() }, isBooked)).toHaveLength(0); + expect(applySlotFilters(rows, { ...base, satellites: new Set() }, isBooked)).toHaveLength(0); + }); +}); diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/slotFilters.ts b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotFilters.ts new file mode 100644 index 0000000..72946c5 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotFilters.ts @@ -0,0 +1,46 @@ +// Чистая логика фильтрации отображения слотов — выделена из TguSlotsTab, чтобы покрыть +// юнит-тестами без рендера компонента. Едина для таблицы и карты: обе читают результат +// applySlotFilters (filteredSlots). + +import type { RevolutionSign, SlotDTO } from "../../model/slotTypes" + +/** Производный статус слота для фильтра видимости. */ +export type SlotStatusKey = "free" | "booked" | "occupied" + +/** Состояние фильтров отображения. Единая модель «selected = visible» на всех осях: + * множество = что ВИДНО; пустое множество = не видно ничего (одинаково для всех осей). */ +export type SlotFilterState = { + status: ReadonlySet + satellites: ReadonlySet + branches: ReadonlySet +} + +/** + * Производный статус слота. Бронь определяется снаружи (isBooked), а не по slot.state, + * потому что свежезабронированный кандидат приходит со state===AVAILABLE до перечитывания + * (бронь важнее state). Иначе BUSY (занят бронью на проходе КА) → occupied, остальное → free. + */ +export function slotStatusKey(slot: Pick, isBooked: boolean): SlotStatusKey { + if (isBooked) return "booked" + if (slot.state === "BUSY") return "occupied" + return "free" +} + +/** + * Фильтрует слоты по статусу/КА/ветви одним проходом (selected=visible на каждой оси). + * @param rows Уже суженные по окну суток слоты. + * @param filters Множества видимых значений по каждой оси. + * @param isBooked Признак брони по слоту (bookedKeys в компоненте). + */ +export function applySlotFilters( + rows: readonly SlotDTO[], + filters: SlotFilterState, + isBooked: (slot: SlotDTO) => boolean, +): SlotDTO[] { + return rows.filter( + (slot) => + filters.status.has(slotStatusKey(slot, isBooked(slot))) && + filters.satellites.has(slot.satelliteId) && + filters.branches.has(slot.revolutionSign), + ) +} diff --git a/services/pcp-tgu-ui-service/src/model/slotTypes.ts b/services/pcp-tgu-ui-service/src/model/slotTypes.ts index c1cd44e..6e58b00 100644 --- a/services/pcp-tgu-ui-service/src/model/slotTypes.ts +++ b/services/pcp-tgu-ui-service/src/model/slotTypes.ts @@ -10,8 +10,9 @@ export type RevolutionSign = "ASC" | "DESC"; */ export type SlotCoverageStrategy = "GREEDY" | "CONTINUOUS"; -/** Статус слота из расчёта покрытия. */ -export type SlotStatus = "AVAILABLE" | "BOOKED" | "OCCUPIED" | string; +/** Статус слота из расчёта покрытия (slots-service: SlotStatus). BUSY — слот занят чужой/этой + * бронью на этом проходе КА: показываем для обзора, но забронировать нельзя. */ +export type SlotStatus = "AVAILABLE" | "BOOKED" | "BUSY" | "PROCESSED" | string; /** Слот покрытия заявки — кандидат на бронирование (slots-service: SlotDTO). */ export type SlotDTO = { @@ -67,4 +68,6 @@ export type RequestSlotsParams = { sun?: number; /** Стратегия автоформирования покрытия; не задано — все слоты-кандидаты. */ coverageStrategy?: SlotCoverageStrategy; + /** true — вернуть и занятые бронью слоты (BUSY) для обзора («Показать все слоты»). */ + all?: boolean; }; diff --git a/services/pcp-tgu-ui-service/src/styles/tgu-planning.css b/services/pcp-tgu-ui-service/src/styles/tgu-planning.css index 50bf157..255ff5e 100644 --- a/services/pcp-tgu-ui-service/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ui-service/src/styles/tgu-planning.css @@ -969,6 +969,74 @@ font-size: 0.82rem; } +.tgu-slots-empty__action { + margin-top: 0.6rem; +} + +/* Панель фильтров видимости (статус/КА/ветвь) — над строкой выбора. Сужает таблицу и карту. */ +.tgu-slots-filterbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem 0.7rem; + padding: 0.4rem 0.55rem; + border-bottom: 1px solid var(--line); + background: var(--bg-panel); +} + +.tgu-slots-filterbar__group { + display: flex; + align-items: center; + gap: 0.3rem; +} + +.tgu-slots-filterbar__label { + color: var(--text-dim); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.tgu-slots-filterbar__reset { + margin-left: auto; +} + +/* Чип фильтра — паттерн пилюли тулбара (.tgu-map-toolbar__toggles). Неактивный = скрыто + фильтром (приглушён). Статус-чипы в активном виде повторяют цвет бейджа статуса в таблице. */ +.tgu-slots-chip { + min-height: 1.7rem; + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.18rem 0.6rem; + background: #101214; + color: var(--text-muted); + font-size: 0.74rem; + font-variant-numeric: tabular-nums; + cursor: pointer; + opacity: 0.5; +} + +.tgu-slots-chip:hover { + opacity: 0.8; +} + +.tgu-slots-chip.is-active { + opacity: 1; + color: var(--text); +} + +.tgu-slots-chip.is-booked.is-active { + border-color: rgba(104, 195, 189, 0.58); + background: rgba(104, 195, 189, 0.16); + color: var(--accent-strong); +} + +.tgu-slots-chip.is-occupied.is-active { + border-color: rgba(214, 69, 69, 0.5); + background: rgba(214, 69, 69, 0.14); + color: #e98a8a; +} + /* Имя выбранной заявки в тулбаре (на месте прежнего выпадающего списка). */ .tgu-slots-request { align-items: center; @@ -1422,23 +1490,6 @@ margin-top: 0.5rem; } -/* Подсказка о перегрузе карты слотами (карту не блокируем). */ -.tgu-slots-map-hint { - position: absolute; - z-index: 5; - left: 50%; - bottom: 0.85rem; - transform: translateX(-50%); - max-width: 90%; - padding: 0.4rem 0.7rem; - border: 1px solid rgba(240, 173, 46, 0.42); - border-radius: 7px; - background: rgba(17, 19, 21, 0.88); - color: #ffe7b5; - font-size: 0.78rem; - backdrop-filter: blur(6px); -} - /* Баннер подтверждения крупной брони. */ .tgu-slots-confirm { position: absolute;