pcp-tgu: вкладка «Планы» — черновик на основе плана + правки UX

- Кнопка «Создать черновик на основе плана» в деталях плана: создаёт
  replace-черновик (окно 1:1, все включения клонируются) и открывает редактор.
  Путь «сделать исправленный план» вместо повторной отправки. Доступна для
  персистентных правимых баз; DRAFT/PLANNED/LAID_IN исключены (для LAID_IN
  replace 1:1 запрещён). Если черновик по базе уже есть — открываем его.
- Диалог создания плана цепочки: вместо списка будущих планов и «Отправить
  вручную» — кнопка «Редактировать» (сразу в редактор черновика).
- Сайдбар: повторный клик по выбранному КА снимает выбор (показать все КА).
This commit is contained in:
Дмитрий Соловьев
2026-06-04 13:07:09 +03:00
parent fd3603a086
commit d9f8260120
4 changed files with 72 additions and 44 deletions
@@ -2,12 +2,11 @@ import { useEffect, useMemo, useState } from "react";
import { import {
createChainPlan, createChainPlan,
fetchZrvWindows, fetchZrvWindows,
promoteDraft,
type CreateChainPlanResponse type CreateChainPlanResponse
} from "../../api/tguPlanApi"; } from "../../api/tguPlanApi";
import type { TguPlanUi } from "../../model/timelineTypes"; import type { TguPlanUi } from "../../model/timelineTypes";
import { TguApiError } from "../../model/tguTypes"; import { TguApiError } from "../../model/tguTypes";
import { formatLocalDateTime, parseLocal } from "../../utils/localTime"; import { formatLocalDateTime } from "../../utils/localTime";
import { buildChainPlanRequest, futureZrvOptions, type ZrvOption } from "./createChainWindows"; import { buildChainPlanRequest, futureZrvOptions, type ZrvOption } from "./createChainWindows";
type CreateChainPlanDialogProps = { type CreateChainPlanDialogProps = {
@@ -15,19 +14,18 @@ type CreateChainPlanDialogProps = {
/** Планы для выбора базы-предыстории (берём persisted этого КА, кроме черновиков/проекции). */ /** Планы для выбора базы-предыстории (берём persisted этого КА, кроме черновиков/проекции). */
plans: TguPlanUi[]; plans: TguPlanUi[];
onClose: () => void; onClose: () => void;
/** Вызвать после ручной отправки созданного плана (для перезагрузки таймлайна). */
onSent: () => void;
/** Вызвать после создания черновика — чтобы он сразу появился на таймлайне (режим «Черновики»). */ /** Вызвать после создания черновика — чтобы он сразу появился на таймлайне (режим «Черновики»). */
onCreated?: () => void; onCreated?: () => void;
/** Открыть созданный черновик в редакторе (переход на страницу редактирования). */
onEdit: (draftPlanId: string) => void;
}; };
/** /**
* Диалог «Создать план» (вкладка «Планы»): оператор берёт за основу прошлый план (или начинает * Диалог «Создать план» (вкладка «Планы»): оператор берёт за основу прошлый план (или начинает
* цепочку без истории), набирает окно из двух реальных ЗРВ и создаёт план-кандидат. Сразу * цепочку без истории), набирает окно из двух реальных ЗРВ и создаёт черновик плана. После
* показывается прогноз будущей цепочки от него; кандидат становится рабочим после ручной отправки * создания сразу предлагается перейти к его редактированию.
* («Отправить вручную» = promote) и последующей приёмки.
*/ */
export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent, onCreated }: CreateChainPlanDialogProps) { export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onCreated, onEdit }: CreateChainPlanDialogProps) {
const [zrvOptions, setZrvOptions] = useState<ZrvOption[]>([]); const [zrvOptions, setZrvOptions] = useState<ZrvOption[]>([]);
const [zrvLoading, setZrvLoading] = useState(true); const [zrvLoading, setZrvLoading] = useState(true);
const [zrvError, setZrvError] = useState<string>(); const [zrvError, setZrvError] = useState<string>();
@@ -39,7 +37,6 @@ export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent, on
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [result, setResult] = useState<CreateChainPlanResponse>(); const [result, setResult] = useState<CreateChainPlanResponse>();
const [sending, setSending] = useState(false);
// Базы-предыстории: persisted планы этого КА (без проекции PLANNED и без черновиков). // Базы-предыстории: persisted планы этого КА (без проекции PLANNED и без черновиков).
const baseOptions = useMemo( const baseOptions = useMemo(
@@ -90,21 +87,6 @@ export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent, on
} }
}; };
const send = async () => {
if (!result) return;
setSending(true);
setError(undefined);
try {
await promoteDraft(result.draftPlanId);
onSent();
onClose();
} catch (err) {
setError(err instanceof TguApiError ? err.message : "Не удалось отправить план.");
} finally {
setSending(false);
}
};
return ( return (
<div className="tgu-chain-dialog__backdrop" role="dialog" aria-modal="true" aria-label="Создать план"> <div className="tgu-chain-dialog__backdrop" role="dialog" aria-modal="true" aria-label="Создать план">
<div className="tgu-chain-dialog"> <div className="tgu-chain-dialog">
@@ -178,33 +160,23 @@ export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent, on
) : ( ) : (
<> <>
<div className="tgu-alert tgu-alert--success"> <div className="tgu-alert tgu-alert--success">
План-кандидат создан (черновик {result.draftPlanId}). Прогноз будущей цепочки от него: Черновик плана создан ({result.draftPlanId}).
</div> </div>
<ul className="tgu-chain-dialog__chain">
{result.projectedChain.length === 0 ? (
<li className="tgu-chain-dialog__hint">Цепочка после этого плана пуста (нет дальнейших ЗРВ).</li>
) : (
result.projectedChain.map((plan) => (
<li key={plan.planId}>
{formatLocalDateTime(parseLocal(plan.startTime))} {formatLocalDateTime(parseLocal(plan.endTime))} ·{" "}
{plan.kppId}
</li>
))
)}
</ul>
{error && <div className="tgu-alert tgu-alert--error">{error}</div>}
<div className="tgu-chain-dialog__actions"> <div className="tgu-chain-dialog__actions">
<button className="tgu-button" type="button" onClick={onClose}> <button className="tgu-button" type="button" onClick={onClose}>
Закрыть Закрыть
</button> </button>
<button className="tgu-button tgu-button--primary" type="button" onClick={send} disabled={sending}> <button
{sending ? "Отправка…" : "Отправить вручную"} className="tgu-button tgu-button--primary"
type="button"
onClick={() => onEdit(result.draftPlanId)}
>
Редактировать
</button> </button>
</div> </div>
<div className="tgu-chain-dialog__hint"> <div className="tgu-chain-dialog__hint">
После отправки план уходит в выдачу; рабочим (якорем цепочки) он станет после приёмки. Откроется страница редактирования черновика.
</div> </div>
</> </>
)} )}
@@ -32,6 +32,8 @@ type TguPlanDetailsProps = {
onEditDraft?: (planId: string) => void; onEditDraft?: (planId: string) => void;
onPromoteDraft?: (planId: string) => void; onPromoteDraft?: (planId: string) => void;
onDeleteDraft?: (planId: string) => void; onDeleteDraft?: (planId: string) => void;
/** Создать черновик-копию на основе этого плана (replace, окно 1:1, все включения). */
onCreateDraftFromPlan?: (planId: string) => void;
onClose: () => void; onClose: () => void;
}; };
@@ -47,6 +49,7 @@ export function TguPlanDetails({
onEditDraft, onEditDraft,
onPromoteDraft, onPromoteDraft,
onDeleteDraft, onDeleteDraft,
onCreateDraftFromPlan,
onClose onClose
}: TguPlanDetailsProps) { }: TguPlanDetailsProps) {
if (!plan) { if (!plan) {
@@ -64,6 +67,11 @@ export function TguPlanDetails({
const duration = formatDuration(plan.endMs - plan.startMs); const duration = formatDuration(plan.endMs - plan.startMs);
// Окно решения закрылось, если с момента старта плана прошло больше grace-периода. // Окно решения закрылось, если с момента старта плана прошло больше grace-периода.
const decisionWindowClosed = Date.now() > plan.startMs + DECISION_GRACE_MINUTES * 60_000; const decisionWindowClosed = Date.now() > plan.startMs + DECISION_GRACE_MINUTES * 60_000;
// «Создать черновик на основе плана» (replace, окно 1:1, все включения) доступно для
// персистентных правимых баз. DRAFT уже имеет свои действия; PLANNED — виртуальная проекция,
// не база; LAID_IN — replace 1:1 запрещён (слот зафиксирован, только fork-forward).
const canCreateDraftFromPlan =
plan.status !== "DRAFT" && plan.status !== "PLANNED" && plan.status !== "LAID_IN";
const reject = () => { const reject = () => {
const reason = window.prompt("Reason для REJECTED", "UI_TEST_REJECTED"); const reason = window.prompt("Reason для REJECTED", "UI_TEST_REJECTED");
@@ -163,6 +171,20 @@ export function TguPlanDetails({
</button> </button>
</div> </div>
))} ))}
{canCreateDraftFromPlan && onCreateDraftFromPlan && (
<div className="tgu-details__actions">
<button
className="tgu-button tgu-button--primary"
type="button"
disabled={draftActionInFlight}
title="Создать отдельный черновик-копию этого плана (то же окно, все включения) для правки и отправки"
onClick={() => onCreateDraftFromPlan(plan.planId)}
>
Создать черновик на основе плана
</button>
</div>
)}
</aside> </aside>
); );
} }
@@ -7,6 +7,7 @@ import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
import { TguRequestsTab } from "../tgu-requests/TguRequestsTab"; import { TguRequestsTab } from "../tgu-requests/TguRequestsTab";
import { import {
abandonDraft, abandonDraft,
createDraft,
fetchDraftPlans, fetchDraftPlans,
fetchZrvWindows, fetchZrvWindows,
promoteDraft, promoteDraft,
@@ -300,6 +301,33 @@ export function TguPlanningPage() {
} }
}; };
// Создать черновик-копию на основе существующего плана (replace, окно 1:1, все включения) и
// открыть его в редакторе. Это путь «сделать исправленный план», а не повторная отправка того же.
const handleCreateDraftFromPlan = async (planId: string) => {
// Один открытый черновик на базу: если он уже есть — открываем его, второй не плодим.
const existing = drafts.find((item) => item.basePlanId === planId);
if (existing) {
handleEditDraft(existing.planId);
return;
}
setDraftActionInFlight(true);
setDecisionError(undefined);
setDecisionNotice(undefined);
try {
const { draftPlanId } = await createDraft(planId, { mode: "replace" });
await loadData(appliedRange);
const base = allPlans.find((item) => item.planId === planId);
setShowDrafts(true);
if (base) setSelectedSpacecraftId(base.spacecraftId);
setSelectedPlanId(draftPlanId);
setActiveTab("editor");
} catch (error) {
setDecisionError(error instanceof Error ? error.message : "Не удалось создать черновик на основе плана.");
} finally {
setDraftActionInFlight(false);
}
};
// Удалить черновик (abandon): убирает DRAFT и его клон-миссию. // Удалить черновик (abandon): убирает DRAFT и его клон-миссию.
const handleDeleteDraft = async (planId: string) => { const handleDeleteDraft = async (planId: string) => {
setDraftActionInFlight(true); setDraftActionInFlight(true);
@@ -414,6 +442,7 @@ export function TguPlanningPage() {
onEditDraft={handleEditDraft} onEditDraft={handleEditDraft}
onPromoteDraft={handlePromoteDraft} onPromoteDraft={handlePromoteDraft}
onDeleteDraft={handleDeleteDraft} onDeleteDraft={handleDeleteDraft}
onCreateDraftFromPlan={handleCreateDraftFromPlan}
onClose={() => setSelectedPlanId(undefined)} onClose={() => setSelectedPlanId(undefined)}
/> />
)} )}
@@ -423,11 +452,16 @@ export function TguPlanningPage() {
spacecraftId={selectedSpacecraftId} spacecraftId={selectedSpacecraftId}
plans={plans} plans={plans}
onClose={() => setCreateChainOpen(false)} onClose={() => setCreateChainOpen(false)}
onSent={() => void loadData(appliedRange)}
onCreated={() => { onCreated={() => {
setShowDrafts(true); setShowDrafts(true);
void loadData(appliedRange); void loadData(appliedRange);
}} }}
onEdit={(draftPlanId) => {
setCreateChainOpen(false);
setSelectedPlanId(draftPlanId);
setDecisionNotice(undefined);
setActiveTab("editor");
}}
/> />
)} )}
</div> </div>
@@ -53,7 +53,7 @@ export function TguSidebar({
className={`tgu-spacecraft ${selected ? "is-selected" : ""}`} className={`tgu-spacecraft ${selected ? "is-selected" : ""}`}
type="button" type="button"
key={platform.spacecraftId} key={platform.spacecraftId}
onClick={() => onSelectSpacecraft(platform.spacecraftId)} onClick={() => onSelectSpacecraft(selected ? undefined : platform.spacecraftId)}
> >
<span className={`tgu-spacecraft__problem ${platform.hasProblemStatus ? "is-problem" : ""}`} /> <span className={`tgu-spacecraft__problem ${platform.hasProblemStatus ? "is-problem" : ""}`} />
<span className="tgu-spacecraft__body"> <span className="tgu-spacecraft__body">