diff --git a/.gitignore b/.gitignore
index 8b1f57c..0b4af92 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,3 +54,4 @@ out/
/pcp-chatgpt-context*.zip
/deploy/local-bundle/build/
/deploy/local-bundle/pcp-local-bundle-*.tar.gz
+/services/pcp-tgu-ops-ui/node_modules/
diff --git a/docs/ui-prototypes/pcp-tgu-mission-ops/.thumbnail b/docs/ui-prototypes/pcp-tgu-mission-ops/.thumbnail
new file mode 100644
index 0000000..8e07e22
Binary files /dev/null and b/docs/ui-prototypes/pcp-tgu-mission-ops/.thumbnail differ
diff --git a/docs/ui-prototypes/pcp-tgu-mission-ops/app.jsx b/docs/ui-prototypes/pcp-tgu-mission-ops/app.jsx
new file mode 100644
index 0000000..b8d272d
--- /dev/null
+++ b/docs/ui-prototypes/pcp-tgu-mission-ops/app.jsx
@@ -0,0 +1,221 @@
+// Корневой компонент: состояние, фильтры, масштаб, тулбар, сборка строк.
+const { useState, useMemo, useEffect, useRef } = React;
+
+// Пресеты масштаба: имя → видимое окно в мс (центрируется на «сейчас» при выборе).
+const HOUR = D.HOUR, DAY = D.DAY;
+const SCALES = [
+ { id: "week", label: "Неделя", span: 7 * DAY, minPx: 64 },
+ { id: "2week", label: "2 недели", span: 14 * DAY, minPx: 70 },
+ { id: "month", label: "Месяц", span: 30 * DAY, minPx: 80 },
+ { id: "range", label: "Весь диапазон", span: D.RANGE_END - D.RANGE_START, minPx: 90 },
+];
+
+function App() {
+ // Видимость групп / скрытые КА.
+ const [groupShown, setGroupShown] = useState(() => {
+ const o = {}; D.GROUP_DEFS.forEach((g) => (o[g.id] = true)); return o;
+ });
+ const [scHidden, setScHidden] = useState(() => new Set());
+ const [typeFilter, setTypeFilter] = useState({ optical: true, radar: true, combo: true });
+ const [search, setSearch] = useState("");
+ const [scaleId, setScaleId] = useState("week");
+ const [selectedIv, setSelectedIv] = useState(null);
+ const [selectedSc, setSelectedSc] = useState(null);
+ const [timelineCollapsed, setTimelineCollapsed] = useState(() => new Set());
+ const [scrollSignal, setScrollSignal] = useState(0);
+ const [winCenter, setWinCenter] = useState(D.NOW);
+
+ const byId = useMemo(() => {
+ const m = {}; D.plans.forEach((iv) => (m[iv.id] = iv)); return m;
+ }, []);
+ const scById = useMemo(() => {
+ const m = {}; D.spacecraft.forEach((sc) => (m[sc.id] = sc)); return m;
+ }, []);
+
+ // Масштаб → px/ms (умещаем span в условные 1100px рабочей ширины).
+ const scale = SCALES.find((s) => s.id === scaleId);
+ const VIEW_W = 1180;
+ const pxPerMs = VIEW_W / scale.span;
+ const rangeStart = D.RANGE_START;
+ const rangeEnd = D.RANGE_END;
+
+ // Фильтрация КА.
+ const scByGroup = useMemo(() => {
+ const m = {};
+ D.spacecraft.forEach((sc) => { (m[sc.groupId] = m[sc.groupId] || []).push(sc); });
+ return m;
+ }, []);
+
+ const q = search.trim().toLowerCase();
+ const matchesSearch = (sc) => !q || sc.name.toLowerCase().includes(q) || sc.short.toLowerCase().includes(q);
+
+ // Счётчики для легенды.
+ const counts = useMemo(() => {
+ const byType = {};
+ D.spacecraft.forEach((sc) => { byType[sc.type] = (byType[sc.type] || 0) + 1; });
+ return { byType };
+ }, []);
+
+ // Интервалы по КА (с учётом типового фильтра не трогаем — фильтр по типу скрывает строки целиком).
+ const ivBySc = useMemo(() => {
+ const m = {};
+ D.plans.forEach((iv) => { (m[iv.scId] = m[iv.scId] || []).push(iv); });
+ return m;
+ }, []);
+
+ // Сборка строк таймлайна.
+ const rows = useMemo(() => {
+ const out = [];
+ for (const g of D.GROUP_DEFS) {
+ if (!groupShown[g.id]) continue;
+ const list = (scByGroup[g.id] || [])
+ .filter((sc) => typeFilter[sc.type])
+ .filter((sc) => !scHidden.has(sc.id))
+ .filter(matchesSearch);
+ if (list.length === 0) continue;
+ out.push({ kind: "group", group: g, count: list.length });
+ if (timelineCollapsed.has(g.id)) continue;
+ for (const sc of list) {
+ out.push({ kind: "sc", sc, ivs: ivBySc[sc.id] || [] });
+ }
+ }
+ return out;
+ }, [groupShown, scHidden, typeFilter, q, timelineCollapsed]);
+
+ // Цепочка родословной выбранного интервала.
+ const lineageSet = useMemo(() => {
+ if (!selectedIv) return null;
+ const lin = lineageOf(selectedIv, byId);
+ return new Set(lin.chain);
+ }, [selectedIv]);
+
+ const selIv = selectedIv ? byId[selectedIv] : null;
+ const selSc = selIv ? scById[selIv.scId] : null;
+ const selGroup = selSc ? D.GROUP_DEFS.find((g) => g.id === selSc.groupId) : null;
+
+ const toggleGroup = (id) => setGroupShown((s) => ({ ...s, [id]: !s[id] }));
+ const toggleSc = (id) => setScHidden((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
+ const toggleCollapse = (id) => setTimelineCollapsed((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
+
+ const onPickSc = (id) => {
+ setSelectedSc(id);
+ // прокрутка к первому интервалу этого КА
+ const ivs = ivBySc[id] || [];
+ if (ivs.length) { setSelectedIv(null); }
+ };
+
+ // Статистика по видимым.
+ const stats = useMemo(() => {
+ let sc = 0, iv = 0, ov = 0;
+ rows.forEach((r) => {
+ if (r.kind === "sc") {
+ sc++;
+ iv += r.ivs.length;
+ ov += overlapSegments(r.ivs).length;
+ }
+ });
+ return { sc, iv, ov };
+ }, [rows]);
+
+ const allOn = D.GROUP_DEFS.every((g) => groupShown[g.id]) && scHidden.size === 0;
+ const showAll = () => { const o = {}; D.GROUP_DEFS.forEach((g) => (o[g.id] = true)); setGroupShown(o); setScHidden(new Set()); };
+
+ return (
+
+
+
+
+ {/* ТУЛБАР */}
+
+ {/* масштаб */}
+
+ {SCALES.map((s) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {/* статистика */}
+
+
+
+ 0 ? "var(--now)" : null} />
+
+
+ {!allOn && (
+
+ )}
+
+
+ {rows.length === 0 ? (
+
+ Нет аппаратов по текущим фильтрам.
+
+ ) : (
+
{}}>
+
+
+ )}
+
+
+
setSelectedIv(null)} />
+
+ );
+}
+
+function WorksLegend() {
+ const items = [
+ { k: "shoot", label: "Съёмка", stripes: true },
+ { k: "downlink", label: "Сброс", fill: "var(--accent)" },
+ { k: "calib", label: "Калибровка", fill: "oklch(0.78 0.02 200)" },
+ { k: "service", label: "Служебная", dashed: true },
+ ];
+ return (
+
+
РАБОТЫ:
+ {items.map((it) => (
+
+
+ {it.label}
+
+ ))}
+
+ );
+}
+
+function Stat({ label, v, accent }) { return (
+
+ {v}
+ {label}
+
+ );
+}
+
+ReactDOM.createRoot(document.getElementById("root")).render();
diff --git a/docs/ui-prototypes/pcp-tgu-mission-ops/data.jsx b/docs/ui-prototypes/pcp-tgu-mission-ops/data.jsx
new file mode 100644
index 0000000..3b6771e
--- /dev/null
+++ b/docs/ui-prototypes/pcp-tgu-mission-ops/data.jsx
@@ -0,0 +1,195 @@
+// Тестовые данные планов КА. Детерминированная генерация (seeded RNG),
+// чтобы при перезагрузке данные были стабильны.
+(function () {
+ function mulberry32(a) {
+ return function () {
+ a |= 0; a = (a + 0x6D2B79F5) | 0;
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+ }
+ const rnd = mulberry32(20260529);
+ const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
+ const randint = (a, b) => a + Math.floor(rnd() * (b - a + 1));
+
+ const HOUR = 3600 * 1000;
+ const DAY = 24 * HOUR;
+ // "Сейчас" — фиксируем под текущую дату проекта.
+ const NOW = new Date("2026-05-29T11:20:00Z").getTime();
+
+ // Три типа по аппаратуре (все аппараты — ДЗЗ).
+ const TYPES = {
+ optical: { id: "optical", label: "Оптическая", short: "ОПТ" },
+ radar: { id: "radar", label: "Радиолокационная", short: "РЛ" },
+ combo: { id: "combo", label: "Комбинированная", short: "ОПТ+РЛ" },
+ };
+
+ // Программы/созвездия (название группы). Каждая группа тяготеет к своим типам.
+ const GROUP_DEFS = [
+ { id: "resurs", name: "Ресурс-ОП", code: "RSO", weights: { optical: 0.8, radar: 0.05, combo: 0.15 } },
+ { id: "kanopus", name: "Канопус-В", code: "KNP", weights: { optical: 0.7, radar: 0.1, combo: 0.2 } },
+ { id: "kondor", name: "Кондор-ФКА", code: "KND", weights: { optical: 0.05, radar: 0.85, combo: 0.1 } },
+ { id: "obzor", name: "Обзор-Р", code: "OBZ", weights: { optical: 0.05, radar: 0.8, combo: 0.15 } },
+ { id: "meteor", name: "Метеор-М", code: "MTR", weights: { optical: 0.55, radar: 0.15, combo: 0.3 } },
+ { id: "aist", name: "Аист-2", code: "AST", weights: { optical: 0.6, radar: 0.1, combo: 0.3 } },
+ { id: "smotr", name: "Смотр-Р", code: "SMT", weights: { optical: 0.1, radar: 0.55, combo: 0.35 } },
+ ];
+
+ function weightedType(weights) {
+ const r = rnd();
+ let acc = 0;
+ for (const k of Object.keys(weights)) {
+ acc += weights[k];
+ if (r <= acc) return k;
+ }
+ return "optical";
+ }
+
+ // Виды РАБОТ внутри плана. У каждой работы — своя категория (kind).
+ const WORK_KINDS = {
+ shoot: { id: "shoot", label: "Съёмка" },
+ downlink: { id: "downlink", label: "Сброс на НКПОР" },
+ calib: { id: "calib", label: "Калибровка" },
+ service: { id: "service", label: "Служебная операция" },
+ };
+ const WORK_KIND_ORDER = ["shoot", "downlink", "calib", "service"];
+ // Подпись работы-съёмки зависит от аппаратуры КА.
+ const SHOOT_LABEL = {
+ optical: ["Панхром. съёмка", "Мультиспектр. съёмка", "Стереосъёмка", "Площадная съёмка"],
+ radar: ["РСА съёмка", "Интерферометрия", "Прожекторный режим", "Сканирующий режим"],
+ combo: ["Совмещ. съёмка", "РСА + панхром.", "Площадная съёмка", "Мониторинг ЧС"],
+ };
+ // Названия самих ПЛАНОВ (объемлющих интервалов).
+ const PLAN_NAMES = ["Суточная программа", "Целевая программа", "Спецзадание", "Программа мониторинга", "Сеансовый план", "Программа ДЗЗ"];
+
+ function genWorks(planStart, planEnd, scType, makeId) {
+ const works = [];
+ const span = planEnd - planStart;
+ // Плотность работ ~ 1 на 4–9 часов плана.
+ let cursor = planStart + randint(0, 2) * HOUR;
+ let guard = 0;
+ while (cursor < planEnd - 1 * HOUR && guard < 40) {
+ guard++;
+ const kind = pick(WORK_KIND_ORDER);
+ let dur;
+ if (kind === "shoot") dur = randint(20, 140) * 60 * 1000; // 20м–2.3ч
+ else if (kind === "downlink") dur = randint(8, 35) * 60 * 1000; // сброс
+ else if (kind === "calib") dur = randint(30, 90) * 60 * 1000;
+ else dur = randint(10, 40) * 60 * 1000; // служебная
+ const wStart = cursor;
+ const wEnd = Math.min(planEnd, wStart + dur);
+ if (wEnd <= wStart) break;
+ const label = kind === "shoot" ? pick(SHOOT_LABEL[scType]) : WORK_KINDS[kind].label;
+ works.push({ id: makeId(), kind, label, start: wStart, end: wEnd });
+ // пауза между работами
+ cursor = wEnd + randint(20, 180) * 60 * 1000;
+ }
+ return works;
+ }
+
+ // Генерация КА.
+ const spacecraft = [];
+ let scCounter = 0;
+ for (const g of GROUP_DEFS) {
+ const count = randint(8, 16); // суммарно ~80
+ for (let i = 0; i < count; i++) {
+ const type = weightedType(g.weights);
+ scCounter++;
+ const num = i + 1;
+ spacecraft.push({
+ id: `${g.id}-${num}`,
+ name: `${g.name} №${num}`,
+ short: `${g.code}-${String(num).padStart(2, "0")}`,
+ groupId: g.id,
+ type,
+ orbit: pick(["ССО 510 км", "ССО 575 км", "ССО 700 км", "ССО 480 км"]),
+ });
+ }
+ }
+
+ // Видимый горизонт планирования: ~ -16 сут .. +30 сут от "сейчас".
+ const RANGE_START = NOW - 16 * DAY;
+ const RANGE_END = NOW + 30 * DAY;
+
+ function statusFor(start, end) {
+ if (end < NOW) return "executed";
+ if (start <= NOW && NOW <= end) return "active";
+ return "planned";
+ }
+
+ // Генерация ПЛАНОВ (объемлющих интервалов) с родословной (ревизии плана = потомки).
+ // Каждый план содержит набор РАБОТ внутри своего интервала.
+ const plans = [];
+ let ivCounter = 0;
+ let workCounter = 0;
+ const makeWorkId = () => `W-${String(++workCounter).padStart(5, "0")}`;
+ for (const sc of spacecraft) {
+ // стартовая точка цепочки
+ let cursor = RANGE_START + randint(0, 3) * DAY + randint(0, 20) * HOUR;
+ let prevId = null;
+ const nChains = randint(2, 4);
+ for (let c = 0; c < nChains; c++) {
+ // базовый план в цепочке
+ const dur = randint(8, 60) * HOUR;
+ const start = cursor;
+ const end = start + dur;
+ ivCounter++;
+ const baseId = `PL-${String(ivCounter).padStart(4, "0")}`;
+ const planName = pick(PLAN_NAMES);
+ plans.push({
+ id: baseId,
+ scId: sc.id,
+ start, end,
+ title: planName,
+ rev: 1,
+ parentId: prevId,
+ status: statusFor(start, end),
+ author: pick(["ЦУП-1", "ЦУП-2", "ПЦ Москва", "ПЦ Дубна", "Авто-планировщик"]),
+ works: genWorks(start, end, sc.type, makeWorkId),
+ });
+ prevId = baseId;
+
+ // c вероятностью — ревизия, перекрывающая базовый план (создаёт пересечение)
+ if (rnd() < 0.45) {
+ const overlapStart = start + Math.floor(dur * (0.35 + rnd() * 0.4));
+ const revDur = randint(10, 50) * HOUR;
+ const revEnd = overlapStart + revDur;
+ ivCounter++;
+ const revId = `PL-${String(ivCounter).padStart(4, "0")}`;
+ plans.push({
+ id: revId,
+ scId: sc.id,
+ start: overlapStart,
+ end: revEnd,
+ title: planName,
+ rev: 2,
+ parentId: baseId,
+ status: statusFor(overlapStart, revEnd),
+ author: pick(["ЦУП-1", "ЦУП-2", "ПЦ Москва", "Авто-планировщик"]),
+ works: genWorks(overlapStart, revEnd, sc.type, makeWorkId),
+ });
+ prevId = revId;
+ cursor = revEnd + randint(4, 36) * HOUR;
+ } else {
+ cursor = end + randint(6, 48) * HOUR;
+ }
+ if (cursor > RANGE_END) break;
+ }
+ }
+
+ // Индексы родословной: дети по родителю.
+ const childrenOf = {};
+ for (const iv of plans) {
+ if (iv.parentId) {
+ (childrenOf[iv.parentId] = childrenOf[iv.parentId] || []).push(iv.id);
+ }
+ }
+
+ window.MissionData = {
+ HOUR, DAY, NOW, RANGE_START, RANGE_END,
+ TYPES, GROUP_DEFS, WORK_KINDS, WORK_KIND_ORDER,
+ spacecraft, plans, childrenOf,
+ typeOrder: ["optical", "radar", "combo"],
+ };
+})();
diff --git a/docs/ui-prototypes/pcp-tgu-mission-ops/details.jsx b/docs/ui-prototypes/pcp-tgu-mission-ops/details.jsx
new file mode 100644
index 0000000..df27bf4
--- /dev/null
+++ b/docs/ui-prototypes/pcp-tgu-mission-ops/details.jsx
@@ -0,0 +1,152 @@
+// Правая панель деталей выбранного интервала + лента родословной (предки/потомки).
+function StatusBadge({ status }) {
+ const map = {
+ executed: { t: "ВЫПОЛНЕН", c: "var(--ink-2)", bg: "var(--bg-3)" },
+ active: { t: "АКТИВЕН", c: "var(--now)", bg: "oklch(0.86 0.16 25 / 0.16)" },
+ planned: { t: "ЗАПЛАНИРОВАН", c: "var(--accent)", bg: "oklch(0.82 0.13 200 / 0.14)" },
+ };
+ const s = map[status] || map.planned;
+ return {s.t};
+}
+
+function Field({ label, children, mono }) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+function Details({ iv, sc, group, byId, onSelectIv, onClose }) {
+ if (!iv) {
+ return (
+
+
+
Выберите план на таймлайне, чтобы увидеть его работы и цепочку ревизий.
+
+ );
+ }
+ const lin = lineageOf(iv.id, byId);
+ const dur = iv.end - iv.start;
+ const col = TYPE_COLOR[sc.type];
+
+ const chainRow = (id) => {
+ const it = byId[id];
+ const isSel = id === iv.id;
+ return (
+
+ );
+ };
+
+ return (
+
+
+
+
{iv.title}
+
+
+ {sc.short} · {group.name}
+
+
+
+
+ {/* Время */}
+
+ {fmtDateTime(iv.start)}
+ {fmtDateTime(iv.end)}
+ {fmtDur(dur)}
+ {iv.works.length}
+
+
+
+
+ {/* Аппарат */}
+
+
АППАРАТ
+
+
+
+
{sc.name}
+
{D.TYPES[sc.type].label} · {sc.orbit}
+
+
+
{iv.author}
+
+
+
+
+ {/* Работы внутри плана */}
+
+
+ РАБОТЫ В ПЛАНЕ
+ {iv.works.length}
+
+
+ {iv.works.length === 0 &&
Работы не заданы.
}
+ {iv.works.map((wk) => {
+ const fill = workFill(wk.kind, sc.type);
+ const isService = wk.kind === "service";
+ return (
+
+
+ {wk.label}
+ {fmtTime(wk.start)}–{fmtTime(wk.end)}
+
+ );
+ })}
+
+
+
+
+
+ {/* Родословная */}
+
+
+ РОДОСЛОВНАЯ ПЛАНА
+ {lin.chain.length} ревизий
+
+
+ {lin.ancestors.length > 0 && (
+
+ ◀ Предки
+ {lin.ancestors.map(chainRow)}
+
+ )}
+
+
+ ● Текущий
+ {chainRow(iv.id)}
+
+
+ {lin.descendants.length > 0 && (
+
+ ▶ Потомки
+ {lin.descendants.map(chainRow)}
+
+ )}
+
+ {lin.ancestors.length === 0 && lin.descendants.length === 0 && (
+
Это единственная ревизия плана — без предков и потомков.
+ )}
+
+
+
+ );
+}
+
+Object.assign(window, { Details });
diff --git a/docs/ui-prototypes/pcp-tgu-mission-ops/sidebar.jsx b/docs/ui-prototypes/pcp-tgu-mission-ops/sidebar.jsx
new file mode 100644
index 0000000..8bdf60c
--- /dev/null
+++ b/docs/ui-prototypes/pcp-tgu-mission-ops/sidebar.jsx
@@ -0,0 +1,122 @@
+// Левая панель: поиск, фильтр по типам (легенда), дерево групп → КА с чекбоксами.
+const { useState: useStateSB } = React;
+
+function TypeDot({ type, size = 9 }) {
+ return (
+
+ );
+}
+
+function Check({ on, onClick, dim }) {
+ return (
+
+ );
+}
+
+function Sidebar(props) {
+ const { groups, scByGroup, typeFilter, setTypeFilter, groupShown, toggleGroup,
+ scHidden, toggleSc, search, setSearch, counts, selectedSc, onPickSc } = props;
+ const [open, setOpen] = useStateSB(() => {
+ const o = {}; D.GROUP_DEFS.forEach((g) => (o[g.id] = true)); return o;
+ });
+
+ return (
+
+ {/* Заголовок */}
+
+
+
+
+
+
+
ПЛАН КА
+
MISSION OPS · ДЗЗ
+
+
+
+
+ {/* Поиск */}
+
+
+
+
setSearch(e.target.value)} placeholder="Поиск КА…"
+ style={{ width: "100%", padding: "7px 8px 7px 28px", background: "var(--bg-0)", border: "1px solid var(--line)", borderRadius: 6, color: "var(--ink)", fontFamily: "var(--mono)", fontSize: 12, outline: "none" }} />
+ {search &&
}
+
+
+
+ {/* Легенда / фильтр по типам */}
+
+
АППАРАТУРА
+
+ {D.typeOrder.map((t) => {
+ const on = typeFilter[t];
+ return (
+
+ );
+ })}
+
+
+
+ {/* Дерево групп → КА */}
+
+
ГРУППЫ / КА
+ {groups.map((g) => {
+ const list = scByGroup[g.id] || [];
+ const isOpen = open[g.id];
+ const shownCount = list.filter((sc) => !scHidden.has(sc.id)).length;
+ return (
+
+
+
+
toggleGroup(g.id)} />
+ {g.code}
+ {g.name}
+ {shownCount}/{list.length}
+
+ {isOpen && (
+
+ {list.map((sc) => {
+ const vis = !scHidden.has(sc.id);
+ const sel = selectedSc === sc.id;
+ return (
+
onPickSc(sc.id)}
+ style={{ display: "flex", alignItems: "center", gap: 7, padding: "4px 7px", borderRadius: 6, cursor: "pointer", background: sel ? "var(--bg-3)" : "transparent" }}>
+ { e.stopPropagation(); toggleSc(sc.id); }} />
+
+ {sc.short}
+
+ );
+ })}
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
+
+Object.assign(window, { Sidebar, TypeDot });
diff --git a/docs/ui-prototypes/pcp-tgu-mission-ops/timeline.jsx b/docs/ui-prototypes/pcp-tgu-mission-ops/timeline.jsx
new file mode 100644
index 0000000..584a1ee
--- /dev/null
+++ b/docs/ui-prototypes/pcp-tgu-mission-ops/timeline.jsx
@@ -0,0 +1,273 @@
+// Таймлайн (Gantt): ось времени, сетка, маркер «сейчас», секции групп,
+// раскладка интервалов по лейнам, пометка пересечений, стрелки родословной.
+const { useRef: useRefTL, useMemo: useMemoTL, useEffect: useEffectTL, useLayoutEffect } = React;
+
+const LABEL_W = 234;
+const LANE_H = 38;
+const ROW_PAD = 8;
+const GROUP_H = 30;
+const AXIS_H = 46;
+
+function Timeline(props) {
+ const { rows, pxPerMs, rangeStart, rangeEnd, now, selectedIv, onSelectIv,
+ lineageSet, byId, scrollSignal, timelineCollapsed, toggleCollapse } = props;
+
+ const bodyRef = useRefTL(null);
+ const axisRef = useRefTL(null);
+ const leftRef = useRefTL(null);
+
+ const x = (ms) => (ms - rangeStart) * pxPerMs;
+ const contentW = (rangeEnd - rangeStart) * pxPerMs;
+
+ // Раскладка строк по вертикали + позиции интервалов.
+ const layout = useMemoTL(() => {
+ let y = 0;
+ const placed = [];
+ const ivPos = {};
+ for (const r of rows) {
+ if (r.kind === "group") {
+ placed.push({ ...r, y, h: GROUP_H });
+ y += GROUP_H;
+ } else {
+ const { laneOf, laneCount } = assignLanes(r.ivs);
+ const h = laneCount * LANE_H + ROW_PAD * 2;
+ const overlaps = overlapSegments(r.ivs);
+ for (const iv of r.ivs) {
+ const lane = laneOf[iv.id];
+ ivPos[iv.id] = {
+ x: x(iv.start), x2: x(iv.end),
+ cy: y + ROW_PAD + lane * LANE_H + LANE_H / 2,
+ };
+ }
+ placed.push({ ...r, y, h, laneOf, laneCount, overlaps });
+ y += h;
+ }
+ }
+ return { placed, total: y, ivPos };
+ }, [rows, pxPerMs, rangeStart]);
+
+ // Сетка по дням + засечки оси.
+ const days = useMemoTL(() => {
+ const out = [];
+ const startD = new Date(rangeStart);
+ let t = Date.UTC(startD.getUTCFullYear(), startD.getUTCMonth(), startD.getUTCDate());
+ while (t <= rangeEnd) {
+ const d = new Date(t);
+ out.push({ t, weekend: d.getUTCDay() === 0 || d.getUTCDay() === 6, dow: d.getUTCDay() });
+ t += 86400000;
+ }
+ return out;
+ }, [rangeStart, rangeEnd]);
+
+ const sub = useMemoTL(() => niceTicks(rangeStart, rangeEnd, pxPerMs, 64), [rangeStart, rangeEnd, pxPerMs]);
+ const showHourTicks = sub.step < 86400000;
+
+ // Синхронизация скролла.
+ const onBodyScroll = () => {
+ const b = bodyRef.current;
+ if (axisRef.current) axisRef.current.scrollLeft = b.scrollLeft;
+ if (leftRef.current) leftRef.current.scrollTop = b.scrollTop;
+ };
+
+ // Прокрутка к «сейчас».
+ useEffectTL(() => {
+ const b = bodyRef.current;
+ if (!b) return;
+ const target = x(now) - b.clientWidth * 0.32;
+ b.scrollTo({ left: Math.max(0, target), behavior: scrollSignal === 0 ? "auto" : "smooth" });
+ onBodyScroll();
+ }, [scrollSignal]);
+
+ // Стрелки родословной для выделенной цепочки.
+ const arrows = useMemoTL(() => {
+ if (!selectedIv) return [];
+ const lin = lineageOf(selectedIv, byId);
+ const segs = [];
+ for (const id of lin.chain) {
+ (D.childrenOf[id] || []).forEach((c) => {
+ if (lin.chain.includes(c) && layout.ivPos[id] && layout.ivPos[c]) {
+ segs.push({ from: layout.ivPos[id], to: layout.ivPos[c] });
+ }
+ });
+ }
+ return segs;
+ }, [selectedIv, layout, lineageSet]);
+
+ const anySelected = !!selectedIv;
+
+ return (
+
+ {/* ОСЬ ВРЕМЕНИ */}
+
+
+ КОСМИЧЕСКИЙ АППАРАТ
+
+
+
+ {/* верхний ярус — дни */}
+ {days.map((d, i) => {
+ const w = 86400000 * pxPerMs;
+ return (
+
+ {pad(new Date(d.t).getUTCDate())}
+ {MONTHS[new Date(d.t).getUTCMonth()]}
+ {WDAYS[d.dow]}
+
+ );
+ })}
+ {/* нижний ярус — засечки */}
+ {sub.ticks.map((tk, i) => (
+
+ {showHourTicks && {fmtTime(tk.t)}}
+
+ ))}
+ {/* метка «сейчас» */}
+
+ СЕЙЧАС {fmtTime(now)}
+
+
+
+
+
+ {/* ТЕЛО */}
+
+ {/* Левая колонка подписей */}
+
+
+ {layout.placed.map((r) => r.kind === "group" ? (
+
toggleCollapse(r.group.id)}
+ style={{ position: "absolute", top: r.y, left: 0, right: 0, height: r.h, display: "flex", alignItems: "center", gap: 7, padding: "0 12px", background: "var(--bg-2)", borderBottom: "1px solid var(--line)", borderTop: "1px solid var(--line)", cursor: "pointer" }}>
+
+
{r.group.code}
+
{r.group.name}
+
{r.count}
+
+ ) : (
+
+
+
+
{r.sc.short}
+
{r.sc.orbit}
+
+
{r.ivs.length}
+
+ ))}
+
+
+
+ {/* Правый холст */}
+
+
+ {/* фон выходных + сетка дней */}
+ {days.map((d, i) => (
+
+ ))}
+ {/* линия «сейчас» */}
+
+
+ {/* строки */}
+ {layout.placed.map((r) => r.kind === "group" ? (
+
+ ) : (
+
|
+ ))}
+
+ {/* стрелки родословной */}
+ {arrows.length > 0 && (
+
+ )}
+
+
+
+
+ );
+}
+
+// Одна строка КА: ПЛАНЫ (объемлющие интервалы) по лейнам, внутри — РАБОТЫ.
+function Row({ r, x, contentW, selectedIv, onSelectIv, lineageSet, anySelected }) {
+ return (
+
+ {/* пересечения планов — штриховка */}
+ {r.overlaps.map((ov, i) => (
+
+ ))}
+ {r.ivs.map((plan) => {
+ const lane = r.laneOf[plan.id];
+ const left = x(plan.start);
+ const w = Math.max(4, x(plan.end) - x(plan.start));
+ const inChain = lineageSet && lineageSet.has(plan.id);
+ const isSel = selectedIv === plan.id;
+ const dim = anySelected && !inChain;
+ const scType = r.sc.type;
+ const col = TYPE_COLOR[scType];
+ const hasParent = !!plan.parentId;
+ const hasChild = (D.childrenOf[plan.id] || []).length > 0;
+ const bandTop = ROW_PAD + lane * LANE_H;
+ const bandH = LANE_H - 8;
+ const wide = w > 78;
+ const showWorks = w > 14;
+ const executed = plan.status === "executed";
+ return (
+
{ e.stopPropagation(); onSelectIv(plan.id); }}
+ title={`План ${plan.id} · ${plan.title} · ${fmtDateTime(plan.start)} → ${fmtDateTime(plan.end)} · работ: ${plan.works.length}`}
+ style={{
+ position: "absolute", left, width: w, top: bandTop, height: bandH,
+ background: `color-mix(in oklch, ${col} 13%, var(--bg-1))`,
+ border: `1px solid color-mix(in oklch, ${col} 45%, var(--bg-1))`,
+ borderLeft: `3px solid ${col}`,
+ borderRadius: 5, cursor: "pointer", zIndex: isSel ? 5 : 3,
+ opacity: dim ? 0.26 : executed ? 0.7 : 1,
+ outline: isSel ? `1.5px solid var(--accent)` : inChain ? `1px solid color-mix(in oklch, var(--accent) 55%, transparent)` : "none",
+ boxShadow: isSel ? "0 0 0 3px oklch(0.82 0.13 200 / 0.18), var(--shadow)" : "none",
+ overflow: "hidden", transition: "opacity .12s",
+ }}>
+ {/* заголовок плана */}
+
+ {hasParent && ◀}
+ {wide && {plan.title}}
+ {wide && plan.status === "active" && }
+ {wide && plan.rev > 1 && r{plan.rev}}
+ {!wide && }
+ {hasChild && ▶}
+
+ {/* трек работ внутри плана */}
+ {showWorks && (
+
+ {plan.works.map((wk) => {
+ const wl = x(wk.start) - left;
+ const ww = Math.max(1.5, x(wk.end) - x(wk.start));
+ const fill = workFill(wk.kind, scType);
+ const isService = wk.kind === "service";
+ return (
+
+ );
+ })}
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+Object.assign(window, { Timeline });
diff --git a/docs/ui-prototypes/pcp-tgu-mission-ops/util.jsx b/docs/ui-prototypes/pcp-tgu-mission-ops/util.jsx
new file mode 100644
index 0000000..39a3ca3
--- /dev/null
+++ b/docs/ui-prototypes/pcp-tgu-mission-ops/util.jsx
@@ -0,0 +1,123 @@
+// Общие утилиты, формат времени, раскладка интервалов по лейнам.
+const D = window.MissionData;
+
+const TYPE_COLOR = {
+ optical: "var(--t-optical)",
+ radar: "var(--t-radar)",
+ combo: "var(--t-combo)",
+};
+const TYPE_DIM = {
+ optical: "var(--t-optical-dim)",
+ radar: "var(--t-radar-dim)",
+ combo: "var(--t-combo-dim)",
+};
+
+// Стиль работы внутри плана по её категории. shoot тонируется типом аппаратуры,
+// остальные — служебной палитрой, отличной от трёх цветов аппаратуры.
+const WORK_KIND_COLOR = {
+ shoot: null, // = цвет типа КА
+ downlink: "var(--accent)",
+ calib: "oklch(0.78 0.02 200)",
+ service: "var(--ink-3)",
+};
+function workFill(kind, scType) {
+ if (kind === "shoot") return TYPE_COLOR[scType];
+ return WORK_KIND_COLOR[kind];
+}
+
+const MONTHS = ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"];
+const WDAYS = ["вс", "пн", "вт", "ср", "чт", "пт", "сб"];
+
+function pad(n) { return String(n).padStart(2, "0"); }
+function fmtTime(ms) {
+ const d = new Date(ms);
+ return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
+}
+function fmtDate(ms) {
+ const d = new Date(ms);
+ return `${pad(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]}`;
+}
+function fmtDateTime(ms) {
+ const d = new Date(ms);
+ return `${pad(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]} ${fmtTime(ms)}`;
+}
+function fmtDur(ms) {
+ const h = ms / 3600000;
+ if (h < 24) return `${h % 1 === 0 ? h : h.toFixed(1)} ч`;
+ const d = Math.floor(h / 24);
+ const rh = Math.round(h - d * 24);
+ return rh ? `${d} сут ${rh} ч` : `${d} сут`;
+}
+
+// Раскладка интервалов одного КА по лейнам (greedy interval graph).
+function assignLanes(ivs) {
+ const sorted = [...ivs].sort((a, b) => a.start - b.start || a.end - b.end);
+ const laneEnds = []; // конец последнего интервала в каждом лейне
+ const out = {};
+ for (const iv of sorted) {
+ let placed = false;
+ for (let l = 0; l < laneEnds.length; l++) {
+ if (iv.start >= laneEnds[l]) {
+ out[iv.id] = l;
+ laneEnds[l] = iv.end;
+ placed = true;
+ break;
+ }
+ }
+ if (!placed) {
+ out[iv.id] = laneEnds.length;
+ laneEnds.push(iv.end);
+ }
+ }
+ return { laneOf: out, laneCount: Math.max(1, laneEnds.length) };
+}
+
+// Пересечения по времени между интервалами одного КА.
+function overlapSegments(ivs) {
+ const segs = [];
+ for (let i = 0; i < ivs.length; i++) {
+ for (let j = i + 1; j < ivs.length; j++) {
+ const a = ivs[i], b = ivs[j];
+ const s = Math.max(a.start, b.start);
+ const e = Math.min(a.end, b.end);
+ if (e > s) segs.push({ start: s, end: e, a: a.id, b: b.id });
+ }
+ }
+ return segs;
+}
+
+// Цепочка родословной (предки + потомки) для интервала.
+function lineageOf(id, byId) {
+ const anc = [];
+ let cur = byId[id];
+ while (cur && cur.parentId) {
+ anc.unshift(cur.parentId);
+ cur = byId[cur.parentId];
+ }
+ const desc = [];
+ const stack = [...(D.childrenOf[id] || [])];
+ while (stack.length) {
+ const c = stack.shift();
+ desc.push(c);
+ (D.childrenOf[c] || []).forEach((x) => stack.push(x));
+ }
+ return { ancestors: anc, descendants: desc, chain: [...anc, id, ...desc] };
+}
+
+// "Хорошие" шаги сетки времени для оси.
+function niceTicks(rangeStart, rangeEnd, pxPerMs, minPx) {
+ const HOUR = 3600000, DAY = 86400000;
+ const steps = [1 * HOUR, 2 * HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, 1 * DAY, 2 * DAY, 7 * DAY];
+ let step = steps[steps.length - 1];
+ for (const s of steps) { if (s * pxPerMs >= minPx) { step = s; break; } }
+ const ticks = [];
+ const startTick = Math.ceil(rangeStart / step) * step;
+ for (let t = startTick; t <= rangeEnd; t += step) ticks.push({ t, step });
+ return { ticks, step };
+}
+
+Object.assign(window, {
+ TYPE_COLOR, TYPE_DIM, MONTHS, WDAYS, WORK_KIND_COLOR, workFill,
+ pad, fmtTime, fmtDate, fmtDateTime, fmtDur,
+ assignLanes, overlapSegments, lineageOf, niceTicks,
+});
diff --git a/docs/ui-prototypes/pcp-tgu-mission-ops/План КА - Mission Ops.html b/docs/ui-prototypes/pcp-tgu-mission-ops/План КА - Mission Ops.html
new file mode 100644
index 0000000..ba8d077
--- /dev/null
+++ b/docs/ui-prototypes/pcp-tgu-mission-ops/План КА - Mission Ops.html
@@ -0,0 +1,93 @@
+
+
+
+
+
+План КА — Mission Ops
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/services/pcp-tgu-ops-ui/Dockerfile b/services/pcp-tgu-ops-ui/Dockerfile
new file mode 100644
index 0000000..7b7cac9
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/Dockerfile
@@ -0,0 +1,14 @@
+FROM node:22-alpine AS build
+WORKDIR /app
+
+COPY package*.json ./
+RUN npm install
+
+COPY . .
+RUN npm run build
+
+FROM nginx:1.27-alpine AS runtime
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+COPY --from=build /app/dist /usr/share/nginx/html
+
+EXPOSE 80
diff --git a/services/pcp-tgu-ops-ui/index.html b/services/pcp-tgu-ops-ui/index.html
new file mode 100644
index 0000000..223f74a
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Планирование ТГУ Ops UI
+
+
+
+
+
+
diff --git a/services/pcp-tgu-ops-ui/nginx.conf b/services/pcp-tgu-ops-ui/nginx.conf
new file mode 100644
index 0000000..e1ffd09
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/nginx.conf
@@ -0,0 +1,20 @@
+server {
+ listen 80;
+ server_name _;
+
+ root /usr/share/nginx/html;
+ index index.html;
+
+ location /api/ {
+ proxy_pass http://host.docker.internal:7008/api/;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+}
diff --git a/services/pcp-tgu-ops-ui/src/App.tsx b/services/pcp-tgu-ops-ui/src/App.tsx
new file mode 100644
index 0000000..8e53210
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/src/App.tsx
@@ -0,0 +1,5 @@
+import { TguPlanningPage } from "./features/tgu-planning/TguPlanningPage";
+
+export default function App() {
+ return ;
+}
diff --git a/services/pcp-tgu-ops-ui/src/api/tguApi.ts b/services/pcp-tgu-ops-ui/src/api/tguApi.ts
new file mode 100644
index 0000000..10575e2
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/src/api/tguApi.ts
@@ -0,0 +1,93 @@
+import {
+ TguApiError,
+ type TguPlan,
+ type TguPlanDecision,
+ type TguPlanDecisionMessage,
+ type TguPlatform
+} from "../model/tguTypes";
+
+export async function fetchPlatforms(): Promise {
+ return fetchJson("/api/tgu-planning/platforms");
+}
+
+export async function fetchPlans(historyDays: number, spacecraftId?: string): Promise {
+ const query = new URLSearchParams({ historyDays: String(historyDays) });
+ const path = spacecraftId
+ ? `/api/tgu-planning/spacecraft/${encodeURIComponent(spacecraftId)}/plans?${query}`
+ : `/api/tgu-planning/plans?${query}`;
+
+ return fetchJson(path);
+}
+
+export async function sendPlanDecision(
+ planId: string,
+ decision: TguPlanDecision,
+ reason?: string
+): Promise {
+ const query = new URLSearchParams({ decision });
+ if (reason?.trim()) {
+ query.set("reason", reason.trim());
+ }
+
+ return fetchJson(
+ `/api/tgu-planning/plans/${encodeURIComponent(planId)}/decision?${query}`,
+ { method: "POST" },
+ true
+ );
+}
+
+async function fetchJson(url: string, init?: RequestInit, decisionRequest = false): Promise {
+ let response: Response;
+ try {
+ response = await fetch(url, {
+ headers: { Accept: "application/json" },
+ ...init
+ });
+ } catch (error) {
+ throw new TguApiError("Не удалось подключиться к backend API.", "network");
+ }
+
+ const text = await response.text();
+ if (!response.ok) {
+ throw new TguApiError(errorMessage(response.status, text, decisionRequest), "http", response.status);
+ }
+
+ if (!text.trim()) {
+ return undefined as T;
+ }
+
+ return JSON.parse(text) as T;
+}
+
+function errorMessage(status: number, body: string, decisionRequest: boolean): string {
+ if (decisionRequest && status === 404) {
+ return "План не найден.";
+ }
+ if (decisionRequest && status === 409) {
+ return "План не является активным планом, ожидающим решения, или для него нет active attempt.";
+ }
+ if (decisionRequest && status >= 500) {
+ return "Ошибка сервиса при отправке решения.";
+ }
+
+ return extractErrorText(body) || "Ошибка backend API.";
+}
+
+function extractErrorText(body: string): string {
+ const trimmed = body.trim();
+ if (!trimmed) return "";
+
+ try {
+ const parsed = JSON.parse(trimmed) as Record;
+ for (const field of ["reason", "message", "error", "detail"]) {
+ const value = parsed[field];
+ if (typeof value === "string" && value.trim()) {
+ return value;
+ }
+ }
+ } catch {
+ return trimmed;
+ }
+
+ return trimmed;
+}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguLegend.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguLegend.tsx
new file mode 100644
index 0000000..c4b6fac
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguLegend.tsx
@@ -0,0 +1,15 @@
+import { STATUS_STYLES } from "./tguStatus";
+
+export function TguLegend() {
+ return (
+
+ {STATUS_STYLES.map((item) => (
+
+
+ {item.label}
+
+ ))}
+ Стрелки показывают следующий план того же КА по времени.
+
+ );
+}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx
new file mode 100644
index 0000000..7712ec7
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx
@@ -0,0 +1,102 @@
+import type { TguPlanDecision } from "../../model/tguTypes";
+import type { TguPlanUi } from "../../model/timelineTypes";
+import { platformLabel } from "./tguTimelineMapper";
+import { statusStyle } from "./tguStatus";
+
+type TguPlanDetailsProps = {
+ plan?: TguPlanUi;
+ decisionInFlight: boolean;
+ notice?: string;
+ error?: string;
+ onDecision: (planId: string, decision: TguPlanDecision, reason?: string) => void;
+ onClose: () => void;
+};
+
+export function TguPlanDetails({ plan, decisionInFlight, notice, error, onDecision, onClose }: TguPlanDetailsProps) {
+ if (!plan) {
+ return (
+
+ );
+ }
+
+ const style = statusStyle(plan.status);
+ const duration = formatDuration(plan.endMs - plan.startMs);
+
+ const reject = () => {
+ const reason = window.prompt("Reason для REJECTED", "UI_TEST_REJECTED");
+ if (reason === null) return;
+ onDecision(plan.planId, "REJECTED", reason);
+ };
+
+ return (
+
+ );
+}
+
+function Detail({ label, value, mono }: { label: string; value?: string; mono?: boolean }) {
+ return (
+
+
{label}
+ {value || "-"}
+
+ );
+}
+
+function formatDateTime(value: string): string {
+ return new Date(value).toLocaleString("ru-RU");
+}
+
+function formatDuration(ms: number): string {
+ const minutes = Math.max(0, Math.round(ms / 60_000));
+ const hours = Math.floor(minutes / 60);
+ const restMinutes = minutes % 60;
+ if (hours === 0) return `${restMinutes} мин`;
+ return `${hours} ч ${restMinutes} мин`;
+}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningLayout.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningLayout.tsx
new file mode 100644
index 0000000..6afb54a
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningLayout.tsx
@@ -0,0 +1,21 @@
+import type { ReactNode } from "react";
+
+type TguPlanningLayoutProps = {
+ toolbar: ReactNode;
+ sidebar: ReactNode;
+ timeline: ReactNode;
+ details: ReactNode;
+};
+
+export function TguPlanningLayout({ toolbar, sidebar, timeline, details }: TguPlanningLayoutProps) {
+ return (
+
+ {toolbar}
+
+ {sidebar}
+
+ {details}
+
+
+ );
+}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx
new file mode 100644
index 0000000..cba487c
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx
@@ -0,0 +1,217 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { fetchPlans, fetchPlatforms, sendPlanDecision } from "../../api/tguApi";
+import type { TguPlanDecision, TguPlatform } from "../../model/tguTypes";
+import type { TguPlanUi, TimelineRange } from "../../model/timelineTypes";
+import { TguPlanDetails } from "./TguPlanDetails";
+import { TguPlanningLayout } from "./TguPlanningLayout";
+import { TguSidebar } from "./TguSidebar";
+import { TguTimeline } from "./TguTimeline";
+import { TguToolbar } from "./TguToolbar";
+import { buildPlatformsForInterval, buildTimelineRows, mapPlans } from "./tguTimelineMapper";
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+export function TguPlanningPage() {
+ const initialRange = useMemo(defaultRange, []);
+ const [fromValue, setFromValue] = useState(formatDateTimeLocal(initialRange.fromMs));
+ const [toValue, setToValue] = useState(formatDateTimeLocal(initialRange.toMs));
+ const [appliedRange, setAppliedRange] = useState(initialRange);
+ const [rawPlatforms, setRawPlatforms] = useState([]);
+ const [plans, setPlans] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [pageError, setPageError] = useState();
+ const [platformLoadFailed, setPlatformLoadFailed] = useState(false);
+ const [search, setSearch] = useState("");
+ const [selectedSpacecraftId, setSelectedSpacecraftId] = useState();
+ const [selectedPlanId, setSelectedPlanId] = useState();
+ const [decisionInFlight, setDecisionInFlight] = useState(false);
+ const [decisionNotice, setDecisionNotice] = useState();
+ const [decisionError, setDecisionError] = useState();
+
+ const parsedInputRange = useMemo(
+ () => ({
+ fromMs: new Date(fromValue).getTime(),
+ toMs: new Date(toValue).getTime()
+ }),
+ [fromValue, toValue]
+ );
+ const invalidRange =
+ !Number.isFinite(parsedInputRange.fromMs) ||
+ !Number.isFinite(parsedInputRange.toMs) ||
+ parsedInputRange.fromMs >= parsedInputRange.toMs;
+
+ const loadData = useCallback(
+ async (range: TimelineRange) => {
+ setLoading(true);
+ setPageError(undefined);
+ setDecisionError(undefined);
+
+ const historyDays = historyDaysForRange(range);
+ const [platformResult, planResult] = await Promise.allSettled([
+ fetchPlatforms(),
+ fetchPlans(historyDays)
+ ]);
+
+ if (platformResult.status === "rejected") {
+ setPlatformLoadFailed(true);
+ } else {
+ setPlatformLoadFailed(false);
+ setRawPlatforms(platformResult.value);
+ }
+
+ if (planResult.status === "rejected") {
+ setPlans([]);
+ setPageError(planResult.reason instanceof Error ? planResult.reason.message : "Не удалось загрузить планы.");
+ } else {
+ const platforms = platformResult.status === "fulfilled" ? platformResult.value : [];
+ setPlans(mapPlans(planResult.value, platforms));
+ }
+
+ setLoading(false);
+ },
+ []
+ );
+
+ useEffect(() => {
+ void loadData(appliedRange);
+ }, [appliedRange, loadData]);
+
+ const platforms = useMemo(
+ () => buildPlatformsForInterval(rawPlatforms, plans, appliedRange),
+ [rawPlatforms, plans, appliedRange]
+ );
+ const rows = useMemo(
+ () => buildTimelineRows(plans, platforms, appliedRange, selectedSpacecraftId),
+ [plans, platforms, appliedRange, selectedSpacecraftId]
+ );
+ const selectedPlan = useMemo(() => {
+ const plan = plans.find((item) => item.planId === selectedPlanId);
+ if (!plan) return undefined;
+ const platform = platforms.find((item) => item.spacecraftId === plan.spacecraftId);
+ return { ...plan, platform };
+ }, [plans, platforms, selectedPlanId]);
+
+ const applyRange = () => {
+ setDecisionNotice(undefined);
+ if (invalidRange) {
+ setPageError("Некорректный интервал: from должен быть меньше to.");
+ return;
+ }
+ setPageError(undefined);
+ setAppliedRange(parsedInputRange);
+ };
+
+ const quickRange = (hours: number) => {
+ const fromMs = startOfCurrentDayMs();
+ const toMs = fromMs + hours * 60 * 60 * 1000;
+ setFromValue(formatDateTimeLocal(fromMs));
+ setToValue(formatDateTimeLocal(toMs));
+ setAppliedRange({ fromMs, toMs });
+ setPageError(undefined);
+ setDecisionNotice(undefined);
+ };
+
+ const refresh = () => {
+ if (invalidRange) {
+ setPageError("Некорректный интервал: from должен быть меньше to.");
+ return;
+ }
+ void loadData(appliedRange);
+ };
+
+ const handleDecision = async (planId: string, decision: TguPlanDecision, reason?: string) => {
+ setDecisionInFlight(true);
+ setDecisionError(undefined);
+ setDecisionNotice(undefined);
+
+ try {
+ await sendPlanDecision(planId, decision, reason);
+ setDecisionNotice(
+ decision === "REJECTED"
+ ? "Решение REJECTED отправлено. План может вернуться в PLANNED для повторной выдачи."
+ : "Решение ACCEPTED отправлено."
+ );
+ await loadData(appliedRange);
+ } catch (error) {
+ setDecisionError(error instanceof Error ? error.message : "Не удалось отправить решение.");
+ } finally {
+ setDecisionInFlight(false);
+ }
+ };
+
+ return (
+
+ }
+ sidebar={
+ {
+ setSelectedSpacecraftId(spacecraftId);
+ setSelectedPlanId(undefined);
+ setDecisionNotice(undefined);
+ }}
+ />
+ }
+ timeline={
+
+ }
+ details={
+ setSelectedPlanId(undefined)}
+ />
+ }
+ />
+ );
+}
+
+function defaultRange(): TimelineRange {
+ const fromMs = startOfCurrentDayMs();
+ return { fromMs, toMs: fromMs + DAY_MS };
+}
+
+function startOfCurrentDayMs(): number {
+ const now = new Date();
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
+}
+
+function formatDateTimeLocal(ms: number): string {
+ const date = new Date(ms);
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ const hours = String(date.getHours()).padStart(2, "0");
+ const minutes = String(date.getMinutes()).padStart(2, "0");
+ return `${year}-${month}-${day}T${hours}:${minutes}`;
+}
+
+function historyDaysForRange(range: TimelineRange): number {
+ const daysBack = Math.ceil(Math.max(0, Date.now() - range.fromMs) / DAY_MS);
+ return Math.max(1, daysBack + 1);
+}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguSidebar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguSidebar.tsx
new file mode 100644
index 0000000..68c0fb0
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguSidebar.tsx
@@ -0,0 +1,74 @@
+import type { TguPlatformUi } from "../../model/timelineTypes";
+import { filterPlatforms, platformLabel } from "./tguTimelineMapper";
+
+type TguSidebarProps = {
+ platforms: TguPlatformUi[];
+ selectedSpacecraftId?: string;
+ search: string;
+ platformLoadFailed: boolean;
+ onSearchChange: (value: string) => void;
+ onSelectSpacecraft: (spacecraftId?: string) => void;
+};
+
+export function TguSidebar({
+ platforms,
+ selectedSpacecraftId,
+ search,
+ platformLoadFailed,
+ onSearchChange,
+ onSelectSpacecraft
+}: TguSidebarProps) {
+ const filtered = filterPlatforms(platforms, search);
+
+ return (
+
+ );
+}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx
new file mode 100644
index 0000000..8e04697
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx
@@ -0,0 +1,201 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import type { TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes";
+import {
+ buildSequentialLinks,
+ buildTimeTicks,
+ buildTimelineSegments,
+ TIMELINE_AXIS_HEIGHT,
+ TIMELINE_BOTTOM_PADDING,
+ TIMELINE_LABEL_WIDTH,
+ TIMELINE_ROW_HEIGHT,
+ timeToX,
+ timelineHeight
+} from "./tguTimelineLayout";
+import { platformLabel } from "./tguTimelineMapper";
+import { statusStyle } from "./tguStatus";
+
+type TguTimelineProps = {
+ rows: TimelineRow[];
+ range: TimelineRange;
+ selectedPlanId?: string;
+ invalidRange?: boolean;
+ onSelectPlan: (planId: string) => void;
+};
+
+export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelectPlan }: TguTimelineProps) {
+ const containerRef = useRef(null);
+ const [containerWidth, setContainerWidth] = useState(1200);
+ const timelineWidth = Math.max(720, containerWidth - TIMELINE_LABEL_WIDTH - 28);
+ const fullWidth = TIMELINE_LABEL_WIDTH + timelineWidth;
+ const height = Math.max(260, timelineHeight(rows.length));
+ const nowMs = Date.now();
+
+ useEffect(() => {
+ if (!containerRef.current) return;
+
+ const observer = new ResizeObserver(([entry]) => {
+ setContainerWidth(Math.floor(entry.contentRect.width));
+ });
+ observer.observe(containerRef.current);
+ return () => observer.disconnect();
+ }, []);
+
+ const segments = useMemo(
+ () => buildTimelineSegments(rows, range, timelineWidth),
+ [rows, range, timelineWidth]
+ );
+ const segmentByPlanId = useMemo(
+ () => new Map(segments.map((segment) => [segment.plan.planId, segment])),
+ [segments]
+ );
+ const links = useMemo(() => buildSequentialLinks(rows), [rows]);
+ const ticks = useMemo(() => buildTimeTicks(range), [range]);
+ const nowX =
+ nowMs >= range.fromMs && nowMs <= range.toMs
+ ? TIMELINE_LABEL_WIDTH + timeToX(nowMs, range, timelineWidth)
+ : undefined;
+
+ if (invalidRange) {
+ return Некорректный интервал: from должен быть меньше to.
;
+ }
+
+ if (rows.length === 0 || segments.length === 0) {
+ return Нет планов в выбранном интервале.
;
+ }
+
+ return (
+
+
+
+ );
+}
+
+function TimelineArrow({ from, to }: { from: TimelinePlanSegment; to: TimelinePlanSegment }) {
+ const y1 = TIMELINE_AXIS_HEIGHT + from.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2;
+ const y2 = TIMELINE_AXIS_HEIGHT + to.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2;
+ const x1 = TIMELINE_LABEL_WIDTH + from.x + from.width + 4;
+ const x2 = TIMELINE_LABEL_WIDTH + to.x - 6;
+ const mid = x1 + Math.max(18, (x2 - x1) / 2);
+ const path = x2 > x1
+ ? `M ${x1} ${y1} C ${mid} ${y1}, ${mid} ${y2}, ${x2} ${y2}`
+ : `M ${x1} ${y1} C ${x1 + 20} ${y1 - 18}, ${x2 - 20} ${y2 - 18}, ${x2} ${y2}`;
+
+ return ;
+}
+
+function formatTickDate(ms: number): string {
+ return new Date(ms).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
+}
+
+function formatTickTime(ms: number): string {
+ return new Date(ms).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
+}
+
+function formatDateTime(value: string): string {
+ return new Date(value).toLocaleString("ru-RU");
+}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx
new file mode 100644
index 0000000..03bb114
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx
@@ -0,0 +1,66 @@
+import { TguLegend } from "./TguLegend";
+
+type TguToolbarProps = {
+ fromValue: string;
+ toValue: string;
+ loading: boolean;
+ error?: string;
+ onFromChange: (value: string) => void;
+ onToChange: (value: string) => void;
+ onApply: () => void;
+ onQuickRange: (hours: number) => void;
+ onRefresh: () => void;
+};
+
+export function TguToolbar({
+ fromValue,
+ toValue,
+ loading,
+ error,
+ onFromChange,
+ onToChange,
+ onApply,
+ onQuickRange,
+ onRefresh
+}: TguToolbarProps) {
+ return (
+
+
+
+
+
+
Планирование ТГУ
+
Mission Ops
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {error && {error}
}
+
+ );
+}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguStatus.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguStatus.ts
new file mode 100644
index 0000000..f288b6e
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguStatus.ts
@@ -0,0 +1,26 @@
+import type { TguPlanStatus } from "../../model/tguTypes";
+import type { TimelineStatusStyle } from "../../model/timelineTypes";
+
+export const STATUS_STYLES: TimelineStatusStyle[] = [
+ { status: "ACCEPTED", label: "ACCEPTED", color: "#2f9e44", className: "status-accepted" },
+ { status: "REJECTED", label: "REJECTED", color: "#d64545", className: "status-rejected" },
+ { status: "WAITING_DECISION", label: "WAITING_DECISION", color: "#f0ad2e", className: "status-waiting" },
+ { status: "PLANNED", label: "PLANNED", color: "#8d99a6", className: "status-planned" },
+ { status: "ISSUING", label: "ISSUING", color: "#2f80ed", className: "status-issuing" },
+ { status: "EXPIRED", label: "EXPIRED", color: "#495057", className: "status-expired" },
+ { status: "SUPERSEDED", label: "SUPERSEDED", color: "#c7ced6", className: "status-superseded" },
+ { status: "START_AMBIGUOUS", label: "START_AMBIGUOUS", color: "#b42318", className: "status-ambiguous" }
+];
+
+const STATUS_BY_NAME = new Map(STATUS_STYLES.map((style) => [style.status, style]));
+
+export function statusStyle(status: TguPlanStatus): TimelineStatusStyle {
+ return (
+ STATUS_BY_NAME.get(status) ?? {
+ status,
+ label: status || "UNKNOWN",
+ color: "#79828d",
+ className: "status-unknown"
+ }
+ );
+}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts
new file mode 100644
index 0000000..512bbd8
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+import type { TguPlanUi, TimelineRow } from "../../model/timelineTypes";
+import { buildSequentialLinks, clipPlanToRange, timeToX } from "./tguTimelineLayout";
+
+const range = {
+ fromMs: Date.parse("2026-05-29T00:00:00"),
+ toMs: Date.parse("2026-05-30T00:00:00")
+};
+
+function plan(id: string, startTime: string, endTime: string, spacecraftId = "56756"): TguPlanUi {
+ return {
+ planId: id,
+ spacecraftId,
+ startTime,
+ endTime,
+ kppId: "KPP-1",
+ status: "PLANNED",
+ startMs: Date.parse(startTime),
+ endMs: Date.parse(endTime)
+ };
+}
+
+describe("tguTimelineLayout", () => {
+ it("maps time to x inside the selected interval", () => {
+ expect(timeToX(Date.parse("2026-05-29T12:00:00"), range, 1200)).toBe(600);
+ });
+
+ it("clips partially visible plans to the selected interval", () => {
+ const clipped = clipPlanToRange(plan("p1", "2026-05-28T23:00:00", "2026-05-29T02:00:00"), range);
+
+ expect(clipped).toEqual({
+ startMs: range.fromMs,
+ endMs: Date.parse("2026-05-29T02:00:00")
+ });
+ });
+
+ it("builds links only between sequential plans of the same spacecraft", () => {
+ const rows: TimelineRow[] = [
+ {
+ spacecraftId: "56756",
+ plans: [
+ plan("p2", "2026-05-29T04:00:00", "2026-05-29T05:00:00"),
+ plan("p1", "2026-05-29T01:00:00", "2026-05-29T02:00:00")
+ ]
+ },
+ {
+ spacecraftId: "777",
+ plans: [
+ plan("p3", "2026-05-29T01:00:00", "2026-05-29T02:00:00", "777")
+ ]
+ }
+ ];
+
+ expect(buildSequentialLinks(rows)).toEqual([
+ { spacecraftId: "56756", fromPlanId: "p1", toPlanId: "p2" }
+ ]);
+ });
+});
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.ts
new file mode 100644
index 0000000..0ade590
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.ts
@@ -0,0 +1,93 @@
+import type { TguPlanUi, TimelineLink, TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes";
+
+export const TIMELINE_LABEL_WIDTH = 220;
+export const TIMELINE_ROW_HEIGHT = 58;
+export const TIMELINE_AXIS_HEIGHT = 46;
+export const TIMELINE_BOTTOM_PADDING = 18;
+
+export function timeToX(timeMs: number, range: TimelineRange, timelineWidth: number): number {
+ return ((timeMs - range.fromMs) / (range.toMs - range.fromMs)) * timelineWidth;
+}
+
+export function clipPlanToRange(plan: TguPlanUi, range: TimelineRange): { startMs: number; endMs: number } | null {
+ if (plan.endMs < range.fromMs || plan.startMs > range.toMs) {
+ return null;
+ }
+
+ return {
+ startMs: Math.max(plan.startMs, range.fromMs),
+ endMs: Math.min(plan.endMs, range.toMs)
+ };
+}
+
+export function buildTimelineSegments(
+ rows: TimelineRow[],
+ range: TimelineRange,
+ timelineWidth: number
+): TimelinePlanSegment[] {
+ const segments: TimelinePlanSegment[] = [];
+
+ rows.forEach((row, rowIndex) => {
+ for (const plan of row.plans) {
+ const clipped = clipPlanToRange(plan, range);
+ if (!clipped) continue;
+
+ const x = timeToX(clipped.startMs, range, timelineWidth);
+ const x2 = timeToX(clipped.endMs, range, timelineWidth);
+ segments.push({
+ plan,
+ rowIndex,
+ x,
+ x2,
+ width: Math.max(3, x2 - x)
+ });
+ }
+ });
+
+ return segments;
+}
+
+export function buildSequentialLinks(rows: TimelineRow[]): TimelineLink[] {
+ const links: TimelineLink[] = [];
+
+ for (const row of rows) {
+ const sorted = [...row.plans].sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
+ for (let index = 0; index < sorted.length - 1; index += 1) {
+ links.push({
+ spacecraftId: row.spacecraftId,
+ fromPlanId: sorted[index].planId,
+ toPlanId: sorted[index + 1].planId
+ });
+ }
+ }
+
+ return links;
+}
+
+export function buildTimeTicks(range: TimelineRange, targetCount = 8): number[] {
+ const span = range.toMs - range.fromMs;
+ const roughStep = span / targetCount;
+ const candidates = [
+ 15 * 60_000,
+ 30 * 60_000,
+ 60 * 60_000,
+ 2 * 60 * 60_000,
+ 3 * 60 * 60_000,
+ 6 * 60 * 60_000,
+ 12 * 60 * 60_000,
+ 24 * 60 * 60_000
+ ];
+ const step = candidates.find((candidate) => candidate >= roughStep) ?? 24 * 60 * 60_000;
+ const first = Math.ceil(range.fromMs / step) * step;
+ const ticks: number[] = [];
+
+ for (let tick = first; tick <= range.toMs; tick += step) {
+ ticks.push(tick);
+ }
+
+ return ticks;
+}
+
+export function timelineHeight(rowCount: number): number {
+ return TIMELINE_AXIS_HEIGHT + rowCount * TIMELINE_ROW_HEIGHT + TIMELINE_BOTTOM_PADDING;
+}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts
new file mode 100644
index 0000000..f5d5b1c
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts
@@ -0,0 +1,162 @@
+import type { TguPlan, TguPlatform } from "../../model/tguTypes";
+import type { TguPlanUi, TguPlatformUi, TimelineRange, TimelineRow } from "../../model/timelineTypes";
+
+const PROBLEM_STATUSES = new Set(["REJECTED", "EXPIRED", "START_AMBIGUOUS"]);
+
+export function mapPlans(plans: TguPlan[], platforms: TguPlatform[]): TguPlanUi[] {
+ const platformByNorad = buildPlatformByNorad(platforms);
+
+ return plans
+ .map((plan) => {
+ const startMs = new Date(plan.startTime).getTime();
+ const endMs = new Date(plan.endTime).getTime();
+ const platform = platformByNorad.get(plan.spacecraftId);
+ return {
+ ...plan,
+ startMs,
+ endMs,
+ platform: platform ? toPlatformUi(plan.spacecraftId, platform, 0, false) : undefined
+ };
+ })
+ .filter((plan) => Number.isFinite(plan.startMs) && Number.isFinite(plan.endMs));
+}
+
+export function buildPlatformsForInterval(
+ platforms: TguPlatform[],
+ plans: TguPlanUi[],
+ range: TimelineRange
+): TguPlatformUi[] {
+ const intervalPlans = filterPlansByRange(plans, range);
+ const platformByNorad = buildPlatformByNorad(platforms);
+ const planStats = new Map();
+
+ for (const plan of intervalPlans) {
+ const stats = planStats.get(plan.spacecraftId) ?? { count: 0, hasProblemStatus: false };
+ stats.count += 1;
+ stats.hasProblemStatus = stats.hasProblemStatus || PROBLEM_STATUSES.has(plan.status);
+ planStats.set(plan.spacecraftId, stats);
+ }
+
+ const platformItems = Array.from(platformByNorad.entries()).map(([spacecraftId, platform]) => {
+ const stats = planStats.get(spacecraftId) ?? { count: 0, hasProblemStatus: false };
+ return toPlatformUi(spacecraftId, platform, stats.count, stats.hasProblemStatus);
+ });
+
+ const knownSpacecraft = new Set(platformItems.map((platform) => platform.spacecraftId));
+ for (const [spacecraftId, stats] of planStats.entries()) {
+ if (!knownSpacecraft.has(spacecraftId)) {
+ platformItems.push({
+ spacecraftId,
+ noradId: spacecraftId,
+ planCount: stats.count,
+ hasProblemStatus: stats.hasProblemStatus
+ });
+ }
+ }
+
+ return platformItems.sort((a, b) => platformLabel(a).localeCompare(platformLabel(b), "ru"));
+}
+
+export function buildTimelineRows(
+ plans: TguPlanUi[],
+ platforms: TguPlatformUi[],
+ range: TimelineRange,
+ selectedSpacecraftId?: string
+): TimelineRow[] {
+ const platformBySpacecraft = new Map(platforms.map((platform) => [platform.spacecraftId, platform]));
+ const rowsBySpacecraft = new Map();
+
+ for (const plan of filterPlansByRange(plans, range)) {
+ if (selectedSpacecraftId && plan.spacecraftId !== selectedSpacecraftId) {
+ continue;
+ }
+
+ const row =
+ rowsBySpacecraft.get(plan.spacecraftId) ??
+ {
+ spacecraftId: plan.spacecraftId,
+ platform: platformBySpacecraft.get(plan.spacecraftId),
+ plans: []
+ };
+
+ row.plans.push(plan);
+ rowsBySpacecraft.set(plan.spacecraftId, row);
+ }
+
+ for (const platform of platforms) {
+ if (selectedSpacecraftId && platform.spacecraftId !== selectedSpacecraftId) {
+ continue;
+ }
+ if (!rowsBySpacecraft.has(platform.spacecraftId) && platform.planCount > 0) {
+ rowsBySpacecraft.set(platform.spacecraftId, {
+ spacecraftId: platform.spacecraftId,
+ platform,
+ plans: []
+ });
+ }
+ }
+
+ return Array.from(rowsBySpacecraft.values())
+ .map((row) => ({
+ ...row,
+ platform: row.platform ?? platformBySpacecraft.get(row.spacecraftId),
+ plans: [...row.plans].sort((a, b) => a.startMs - b.startMs)
+ }))
+ .sort((a, b) => platformLabel(a.platform, a.spacecraftId).localeCompare(platformLabel(b.platform, b.spacecraftId), "ru"));
+}
+
+export function filterPlatforms(platforms: TguPlatformUi[], query: string): TguPlatformUi[] {
+ const normalized = query.trim().toLowerCase();
+ if (!normalized) return platforms;
+
+ return platforms.filter((platform) => {
+ const searchText = [
+ platform.name,
+ platform.noradId,
+ platform.spacecraftId,
+ platform.mission,
+ platform.status
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
+
+ return searchText.includes(normalized);
+ });
+}
+
+export function filterPlansByRange(plans: TguPlanUi[], range: TimelineRange): TguPlanUi[] {
+ return plans.filter((plan) => plan.endMs >= range.fromMs && plan.startMs <= range.toMs);
+}
+
+export function platformLabel(platform?: TguPlatformUi, fallback?: string): string {
+ return platform?.name || platform?.noradId || fallback || platform?.spacecraftId || "КА";
+}
+
+function buildPlatformByNorad(platforms: TguPlatform[]): Map {
+ const result = new Map();
+ for (const platform of platforms) {
+ if (platform.noradId !== null && platform.noradId !== undefined) {
+ result.set(String(platform.noradId), platform);
+ }
+ }
+ return result;
+}
+
+function toPlatformUi(
+ spacecraftId: string,
+ platform: TguPlatform,
+ planCount: number,
+ hasProblemStatus: boolean
+): TguPlatformUi {
+ return {
+ spacecraftId,
+ name: platform.name ?? undefined,
+ noradId: platform.noradId === null || platform.noradId === undefined ? undefined : String(platform.noradId),
+ status: platform.status ?? undefined,
+ mission: platform.mission ?? undefined,
+ planCount,
+ hasProblemStatus,
+ platform
+ };
+}
diff --git a/services/pcp-tgu-ops-ui/src/main.tsx b/services/pcp-tgu-ops-ui/src/main.tsx
new file mode 100644
index 0000000..f3df47a
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/src/main.tsx
@@ -0,0 +1,11 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import App from "./App";
+import "./styles/theme.css";
+import "./styles/tgu-planning.css";
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+);
diff --git a/services/pcp-tgu-ops-ui/src/model/tguTypes.ts b/services/pcp-tgu-ops-ui/src/model/tguTypes.ts
new file mode 100644
index 0000000..e62484d
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/src/model/tguTypes.ts
@@ -0,0 +1,56 @@
+export type TguPlanStatus =
+ | "ACCEPTED"
+ | "REJECTED"
+ | "WAITING_DECISION"
+ | "PLANNED"
+ | "ISSUING"
+ | "EXPIRED"
+ | "SUPERSEDED"
+ | "START_AMBIGUOUS"
+ | string;
+
+export type TguPlanDecision = "ACCEPTED" | "REJECTED";
+
+export type TguPlan = {
+ planId: string;
+ spacecraftId: string;
+ startTime: string;
+ endTime: string;
+ kppId: string;
+ status: TguPlanStatus;
+};
+
+export type TguPlatform = {
+ id?: string | null;
+ businessKey?: string | null;
+ name?: string | null;
+ status?: string | null;
+ noradId?: number | string | null;
+ mission?: string | null;
+ validFrom?: string | null;
+ validTo?: string | null;
+};
+
+export type TguPlanDecisionMessage = {
+ eventId: string;
+ spacecraftId: string;
+ planId: string;
+ attemptId: string;
+ decision: TguPlanDecision;
+ decisionTime: string;
+ reason?: string | null;
+};
+
+export type TguApiErrorKind = "network" | "http";
+
+export class TguApiError extends Error {
+ readonly kind: TguApiErrorKind;
+ readonly status?: number;
+
+ constructor(message: string, kind: TguApiErrorKind, status?: number) {
+ super(message);
+ this.name = "TguApiError";
+ this.kind = kind;
+ this.status = status;
+ }
+}
diff --git a/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts b/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts
new file mode 100644
index 0000000..8653c44
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts
@@ -0,0 +1,50 @@
+import type { TguPlan, TguPlanStatus, TguPlatform } from "./tguTypes";
+
+export type TguPlatformUi = {
+ spacecraftId: string;
+ name?: string;
+ noradId?: string;
+ status?: string;
+ mission?: string;
+ planCount: number;
+ hasProblemStatus: boolean;
+ platform?: TguPlatform;
+};
+
+export type TguPlanUi = TguPlan & {
+ startMs: number;
+ endMs: number;
+ platform?: TguPlatformUi;
+};
+
+export type TimelineRange = {
+ fromMs: number;
+ toMs: number;
+};
+
+export type TimelineRow = {
+ spacecraftId: string;
+ platform?: TguPlatformUi;
+ plans: TguPlanUi[];
+};
+
+export type TimelinePlanSegment = {
+ plan: TguPlanUi;
+ rowIndex: number;
+ x: number;
+ x2: number;
+ width: number;
+};
+
+export type TimelineLink = {
+ spacecraftId: string;
+ fromPlanId: string;
+ toPlanId: string;
+};
+
+export type TimelineStatusStyle = {
+ status: TguPlanStatus;
+ label: string;
+ color: string;
+ className: string;
+};
diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css
new file mode 100644
index 0000000..f79f239
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css
@@ -0,0 +1,569 @@
+.tgu-app-shell {
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ height: 100%;
+ background:
+ linear-gradient(180deg, rgba(104, 195, 189, 0.06), transparent 22rem),
+ var(--bg-base);
+}
+
+.tgu-toolbar {
+ border-bottom: 1px solid var(--line);
+ background: rgba(23, 26, 28, 0.96);
+ box-shadow: var(--shadow);
+ z-index: 2;
+}
+
+.tgu-toolbar__row {
+ display: flex;
+ align-items: end;
+ gap: 0.75rem;
+ min-height: 4.4rem;
+ padding: 0.75rem 1rem 0.65rem;
+ overflow-x: auto;
+}
+
+.tgu-toolbar__brand {
+ display: flex;
+ align-items: center;
+ gap: 0.65rem;
+ min-width: 13.5rem;
+ padding-right: 0.5rem;
+}
+
+.tgu-toolbar__mark {
+ width: 1.5rem;
+ height: 1.5rem;
+ border: 2px solid var(--accent);
+ border-radius: 50%;
+ box-shadow: 0 0 18px rgba(104, 195, 189, 0.42);
+}
+
+.tgu-toolbar__title {
+ font-size: 0.96rem;
+ font-weight: 700;
+}
+
+.tgu-toolbar__subtitle {
+ margin-top: 0.1rem;
+ color: var(--text-dim);
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 0.68rem;
+ text-transform: uppercase;
+}
+
+.tgu-field {
+ display: grid;
+ gap: 0.25rem;
+ color: var(--text-muted);
+ font-size: 0.72rem;
+}
+
+.tgu-field input,
+.tgu-search input {
+ min-height: 2rem;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: #101214;
+ color: var(--text);
+ outline: none;
+}
+
+.tgu-field input {
+ width: 12.8rem;
+ padding: 0.38rem 0.55rem;
+}
+
+.tgu-field input:focus,
+.tgu-search input:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px rgba(104, 195, 189, 0.16);
+}
+
+.tgu-button {
+ min-height: 2rem;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 0.38rem 0.72rem;
+ background: var(--bg-panel-2);
+ color: var(--text);
+ white-space: nowrap;
+}
+
+.tgu-button:hover:not(:disabled) {
+ border-color: #4e5a5d;
+ background: var(--bg-elevated);
+}
+
+.tgu-button--primary {
+ border-color: rgba(104, 195, 189, 0.58);
+ background: #214341;
+ color: #eafffd;
+}
+
+.tgu-button--accept {
+ border-color: rgba(47, 158, 68, 0.65);
+ background: rgba(47, 158, 68, 0.16);
+}
+
+.tgu-button--reject {
+ border-color: rgba(214, 69, 69, 0.7);
+ background: rgba(214, 69, 69, 0.14);
+}
+
+.tgu-legend {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.48rem 0.82rem;
+ padding: 0 1rem 0.75rem;
+ color: var(--text-muted);
+ font-size: 0.74rem;
+}
+
+.tgu-legend__item {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ white-space: nowrap;
+}
+
+.tgu-legend__swatch {
+ width: 1.05rem;
+ height: 0.46rem;
+ border-radius: 999px;
+ background: #79828d;
+}
+
+.tgu-legend__note {
+ color: var(--accent-strong);
+}
+
+.status-accepted {
+ background-color: #2f9e44;
+}
+
+.status-rejected {
+ background-color: #d64545;
+}
+
+.status-waiting {
+ background-color: #f0ad2e;
+}
+
+.status-planned {
+ background-color: #8d99a6;
+}
+
+.status-issuing {
+ background-color: #2f80ed;
+}
+
+.status-expired {
+ background-color: #495057;
+}
+
+.status-superseded {
+ background-color: #c7ced6;
+}
+
+.status-ambiguous {
+ background-color: #b42318;
+ outline: 1px dashed #ffd1d1;
+}
+
+.status-unknown {
+ background-color: #79828d;
+}
+
+.tgu-workspace {
+ display: grid;
+ grid-template-columns: 18.5rem minmax(0, 1fr) 22rem;
+ min-width: 0;
+ min-height: 0;
+}
+
+.tgu-sidebar,
+.tgu-details {
+ min-height: 0;
+ background: var(--bg-panel);
+}
+
+.tgu-sidebar {
+ display: flex;
+ flex-direction: column;
+ border-right: 1px solid var(--line);
+}
+
+.tgu-panel-heading {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ min-height: 3rem;
+ padding: 0.75rem 0.9rem;
+ border-bottom: 1px solid var(--line-soft);
+ font-size: 0.82rem;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+.tgu-link-button {
+ border: 0;
+ background: transparent;
+ color: var(--accent);
+ font-size: 0.78rem;
+}
+
+.tgu-search {
+ padding: 0.75rem 0.8rem;
+}
+
+.tgu-search input {
+ width: 100%;
+ padding: 0.42rem 0.65rem;
+}
+
+.tgu-spacecraft-list {
+ display: grid;
+ align-content: start;
+ gap: 0.35rem;
+ min-height: 0;
+ overflow: auto;
+ padding: 0 0.55rem 0.9rem;
+}
+
+.tgu-spacecraft {
+ display: grid;
+ grid-template-columns: 0.58rem minmax(0, 1fr) auto;
+ gap: 0.55rem;
+ align-items: center;
+ width: 100%;
+ min-height: 4.35rem;
+ border: 1px solid transparent;
+ border-radius: 7px;
+ padding: 0.55rem 0.62rem;
+ background: transparent;
+ color: var(--text);
+ text-align: left;
+}
+
+.tgu-spacecraft:hover,
+.tgu-spacecraft.is-selected {
+ border-color: var(--line);
+ background: var(--bg-panel-2);
+}
+
+.tgu-spacecraft.is-selected {
+ border-color: rgba(104, 195, 189, 0.6);
+}
+
+.tgu-spacecraft__problem {
+ width: 0.48rem;
+ height: 0.48rem;
+ border-radius: 50%;
+ background: #465154;
+}
+
+.tgu-spacecraft__problem.is-problem {
+ background: var(--danger);
+ box-shadow: 0 0 12px rgba(214, 69, 69, 0.55);
+}
+
+.tgu-spacecraft__body {
+ display: grid;
+ min-width: 0;
+ gap: 0.18rem;
+}
+
+.tgu-spacecraft__name {
+ overflow: hidden;
+ font-size: 0.86rem;
+ font-weight: 650;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tgu-spacecraft__meta,
+.tgu-spacecraft__status {
+ overflow: hidden;
+ color: var(--text-dim);
+ font-size: 0.72rem;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tgu-spacecraft__count {
+ display: grid;
+ place-items: center;
+ min-width: 1.75rem;
+ height: 1.5rem;
+ border-radius: 5px;
+ background: #101214;
+ color: var(--accent-strong);
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 0.78rem;
+}
+
+.tgu-center {
+ min-width: 0;
+ min-height: 0;
+ overflow: auto;
+ background: #121516;
+}
+
+.tgu-timeline {
+ min-width: 48rem;
+ min-height: 100%;
+ padding: 0.85rem;
+}
+
+.tgu-timeline__svg {
+ display: block;
+ width: 100%;
+ min-height: 26rem;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #151819;
+}
+
+.tgu-timeline__background {
+ fill: #151819;
+}
+
+.tgu-timeline__labels-background {
+ fill: #181c1e;
+}
+
+.tgu-timeline__axis,
+.tgu-timeline__grid {
+ stroke: #343c3f;
+ stroke-width: 1;
+}
+
+.tgu-timeline__grid {
+ stroke-dasharray: 3 5;
+}
+
+.tgu-timeline__tick-date {
+ fill: #d2dddd;
+ font-size: 12px;
+ font-weight: 650;
+}
+
+.tgu-timeline__tick-time,
+.tgu-timeline__row-meta {
+ fill: #79878a;
+ font-size: 11px;
+}
+
+.tgu-timeline__now {
+ stroke: #f46e4f;
+ stroke-width: 1.7;
+ stroke-dasharray: 5 5;
+}
+
+.tgu-timeline__now-label {
+ fill: #f46e4f;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.tgu-timeline__row {
+ fill: #151819;
+ stroke: #22282a;
+ stroke-width: 1;
+}
+
+.tgu-timeline__row--alt {
+ fill: #171b1d;
+}
+
+.tgu-timeline__row-title {
+ fill: #e2e9e9;
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.tgu-timeline__arrow {
+ fill: none;
+ stroke: #8aa2a6;
+ stroke-width: 1.5;
+ opacity: 0.8;
+}
+
+.tgu-timeline-plan {
+ cursor: pointer;
+}
+
+.tgu-timeline-plan__bar {
+ stroke-linecap: round;
+ stroke-width: 13;
+}
+
+.tgu-timeline-plan__selection {
+ stroke: #efffff;
+ stroke-linecap: round;
+ stroke-width: 19;
+ opacity: 0.85;
+}
+
+.tgu-timeline-plan__label {
+ fill: #101214;
+ font-size: 11px;
+ font-weight: 700;
+ pointer-events: none;
+}
+
+.tgu-details {
+ display: flex;
+ flex-direction: column;
+ gap: 0.9rem;
+ overflow: auto;
+ border-left: 1px solid var(--line);
+ padding: 1rem;
+}
+
+.tgu-details--empty {
+ align-items: center;
+ justify-content: center;
+}
+
+.tgu-details__placeholder {
+ display: grid;
+ justify-items: center;
+ gap: 0.75rem;
+ color: var(--text-dim);
+ text-align: center;
+}
+
+.tgu-details__placeholder-icon {
+ width: 2.2rem;
+ height: 1rem;
+ border: 2px solid var(--line);
+ border-radius: 999px;
+}
+
+.tgu-details__header {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 0.8rem;
+}
+
+.tgu-details__eyebrow {
+ color: var(--text-dim);
+ font-size: 0.68rem;
+ text-transform: uppercase;
+}
+
+.tgu-details h2 {
+ margin: 0.25rem 0 0;
+ overflow-wrap: anywhere;
+ font-size: 1rem;
+ line-height: 1.25;
+}
+
+.tgu-icon-button {
+ display: grid;
+ place-items: center;
+ width: 1.8rem;
+ height: 1.8rem;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: var(--bg-panel-2);
+ color: var(--text-muted);
+}
+
+.tgu-status-pill {
+ align-self: flex-start;
+ border-radius: 999px;
+ padding: 0.22rem 0.65rem;
+ color: #101214;
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 0.72rem;
+ font-weight: 800;
+}
+
+.tgu-details-grid {
+ display: grid;
+ gap: 0.72rem;
+ margin: 0;
+}
+
+.tgu-details-grid dt {
+ margin-bottom: 0.18rem;
+ color: var(--text-dim);
+ font-size: 0.68rem;
+ text-transform: uppercase;
+}
+
+.tgu-details-grid dd {
+ margin: 0;
+ overflow-wrap: anywhere;
+ color: var(--text);
+ font-size: 0.84rem;
+}
+
+.tgu-details__actions {
+ display: flex;
+ gap: 0.6rem;
+ padding-top: 0.35rem;
+}
+
+.tgu-alert {
+ border: 1px solid var(--line);
+ border-radius: 7px;
+ padding: 0.62rem 0.72rem;
+ font-size: 0.8rem;
+ line-height: 1.35;
+}
+
+.tgu-toolbar > .tgu-alert {
+ margin: 0 1rem 0.75rem;
+}
+
+.tgu-alert--danger {
+ border-color: rgba(214, 69, 69, 0.48);
+ background: rgba(214, 69, 69, 0.12);
+ color: #ffd9d9;
+}
+
+.tgu-alert--warning {
+ border-color: rgba(240, 173, 46, 0.48);
+ background: rgba(240, 173, 46, 0.12);
+ color: #ffe7b5;
+}
+
+.tgu-alert--success {
+ border-color: rgba(47, 158, 68, 0.48);
+ background: rgba(47, 158, 68, 0.12);
+ color: #c9f7d2;
+}
+
+.tgu-empty {
+ display: grid;
+ place-items: center;
+ min-height: 18rem;
+ padding: 2rem;
+ color: var(--text-dim);
+ text-align: center;
+}
+
+.tgu-empty--compact {
+ min-height: 5rem;
+ padding: 1rem;
+ font-size: 0.82rem;
+}
+
+@media (max-width: 1100px) {
+ .tgu-workspace {
+ grid-template-columns: 16rem minmax(0, 1fr);
+ }
+
+ .tgu-details {
+ grid-column: 1 / -1;
+ max-height: 18rem;
+ border-left: 0;
+ border-top: 1px solid var(--line);
+ }
+}
diff --git a/services/pcp-tgu-ops-ui/src/styles/theme.css b/services/pcp-tgu-ops-ui/src/styles/theme.css
new file mode 100644
index 0000000..6b08e61
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/src/styles/theme.css
@@ -0,0 +1,57 @@
+:root {
+ font-family:
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ color: #e7ecec;
+ background: #111315;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+
+ --bg-base: #111315;
+ --bg-panel: #171a1c;
+ --bg-panel-2: #1f2325;
+ --bg-elevated: #262b2e;
+ --line: #30373a;
+ --line-soft: #252b2d;
+ --text: #e7ecec;
+ --text-muted: #a4b0b1;
+ --text-dim: #748183;
+ --accent: #68c3bd;
+ --accent-strong: #82ded8;
+ --danger: #d64545;
+ --warning: #f0ad2e;
+ --success: #2f9e44;
+ --shadow: 0 16px 42px rgba(0, 0, 0, 0.28);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+ min-width: 0;
+ min-height: 0;
+ width: 100%;
+ height: 100%;
+ margin: 0;
+}
+
+button,
+input {
+ font: inherit;
+}
+
+button {
+ cursor: pointer;
+}
+
+button:disabled {
+ cursor: not-allowed;
+ opacity: 0.62;
+}
+
+.mono {
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
+}
diff --git a/services/pcp-tgu-ops-ui/tsconfig.app.json b/services/pcp-tgu-ops-ui/tsconfig.app.json
new file mode 100644
index 0000000..7a7a882
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/tsconfig.app.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src"]
+}
diff --git a/services/pcp-tgu-ops-ui/tsconfig.json b/services/pcp-tgu-ops-ui/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/services/pcp-tgu-ops-ui/tsconfig.node.json b/services/pcp-tgu-ops-ui/tsconfig.node.json
new file mode 100644
index 0000000..c077346
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/tsconfig.node.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/services/pcp-tgu-ops-ui/vite.config.ts b/services/pcp-tgu-ops-ui/vite.config.ts
new file mode 100644
index 0000000..1749eb9
--- /dev/null
+++ b/services/pcp-tgu-ops-ui/vite.config.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from "vitest/config";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5174,
+ proxy: {
+ "/api": {
+ target: "http://localhost:7008",
+ changeOrigin: true
+ }
+ }
+ },
+ test: {
+ environment: "jsdom",
+ globals: true
+ }
+});