Spaces:
Sleeping
Sleeping
| // Copyright (c) 2025-2026, RTE (https://www.rte-france.com) | |
| // This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. | |
| // If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, | |
| // you can obtain one at http://mozilla.org/MPL/2.0/. | |
| // SPDX-License-Identifier: MPL-2.0 | |
| // This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study. | |
| import React, { useState, useEffect, useMemo, useRef } from 'react'; | |
| import { api } from '../api'; | |
| import type { AnalysisResult, CombinedAction, ActionDetail, ActionOverviewFilters } from '../types'; | |
| import { interactionLogger } from '../utils/interactionLogger'; | |
| import { | |
| DEFAULT_ACTION_OVERVIEW_FILTERS, | |
| matchesActionTypeFilter, | |
| resolveRowSeverity, | |
| rowPassesActionFilters, | |
| rowPassesSeverityFilter, | |
| } from '../utils/actionTypes'; | |
| import ComputedPairsTable, { type ComputedPairEntry } from './ComputedPairsTable'; | |
| import ExplorePairsTab from './ExplorePairsTab'; | |
| import ActionFilterRings from './ActionFilterRings'; | |
| import { colors } from '../styles/tokens'; | |
| import { apiErrorMessage } from '../utils/apiError'; | |
| interface SimulationFeedback { | |
| max_rho: number | null; | |
| max_rho_line: string; | |
| is_rho_reduction: boolean; | |
| is_islanded?: boolean; | |
| disconnected_mw?: number; | |
| non_convergence?: string | null; | |
| } | |
| interface Props { | |
| isOpen: boolean; | |
| onClose: () => void; | |
| analysisResult: AnalysisResult | null; | |
| simulatedActions?: Record<string, ActionDetail>; | |
| /** Currently APPLIED contingency (list of element IDs). */ | |
| disconnectedElement: string[]; | |
| // Called when a COMBINED pair is simulated (green "Simulate Combined" | |
| // button). Promotes the new pair into Selected Actions. | |
| onSimulateCombined: (actionId: string, detail: ActionDetail, linesOverloaded: string[]) => void; | |
| // Called when a SINGLE action is (re-)simulated from the Explore Pairs | |
| // table. Updates the action in place — the action stays in its current | |
| // bucket (Suggested / Selected) and is not auto-promoted. | |
| onSimulateSingleAction: (actionId: string, detail: ActionDetail, linesOverloaded: string[]) => void; | |
| monitoringFactor?: number; | |
| linesOverloaded?: string[]; | |
| displayName?: (id: string) => string; | |
| } | |
| /** Canonicalize a combined action ID by sorting the parts alphabetically. */ | |
| function canonicalizeId(id: string): string { | |
| if (!id || !id.includes('+')) return id; | |
| return id.split('+').map(p => p.trim()).sort().join('+'); | |
| } | |
| const CombinedActionsModal: React.FC<Props> = ({ | |
| isOpen, | |
| onClose, | |
| analysisResult, | |
| simulatedActions = {}, | |
| disconnectedElement, | |
| onSimulateCombined, | |
| onSimulateSingleAction, | |
| monitoringFactor = 1.0, | |
| linesOverloaded = [], | |
| displayName = (id: string) => id, | |
| }) => { | |
| const [activeTab, setActiveTab] = useState<'computed' | 'explore'>('computed'); | |
| const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); | |
| const [preview, setPreview] = useState<CombinedAction | null>(null); | |
| const [loading, setLoading] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| const [simulating, setSimulating] = useState(false); | |
| const [simulationFeedback, setSimulationFeedback] = useState<SimulationFeedback | null>(null); | |
| // Per-pair simulation results tracked within this modal session | |
| const [sessionSimResults, setSessionSimResults] = useState<Record<string, SimulationFeedback>>({}); | |
| const lastSelectionRef = useRef<string>(''); | |
| // Shared severity + action-type filter for BOTH modal tabs. Modal- | |
| // local (independent of the sidebar's global overviewFilters) — the | |
| // ActionFilterRings instance lives next to the modal title. | |
| const [filters, setFilters] = useState<ActionOverviewFilters>(DEFAULT_ACTION_OVERVIEW_FILTERS); | |
| // actionId → recommender score-type, so pair constituents and | |
| // scored rows classify by type the same way the sidebar feed does. | |
| const scoreTypeByActionId = useMemo(() => { | |
| const m = new Map<string, string>(); | |
| const scores = analysisResult?.action_scores; | |
| if (!scores) return m; | |
| for (const [type, data] of Object.entries(scores)) { | |
| const s = data?.scores || {}; | |
| for (const actionId of Object.keys(s)) { | |
| if (!m.has(actionId)) m.set(actionId, type); | |
| } | |
| } | |
| return m; | |
| }, [analysisResult]); | |
| // Scored actions for exploration, derived from analysisResult.action_scores | |
| const scoredActionsList = useMemo(() => { | |
| if (!analysisResult?.action_scores) return []; | |
| const list: { actionId: string; score: number; type: string; mwStart: number | null }[] = []; | |
| for (const [type, data] of Object.entries(analysisResult.action_scores)) { | |
| const scores = data?.scores || {}; | |
| const mwStartMap = data?.mw_start; | |
| for (const [actionId, score] of Object.entries(scores)) { | |
| // Filter out estimated-only combined actions from the exploration list | |
| if (actionId.includes('+')) { | |
| const detail = analysisResult.actions?.[actionId]; | |
| if (detail?.is_estimated || !detail?.rho_after || detail.rho_after.length === 0) continue; | |
| } | |
| const mwStart = mwStartMap?.[actionId] ?? null; | |
| list.push({ actionId, score, type, mwStart: mwStart != null ? Number(mwStart) : null }); | |
| } | |
| } | |
| return list.sort((a, b) => { | |
| if (a.type !== b.type) return a.type.localeCompare(b.type); | |
| return b.score - a.score; | |
| }); | |
| }, [analysisResult]); | |
| // Explore-Pairs rows after the shared filter. Severity classifies | |
| // off the SIMULATED max-loading (session or analysis result) when | |
| // available; scored-but-untested rows have no value, so they drop | |
| // out as soon as a severity bucket is deselected. | |
| const filteredScoredActionsList = useMemo(() => { | |
| return scoredActionsList.filter(item => { | |
| const sessionSim = sessionSimResults[item.actionId]; | |
| const analysisSim = analysisResult?.actions?.[item.actionId]; | |
| const simulatedDetail = sessionSim | |
| ?? (analysisSim && !analysisSim.is_estimated && analysisSim.rho_after && analysisSim.rho_after.length > 0 | |
| ? analysisSim | |
| : null); | |
| return rowPassesActionFilters(filters, { | |
| actionId: item.actionId, | |
| scoreType: item.type, | |
| simulatedMaxRho: simulatedDetail?.max_rho ?? null, | |
| estimatedMaxRho: analysisSim?.estimated_max_rho ?? null, | |
| isFault: !!(simulatedDetail?.is_islanded || simulatedDetail?.non_convergence), | |
| }, monitoringFactor); | |
| }); | |
| }, [scoredActionsList, filters, sessionSimResults, analysisResult, monitoringFactor]); | |
| // Pre-computed combined pairs from analysis | |
| const computedPairsList = useMemo(() => { | |
| const combined_actions = analysisResult?.combined_actions || {}; | |
| const combinedEntries = Object.entries(combined_actions); | |
| // Build a set of canonical keys present in combined_actions | |
| const combinedCanonicalKeys = new Set(combinedEntries.map(([id]) => canonicalizeId(id))); | |
| // Also include any simulated pairs in result.actions not in combined_actions | |
| const simulatedOnly = Object.entries(analysisResult?.actions || {}) | |
| .filter(([id]) => id.includes('+') && !combined_actions[id] && !combinedCanonicalKeys.has(canonicalizeId(id))); | |
| const allPairs = [ | |
| ...combinedEntries.map(([id, ca]) => { | |
| const cId = canonicalizeId(id); | |
| const sessionResult = sessionSimResults[id] || sessionSimResults[cId]; | |
| const parentSimData = simulatedActions[id] || simulatedActions[cId]; | |
| const analysisSimData = analysisResult?.actions?.[id] || analysisResult?.actions?.[cId]; | |
| const simData = parentSimData || analysisSimData; | |
| const isRealSim = !!sessionResult || (simData && !simData.is_estimated && simData.rho_after && simData.rho_after.length > 0); | |
| return { id, data: ca, simData: isRealSim ? (sessionResult || simData) : null }; | |
| }), | |
| ...simulatedOnly.map(([id, data]) => ({ id, data: {} as CombinedAction, simData: data })), | |
| ]; | |
| return allPairs | |
| .sort((a, b) => { | |
| const valA = (a.data.estimated_max_rho ?? a.data.max_rho) ?? 999; | |
| const valB = (b.data.estimated_max_rho ?? b.data.max_rho) ?? 999; | |
| return valA - valB; | |
| }) | |
| .map(({ id, data, simData }) => { | |
| const parts = id.split('+'); | |
| const isSimulated = !!simData; | |
| const simMaxRho = (simData as ActionDetail | SimulationFeedback)?.max_rho ?? null; | |
| const simMaxRhoLine = (simData as ActionDetail | SimulationFeedback)?.max_rho_line ?? null; | |
| const estMaxRho = data.estimated_max_rho ?? data.max_rho; | |
| const estMaxRhoLine = data.estimated_max_rho_line ?? data.max_rho_line; | |
| return { | |
| id, | |
| action1: parts[0]?.trim() || 'N/A', | |
| action2: parts[1]?.trim() || 'N/A', | |
| betas: data.betas, | |
| estimated_max_rho: estMaxRho, | |
| estimated_max_rho_line: estMaxRhoLine, | |
| target_max_rho: data.target_max_rho ?? null, | |
| target_max_rho_line: data.target_max_rho_line, | |
| is_suspect: !!data.is_islanded, | |
| isSimulated, | |
| simulated_max_rho: simMaxRho, | |
| simulated_max_rho_line: simMaxRhoLine, | |
| simData: simData | |
| }; | |
| }); | |
| }, [analysisResult, simulatedActions, sessionSimResults]); | |
| // Computed-Pairs rows after the shared filter. A pair matches the | |
| // type ring when EITHER constituent matches; severity classifies | |
| // off the pair's simulated max-loading, falling back to the | |
| // estimated value. | |
| const filteredComputedPairsList = useMemo(() => { | |
| return computedPairsList.filter(p => { | |
| const typeOk = filters.actionType === 'all' | |
| || matchesActionTypeFilter(filters.actionType, p.action1, null, scoreTypeByActionId.get(p.action1) ?? null) | |
| || matchesActionTypeFilter(filters.actionType, p.action2, null, scoreTypeByActionId.get(p.action2) ?? null); | |
| if (!typeOk) return false; | |
| const simData = p.simData as (ActionDetail | SimulationFeedback) | null; | |
| const simulatedMaxRho = p.isSimulated ? p.simulated_max_rho : null; | |
| const estimatedMaxRho = p.estimated_max_rho ?? null; | |
| const severity = resolveRowSeverity({ | |
| simulatedMaxRho, | |
| estimatedMaxRho, | |
| isFault: !!(simData?.is_islanded || simData?.non_convergence) || p.is_suspect, | |
| }, monitoringFactor); | |
| if (!rowPassesSeverityFilter(severity, filters.categories)) return false; | |
| // Max-loading threshold — same precedence as ``rowPassesActionFilters`` | |
| // (simulated first, estimated fallback), so the slider applies | |
| // consistently to unitary cards and combined pairs. | |
| const referenceRho = simulatedMaxRho ?? estimatedMaxRho; | |
| if (referenceRho != null && referenceRho > filters.threshold) return false; | |
| return true; | |
| }); | |
| }, [computedPairsList, filters, scoreTypeByActionId, monitoringFactor]); | |
| // Log modal open/close and cleanup when modal closes | |
| useEffect(() => { | |
| if (isOpen) { | |
| interactionLogger.record('combine_modal_opened'); | |
| } else { | |
| interactionLogger.record('combine_modal_closed'); | |
| setSelectedIds(new Set()); | |
| setPreview(null); | |
| setError(null); | |
| setActiveTab('computed'); | |
| setSimulationFeedback(null); | |
| setSimulating(false); | |
| setSessionSimResults({}); | |
| } | |
| }, [isOpen]); | |
| // Drive the estimation/comparison card from the current selection | |
| // and active tab only. We intentionally do NOT depend on | |
| // analysisResult here: a successful Simulate Combined mutates | |
| // analysisResult.actions through onSimulateCombined, and we must | |
| // keep the preview card visible so the user can read the simulation | |
| // result in place. The card is only reset when the user changes | |
| // their pair selection or leaves the Explore Pairs tab. | |
| useEffect(() => { | |
| const currentSelection = Array.from(selectedIds).sort().join('+'); | |
| lastSelectionRef.current = currentSelection; | |
| if (activeTab === 'explore' && selectedIds.size === 2 && disconnectedElement && disconnectedElement.length > 0) { | |
| const [id1, id2] = Array.from(selectedIds); | |
| const pairKey = [id1, id2].sort().join('+'); | |
| const preComputed = analysisResult?.combined_actions?.[pairKey]; | |
| if (preComputed) { | |
| interactionLogger.record('combine_pair_estimated', { | |
| action1_id: id1, action2_id: id2, | |
| estimated_max_rho: preComputed.estimated_max_rho ?? preComputed.max_rho, | |
| estimated_max_rho_line: preComputed.estimated_max_rho_line ?? preComputed.max_rho_line, | |
| }); | |
| setPreview(preComputed); | |
| setError(null); | |
| } else { | |
| setPreview(null); | |
| setSimulationFeedback(null); | |
| } | |
| } else { | |
| setPreview(null); | |
| setError(null); | |
| setSimulationFeedback(null); | |
| } | |
| // analysisResult is deliberately omitted — see comment above. | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [selectedIds, disconnectedElement, activeTab]); | |
| const handleEstimate = async () => { | |
| if (activeTab !== 'explore' || selectedIds.size !== 2 || !disconnectedElement || disconnectedElement.length === 0) return; | |
| const [id1, id2] = Array.from(selectedIds); | |
| setLoading(true); | |
| setError(null); | |
| try { | |
| const result = await api.computeSuperposition(id1, id2, disconnectedElement); | |
| if (result.error) { | |
| setError(result.error); | |
| setPreview(result); | |
| } else { | |
| interactionLogger.record('combine_pair_estimated', { | |
| action1_id: id1, action2_id: id2, | |
| estimated_max_rho: result.estimated_max_rho ?? result.max_rho, | |
| estimated_max_rho_line: result.estimated_max_rho_line ?? result.max_rho_line, | |
| }); | |
| setPreview(result); | |
| setError(null); | |
| } | |
| } catch (e: unknown) { | |
| const err = e as { response?: { data?: { detail?: string } }, message?: string }; | |
| setError(apiErrorMessage(err, 'Failed to compute superposition')); | |
| setPreview(null); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| if (!isOpen) return null; | |
| // Estimation is available for every action type: the Generalized | |
| // Superposition Theorem (GST) now combines load shedding / curtailment / | |
| // redispatch (injection changes) with topology actions, so no selection is | |
| // restricted from the estimation path anymore. | |
| const hasRestricted = false; | |
| const handleToggle = (id: string) => { | |
| setSimulationFeedback(null); | |
| setError(null); | |
| const newSet = new Set(selectedIds); | |
| if (newSet.has(id)) { | |
| newSet.delete(id); | |
| interactionLogger.record('combine_pair_toggled', { action_id: id, selected: false }); | |
| } else { | |
| if (newSet.size >= 2) return; // Only allow 2 | |
| newSet.add(id); | |
| interactionLogger.record('combine_pair_toggled', { action_id: id, selected: true }); | |
| } | |
| setSelectedIds(newSet); | |
| }; | |
| const handleSimulate = async (actionId?: string, targetMw?: number) => { | |
| const idToSimulate = actionId ? (actionId.includes('+') ? canonicalizeId(actionId) : actionId) : Array.from(selectedIds).sort().join('+'); | |
| if (!idToSimulate || !disconnectedElement || disconnectedElement.length === 0) return; | |
| // Try to find estimation data to preserve it | |
| const estimationData = actionId | |
| ? (analysisResult?.combined_actions?.[idToSimulate] || analysisResult?.combined_actions?.[actionId]) | |
| : preview; | |
| // Build action_content from saved topologies | |
| let actionContent: Record<string, unknown> | null = null; | |
| const parts = idToSimulate.includes('+') ? idToSimulate.split('+') : [idToSimulate]; | |
| const allActions = { ...simulatedActions, ...analysisResult?.actions }; | |
| const perAction: Record<string, unknown> = {}; | |
| for (const part of parts) { | |
| const partDetail = allActions[part]; | |
| if (partDetail?.action_topology) perAction[part] = partDetail.action_topology; | |
| } | |
| if (Object.keys(perAction).length > 0) actionContent = perAction; | |
| setSimulating(true); | |
| if (!actionId || actionId.includes('+')) { | |
| setSimulationFeedback(null); | |
| } | |
| setError(null); | |
| try { | |
| const actualLinesOverloaded = (linesOverloaded && linesOverloaded.length > 0) ? linesOverloaded : null; | |
| // ``targetMw`` is only supplied for single injection-action rows | |
| // (Explore Pairs): the target shed / curtail amount, or the signed | |
| // redispatch delta. Combined-pair simulations leave it undefined. | |
| const result = await api.simulateManualAction(idToSimulate, disconnectedElement, actionContent, actualLinesOverloaded, targetMw ?? null); | |
| const feedback: SimulationFeedback = { | |
| max_rho: result.max_rho, | |
| max_rho_line: result.max_rho_line, | |
| is_rho_reduction: result.is_rho_reduction, | |
| is_islanded: result.is_islanded, | |
| disconnected_mw: result.disconnected_mw, | |
| non_convergence: result.non_convergence, | |
| }; | |
| const simParts = idToSimulate.split('+'); | |
| interactionLogger.record('combine_pair_simulated', { | |
| combined_id: idToSimulate, | |
| action1_id: simParts[0], | |
| action2_id: simParts[1], | |
| simulated_max_rho: result.max_rho, | |
| }); | |
| setSimulationFeedback(feedback); | |
| // Store per-pair result in session map so the computed pairs table | |
| // correctly reflects each pair's own simulation result | |
| setSessionSimResults(prev => ({ ...prev, [idToSimulate]: feedback })); | |
| if (!actionId || actionId.includes('+')) { | |
| setSimulationFeedback(feedback); | |
| } | |
| const detail: ActionDetail = { | |
| description_unitaire: result.description_unitaire, | |
| rho_before: result.rho_before, | |
| rho_after: result.rho_after, | |
| max_rho: result.max_rho, | |
| max_rho_line: result.max_rho_line, | |
| is_rho_reduction: result.is_rho_reduction, | |
| is_islanded: result.is_islanded, | |
| n_components: result.n_components, | |
| disconnected_mw: result.disconnected_mw, | |
| non_convergence: result.non_convergence, | |
| lines_overloaded_after: result.lines_overloaded_after, | |
| load_shedding_details: result.load_shedding_details, | |
| curtailment_details: result.curtailment_details, | |
| redispatch_details: result.redispatch_details, | |
| pst_details: result.pst_details, | |
| estimated_max_rho: estimationData?.estimated_max_rho ?? estimationData?.max_rho, | |
| estimated_max_rho_line: estimationData?.estimated_max_rho_line ?? estimationData?.max_rho_line, | |
| is_estimated: false, | |
| action_topology: result.action_topology | |
| }; | |
| // A "single action" simulation is one triggered from a row in | |
| // the Explore Pairs table (actionId is passed AND does not | |
| // contain '+'). In that case the user is just previewing an | |
| // individual action to compare before combining — it should | |
| // land in Suggested Actions (not Selected). | |
| // | |
| // A combined pair simulation (no actionId, or an id containing | |
| // '+') is an explicit user action to add the pair as a new | |
| // candidate and is promoted into Selected Actions. | |
| // | |
| // In either case we deliberately DO NOT close the modal: the | |
| // updated action card and its action-variant diagram are | |
| // populated in the background so the user can keep interacting | |
| // with the modal (e.g. simulate more rows, compare another | |
| // pair) without losing their place. | |
| const isSingleActionFromExplore = actionId !== undefined && !actionId.includes('+'); | |
| if (isSingleActionFromExplore) { | |
| onSimulateSingleAction(idToSimulate, detail, result.lines_overloaded || []); | |
| } else { | |
| onSimulateCombined(idToSimulate, detail, result.lines_overloaded || []); | |
| } | |
| } catch (e: unknown) { | |
| const err = e as { response?: { data?: { detail?: string } }, message?: string }; | |
| setError(apiErrorMessage(err, 'Simulation failed')); | |
| } finally { | |
| setSimulating(false); | |
| } | |
| }; | |
| return ( | |
| <div style={{ | |
| position: 'fixed', | |
| top: 0, left: 0, right: 0, bottom: 0, | |
| backgroundColor: 'rgba(0,0,0,0.5)', | |
| zIndex: 10000, | |
| // Anchor the modal card to a fixed viewport offset | |
| // (``alignItems: flex-start`` + a top margin set on the | |
| // card) instead of centering it vertically — otherwise the | |
| // title + filter header hops up and down every time the | |
| // Computed / Explore Pairs body changes height (chip | |
| // filter, tab switch, action-type drilldown). | |
| display: 'flex', | |
| alignItems: 'flex-start', | |
| justifyContent: 'center' | |
| }}> | |
| <div | |
| data-testid="combine-modal-card" | |
| style={{ | |
| background: colors.surface, | |
| borderRadius: '12px', | |
| // Use (almost) the full viewport width instead of a | |
| // fixed 950px so wide tables in the Computed / Explore | |
| // Pairs tabs fit without forcing a horizontal | |
| // scrollbar on the modal itself. | |
| width: '95vw', | |
| maxWidth: '95vw', | |
| marginTop: '7.5vh', | |
| maxHeight: '85vh', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| boxShadow: '0 10px 25px rgba(0,0,0,0.25)', | |
| overflow: 'hidden' | |
| }} | |
| > | |
| <div style={{ padding: '15px 24px', borderBottom: `1px solid ${colors.borderSubtle}`, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px', background: colors.surfaceRaised }}> | |
| {/* Shared severity + action-type filter sits next to | |
| the title so both tabs (Computed / Explore Pairs) | |
| are driven by one control. */} | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '16px', flexWrap: 'wrap' }}> | |
| <h2 style={{ margin: 0, fontSize: '1.25rem' }}>Combine Actions</h2> | |
| <ActionFilterRings filters={filters} onFiltersChange={setFilters} /> | |
| </div> | |
| <button onClick={onClose} style={{ border: 'none', background: 'none', fontSize: '24px', cursor: 'pointer', color: colors.textTertiary }}>×</button> | |
| </div> | |
| <div style={{ display: 'flex', borderBottom: `1px solid ${colors.border}`, background: colors.surfaceRaised, padding: '0 24px' }}> | |
| <div className={`modal-tab ${activeTab === 'computed' ? 'active' : ''}`} onClick={() => setActiveTab('computed')} data-testid="tab-computed">Computed Pairs</div> | |
| <div className={`modal-tab ${activeTab === 'explore' ? 'active' : ''}`} onClick={() => setActiveTab('explore')} data-testid="tab-explore">Explore Pairs</div> | |
| </div> | |
| <div | |
| data-testid="combine-modal-body" | |
| style={{ | |
| padding: '20px 24px', | |
| overflowY: 'auto', | |
| // Tables inside the modal manage their own scroll; | |
| // never let horizontal overflow escape to the | |
| // modal level. | |
| overflowX: 'hidden', | |
| flex: 1, | |
| display: 'flex', | |
| flexDirection: 'column', | |
| minWidth: 0, | |
| }} | |
| > | |
| {activeTab === 'computed' ? ( | |
| <ComputedPairsTable | |
| computedPairsList={filteredComputedPairsList as ComputedPairEntry[]} | |
| monitoringFactor={monitoringFactor} | |
| simulating={simulating} | |
| onSimulate={handleSimulate} | |
| displayName={displayName} | |
| /> | |
| ) : ( | |
| <ExplorePairsTab | |
| scoredActionsList={filteredScoredActionsList} | |
| selectedIds={selectedIds} | |
| onToggle={handleToggle} | |
| onClearSelection={() => setSelectedIds(new Set())} | |
| preview={preview} | |
| simulationFeedback={simulationFeedback} | |
| sessionSimResults={sessionSimResults} | |
| analysisResult={analysisResult} | |
| loading={loading} | |
| error={error} | |
| simulating={simulating} | |
| hasRestricted={hasRestricted} | |
| monitoringFactor={monitoringFactor} | |
| onEstimate={handleEstimate} | |
| onSimulate={() => handleSimulate()} | |
| onSimulateSingle={handleSimulate} | |
| displayName={displayName} | |
| /> | |
| )} | |
| </div> | |
| <div style={{ padding: '16px 24px', borderTop: `1px solid ${colors.borderSubtle}`, display: 'flex', justifyContent: 'flex-end', gap: '12px', background: colors.surfaceRaised }}> | |
| <button onClick={onClose} style={{ padding: '10px 20px', background: colors.surface, border: `1px solid ${colors.border}`, borderRadius: '6px', cursor: 'pointer', fontWeight: 500, color: colors.textTertiary }}>Close</button> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default CombinedActionsModal; | |