pcp-tgu: редактор — таймлайн зум/драг/секундная шкала, подтверждения в деталях плана, без авто-кнопок

- детали плана: подтверждение (confirm) на «Отправить» и «Удалить» черновика
- редактор: убраны кнопки «Применить»/«Отмена», undo/redo и сдвиг ◀▶ из тулбара
- окно: предел отдаления план±1ч, дефолт тоже план±1ч; зум кнопками и колесом мыши (центр — курсор)
- таймлайн: drag по горизонтали как карту; минимум окна ~10с
- ось времени: адаптивные деления от секунд до недель, секунды в подписи при мелком масштабе
This commit is contained in:
Дмитрий Соловьев
2026-06-04 14:20:01 +03:00
parent 9e7cae4a56
commit 5235142fa6
6 changed files with 211 additions and 149 deletions
+2 -6
View File
@@ -1,15 +1,11 @@
1. Перевод сервиса станций на ordinis по примеру получения аппаратов в сервисе tgu
2. Ошибка при попытке принять план во вкладке планы
4. Необходимо обновить статусы во вкладке планы (слева внизу), чтобы они были актуальные. И нужно пусть для меня хотя бы пояснение по каждому из них в какой момент какой статус наступает и что он означает
Эту информацию лучше положить в docs, чтобы заново не выискивать потом
5. Пропадают витки с вкладки редактор
6. Когда мы кликаем по полосам обзора, если там пересекаются несколько, то их надо выводить списком на панели справа. И при нажатии на полосу обзора на панели выделять объект на карте.
TODO:
- Отображение трасс полос исходя их возможности съемки КА. Для оптики нужно отображать только части, которые находятся в допустимом угле солнца.
-
- Отображение заявок на вкладке редактор только тех, которые может удовлетворить аппарат (не показывать только локацию для оптики например)
- Чуть расширить вправо левую панель с информацией на вкладке Редактор ибо цифры на границе смотрятся плохо
@@ -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<HTMLDivElement | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
event.stopPropagation();
event.preventDefault();
}
};
return (
<div className="tgu-editor-timeline" ref={wrapperRef} onClick={onClearSelection}>
<div
className="tgu-editor-timeline"
ref={wrapperRef}
onClick={onClearSelection}
onClickCapture={handleClickCapture}
onMouseDown={handleMouseDown}
style={onPanTo ? { cursor: grabbing ? "grabbing" : "grab" } : undefined}
>
<div className="tgu-editor-timeline__surface" style={{ width: LABEL_WIDTH + bodyWidth, height: Math.max(220, surfaceHeight) }}>
<div className="tgu-editor-timeline__axis-label" style={{ width: LABEL_WIDTH }}>
Время
@@ -96,7 +179,7 @@ export function EditorTimeline({
))}
{ticks.map((tick) => (
<div className="tgu-editor-timeline__tick-label" key={tick} style={{ left: toX(tick) }}>
{formatTime(tick)}
{formatTick(tick, tickStep)}
</div>
))}
</div>
@@ -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({
<div className="tgu-editor-toolbar__spacer" />
<div className="tgu-editor-window-control" aria-label="Окно редактора">
<button type="button" onClick={() => onPan(-0.25)} title="Назад по времени"></button>
<div className="tgu-editor-window-control" aria-label="Масштаб окна" title="Колесо мыши над таймлайном — тоже масштаб">
<button type="button" onClick={() => onZoom(1.4)} title="Отдалить"></button>
<span>{windowLabel || formatDuration(0)}</span>
<button type="button" onClick={() => onZoom(0.7)} title="Приблизить">+</button>
<button type="button" onClick={() => onPan(0.25)} title="Вперёд по времени"></button>
</div>
<div className="tgu-editor-toolbar__group">
<button className="tgu-icon-button" type="button" onClick={onUndo} disabled={!canUndo} title="Отменить">
</button>
<button className="tgu-icon-button" type="button" onClick={onRedo} disabled={!canRedo} title="Вернуть">
</button>
</div>
<button
@@ -110,21 +77,6 @@ export function EditorToolbar({
>
{checking ? "Проверка..." : conflictCount > 0 ? `Конфликты · ${conflictCount}` : "Проверить"}
</button>
{dirty && (
<button
className="tgu-button tgu-button--reject"
type="button"
onClick={onCancel}
disabled={canceling || applying}
>
{canceling ? "Отмена..." : "Отмена"}
</button>
)}
<button className="tgu-button tgu-button--primary" type="button" onClick={onApply} disabled={!dirty || applying || canceling}>
{applying ? "Применение..." : `Применить${addedCount > 0 ? ` · +${addedCount}` : ""}`}
</button>
</div>
);
}
@@ -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<EditorTimelineWindow>(() => defaultEditorWindow(selectedPlan, appliedRange));
// Предел отдаления: окно не шире, чем план ± час (тот же диапазон, что и дефолтное окно).
const maxWindow = useMemo<EditorTimelineWindow>(
() =>
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<EditorRightMode>("none");
const [selectedWorkId, setSelectedWorkId] = useState<string>();
const [target, setTarget] = useState<EditorTarget>();
@@ -113,8 +127,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const [stationDtos, setStationDtos] = useState<StationDto[]>([]);
const [serviceConflicts, setServiceConflicts] = useState<EditorConflictMap>({});
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [canceling, setCanceling] = useState(false);
const [notice, setNotice] = useState<string>();
const [applyError, setApplyError] = useState<string>();
const [contextSpacecraftIds, setContextSpacecraftIds] = useState<Set<string>>(new Set());
@@ -122,7 +134,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const [planModes, setPlanModes] = useState<PlanMode[]>([]);
// Ленивый черновик (Ф5/Ф6): создаётся клоном базового плана на ПЕРВУЮ правку (replace).
// Пока null — редактор показывает живой план read-only; после создания все правки и валидация
// адресуются в этот DRAFT, а «Применить» делает его promote в живую цепочку.
// адресуются в этот DRAFT. Отправка (promote) и удаление черновика делаются на вкладке «Планы».
const [draftPlanId, setDraftPlanId] = useState<string>();
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}
/>
<div className="tgu-editor-notices">
@@ -1061,6 +1059,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setMapFocus((prev) => ({ ...prev, objectId: undefined }));
}
}}
onWheelZoom={zoomAt}
onPanTo={panTo}
/>
</section>
@@ -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
};
}
@@ -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 {
@@ -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 (
<aside className="tgu-details">
<div className="tgu-details__header">
@@ -135,7 +145,7 @@ export function TguPlanDetails({
className="tgu-button tgu-button--accept"
type="button"
disabled={draftActionInFlight}
onClick={() => onPromoteDraft?.(plan.planId)}
onClick={promoteDraft}
>
Отправить
</button>
@@ -143,7 +153,7 @@ export function TguPlanDetails({
className="tgu-button tgu-button--reject"
type="button"
disabled={draftActionInFlight}
onClick={() => onDeleteDraft?.(plan.planId)}
onClick={deleteDraft}
>
Удалить
</button>