tgu: DELETE /plans/{draftPlanId} — отказ от черновика (бэкенд + кнопка «Отмена» и авто-отмена в редакторе)

This commit is contained in:
Дмитрий Соловьев
2026-06-03 13:59:52 +03:00
parent ec67d9d83d
commit 2a841fc7b3
7 changed files with 236 additions and 4 deletions
@@ -60,6 +60,24 @@ export async function promoteDraft(draftPlanId: string): Promise<PromoteDraftRes
);
}
/**
* Отказ от черновика (pcp-tgu-service, DELETE /api/plans/{draftPlanId}): удаляет DRAFT и его
* клон-миссию, снимая блокировку «один открытый черновик на базу». 404 — нет; 422 — не DRAFT.
*/
export async function abandonDraft(draftPlanId: string): Promise<void> {
const url = `${BASE}/api/plans/${encodeURIComponent(draftPlanId)}`;
let response: Response;
try {
response = await fetch(url, { method: "DELETE", headers: { Accept: "application/json" } });
} catch {
throw new TguApiError("Не удалось подключиться к сервису планирования ТГУ.", "network");
}
if (!response.ok) {
const text = await response.text();
throw new TguApiError(extractErrorText(text) || "Не удалось отменить черновик плана.", "http", response.status);
}
}
async function postJson<T>(url: string, body: unknown, fallbackMessage: string): Promise<T> {
let response: Response;
try {
@@ -12,6 +12,7 @@ type EditorToolbarProps = {
addedCount: number;
dirty: boolean;
applying: boolean;
canceling: boolean;
windowLabel: string;
rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo";
onSetRightMode: (mode: "shoot" | "downlink") => void;
@@ -19,6 +20,7 @@ type EditorToolbarProps = {
onRedo: () => void;
onCheck: () => void;
onApply: () => void;
onCancel: () => void;
onZoom: (factor: number) => void;
onPan: (factor: number) => void;
};
@@ -34,6 +36,7 @@ export function EditorToolbar({
addedCount,
dirty,
applying,
canceling,
windowLabel,
rightMode,
onSetRightMode,
@@ -41,6 +44,7 @@ export function EditorToolbar({
onRedo,
onCheck,
onApply,
onCancel,
onZoom,
onPan
}: EditorToolbarProps) {
@@ -107,7 +111,18 @@ export function EditorToolbar({
{checking ? "Проверка..." : conflictCount > 0 ? `Конфликты · ${conflictCount}` : "Проверить"}
</button>
<button className="tgu-button tgu-button--primary" type="button" onClick={onApply} disabled={!dirty || applying}>
{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 { createDraft, promoteDraft } from "../../api/tguPlanApi";
import { abandonDraft, createDraft, promoteDraft } from "../../api/tguPlanApi";
import { parseUtc } from "../../utils/utcTime";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
@@ -111,6 +111,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
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());
@@ -416,6 +417,18 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setServiceConflicts({});
}, [selectedPlan]);
// Зеркало draftPlanId в ref — чтобы при уходе/смене плана отменить незавершённый черновик,
// не оставляя осиротевший DRAFT (он бы заблокировал createDraft по той же базе на 409).
const draftPlanIdRef = useRef<string | undefined>(undefined);
useEffect(() => { draftPlanIdRef.current = draftPlanId; }, [draftPlanId]);
useEffect(() => () => {
const stale = draftPlanIdRef.current;
if (stale) {
// best-effort: применённый/уже отменённый черновик сюда не попадёт (ref обнулён).
void abandonDraft(stale).catch(() => {});
}
}, [selectedPlan]);
// Источник истины для включений — DRAFT, если он уже создан, иначе живой план (read-only превью).
useEffect(() => {
const missionId = draftPlanId ?? selectedPlan?.planId;
@@ -655,6 +668,28 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
}
};
// «Отмена» = отказ от черновика (Ф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 нет) — только локально.
@@ -745,6 +780,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
addedCount={addedCount}
dirty={dirty}
applying={applying}
canceling={canceling}
windowLabel={formatDuration(window.toMs - window.fromMs)}
rightMode={rightMode}
onSetRightMode={(mode) => {
@@ -757,6 +793,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
onRedo={() => setHistory((current) => redo(current))}
onCheck={runCheck}
onApply={applyDraft}
onCancel={cancelDraft}
onZoom={zoom}
onPan={pan}
/>