pcp-tgu: пауза цепочки после REJECTED + режим «Черновики» + UI цепочек/ЗРВ
Бэкенд (pcp-tgu-service): - REJECTED трактуется как «план неправильный»: авто-перевыдача больше НЕ взводится, цепочка встаёт на паузу (новый флаг SpacecraftPlanningStateEntity.pausedAfterReject, миграция V13). Технические негативы («не заложен»/«не принят») по-прежнему дают срочную перевыдачу. - Пауза централизована в PlanProjectionService.project(): для отклонённой цепочки проекция пустая, воркер выдачи ничего не материализует. Флаг снимается при ручной выдаче/промоуте черновика/приёмке (оператор вмешался). - Режим «Черновики»: GET /plans/drafts (опц. ?spacecraftId) -> только DRAFT. - Гард дублей черновиков: (КА + начало + конец) уникальны среди DRAFT, повторное создание окна -> 409. - ZrvWindowMatcher: сравнение по истинной abs-длительности Duration (<=1с), устойчиво к усечению ISO на фронте до целых секунд. Фронт (pcp-tgu-ops-ui): вкладка «Планы» — таймлайн цепочек, детали плана, панель ЗРВ (TguZrvPanel), диалог создания цепочки, режим черновиков. Удалён устаревший docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md.
This commit is contained in:
@@ -3,21 +3,22 @@ import type { TimelinePlanSegment, TimelineRange, TimelineRow } from "../../mode
|
||||
import {
|
||||
buildSequentialLinks,
|
||||
buildTimeTicks,
|
||||
buildTimelineSegments,
|
||||
buildTimelineLayout,
|
||||
TIMELINE_AXIS_HEIGHT,
|
||||
TIMELINE_BOTTOM_PADDING,
|
||||
TIMELINE_LABEL_WIDTH,
|
||||
TIMELINE_ROW_HEIGHT,
|
||||
timeToX,
|
||||
timelineHeight
|
||||
TIMELINE_ZRV_BAND_HEIGHT,
|
||||
timeToX
|
||||
} from "./tguTimelineLayout";
|
||||
import { platformLabel } from "./tguTimelineMapper";
|
||||
import { statusStyle } from "./tguStatus";
|
||||
import { formatUtcDate, formatUtcDateTime, formatUtcTime, parseUtc } from "../../utils/utcTime";
|
||||
|
||||
export type ZrvWindow = { startMs: number; endMs: number; kppId: string };
|
||||
|
||||
type ZrvBands = {
|
||||
spacecraftId: string;
|
||||
windows: { startMs: number; endMs: number }[];
|
||||
windows: ZrvWindow[];
|
||||
};
|
||||
|
||||
type TguTimelineProps = {
|
||||
@@ -27,15 +28,34 @@ type TguTimelineProps = {
|
||||
invalidRange?: boolean;
|
||||
/** ЗРВ выбранного КА — подсветка доступных зон на его строке. */
|
||||
zrvBands?: ZrvBands;
|
||||
/** id выбранной ЗРВ (`${kppId}|${startMs}`) — подсветка кликнутой зоны. */
|
||||
selectedZrvId?: string;
|
||||
/** КА с цепочкой на паузе после REJECTED — рисуем пометку у имени КА. */
|
||||
pausedSpacecraftIds?: ReadonlySet<string>;
|
||||
onSelectPlan: (planId: string) => void;
|
||||
/** Клик по ЗРВ: передаём пересекающиеся в этой точке зоны и индекс кликнутой. */
|
||||
onSelectZrv?: (windows: ZrvWindow[], selectedIndex: number) => void;
|
||||
};
|
||||
|
||||
export function TguTimeline({ rows, range, selectedPlanId, invalidRange, zrvBands, onSelectPlan }: TguTimelineProps) {
|
||||
function zrvId(zrv: ZrvWindow): string {
|
||||
return `${zrv.kppId}|${zrv.startMs}`;
|
||||
}
|
||||
|
||||
export function TguTimeline({
|
||||
rows,
|
||||
range,
|
||||
selectedPlanId,
|
||||
invalidRange,
|
||||
zrvBands,
|
||||
selectedZrvId,
|
||||
pausedSpacecraftIds,
|
||||
onSelectPlan,
|
||||
onSelectZrv
|
||||
}: TguTimelineProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(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();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -50,10 +70,12 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, zrvBand
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const segments = useMemo(
|
||||
() => buildTimelineSegments(rows, range, timelineWidth),
|
||||
const layout = useMemo(
|
||||
() => buildTimelineLayout(rows, range, timelineWidth),
|
||||
[rows, range, timelineWidth]
|
||||
);
|
||||
const segments = layout.segments;
|
||||
const height = Math.max(260, layout.height);
|
||||
const segmentByPlanId = useMemo(
|
||||
() => new Map(segments.map((segment) => [segment.plan.planId, segment])),
|
||||
[segments]
|
||||
@@ -114,43 +136,72 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, zrvBand
|
||||
</g>
|
||||
)}
|
||||
|
||||
{rows.map((row, rowIndex) => {
|
||||
const y = TIMELINE_AXIS_HEIGHT + rowIndex * TIMELINE_ROW_HEIGHT;
|
||||
{layout.rowLayouts.map(({ row, rowIndex, y, height: rowHeight, zrvBandY }) => {
|
||||
return (
|
||||
<g key={row.spacecraftId}>
|
||||
<rect
|
||||
x="0"
|
||||
y={y}
|
||||
width={fullWidth}
|
||||
height={TIMELINE_ROW_HEIGHT}
|
||||
height={rowHeight}
|
||||
className={rowIndex % 2 === 0 ? "tgu-timeline__row" : "tgu-timeline__row tgu-timeline__row--alt"}
|
||||
/>
|
||||
<text x="16" y={y + 24} className="tgu-timeline__row-title">
|
||||
<text x="16" y={y + 22} className="tgu-timeline__row-title">
|
||||
{platformLabel(row.platform, row.spacecraftId)}
|
||||
</text>
|
||||
<text x="16" y={y + 42} className="tgu-timeline__row-meta">
|
||||
<text x="16" y={y + 38} className="tgu-timeline__row-meta">
|
||||
NORAD {row.platform?.noradId || row.spacecraftId}
|
||||
</text>
|
||||
{zrvBands?.spacecraftId === row.spacecraftId &&
|
||||
zrvBands.windows.map((zrv, zrvIndex) => {
|
||||
const from = Math.max(zrv.startMs, range.fromMs);
|
||||
const to = Math.min(zrv.endMs, range.toMs);
|
||||
if (to <= from) return null;
|
||||
const bx = TIMELINE_LABEL_WIDTH + timeToX(from, range, timelineWidth);
|
||||
const bw = Math.max(2, timeToX(to, range, timelineWidth) - timeToX(from, range, timelineWidth));
|
||||
return (
|
||||
<rect
|
||||
key={`zrv-${zrvIndex}`}
|
||||
className="tgu-timeline__zrv"
|
||||
x={bx}
|
||||
y={y + 6}
|
||||
width={bw}
|
||||
height={TIMELINE_ROW_HEIGHT - 12}
|
||||
>
|
||||
<title>ЗРВ {formatUtcDateTime(zrv.startMs)} – {formatUtcDateTime(zrv.endMs)}</title>
|
||||
</rect>
|
||||
);
|
||||
})}
|
||||
{pausedSpacecraftIds?.has(row.spacecraftId) && (
|
||||
<text
|
||||
x={TIMELINE_LABEL_WIDTH - 12}
|
||||
y={y + 22}
|
||||
textAnchor="end"
|
||||
className="tgu-timeline__paused-badge"
|
||||
>
|
||||
⏸ на паузе
|
||||
<title>Цепочка на паузе: план отклонён — создайте исправленный план («Создать план»).</title>
|
||||
</text>
|
||||
)}
|
||||
{zrvBands?.spacecraftId === row.spacecraftId && (
|
||||
<>
|
||||
<text x="16" y={zrvBandY + TIMELINE_ZRV_BAND_HEIGHT - 3} className="tgu-timeline__zrv-label">
|
||||
ЗРВ
|
||||
</text>
|
||||
{zrvBands.windows.map((zrv, zrvIndex) => {
|
||||
const from = Math.max(zrv.startMs, range.fromMs);
|
||||
const to = Math.min(zrv.endMs, range.toMs);
|
||||
if (to <= from) return null;
|
||||
const bx = TIMELINE_LABEL_WIDTH + timeToX(from, range, timelineWidth);
|
||||
const bw = Math.max(2, timeToX(to, range, timelineWidth) - timeToX(from, range, timelineWidth));
|
||||
const id = zrvId(zrv);
|
||||
return (
|
||||
<rect
|
||||
key={`zrv-${zrvIndex}`}
|
||||
className={`tgu-timeline__zrv${selectedZrvId === id ? " is-selected" : ""}`}
|
||||
x={bx}
|
||||
y={zrvBandY}
|
||||
width={bw}
|
||||
height={TIMELINE_ZRV_BAND_HEIGHT}
|
||||
style={onSelectZrv ? { cursor: "pointer" } : undefined}
|
||||
onClick={(event) => {
|
||||
if (!onSelectZrv) return;
|
||||
event.stopPropagation();
|
||||
// Зоны «в этой точке» — все ЗРВ, пересекающие кликнутую по времени
|
||||
// (разные КПП могут перекрываться), как «заявки в точке» на карте.
|
||||
const overlapping = zrvBands.windows.filter(
|
||||
(w) => w.startMs < zrv.endMs && w.endMs > zrv.startMs
|
||||
);
|
||||
const idx = overlapping.findIndex((w) => zrvId(w) === id);
|
||||
onSelectZrv(overlapping, Math.max(0, idx));
|
||||
}}
|
||||
>
|
||||
<title>ЗРВ {zrv.kppId} · {formatUtcDateTime(zrv.startMs)} – {formatUtcDateTime(zrv.endMs)}</title>
|
||||
</rect>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
@@ -163,11 +214,7 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, zrvBand
|
||||
})}
|
||||
|
||||
{segments.map((segment) => {
|
||||
const y =
|
||||
TIMELINE_AXIS_HEIGHT +
|
||||
segment.rowIndex * TIMELINE_ROW_HEIGHT +
|
||||
TIMELINE_ROW_HEIGHT / 2 +
|
||||
segment.laneOffset;
|
||||
const y = segment.centerY;
|
||||
const x1 = TIMELINE_LABEL_WIDTH + segment.x;
|
||||
const x2 = TIMELINE_LABEL_WIDTH + segment.x + segment.width;
|
||||
const style = statusStyle(segment.plan.status);
|
||||
@@ -208,8 +255,8 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, zrvBand
|
||||
}
|
||||
|
||||
function TimelineArrow({ from, to }: { from: TimelinePlanSegment; to: TimelinePlanSegment }) {
|
||||
const y1 = TIMELINE_AXIS_HEIGHT + from.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2 + from.laneOffset;
|
||||
const y2 = TIMELINE_AXIS_HEIGHT + to.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2 + to.laneOffset;
|
||||
const y1 = from.centerY;
|
||||
const y2 = to.centerY;
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user