From 5235142fa6b7d3b9d061b142553cf3b927daf2bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Thu, 4 Jun 2026 14:20:01 +0300 Subject: [PATCH] =?UTF-8?q?pcp-tgu:=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80=20=E2=80=94=20=D1=82=D0=B0=D0=B9=D0=BC=D0=BB=D0=B0?= =?UTF-8?q?=D0=B9=D0=BD=20=D0=B7=D1=83=D0=BC/=D0=B4=D1=80=D0=B0=D0=B3/?= =?UTF-8?q?=D1=81=D0=B5=D0=BA=D1=83=D0=BD=D0=B4=D0=BD=D0=B0=D1=8F=20=D1=88?= =?UTF-8?q?=D0=BA=D0=B0=D0=BB=D0=B0,=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20?= =?UTF-8?q?=D0=B4=D0=B5=D1=82=D0=B0=D0=BB=D1=8F=D1=85=20=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0,=20=D0=B1=D0=B5=D0=B7=20=D0=B0=D0=B2=D1=82=D0=BE-?= =?UTF-8?q?=D0=BA=D0=BD=D0=BE=D0=BF=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - детали плана: подтверждение (confirm) на «Отправить» и «Удалить» черновика - редактор: убраны кнопки «Применить»/«Отмена», undo/redo и сдвиг ◀▶ из тулбара - окно: предел отдаления план±1ч, дефолт тоже план±1ч; зум кнопками и колесом мыши (центр — курсор) - таймлайн: drag по горизонтали как карту; минимум окна ~10с - ось времени: адаптивные деления от секунд до недель, секунды в подписи при мелком масштабе --- docs/задачи для pcp-tgu-ops-ui.txt | 8 +- .../features/tgu-editor/EditorTimeline.tsx | 93 +++++++++- .../src/features/tgu-editor/EditorToolbar.tsx | 52 +----- .../src/features/tgu-editor/TguEditorTab.tsx | 166 +++++++++--------- .../features/tgu-editor/editorPresentation.ts | 27 ++- .../features/tgu-planning/TguPlanDetails.tsx | 14 +- 6 files changed, 211 insertions(+), 149 deletions(-) diff --git a/docs/задачи для pcp-tgu-ops-ui.txt b/docs/задачи для pcp-tgu-ops-ui.txt index acb83f4..44d3da3 100644 --- a/docs/задачи для pcp-tgu-ops-ui.txt +++ b/docs/задачи для pcp-tgu-ops-ui.txt @@ -1,15 +1,11 @@ 1. Перевод сервиса станций на ordinis по примеру получения аппаратов в сервисе tgu 2. Ошибка при попытке принять план во вкладке планы - -4. Необходимо обновить статусы во вкладке планы (слева внизу), чтобы они были актуальные. И нужно пусть для меня хотя бы пояснение по каждому из них в какой момент какой статус наступает и что он означает -Эту информацию лучше положить в docs, чтобы заново не выискивать потом -5. Пропадают витки с вкладки редактор - 6. Когда мы кликаем по полосам обзора, если там пересекаются несколько, то их надо выводить списком на панели справа. И при нажатии на полосу обзора на панели выделять объект на карте. TODO: - Отображение трасс полос исходя их возможности съемки КА. Для оптики нужно отображать только части, которые находятся в допустимом угле солнца. -- +- Отображение заявок на вкладке редактор только тех, которые может удовлетворить аппарат (не показывать только локацию для оптики например) +- Чуть расширить вправо левую панель с информацией на вкладке Редактор ибо цифры на границе смотрятся плохо diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorTimeline.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorTimeline.tsx index 8e6f105..8aaca74 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorTimeline.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorTimeline.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { formatDate, formatDateTime, formatTime, kindColor, kindLabel, niceTicks } from "./editorPresentation"; +import { formatDate, formatDateTime, formatTick, formatTime, kindColor, kindLabel, niceTicks } from "./editorPresentation"; import type { EditorContextLane, EditorTimelineWindow, @@ -20,6 +20,10 @@ type EditorTimelineProps = { visibilityTracks: EditorVisibilityTrack[]; onSelectWork: (workId: string) => void; onClearSelection: () => void; + /** Масштаб колесом мыши: factor (>1 — отдалить, <1 — приблизить) и время под курсором. */ + onWheelZoom?: (factor: number, anchorMs: number) => void; + /** Сдвиг окна перетаскиванием: новое значение левой границы окна (мс). */ + onPanTo?: (fromMs: number) => void; }; const LABEL_WIDTH = 168; @@ -40,15 +44,21 @@ export function EditorTimeline({ contextLanes, visibilityTracks, onSelectWork, - onClearSelection + onClearSelection, + onWheelZoom, + onPanTo }: EditorTimelineProps) { const wrapperRef = useRef(null); const [bodyWidth, setBodyWidth] = useState(900); const [bodyHeight, setBodyHeight] = useState(260); + const [grabbing, setGrabbing] = useState(false); const span = Math.max(1, timelineWindow.toMs - timelineWindow.fromMs); const pxPerMs = bodyWidth / span; const toX = (timeMs: number) => (timeMs - timelineWindow.fromMs) * pxPerMs; - const ticks = useMemo(() => niceTicks(timelineWindow.fromMs, timelineWindow.toMs, pxPerMs, 72), [pxPerMs, timelineWindow.fromMs, timelineWindow.toMs]); + const { ticks, step: tickStep } = useMemo( + () => niceTicks(timelineWindow.fromMs, timelineWindow.toMs, pxPerMs, 72), + [pxPerMs, timelineWindow.fromMs, timelineWindow.toMs] + ); const days = useMemo(() => buildDays(timelineWindow), [timelineWindow]); // Фиксированная высота частей ниже активной дорожки (треки видимости + контекст КА). const belowActiveHeight = @@ -81,8 +91,81 @@ export function EditorTimeline({ return () => observer.disconnect(); }, []); + // Свежие значения окна/геометрии для нативного wheel-слушателя (он навешивается один раз). + const wheelStateRef = useRef({ fromMs: timelineWindow.fromMs, pxPerMs, bodyWidth, onWheelZoom }); + wheelStateRef.current = { fromMs: timelineWindow.fromMs, pxPerMs, bodyWidth, onWheelZoom }; + + useEffect(() => { + const element = wrapperRef.current; + if (!element) return; + + // Нативный слушатель с passive: false — иначе preventDefault не сработает и страница + // прокрутится вместо масштабирования таймлайна. + const handleWheel = (event: WheelEvent) => { + const state = wheelStateRef.current; + if (!state.onWheelZoom) return; + event.preventDefault(); + const rect = element.getBoundingClientRect(); + const x = event.clientX - rect.left - LABEL_WIDTH; + const clampedX = Math.min(Math.max(x, 0), state.bodyWidth); + const anchorMs = state.fromMs + clampedX / state.pxPerMs; + const factor = event.deltaY > 0 ? 1.1 : 0.9; + state.onWheelZoom(factor, anchorMs); + }; + + element.addEventListener("wheel", handleWheel, { passive: false }); + return () => element.removeEventListener("wheel", handleWheel); + }, []); + + // Перетаскивание окна по горизонтали (как карту). На mousedown фиксируем точку и левую + // границу окна, дальше двигаем абсолютно (без накопления дрейфа). Сдвиг > порога помечаем + // как drag, чтобы погасить последующий click (иначе выделение/сброс включения). + const dragRef = useRef<{ startX: number; startFromMs: number; pxPerMs: number; moved: boolean } | null>(null); + const suppressClickRef = useRef(false); + + const handleMouseDown = (event: React.MouseEvent) => { + if (event.button !== 0 || !onPanTo || pxPerMs <= 0) return; + const drag = { startX: event.clientX, startFromMs: timelineWindow.fromMs, pxPerMs, moved: false }; + dragRef.current = drag; + + const onMove = (move: MouseEvent) => { + const deltaPx = move.clientX - drag.startX; + if (Math.abs(deltaPx) > 3) { + drag.moved = true; + setGrabbing(true); + } + // Тянем содержимое вправо → показываем более раннее время (окно едет влево). + onPanTo(drag.startFromMs - deltaPx / drag.pxPerMs); + }; + const onUp = () => { + if (drag.moved) suppressClickRef.current = true; + dragRef.current = null; + setGrabbing(false); + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }; + + // Капче-фаза: если только что был drag — гасим click до того, как он дойдёт до полос/фона. + const handleClickCapture = (event: React.MouseEvent) => { + if (suppressClickRef.current) { + suppressClickRef.current = false; + event.stopPropagation(); + event.preventDefault(); + } + }; + return ( -
+
Время @@ -96,7 +179,7 @@ export function EditorTimeline({ ))} {ticks.map((tick) => (
- {formatTime(tick)} + {formatTick(tick, tickStep)}
))}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx index 3debbf1..4d0bee5 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx @@ -5,48 +5,26 @@ type EditorToolbarProps = { selectedSpacecraftId?: string; selectedPlan?: TguPlanUi; platforms: TguPlatformUi[]; - canUndo: boolean; - canRedo: boolean; checking: boolean; conflictCount: number; - addedCount: number; - dirty: boolean; - applying: boolean; - canceling: boolean; windowLabel: string; rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo"; onSetRightMode: (mode: "shoot" | "downlink") => void; - onUndo: () => void; - onRedo: () => void; onCheck: () => void; - onApply: () => void; - onCancel: () => void; onZoom: (factor: number) => void; - onPan: (factor: number) => void; }; export function EditorToolbar({ selectedSpacecraftId, selectedPlan, platforms, - canUndo, - canRedo, checking, conflictCount, - addedCount, - dirty, - applying, - canceling, windowLabel, rightMode, onSetRightMode, - onUndo, - onRedo, onCheck, - onApply, - onCancel, - onZoom, - onPan + onZoom }: EditorToolbarProps) { const platformLabel = selectedSpacecraftId ? platforms.find((platform) => platform.spacecraftId === selectedSpacecraftId)?.name ?? selectedSpacecraftId @@ -85,21 +63,10 @@ export function EditorToolbar({
-
- +
{windowLabel || formatDuration(0)} - -
- -
- -
- - {dirty && ( - - )} - -
); } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index b7cddc8..466798a 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -13,7 +13,7 @@ import { validateMission, type SurveyRoute } from "../../api/missionPlanningApi"; -import { abandonDraft, createDraft, promoteDraft } from "../../api/tguPlanApi"; +import { abandonDraft, createDraft } from "../../api/tguPlanApi"; import { parseLocal, toLocalIso } from "../../utils/localTime"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; @@ -30,9 +30,7 @@ import { duplicateWork, mutate, planModesToWorks, - redo, seedDraft, - undo, updateWork } from "./editorDraft"; import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts"; @@ -58,6 +56,14 @@ import type { type EditorRightMode = "none" | "inspect" | "shoot" | "downlink" | "mapInfo" | "mapPick"; const HOUR_MS = 60 * 60 * 1000; +/** + * Отступ окна редактора от границ плана при самом отдалённом масштабе: начало и конец + * плана отстоят на час от краёв видимого окна. Это же — предел «отдаления» (zoom out). + */ +const EDITOR_WINDOW_PAD_MS = HOUR_MS; +/** Минимальная ширина окна редактора при «приближении» (zoom in): ~10 с, чтобы на оси + * появлялись секундные деления. */ +const MIN_EDITOR_WINDOW_MS = 10 * 1000; /** Фиксированная длительность окна съёмки, строится вокруг момента видимости. */ const SHOOT_WINDOW_MS = 6 * 60 * 1000; /** @@ -94,6 +100,14 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, future: [] })); const [window, setWindow] = useState(() => defaultEditorWindow(selectedPlan, appliedRange)); + // Предел отдаления: окно не шире, чем план ± час (тот же диапазон, что и дефолтное окно). + const maxWindow = useMemo( + () => + selectedPlan + ? { fromMs: selectedPlan.startMs - EDITOR_WINDOW_PAD_MS, toMs: selectedPlan.endMs + EDITOR_WINDOW_PAD_MS } + : { fromMs: appliedRange.fromMs, toMs: appliedRange.toMs }, + [selectedPlan, appliedRange] + ); const [rightMode, setRightMode] = useState("none"); const [selectedWorkId, setSelectedWorkId] = useState(); const [target, setTarget] = useState(); @@ -113,8 +127,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const [stationDtos, setStationDtos] = useState([]); const [serviceConflicts, setServiceConflicts] = useState({}); const [checking, setChecking] = useState(false); - const [applying, setApplying] = useState(false); - const [canceling, setCanceling] = useState(false); const [notice, setNotice] = useState(); const [applyError, setApplyError] = useState(); const [contextSpacecraftIds, setContextSpacecraftIds] = useState>(new Set()); @@ -122,7 +134,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const [planModes, setPlanModes] = useState([]); // Ленивый черновик (Ф5/Ф6): создаётся клоном базового плана на ПЕРВУЮ правку (replace). // Пока null — редактор показывает живой план read-only; после создания все правки и валидация - // адресуются в этот DRAFT, а «Применить» делает его promote в живую цепочку. + // адресуются в этот DRAFT. Отправка (promote) и удаление черновика делаются на вкладке «Планы». const [draftPlanId, setDraftPlanId] = useState(); const [passFrom, setPassFrom] = useState(1); const [passTo, setPassTo] = useState(999); @@ -237,10 +249,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, .sort((a, b) => a.startMs - b.startMs); }, [planModes, selectedZone]); - // «Применить» доступно, когда на сервере есть черновик (создаётся на первую сохранённую правку); - // локальные несохранённые твики без DRAFT применять нечего. - const dirty = Boolean(draftPlanId); - const addedCount = draft.works.filter((work) => work.isNew).length; // Реальный состав плана берём из включений (plan modes), а не из черновика: // черновик сидируется заглушкой, когда у плана нет встроенных works. const planSurveyCount = useMemo(() => planModes.filter((mode) => mode.type === "SURVEY").length, [planModes]); @@ -704,53 +712,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, } }; - // «Применить» = promote черновика (Ф5): DRAFT уходит в живую цепочку и выдаётся. Под - // оптимистичным локом базы и гардом точки невозврата (409/422 с пояснением приходят с бэка). - const applyDraft = async () => { - if (!draftPlanId) { - setApplyError("Нет правок для применения — черновик ещё не создан."); - setNotice(undefined); - return; - } - - setApplying(true); - setApplyError(undefined); - setNotice(undefined); - try { - const result = await promoteDraft(draftPlanId); - setNotice(`Черновик применён: план ${result.planId} отправлен на выдачу.`); - setDraftPlanId(undefined); - setServiceConflicts({}); - setHistory((current) => ({ ...current, past: [], future: [] })); - } catch (error) { - setApplyError(error instanceof Error ? error.message : "Не удалось применить правки плана."); - } finally { - setApplying(false); - } - }; - - // «Отмена» = отказ от черновика (Ф6): удаляет DRAFT и клон-миссию на сервере, возвращает - // редактор к живому плану (read-only) и сбрасывает локальные правки. - const cancelDraft = async () => { - if (!draftPlanId) return; - setCanceling(true); - setApplyError(undefined); - setNotice(undefined); - try { - await abandonDraft(draftPlanId); - setDraftPlanId(undefined); - setServiceConflicts({}); - setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] }); - setSelectedWorkId(undefined); - setRightMode("none"); - setNotice("Черновик отменён — правки отброшены."); - } catch (error) { - setApplyError(error instanceof Error ? error.message : "Не удалось отменить черновик."); - } finally { - setCanceling(false); - } - }; - // Удаление включения: персистнутые режимы (с planModeId) удаляются в БД черновика (Ф3, // с каскадами drop↔survey), затем убираются из локального представления. Несохранённые // (planModeId нет) — только локально. @@ -770,20 +731,68 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setRightMode("none"); }; - const zoom = (factor: number) => { - setWindow((current) => { - const center = (current.fromMs + current.toMs) / 2; - const half = ((current.toMs - current.fromMs) / 2) * factor; - return { fromMs: center - half, toMs: center + half }; - }); - }; + // Масштабирование окна относительно точки-якоря (центр окна для кнопок, точка под + // курсором для колеса мыши). Ширину зажимаем в [MIN_EDITOR_WINDOW_MS; ширина maxWindow], + // затем двигаем окно так, чтобы оно не вышло за план ± час. + const zoomAt = useCallback( + (factor: number, anchorMs: number) => { + setWindow((current) => { + const span = current.toMs - current.fromMs; + const maxSpan = maxWindow.toMs - maxWindow.fromMs; + const nextSpan = Math.min(Math.max(span * factor, MIN_EDITOR_WINDOW_MS), maxSpan); + const ratio = span > 0 ? (anchorMs - current.fromMs) / span : 0.5; + let fromMs = anchorMs - ratio * nextSpan; + let toMs = fromMs + nextSpan; + if (fromMs < maxWindow.fromMs) { + fromMs = maxWindow.fromMs; + toMs = fromMs + nextSpan; + } + if (toMs > maxWindow.toMs) { + toMs = maxWindow.toMs; + fromMs = toMs - nextSpan; + } + return { fromMs: Math.max(fromMs, maxWindow.fromMs), toMs }; + }); + }, + [maxWindow] + ); - const pan = (factor: number) => { - setWindow((current) => { - const delta = (current.toMs - current.fromMs) * factor; - return { fromMs: current.fromMs + delta, toMs: current.toMs + delta }; - }); - }; + const zoom = useCallback( + (factor: number) => { + setWindow((current) => { + const center = (current.fromMs + current.toMs) / 2; + const span = current.toMs - current.fromMs; + const maxSpan = maxWindow.toMs - maxWindow.fromMs; + const nextSpan = Math.min(Math.max(span * factor, MIN_EDITOR_WINDOW_MS), maxSpan); + let fromMs = center - nextSpan / 2; + let toMs = center + nextSpan / 2; + if (fromMs < maxWindow.fromMs) { + fromMs = maxWindow.fromMs; + toMs = fromMs + nextSpan; + } + if (toMs > maxWindow.toMs) { + toMs = maxWindow.toMs; + fromMs = toMs - nextSpan; + } + return { fromMs: Math.max(fromMs, maxWindow.fromMs), toMs }; + }); + }, + [maxWindow] + ); + + // Горизонтальный сдвиг окна перетаскиванием: ширину окна не меняем, левую границу зажимаем + // в [maxWindow.fromMs; maxWindow.toMs − ширина], чтобы не выехать за план ± час. + const panTo = useCallback( + (fromMs: number) => { + setWindow((current) => { + const span = current.toMs - current.fromMs; + let from = Math.min(Math.max(fromMs, maxWindow.fromMs), maxWindow.toMs - span); + if (from < maxWindow.fromMs) from = maxWindow.fromMs; + return { fromMs: from, toMs: from + span }; + }); + }, + [maxWindow] + ); // Включение (съёмка/сброс), соответствующее объекту карты: контур маршрута (planWorks) или // линия сброса (drop) → работа таймлайна по modeId ↔ planModeId. Прочие объекты → undefined. @@ -933,14 +942,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, selectedSpacecraftId={selectedSpacecraftId} selectedPlan={selectedPlan} platforms={platforms} - canUndo={history.past.length > 0} - canRedo={history.future.length > 0} checking={checking} conflictCount={conflictCount(conflicts)} - addedCount={addedCount} - dirty={dirty} - applying={applying} - canceling={canceling} windowLabel={formatDuration(window.toMs - window.fromMs)} rightMode={rightMode} onSetRightMode={(mode) => { @@ -950,13 +953,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, clearMapPick(); setNotice(undefined); }} - onUndo={() => setHistory((current) => undo(current))} - onRedo={() => setHistory((current) => redo(current))} onCheck={runCheck} - onApply={applyDraft} - onCancel={cancelDraft} onZoom={zoom} - onPan={pan} />
@@ -1061,6 +1059,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setMapFocus((prev) => ({ ...prev, objectId: undefined })); } }} + onWheelZoom={zoomAt} + onPanTo={panTo} /> @@ -1217,8 +1217,8 @@ function defaultEditorWindow( ): EditorTimelineWindow { if (selectedPlan) { return { - fromMs: selectedPlan.startMs - 3 * HOUR_MS, - toMs: selectedPlan.endMs + 3 * HOUR_MS + fromMs: selectedPlan.startMs - EDITOR_WINDOW_PAD_MS, + toMs: selectedPlan.endMs + EDITOR_WINDOW_PAD_MS }; } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts index 4417ab9..f56a6dc 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts @@ -27,6 +27,18 @@ export function formatTime(ms: number): string { return `${pad(date.getHours())}:${pad(date.getMinutes())}`; } +/** + * Подпись деления оси с учётом шага: при шаге меньше минуты показываем секунды + * (ЧЧ:ММ:СС), иначе — ЧЧ:ММ. Так при сильном зуме видно более мелкий масштаб времени. + */ +export function formatTick(ms: number, stepMs: number): string { + const date = new Date(ms); + if (stepMs < 60_000) { + return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; + } + return `${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + export function formatDate(ms: number): string { const date = new Date(ms); return `${pad(date.getDate())} ${EDITOR_MONTHS[date.getMonth()]}`; @@ -57,10 +69,19 @@ export function formatDuration(ms: number): string { return restHours > 0 ? `${days} сут ${restHours} ч` : `${days} сут`; } -export function niceTicks(fromMs: number, toMs: number, pxPerMs: number, minPx: number): number[] { +/** Деления оси времени: {@link ticks} — отметки, {@link step} — выбранный шаг (мс). */ +export type TimelineTicks = { ticks: number[]; step: number }; + +export function niceTicks(fromMs: number, toMs: number, pxPerMs: number, minPx: number): TimelineTicks { + const minuteMs = 60_000; const hourMs = 3_600_000; const dayMs = 24 * hourMs; - const steps = [hourMs, 2 * hourMs, 3 * hourMs, 6 * hourMs, 12 * hourMs, dayMs, 2 * dayMs, 7 * dayMs]; + // От секунд до недели — чем сильнее зум, тем мельче шаг, который влезает по minPx. + const steps = [ + 1_000, 2_000, 5_000, 10_000, 15_000, 30_000, + minuteMs, 2 * minuteMs, 5 * minuteMs, 10 * minuteMs, 15 * minuteMs, 30 * minuteMs, + hourMs, 2 * hourMs, 3 * hourMs, 6 * hourMs, 12 * hourMs, dayMs, 2 * dayMs, 7 * dayMs + ]; const step = steps.find((item) => item * pxPerMs >= minPx) ?? steps[steps.length - 1]; const ticks: number[] = []; const firstTick = Math.ceil(fromMs / step) * step; @@ -69,7 +90,7 @@ export function niceTicks(fromMs: number, toMs: number, pxPerMs: number, minPx: ticks.push(timeMs); } - return ticks; + return { ticks, step }; } function pad(value: number): string { 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 index 331e14f..9761f41 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx @@ -79,6 +79,16 @@ export function TguPlanDetails({ onDecision(plan.planId, "REJECTED", reason); }; + const promoteDraft = () => { + if (!window.confirm("Отправить черновик на выдачу? Он уйдёт в цепочку и станет рабочим после приёмки.")) return; + onPromoteDraft?.(plan.planId); + }; + + const deleteDraft = () => { + if (!window.confirm("Удалить черновик? Его нельзя будет восстановить.")) return; + onDeleteDraft?.(plan.planId); + }; + return (