diff --git a/apps/web/console/README.md b/apps/web/console/README.md index c4a80ff..60d105a 100644 --- a/apps/web/console/README.md +++ b/apps/web/console/README.md @@ -20,11 +20,13 @@ Current scope: - authenticated shell entry only after `/v1/me` succeeds - access and refresh tokens are held in React memory only - left navigation with Contracts, Delivery Readiness, and Proof product - surfaces; Contracts consumes authenticated, organization-scoped, read-only - Contract endpoints: `GET /v1/contracts`, `GET /v1/contracts/{id}`, and - `GET /v1/contracts/{id}/current-draft`; it renders a contract rail/list plus - selected aggregate detail, current draft body when available, and read-only - Organization / Project / Repository context metadata from + surfaces; the current authenticated Contracts entry renders the imported + `apps/web/demo-change-packet-ru` demo contracts page shell, isolated from + the rest of the Console CSS, and backs its Contract rows/detail/current draft + data with the existing authenticated read-only Contract endpoints: + `GET /v1/contracts`, `GET /v1/contracts/{id}`, and + `GET /v1/contracts/{id}/current-draft`, plus read-only Organization / + Project / Repository context metadata from `GET /v1/organizations/{organization_id}/repository-context`; Delivery Readiness consumes read-only `GET /v1/qualification-feed` while authenticated and renders Qualification / Clarification / Contract / @@ -78,17 +80,18 @@ Delivery rule: `draft_contract` controls; linked contract cards expose `Open contract` navigation only, which loads the selected contract through `GET /v1/contracts/{id}` -- The Contracts surface consumes authenticated, organization-scoped read-only - Contract discovery at +- The current Contracts entry is the imported RU demo contracts UI backed by + authenticated, organization-scoped read-only Contract discovery at `GET /v1/contracts?project_id=&repo_binding_id=&goal_id=&state=&limit=`, loads `GET /v1/contracts?limit=50` by default, supports state and - repo-binding filters plus manual refresh, keeps manual ID lookup as a - secondary fallback, and shows selected detail through authenticated, + repo-binding filters plus manual refresh, and shows selected detail through + authenticated, organization-scoped, read-only `GET /v1/contracts/{id}` as the compact public Contract aggregate only -- The Contracts surface also uses `/v1/me` to determine `organization_id` and - reads `GET /v1/organizations/{organization_id}/repository-context` for a - compact metadata-only context panel. If the selected Contract +- The Contracts surface also uses `/v1/me` to determine + `organization_id` and reads + `GET /v1/organizations/{organization_id}/repository-context` for a compact + metadata-only context panel. If the selected Contract `repo_binding_id` matches a returned context, that context is shown; if no Contract is selected, the first Organization repository context is shown; if the selected binding is absent from the response, the Contract stays visible @@ -204,9 +207,10 @@ wait/cursor semantics, SSE, WebSocket, a daemon, or an event stream. main user flow. - Delivery Readiness shows qualification state and handoff to Contracts, not lifecycle controls. -- The Contracts surface is read-only, lists contracts from discovery, supports - state and repo-binding filtering plus manual refresh, and can show selected - detail or a manual ID lookup result. +- The current imported RU demo Contracts shell is read-only, lists contracts + from backend discovery, supports state and repo-binding filtering plus manual + refresh, and shows selected detail/current draft from the backend-backed + selected Contract state. D-0091 display behavior: - Delivery Readiness cards show one frontend-projected primary status instead diff --git a/apps/web/console/src/App.css b/apps/web/console/src/App.css index a5562f3..c11e913 100644 --- a/apps/web/console/src/App.css +++ b/apps/web/console/src/App.css @@ -175,6 +175,15 @@ select:focus-visible { border: 0; } +.demoContractsOverlay { + position: fixed; + inset: 0; + z-index: 100; + min-width: 320px; + min-height: 100vh; + background: var(--bg); +} + .brand { display: flex; align-items: center; diff --git a/apps/web/console/src/App.test.tsx b/apps/web/console/src/App.test.tsx index 1e24c03..640d413 100644 --- a/apps/web/console/src/App.test.tsx +++ b/apps/web/console/src/App.test.tsx @@ -305,6 +305,15 @@ function expectNoWorkflowMutationRequests() { ))).toBe(false); } +async function findDemoContractsShadowRoot() { + await waitFor(() => { + const host = document.querySelector('.demoContractsHost') as HTMLDivElement | null; + expect(host?.shadowRoot).toBeTruthy(); + }); + + return (document.querySelector('.demoContractsHost') as HTMLDivElement).shadowRoot as ShadowRoot; +} + async function setLocale(locale: 'en' | 'ru') { await i18n.changeLanguage(locale); document.documentElement.lang = locale; @@ -1502,6 +1511,54 @@ describe('App', () => { ); }); + it('renders backend contract data inside the imported contracts page shell', async () => { + await loginSuccessfully('ru', 'owner', [ + contractResponse({ + id: 'backend-live-contract', + goal_id: 'backend-live-goal', + repo_binding_id: 'backend-live-repo-binding', + state: 'ready_for_approval', + current_seed_id: 'backend-live-seed', + current_draft_id: 'backend-live-draft', + updated_at: '2026-05-09T10:15:00Z', + }), + ], { + id: 'backend-live-draft', + contract_id: 'backend-live-contract', + contract_seed_id: 'backend-live-seed', + goal_id: 'backend-live-goal', + repo_binding_id: 'backend-live-repo-binding', + title: 'Backend live draft title', + intent_summary: 'Backend live draft intent summary.', + proposed_scope: ['Backend live scope item'], + proposed_non_goals: ['No browser lifecycle mutation'], + proposed_acceptance_criteria: ['Backend page shows current draft'], + proposed_expected_checks: ['Backend read-only smoke'], + proposed_proof_expectations: ['Backend proof expectation text'], + state: 'ready_for_approval', + }, [ + repositoryContextRecord({ + repoBindingId: 'backend-live-repo-binding', + repositoryFullName: 'heurema/backend-live', + projectDisplayName: 'Backend Live Project', + }), + ]); + + const shadowRoot = await findDemoContractsShadowRoot(); + + await waitFor(() => { + expect(shadowRoot.textContent).toContain('backend-live-contract'); + expect(shadowRoot.textContent).toContain('Backend live draft title'); + }); + expect(shadowRoot.textContent).toContain('heurema/backend-live'); + expect(shadowRoot.textContent).toContain('Backend live draft intent summary.'); + expect(shadowRoot.textContent).toContain('Backend live scope item'); + expect(shadowRoot.textContent).toContain('No browser lifecycle mutation'); + expect(shadowRoot.textContent).toContain('Read-only API'); + expect(fetchMock.mock.calls.map(([url]) => String(url))).toContain('/v1/contracts/backend-live-contract/current-draft'); + expectNoWorkflowMutationRequests(); + }); + it('does not call current draft detail when the selected Contract has no current_draft_id', async () => { await loginSuccessfully('en', 'owner', [ contractResponse({ diff --git a/apps/web/console/src/App.tsx b/apps/web/console/src/App.tsx index 82e0649..778aa15 100644 --- a/apps/web/console/src/App.tsx +++ b/apps/web/console/src/App.tsx @@ -50,6 +50,7 @@ import type { ContractDraftResponse, ContractDraftSourceRef } from './contractDr import { READINESS_DISPLAY_LANES, projectReadinessDisplay, sortReadinessItems } from './readinessDisplay'; import { formatCalmTimestamp } from './uiTime'; import StartPage from './StartPage'; +import DemoContractsPage from './DemoContractsPage'; import './App.css'; @@ -1929,6 +1930,35 @@ function ConsoleApp() { )} + {screen === 'console' && activeSurface === 'contracts' ? ( +
+ { + void refreshContractsSurface(true); + }, + onRepoBindingFilterChange: setContractListRepoBindingFilter, + onStateFilterChange: setContractListStateFilter, + }} + /> +
+ ) : null} + {isDrawerOpen ? ( <> + ))} + + + +
+
Выбранный контракт
+

{selectedContract.title}

+
+
+
репозиторий
+
{selectedContract.repo}
+
+
+
текущая стадия
+
{selectedContract.stage}
+
+
+
политика
+
{selectedContract.policy}
+
+
+
решение человека
+
{selectedContract.humanDecision}
+
+
+
Стадия: {selectedContract.stage} · {selectedContract.stageProgress}
+
+ +
+ {selectedContract.detail.changePacket} + {selectedContract.detail.evidence} + {selectedContract.detail.projectContext} + {selectedContract.detail.decisionTrail} +
+ + ); +} + +function MobileReadinessSurface({ selectedRepo, onSelectRepo }: { selectedRepo: RepoId; onSelectRepo: (repo: RepoId) => void }) { + const context = REPO_CONTEXTS[selectedRepo]; + + return ( +
+
+
Готовность
+
+ + + +
+
+ +
+
+
+
Очередь репозиториев
+

Готовность репозитория

+
+ проверка +
+
+ {MOBILE_REPO_QUEUE.map((repo) => ( + + ))} +
+
+ +
+
Детали репозитория
+

{context.repo}

+
+
+
готовность
+
{context.readiness}/100
+
+
+
инициализация
+
{context.init}
+
+
+
документов
+
{context.docsIndexed}
+
+
+
тесты
+
{getCompactReadinessSignal(context.testsStatus)}
+
+
+
CI
+
{context.ciStatus}
+
+
+
Правила агента
+
{context.ownersRulesStatus.replace('Правила агента ', '')}
+
+
+
поверхность подтверждения
+
{context.proofSurfaceStatus}
+
+
+
рекомендованный режим
+
{context.recommendedMode}
+
+
+
+ +
+
Действия
+
+ + + +
+

Настройку лучше делать на десктопе. Демо-кнопки ничего не подключают и не меняют.

+
+
+ ); +} + +function MobileProofSurface({ + selectedProof, + onSelectProof, +}: { + selectedProof: ProofFeedItem; + onSelectProof: (proofId: string) => void; +}) { + const proofDecisionReady = selectedProof.contractId === 'C-0147' && selectedProof.criteriaCoverage.startsWith('5/5'); + const archivedProof = selectedProof.proofStatus === 'Принято'; + const decisionRestriction = archivedProof ? 'Архивное подтверждение: только чтение' : 'Покрытие критериев неполное'; + + return ( +
+
+
+
+
Очередь пакетов подтверждения
+

Проверка решения

+
+ безопасная проверка +
+
+ {MOBILE_PROOF_QUEUE.map((proof) => ( + + ))} +
+
+ +
+
Выбранный пакет подтверждения
+

{selectedProof.contractId}

+
+
+
контракт
+
{selectedProof.contractId}
+
+
+
репозиторий
+
{selectedProof.repo}
+
+
+
статус подтверждения
+
{selectedProof.proofStatus}
+
+
+
покрытие критериев
+
{selectedProof.criteriaCoverage}
+
+
+
решение человека
+
{selectedProof.contractId === 'C-0147' ? 'Ожидает' : selectedProof.humanApproval}
+
+
+
+ +
+ {selectedProof.changed[0]} + {selectedProof.verified[0]} + {selectedProof.linkedEvidence} + {selectedProof.decisionTrail.slice(0, 3).map(formatActivityKind).join(' · ')} + {selectedProof.archiveLine} +
+ +
+
Действие
+ {proofDecisionReady ? ( + <> + +

Только безопасная проверка. Финальное решение нельзя принять одним тапом на телефоне.

+ + ) : ( + <> + Решение недоступно +

{decisionRestriction}

+ + )} +
+
+ ); +} + +function MobileCompanionPreview({ liveContracts }: { liveContracts?: DemoContractsLiveData }) { + const hasLiveContracts = Boolean(liveContracts); + const [mobileSurface, setMobileSurface] = useState(() => (liveContracts ? 'contracts' : 'proof')); + const [selectedMobileContractId, setSelectedMobileContractId] = useState(() => liveContracts?.selectedContract?.id ?? 'C-0147'); + const [selectedMobileRepo, setSelectedMobileRepo] = useState('trialops-demo'); + const [selectedMobileProofId, setSelectedMobileProofId] = useState('PF-0147'); + + const liveRepoLabel = liveContracts?.repositoryContext?.contexts[0]?.repo_binding.repository_full_name ?? 'backend'; + const liveContractRecords = useMemo(() => { + if (!liveContracts) { + return []; + } + + return liveContracts.contracts.map((contract) => ( + mapLiveContract(contract, liveContracts.repositoryContext, liveContracts.selectedDraft) + )); + }, [liveContracts]); + const mobileContracts = hasLiveContracts + ? (liveContractRecords.length > 0 ? liveContractRecords.map(mapMobileContractQueueItem) : [mapMobileContractQueueItem(EMPTY_LIVE_CONTRACT)]) + : MOBILE_CONTRACT_QUEUE; + const selectedMobileContract = mobileContracts.find((contract) => contract.id === selectedMobileContractId) ?? mobileContracts[0]; + const selectedMobileProof = PROOF_FEED.find((proof) => proof.id === selectedMobileProofId) ?? PROOF_FEED[0]; + + useEffect(() => { + if (!hasLiveContracts) { + return; + } + + const nextContractId = liveContracts?.selectedContract?.id ?? mobileContracts[0]?.id; + if (nextContractId && !mobileContracts.some((contract) => contract.id === selectedMobileContractId)) { + setSelectedMobileContractId(nextContractId); + } + }, [hasLiveContracts, liveContracts?.selectedContract?.id, mobileContracts, selectedMobileContractId]); + + const handleMobileContractSelect = (contractId: string) => { + setSelectedMobileContractId(contractId); + const selectedBackendContract = liveContractRecords.find((contract) => contract.id === contractId)?.backendContract; + if (selectedBackendContract) { + liveContracts?.onContractSelect(selectedBackendContract); + } + }; + + return ( +
+
+
Goalrail
+

Goalrail

+

Короткий режим проверки: контракты, готовность и подтверждения.

+ Полная консоль оператора доступна на десктопе. +
+ + + + {mobileSurface === 'contracts' ? ( + + ) : mobileSurface === 'readiness' ? ( + + ) : ( + + )} +
+ ); +} + +function renderStageContent({ + contract, + projectContext, + step, + approval, + visibleClarifications, + visibleEvidence, + visibleVerification, + onAdvance, + onDecision, +}: { + contract: ContractRecord; + projectContext: RepoContext; + step: StepIndex; + approval: ApprovalState; + visibleClarifications: number; + visibleEvidence: number; + visibleVerification: number; + onAdvance: () => void; + onDecision: (decision: ApprovalState) => void; +}) { + if (step === 0) { + return ( +
+
Входящий запрос
+
+
+
Входящий запрос
+

{contract.title}

+

{contract.goal}

+
    + {contract.intakeNotes.map((note) => ( +
  • {note}
  • + ))} +
+
+ +
+
Пакет запроса
+
+
+
Репозиторий
+
{contract.repo}
+
+
+
Контракт
+
{contract.id}
+
+
+
Раздел
+
{contract.scopeSurface}
+
+
+
Политика
+
{projectContext.runtimePolicy}
+
+
+
+ Привязка репозитория уже есть, но это контекст проекта, а не стадия pipeline. +
+
+
+
+ ); + } + + if (step === 1) { + return ( +
+
Уточнения · {contract.clarifications.length} из {contract.clarifications.length}
+
+ {contract.clarifications.map((card, index) => { + const pinned = index < visibleClarifications; + + return ( +
+
+ Q{index + 1} + {card.ref} +
+
{card.question}
+
{card.answer}
+
{card.note}
+
{pinned ? 'Ответ закреплен в контракте' : 'Ожидает закрепления в контракте'}
+
+ ); + })} +
+
+ ); + } + + if (step === 2) { + return ( +
+
Рабочий контракт · черновик v3
+
+
Цель
+

{contract.goal}

+
+ +
+ + + + +
+ +
+
Контекст проекта / политика
+

{contract.policyNote}

+
+ + +
+
+
+ ); + } + + if (step === 3) { + return ( +
+
Задачи
+
+ {contract.workItems.map((item) => ( +
+
+
+
{item.id}
+
{item.title}
+
+
{item.status}
+
+
+
+
Тип
+
{item.lane}
+
+
+
Область / раздел
+
{item.scope}
+
+
+
Статус
+
{item.status}
+
+
+
Обязанность по подтверждению
+
{item.proofObligation}
+
+
+
+ ))} +
+
+ ); + } + + if (step === 4) { + return ( +
+
Артефакты выполнения
+
+ Выполнение прошло вне Goalrail. Goalrail сохраняет синхронизированные артефакты для выбранного контракта, а не чат-лог. +
+
+ {contract.evidence.slice(0, visibleEvidence).map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+
+ ); + } + + if (step === 5) { + return ( +
+
Проверка
+
+ {contract.verification.slice(0, visibleVerification).map((row) => ( +
+
+
{row.criterion}
+
{row.support}
+
+
+
Итог
+
{row.outcome}
+
+
+ ))} +
+
+ ); + } + + if (step === 6) { + return ( +
+
Пакет подтверждения
+
+ + +
+ +
+ ); + } + + const approvalLabel = + approval === 'accepted' + ? 'Принято' + : approval === 'blocked' + ? 'Заблокировано' + : approval === 'rework' + ? 'Нужна доработка' + : 'Ждет решения'; + + return ( +
+
Решение
+
+
Состояние решения
+
{approvalLabel}
+
+ +
+ + + + +
+ +
+
Ручное решение
+
+ + + +
+
+
+ ); +} + +function DeliveryReadinessSurface({ selectedRepo, onSelectRepo }: { selectedRepo: RepoId; onSelectRepo: (repo: RepoId) => void }) { + return ( +
+
+
+
+
Готовность
+
Настройка репозитория и режим работы
+
+
+ Раздел рабочей области + Только демо +
+
+ +
+
+
Готовность · уровень репозитория и проекта
+

+ Этот раздел показывает подключенные репозитории, сигналы готовности, действия настройки и рекомендованный режим работы. Это не + стадия цепочки контракта. +

+
+ +
+ {WORKSPACE_REPOS.map((repo) => { + const context = REPO_CONTEXTS[repo]; + + return ( + + ); + })} + +
+
+
+
Добавить репозиторий
+
Подключить следующий репозиторий
+
+ Действие настройки +
+

Подключить репозиторий, выполнить инициализацию, просканировать контекст и посчитать готовность.

+ +
+
+
+
+
+ ); +} + +function DeliveryReadinessInspector({ selectedRepo }: { selectedRepo: RepoId }) { + const context = REPO_CONTEXTS[selectedRepo]; + + return ( + + ); +} + +function DeliveryReadinessBottomPanel({ selectedRepo }: { selectedRepo: RepoId }) { + const context = REPO_CONTEXTS[selectedRepo]; + + return ( +
+
+
+
Активность готовности
+
Демо-события настройки
+
+
+ {[ + ['09:31:02', 'repo.selected', `${context.repo} выбран для деталей готовности`, 'mauve'], + ['09:31:12', 'context.scan', context.scanStatus, getReadinessTone(context.readiness)], + ['09:31:22', 'mode.recommended', context.recommendedMode, getReadinessTone(context.readiness)], + ].map(([ts, kind, note, tone]) => ( +
+
{ts}
+
+
{formatActivityKind(kind)}
+
{note}
+
+
{tone === 'pass' ? 'готово' : tone === 'block' ? 'настройка' : 'проверка'}
+
+ ))} +
+
+ +
+
+
Управление разделом
+
Нет реальных интеграций
+
+
+ Анализ, инициализация, сканирование контекста и добавление репозитория — демо-действия. Они не вызывают бэкенд и не меняют постоянное состояние. +
+
+ Репозиторий: {context.repo} + Готовность: {context.readiness}/100 +
+
+
+ ); +} + +function ProofFeedSurface({ selectedProofId, onSelectProof }: { selectedProofId: string; onSelectProof: (proofId: string) => void }) { + return ( +
+
+
+
+
Подтверждения
+
Артефакты и решения по контрактам
+
+
+ Все репозитории по умолчанию + Не чат-лог +
+
+ +
+
+
Подтверждения · обзор по контрактам и репозиториям
+

+ Область по умолчанию — все репозитории. Чипы репозиториев остаются статическими демо-контролы, чтобы раздел читался как контроль подтверждений на уровне рабочей области. +

+
+ Все репозитории + trialops-demo + billing-api + frontend-console +
+
+ +
+ {PROOF_FEED.map((item) => ( + + ))} +
+
+
+
+ ); +} + +function ProofFeedInspector({ selectedProof }: { selectedProof: ProofFeedItem }) { + return ( + + ); +} + +function ProofFeedBottomPanel({ selectedProof }: { selectedProof: ProofFeedItem }) { + return ( +
+
+
+
Активность ленты подтверждений
+
Только артефакты и решения
+
+
+ {selectedProof.decisionTrail.map((entry, index) => ( +
+
10:{String(12 + index).padStart(2, '0')}:04
+
+
{formatActivityKind(entry)}
+
{selectedProof.contractId} · {selectedProof.repo}
+
+
{selectedProof.tone === 'block' ? 'блок' : selectedProof.tone === 'pass' ? 'готово' : 'событие'}
+
+ ))} +
+
+ +
+
+
Управление лентой
+
Статическая область
+
+
+ Чипы репозиториев — только демо. Разбор статусов находится слева в очереди подтверждений, поэтому лента остается между контрактами и между репозиториями. +
+
+ Подтверждение: {selectedProof.contractId} + Область по умолчанию: все репозитории +
+
+
+ ); +} + +function DesktopConsole({ liveContracts }: { liveContracts?: DemoContractsLiveData }) { + const hasLiveContracts = Boolean(liveContracts); + const [activeSurface, setActiveSurface] = useState('contracts'); + const [repoFilter, setRepoFilter] = useState(() => (liveContracts ? LIVE_ALL_REPOSITORIES_FILTER : 'trialops-demo')); + const [repoSelectorOpen, setRepoSelectorOpen] = useState(false); + const [contractSearch, setContractSearch] = useState(''); + const [repoSearch, setRepoSearch] = useState(''); + const [proofSearch, setProofSearch] = useState(''); + const [selectedContractId, setSelectedContractId] = useState('C-0147'); + const [selectedReadinessRepo, setSelectedReadinessRepo] = useState('trialops-demo'); + const [selectedProofId, setSelectedProofId] = useState(PROOF_FEED[0].id); + const [contractSteps, setContractSteps] = useState>(INITIAL_STEPS); + const [approvalStates, setApprovalStates] = useState>(INITIAL_APPROVALS); + const [visibleClarifications, setVisibleClarifications] = useState(0); + const [visibleEvidence, setVisibleEvidence] = useState(0); + const [visibleVerification, setVisibleVerification] = useState(0); + + const liveRepoOptions = useMemo(() => makeLiveRepoOptions(liveContracts?.repositoryContext ?? null), [liveContracts?.repositoryContext]); + const liveRepoContexts = useMemo(() => makeLiveRepoContexts(liveContracts?.repositoryContext ?? null), [liveContracts?.repositoryContext]); + const liveContractRecords = useMemo(() => { + if (!liveContracts) { + return []; + } + + return liveContracts.contracts.map((contract) => ( + mapLiveContract(contract, liveContracts.repositoryContext, liveContracts.selectedDraft) + )); + }, [liveContracts]); + const contractSource = hasLiveContracts ? liveContractRecords : CONTRACTS; + const repoOptions = hasLiveContracts ? liveRepoOptions : REPO_OPTIONS; + const selectedLiveContractId = liveContracts?.selectedContract?.id; + const selectedLiveContract = selectedLiveContractId + ? liveContractRecords.find((contract) => contract.id === selectedLiveContractId) + : null; + + const repoScopedContracts = useMemo(() => { + return repoFilter === LIVE_ALL_REPOSITORIES_FILTER + ? contractSource + : contractSource.filter((contract) => (contract.repoFilterValue ?? contract.repo) === repoFilter); + }, [contractSource, repoFilter]); + + const visibleContracts = useMemo(() => { + return repoScopedContracts.filter((contract) => + matchesQuery([contract.id, contract.title, contract.repo, contract.owner, contract.summary, getStatus(contractSteps[contract.id] ?? contract.defaultStep, approvalStates[contract.id] ?? 'pending')], contractSearch), + ); + }, [approvalStates, contractSearch, contractSteps, repoScopedContracts]); + + const visibleRepos = useMemo(() => { + return WORKSPACE_REPOS.filter((repo) => { + const context = REPO_CONTEXTS[repo]; + return matchesQuery([context.repo, context.init, context.scanStatus, context.testsStatus, context.ciStatus, context.ownersRulesStatus, context.recommendedMode], repoSearch); + }); + }, [repoSearch]); + + const visibleProofs = useMemo(() => { + return PROOF_FEED.filter((item) => + matchesQuery([item.contractId, item.repo, item.proofStatus, item.decisionStatus, item.humanApproval, item.criteriaCoverage, item.summary], proofSearch), + ); + }, [proofSearch]); + + useEffect(() => { + if (hasLiveContracts) { + if (selectedLiveContractId) { + setSelectedContractId(selectedLiveContractId); + return; + } + setSelectedContractId(repoScopedContracts[0]?.id ?? EMPTY_LIVE_CONTRACT.id); + return; + } + + if (!repoScopedContracts.some((contract) => contract.id === selectedContractId)) { + const nextContract = repoScopedContracts[0]; + if (nextContract) { + setSelectedContractId(nextContract.id); + return; + } + + setSelectedContractId(CONTRACTS[0].id); + } + }, [hasLiveContracts, repoScopedContracts, selectedContractId, selectedLiveContractId]); + + useEffect(() => { + if (selectedLiveContractId) { + setSelectedContractId(selectedLiveContractId); + } + }, [selectedLiveContractId]); + + useEffect(() => { + if (hasLiveContracts) { + setRepoFilter(liveContracts?.repoBindingFilter ?? LIVE_ALL_REPOSITORIES_FILTER); + } + }, [hasLiveContracts, liveContracts?.repoBindingFilter]); + + const selectedContract = useMemo(() => { + if (selectedLiveContract) { + return selectedLiveContract; + } + return contractSource.find((contract) => contract.id === selectedContractId) + ?? (hasLiveContracts ? EMPTY_LIVE_CONTRACT : CONTRACTS[0]); + }, [contractSource, hasLiveContracts, selectedContractId, selectedLiveContract]); + + const step = contractSteps[selectedContract.id] ?? selectedContract.defaultStep; + const approval = approvalStates[selectedContract.id] ?? 'pending'; + const selectedStatus = getStatus(step, approval); + const projectContext = + (selectedContract.repoFilterValue ? liveRepoContexts[selectedContract.repoFilterValue] : undefined) + ?? REPO_CONTEXTS[selectedContract.repo as RepoId] + ?? Object.values(liveRepoContexts)[0] + ?? REPO_CONTEXTS['trialops-demo']; + const meters = getMeters(step, approval); + const activity = useMemo(() => getActivity(selectedContract, step, approval), [selectedContract, step, approval]); + const selectedProof = useMemo(() => PROOF_FEED.find((item) => item.id === selectedProofId) ?? PROOF_FEED[0], [selectedProofId]); + const averageReadiness = Math.round(WORKSPACE_REPOS.reduce((sum, repo) => sum + REPO_CONTEXTS[repo].readiness, 0) / WORKSPACE_REPOS.length); + const acceptedProofs = PROOF_FEED.filter((item) => item.proofStatus === 'Принято').length; + const blockedProofs = PROOF_FEED.filter((item) => item.proofStatus === 'Заблокировано').length; + const topbarMeters = + activeSurface === 'contracts' + ? [ + { tone: 'amber' as Tone, label: 'Контракт', value: meters.contract.label, percent: meters.contract.percent }, + { tone: 'mauve' as Tone, label: 'Выполнение', value: meters.execution.label, percent: meters.execution.percent }, + { tone: 'pass' as Tone, label: 'Подтверждение', value: meters.proof.label, percent: meters.proof.percent }, + ] + : activeSurface === 'readiness' + ? [ + { tone: 'mauve' as Tone, label: 'Рабочая область', value: `${WORKSPACE_REPOS.length} репозитория`, percent: 100 }, + { tone: 'amber' as Tone, label: 'Готовность', value: `${averageReadiness}/100 среднее`, percent: averageReadiness }, + { tone: 'pass' as Tone, label: 'Настройка', value: 'Только демо-действия', percent: 72 }, + ] + : [ + { tone: 'mauve' as Tone, label: 'Подтверждения', value: 'Все репозитории', percent: 100 }, + { tone: 'amber' as Tone, label: 'Ожидают', value: '2 активных', percent: 50 }, + { tone: 'pass' as Tone, label: 'Решения', value: `${acceptedProofs} принято · ${blockedProofs} блок`, percent: 70 }, + ]; + + useEffect(() => { + if (step === 1) { + setVisibleClarifications(0); + const timers = CLARIFICATION_DELAYS.slice(0, selectedContract.clarifications.length).map((delay, index) => + window.setTimeout(() => { + setVisibleClarifications(index + 1); + }, delay), + ); + + return () => { + timers.forEach((timer) => window.clearTimeout(timer)); + }; + } + + setVisibleClarifications(step > 1 ? selectedContract.clarifications.length : 0); + + return undefined; + }, [selectedContract, step]); + + useEffect(() => { + if (step === 4) { + setVisibleEvidence(0); + const timers = selectedContract.evidence.map((_, index) => + window.setTimeout(() => { + setVisibleEvidence(index + 1); + }, (index + 1) * EVIDENCE_DELAY), + ); + + return () => { + timers.forEach((timer) => window.clearTimeout(timer)); + }; + } + + setVisibleEvidence(step > 4 ? selectedContract.evidence.length : 0); + + return undefined; + }, [selectedContract, step]); + + useEffect(() => { + if (step === 5) { + setVisibleVerification(0); + const timers = selectedContract.verification.map((_, index) => + window.setTimeout(() => { + setVisibleVerification(index + 1); + }, (index + 1) * VERIFICATION_DELAY), + ); + + return () => { + timers.forEach((timer) => window.clearTimeout(timer)); + }; + } + + setVisibleVerification(step > 5 ? selectedContract.verification.length : 0); + + return undefined; + }, [selectedContract, step]); + + const setStepForSelected = (nextStep: StepIndex) => { + setContractSteps((current) => ({ ...current, [selectedContract.id]: nextStep })); + }; + + const goNext = () => { + if (step < 7) { + setStepForSelected((step + 1) as StepIndex); + } + }; + + const goBack = () => { + if (step > 0) { + setStepForSelected((step - 1) as StepIndex); + } + }; + + const resetSelected = () => { + setContractSteps((current) => ({ ...current, [selectedContract.id]: selectedContract.defaultStep })); + setApprovalStates((current) => ({ ...current, [selectedContract.id]: 'pending' })); + }; + + const handleDecision = (decision: ApprovalState) => { + setApprovalStates((current) => ({ ...current, [selectedContract.id]: decision })); + setStepForSelected(7); + }; + + const selectedRepoOption = repoOptions.find((option) => option.value === repoFilter) ?? repoOptions[0]; + + const handleRepoFilterSelect = (nextRepoFilter: RepoFilter) => { + setRepoFilter(nextRepoFilter); + if (hasLiveContracts) { + liveContracts?.onRepoBindingFilterChange(nextRepoFilter); + } + setRepoSelectorOpen(false); + }; + + const primaryActionLabel = + step === 0 + ? 'Начать' + : step === 1 + ? 'К контракту' + : step === 2 + ? 'Зафиксировать контракт' + : step === 3 + ? 'К артефактам' + : step === 4 + ? 'К проверке' + : step === 5 + ? 'К подтверждению' + : step === 6 + ? 'К решению' + : approval === 'accepted' + ? 'Повторить' + : null; + + const activeSurfaceLabel = activeSurface === 'contracts' ? 'Контракты' : activeSurface === 'readiness' ? 'Готовность' : 'Подтверждения'; + const topbarStateLabel = activeSurface === 'contracts' ? 'Статус' : activeSurface === 'readiness' ? 'Репозиторий' : 'Область'; + const topbarStateValue = activeSurface === 'contracts' ? selectedStatus : activeSurface === 'readiness' ? selectedReadinessRepo : 'Все репозитории'; + + return ( +
+
+
+
+ +
+ Goalrail · консоль +
+
+ +
+ {topbarMeters.map((meter) => ( +
+
+
{meter.label}
+
{meter.value}
+
+
+ +
+
+ ))} +
+ +
+
+ Раздел + + {activeSurfaceLabel} + +
+
+ {topbarStateLabel} + + {topbarStateValue} + +
+
+
+ +