diff --git a/front/public/locales/en/translation.json b/front/public/locales/en/translation.json index d66177fe068..227a25f1086 100644 --- a/front/public/locales/en/translation.json +++ b/front/public/locales/en/translation.json @@ -842,6 +842,8 @@ "timeFromPreviousOp": "time from above waypoint", "totalTravelTime": "total travel time", "trackName": "track", - "waypoint": "Waypoint {{id}}" + "waypoint": "Waypoint {{id}}", + "powerRestrictionIncompatibility_one": "{{count}} power restriction code isn't compatible with the electrification.", + "powerRestrictionIncompatibility_other": "{{count}} power restriction codes aren't compatible with the electrification." } } diff --git a/front/public/locales/fr/translation.json b/front/public/locales/fr/translation.json index 80de8e1d5c8..6cd77164dea 100644 --- a/front/public/locales/fr/translation.json +++ b/front/public/locales/fr/translation.json @@ -842,6 +842,8 @@ "timeFromPreviousOp": "temps depuis le point précédent", "totalTravelTime": "temps total du trajet", "trackName": "voie", - "waypoint": "Via {{id}}" + "waypoint": "Via {{id}}", + "powerRestrictionIncompatibility_one": "{{count}} code de restriction de puissance n'est pas compatible avec l'électrification.", + "powerRestrictionIncompatibility_other": "{{count}} codes de restriction de puissance ne sont pas compatibles avec l'électrification." } } diff --git a/front/src/applications/operationalStudies/views/Scenario/components/SimulationResults/SimulationResults.tsx b/front/src/applications/operationalStudies/views/Scenario/components/SimulationResults/SimulationResults.tsx index 58cbc5e6be4..e9c618a8aaa 100644 --- a/front/src/applications/operationalStudies/views/Scenario/components/SimulationResults/SimulationResults.tsx +++ b/front/src/applications/operationalStudies/views/Scenario/components/SimulationResults/SimulationResults.tsx @@ -373,6 +373,8 @@ const SimulationResults = ({ upsertTimetableItems={upsertTimetableItems} isSimulationDataLoading={isSimulationDataLoading} operationalPointsOnPath={simulationResults.pathProperties?.operationalPoints} + voltages={simulationResults.pathProperties?.voltages} + rollingStock={simulationResults.rollingStock} {...(simulationResults?.isValid && simulationSummary?.isValid && { isValid: true, diff --git a/front/src/modules/timesStops/TimesStopsOutput.tsx b/front/src/modules/timesStops/TimesStopsOutput.tsx index 000939342ca..0e94de5cac6 100644 --- a/front/src/modules/timesStops/TimesStopsOutput.tsx +++ b/front/src/modules/timesStops/TimesStopsOutput.tsx @@ -4,18 +4,26 @@ import cx from 'classnames'; import { useSelector } from 'react-redux'; import type { PathPropertiesFormatted } from 'applications/operationalStudies/types'; +import { + getPowerRestrictionsWarnings, + countWarnings, +} from 'applications/operationalStudies/views/Scenario/components/ManageTimetableItem/PowerRestrictionsSelector/helpers/powerRestrictionWarnings'; import type { CorePathfindingResultSuccess, ReceptionSignal, + RollingStock, SimulationResponseSuccess, } from 'common/api/osrdEditoastApi'; +import { matchPathStepAndOp } from 'modules/pathfinding/utils'; +import { NO_POWER_RESTRICTION } from 'modules/powerRestriction/consts'; import type { SimulationSummary, TimetableItemWithDetails } from 'modules/timetableItem/types'; import type { TimetableItem, Train } from 'reducers/osrdconf/types'; import { getUseNewTimesStopsTable } from 'reducers/user/userSelectors'; import { formatLocalTime } from 'utils/date'; import { Duration } from 'utils/duration'; -import { computeOptimisticSchedule } from './helpers/cellUpdate'; +import { buildPowerRestrictionsFromRows, computeOptimisticRow } from './helpers/cellUpdate'; +import { buildOpMatchParams } from './helpers/utils'; import useOutputTableData from './hooks/useOutputTableData'; import useTimesStopsTableData from './hooks/useTimesStopsTableData'; import useUpdateTimesStopsTable from './hooks/useUpdateTimesStopsTable'; @@ -34,7 +42,9 @@ type TimesStopsOutputProps = { simulatedPathItemTimes?: Extract['pathItemTimes']; simulatedPathItemRespect?: Extract['pathItemRespect']; operationalPointsOnPath?: PathPropertiesFormatted['operationalPoints']; + voltages?: PathPropertiesFormatted['voltages']; isSimulationDataLoading?: boolean; + rollingStock?: RollingStock; }; const TimesStopsOutput = ({ @@ -47,7 +57,9 @@ const TimesStopsOutput = ({ simulatedPathItemTimes, simulatedPathItemRespect, operationalPointsOnPath, + voltages, isSimulationDataLoading = false, + rollingStock, }: TimesStopsOutputProps) => { const useNewTimesStopsTable = useSelector(getUseNewTimesStopsTable); @@ -93,12 +105,15 @@ const TimesStopsOutput = ({ ? pinnedState.edit : null; + // The single source of truth for what the table displays. Any derived data fed to + // TimesStopsTable (warnings, styling, etc.) should be computed from optimisticRows, + // not from selectedTrain, to stay in sync with the displayed values during edits. const optimisticRows = useMemo( () => optimisticEdit ? newRows.map((row) => row.id === optimisticEdit.rowId - ? { ...row, ...computeOptimisticSchedule(row, optimisticEdit) } + ? { ...row, ...computeOptimisticRow(row, optimisticEdit) } : row ) : newRows, @@ -107,14 +122,76 @@ const TimesStopsOutput = ({ const startTime = useMemo(() => new Date(selectedTrain.start_time), [selectedTrain.start_time]); - const { updateArrival, updateStopDuration, updateDeparture, updateReceptionSignal } = - useUpdateTimesStopsTable( - selectedTrain, - newRows, - timetableItemsWithDetails, - upsertTimetableItems + const availablePowerRestrictions = useMemo( + () => Object.keys(rollingStock?.power_restrictions ?? {}), + [rollingStock] + ); + + const { powerRestrictionWarningCount, incompatiblePowerRestrictionIds } = useMemo(() => { + const empty = { + powerRestrictionWarningCount: 0, + incompatiblePowerRestrictionIds: new Set(), + }; + // Built from optimisticRows (not selectedTrain.power_restrictions) so warnings + // update immediately when the user edits a cell, before the API round-trip completes. + const powerRestrictions = buildPowerRestrictionsFromRows(optimisticRows); + if (!voltages?.length || !rollingStock || !powerRestrictions.length) return empty; + + const pathStepPositions = new Map(); + selectedTrain.path.forEach((pathStep) => { + const matchingOp = operationalPointsOnPath?.find((op) => + matchPathStepAndOp(pathStep.location, buildOpMatchParams(op)) + ); + if (matchingOp) pathStepPositions.set(pathStep.id, matchingOp.position); + }); + + const rangesWithId = powerRestrictions.flatMap((pr) => { + if (pr.value === NO_POWER_RESTRICTION) return []; + const begin = pathStepPositions.get(pr.from); + const end = pathStepPositions.get(pr.to); + if (begin === undefined || end === undefined) return []; + return [{ begin, end, value: pr.value, fromId: pr.from }]; + }); + + if (!rangesWithId.length) return empty; + + const warnings = getPowerRestrictionsWarnings( + rangesWithId, + voltages, + rollingStock.effort_curves.modes ); + const warningRanges = [ + ...warnings.invalidCombinationWarnings, + ...warnings.modeNotSupportedWarnings, + ...warnings.missingPowerRestrictionWarnings, + ]; + + const incompatibleIds = new Set( + rangesWithId + .filter((pr) => warningRanges.some((w) => w.end > pr.begin && w.begin < pr.end)) + .map((pr) => pr.fromId) + ); + + return { + powerRestrictionWarningCount: countWarnings(warnings), + incompatiblePowerRestrictionIds: incompatibleIds, + }; + }, [optimisticRows, voltages, selectedTrain.path, operationalPointsOnPath, rollingStock]); + + const { + updateArrival, + updateStopDuration, + updateDeparture, + updateReceptionSignal, + updatePowerRestrictions, + } = useUpdateTimesStopsTable( + selectedTrain, + newRows, + timetableItemsWithDetails, + upsertTimetableItems + ); + // True if we are still waiting for fresh simulation data after a user edit. // Both conditions must be false before we clear the loading state: // - Condition 1 (batch summary): simulatedPathItemTimes must get a new reference. @@ -176,6 +253,11 @@ const TimesStopsOutput = ({ ); }; + const handlePowerRestrictionChange = (row: TimesStopsRowNew, value: string | null) => + commitEdit({ rowId: row.id, field: 'powerRestriction', value }, () => + updatePowerRestrictions(row, value) + ); + if (useNewTimesStopsTable) { return ( ); } diff --git a/front/src/modules/timesStops/TimesStopsTable.tsx b/front/src/modules/timesStops/TimesStopsTable.tsx index 54aa0c76d70..8a67e311b48 100644 --- a/front/src/modules/timesStops/TimesStopsTable.tsx +++ b/front/src/modules/timesStops/TimesStopsTable.tsx @@ -1,7 +1,7 @@ import { useCallback, Fragment, useMemo, useRef } from 'react'; import { Checkbox } from '@osrd-project/ui-core'; -import { Moon } from '@osrd-project/ui-icons'; +import { Alert, Moon, TriangleDown } from '@osrd-project/ui-icons'; import { createColumnHelper, flexRender, @@ -14,6 +14,7 @@ import cx from 'classnames'; import { useTranslation } from 'react-i18next'; import type { ReceptionSignal } from 'common/api/osrdEditoastApi'; +import { NO_POWER_RESTRICTION } from 'modules/powerRestriction/consts'; import { formatLocalTime, useDateTimeLocale } from 'utils/date'; import { calculateTimeDifferenceInDays } from 'utils/timeManipulation'; @@ -33,10 +34,14 @@ declare module '@tanstack/react-table' { interface TableMeta { allRows: TimesStopsRowNew[]; isComputedDataPending?: boolean; + availablePowerRestrictions: string[]; + powerRestrictionWarningCount: number; + incompatiblePowerRestrictionIds: Set; onArrivalChange: (row: TimesStopsRowNew, arrival: Date | null) => void; onStopDurationChange: (row: TimesStopsRowNew, durationSeconds: number | null) => void; onDepartureChange: (row: TimesStopsRowNew, departure: Date | null) => void; onReceptionSignalChange: (row: TimesStopsRowNew, signal: ReceptionSignal | undefined) => void; + onPowerRestrictionChange: (row: TimesStopsRowNew, value: string | null) => void; } } @@ -80,10 +85,14 @@ type TimesStopsTableProps = { startTime: Date; isValid: boolean; isComputedDataPending?: boolean; + availablePowerRestrictions: string[]; + powerRestrictionWarningCount?: number; + incompatiblePowerRestrictionIds?: Set; onArrivalChange: (row: TimesStopsRowNew, arrival: Date | null) => void; onStopDurationChange: (row: TimesStopsRowNew, durationSeconds: number | null) => void; onDepartureChange: (row: TimesStopsRowNew, departure: Date | null) => void; onReceptionSignalChange: (row: TimesStopsRowNew, signal: ReceptionSignal | undefined) => void; + onPowerRestrictionChange: (row: TimesStopsRowNew, value: string | null) => void; }; const columnHelper = createColumnHelper(); @@ -100,10 +109,14 @@ const TimesStopsTable = ({ startTime, isValid, isComputedDataPending, + availablePowerRestrictions, + powerRestrictionWarningCount = 0, + incompatiblePowerRestrictionIds, onArrivalChange, onStopDurationChange, onDepartureChange, onReceptionSignalChange, + onPowerRestrictionChange, }: TimesStopsTableProps) => { const { t } = useTranslation('translation', { keyPrefix: 'timeStopTable' }); const dateTimeLocale = useDateTimeLocale(); @@ -375,6 +388,34 @@ const TimesStopsTable = ({ }), columnHelper.accessor('powerRestriction', { header: () => t('powerRestriction'), + cell: (info) => { + const { + availablePowerRestrictions: codes, + onPowerRestrictionChange: onRestrictionChange, + } = info.table.options.meta!; + const value = info.getValue(); + const row = info.row.original; + return ( +
+ + +
+ ); + }, meta: { className: 'col-power-restriction', }, @@ -427,10 +468,14 @@ const TimesStopsTable = ({ meta: { allRows: rows, isComputedDataPending, + availablePowerRestrictions, + powerRestrictionWarningCount, + incompatiblePowerRestrictionIds: incompatiblePowerRestrictionIds ?? new Set(), onArrivalChange, onStopDurationChange, onDepartureChange, onReceptionSignalChange, + onPowerRestrictionChange, }, }); @@ -470,6 +515,14 @@ const TimesStopsTable = ({
+ {powerRestrictionWarningCount > 0 && ( +
+ + + {t('powerRestrictionIncompatibility', { count: powerRestrictionWarningCount })} + +
+ )} {table.getHeaderGroups().map((headerGroup) => ( @@ -523,7 +576,14 @@ const TimesStopsTable = ({ })} > {row.getVisibleCells().map((cell) => ( - ))} diff --git a/front/src/modules/timesStops/helpers/cellUpdate.ts b/front/src/modules/timesStops/helpers/cellUpdate.ts index 66c2202bc70..9bb18f93a62 100644 --- a/front/src/modules/timesStops/helpers/cellUpdate.ts +++ b/front/src/modules/timesStops/helpers/cellUpdate.ts @@ -1,6 +1,11 @@ import { v4 as uuidV4 } from 'uuid'; -import type { TrainSchedule, PathItem, ScheduleItem } from 'common/api/osrdEditoastApi'; +import type { + TrainSchedule, + PathItem, + PowerRestrictionItem, + ScheduleItem, +} from 'common/api/osrdEditoastApi'; import type { Train } from 'reducers/osrdconf/types'; import { addElementAtIndex } from 'utils/array'; import { Duration } from 'utils/duration'; @@ -69,7 +74,7 @@ export type ScheduleState = { */ export const applyScheduleEdit = ( current: ScheduleState, - edit: OptimisticEdit + edit: Exclude ): ScheduleState & { departure: Date | null } => { const { arrival, stop } = current; @@ -115,7 +120,7 @@ export const applyScheduleEdit = ( } case 'receptionSignal': { - // receptionSignal doesn't affect arrival/stop/departure values + // TODO: receptionSignal doesn't affect arrival/stop/departure values return { arrival, stop, @@ -165,15 +170,30 @@ export const insertScheduleItemInOrder = ( }; /** - * Compute the optimistic display values for all schedule fields when a cell is edited. + * Compute the optimistic display values for all row fields when a cell is edited. */ -export const computeOptimisticSchedule = ( +export const computeOptimisticRow = ( row: TimesStopsRowNew, edit: OptimisticEdit ): Pick< TimesStopsRowNew, - 'requestedArrival' | 'stopDuration' | 'requestedDeparture' | 'closedSignal' | 'shortSlipDistance' + | 'requestedArrival' + | 'stopDuration' + | 'requestedDeparture' + | 'closedSignal' + | 'shortSlipDistance' + | 'powerRestriction' > => { + if (edit.field === 'powerRestriction') { + return { + requestedArrival: row.requestedArrival, + stopDuration: row.stopDuration, + requestedDeparture: row.requestedDeparture, + closedSignal: row.closedSignal, + shortSlipDistance: row.shortSlipDistance, + powerRestriction: edit.value, + }; + } const { arrival, stop, departure } = applyScheduleEdit( { arrival: row.requestedArrival, stop: row.stopDuration }, edit @@ -187,16 +207,58 @@ export const computeOptimisticSchedule = ( stopDuration: stop, requestedDeparture: departure, ...checkboxes, + powerRestriction: row.powerRestriction, }; }; +/** + * Rebuilds the power_restrictions API array from path step rows. + * Each non-null powerRestriction on a path step row marks the start of a range. + * The range extends until the next path step with an explicit value (or the last path step). + * + * @example + * // rows: [{ id: 'A', powerRestriction: 'C1' }, { id: 'B', powerRestriction: null }, { id: 'C', powerRestriction: '∅' }, { id: 'D', powerRestriction: null }] + * // → [{ from: 'A', to: 'C', value: 'C1' }, { from: 'C', to: 'D', value: '∅' }] + * + * // rows: [{ id: 'A', powerRestriction: null }, { id: 'B', powerRestriction: 'C2' }, { id: 'C', powerRestriction: null }] + * // → [{ from: 'B', to: 'C', value: 'C2' }] + */ +export const buildPowerRestrictionsFromRows = ( + rows: TimesStopsRowNew[] +): PowerRestrictionItem[] => { + const pathStepRows = rows.filter((r) => r.isPathStep); + const result: PowerRestrictionItem[] = []; + + for (let i = 0; i < pathStepRows.length - 1; i++) { + const code = pathStepRows[i].powerRestriction; + if (!code) continue; + + const from = pathStepRows[i].id; + let j = i + 1; + while (j < pathStepRows.length - 1 && !pathStepRows[j].powerRestriction) { + j++; + } + const to = pathStepRows[j].id; + result.push({ from, to, value: code }); + } + + return result; +}; + /** Build a TrainSchedule object from a Train with updated path and schedule. */ -export const buildUpdatedOccurrence = ( - selectedTrain: Train, - updatedPath: PathItem[], - updatedSchedule: ScheduleItem[], - trainName: string -): TrainSchedule => ({ +export const buildUpdatedOccurrence = ({ + selectedTrain, + updatedPath, + updatedSchedule, + trainName, + powerRestrictions, +}: { + selectedTrain: Train; + updatedPath: PathItem[]; + updatedSchedule: ScheduleItem[]; + trainName: string; + powerRestrictions?: PowerRestrictionItem[]; +}): TrainSchedule => ({ category: selectedTrain.category, comfort: selectedTrain.comfort, constraint_distribution: selectedTrain.constraint_distribution, @@ -205,7 +267,7 @@ export const buildUpdatedOccurrence = ( margins: selectedTrain.margins, options: selectedTrain.options, path: updatedPath, - power_restrictions: selectedTrain.power_restrictions, + power_restrictions: powerRestrictions ?? selectedTrain.power_restrictions, rolling_stock_name: selectedTrain.rolling_stock_name, schedule: updatedSchedule, speed_limit_tag: selectedTrain.speed_limit_tag, diff --git a/front/src/modules/timesStops/hooks/useTimesStopsTableData.ts b/front/src/modules/timesStops/hooks/useTimesStopsTableData.ts index 3d53f1ae4bc..a1380486e07 100644 --- a/front/src/modules/timesStops/hooks/useTimesStopsTableData.ts +++ b/front/src/modules/timesStops/hooks/useTimesStopsTableData.ts @@ -9,6 +9,7 @@ import { useScenarioContext } from 'applications/operationalStudies/hooks/useSce import type { PathPropertiesFormatted } from 'applications/operationalStudies/types'; import type { PathItemLocation, + PowerRestrictionItem, SimulationResponseSuccess, TrackSection, ScheduleItem, @@ -28,6 +29,23 @@ import { } from '../helpers/utils'; import { type StepStatus, type TimesStopsRowNew } from '../types'; +/** + * Returns the power restriction code that explicitly STARTS at a given path step index, + * or null if no restriction starts here (even if one is active from a previous step). + */ +const getPowerRestrictionForPathStep = ( + stepIndex: number, + pathIdToIndex: Map, + powerRestrictions: PowerRestrictionItem[] | undefined +): string | null => { + if (!powerRestrictions) return null; + for (const restriction of powerRestrictions) { + const fromIndex = pathIdToIndex.get(restriction.from); + if (fromIndex === stepIndex) return restriction.value; + } + return null; +}; + type BuildTableRowParams = { id: string; opOnPathIndex: number; @@ -41,6 +59,7 @@ type BuildTableRowParams = { invalidPathStep?: boolean; scheduleNotHonored?: boolean; marginNotHonored?: boolean; + powerRestriction?: string | null; location: PathItemLocation; isPathStep: boolean; shortSlipDistance?: boolean; @@ -60,6 +79,7 @@ const buildTableRow = ({ invalidPathStep, scheduleNotHonored, marginNotHonored, + powerRestriction = null, location, isPathStep, shortSlipDistance, @@ -123,7 +143,7 @@ const buildTableRow = ({ location, closedSignal, shortSlipDistance, - powerRestriction: null, // TODO : Implementation to be done with the integration of the new columns + powerRestriction, requestedTheoreticalMargin: null, // TODO : Idem isTheoreticalMarginBoundary: false, // TODO : Idem computedTheoreticalMarginSeconds: null, // TODO : Idem @@ -219,6 +239,7 @@ const useTimesStopsTableData = ( const rows = useMemo(() => { const startDate = new Date(selectedTrain.start_time); const scheduleByAt = keyBy(selectedTrain.schedule, 'at'); + const pathIdToIndex = new Map(selectedTrain.path.map((step, idx) => [step.id, idx])); const pathStepRowsById = new Map( selectedTrain.path.map((pathStep, stepIndex) => { @@ -260,6 +281,12 @@ const useTimesStopsTableData = ( schedule?.reception_signal ); + const powerRestriction = getPowerRestrictionForPathStep( + stepIndex, + pathIdToIndex, + selectedTrain.power_restrictions + ); + const row = buildTableRow({ id: pathStep.id, // opOnPathIndex is a placeholder here (-1), it will be replaced by opIndex when matching with operationalPointsOnPath @@ -274,6 +301,7 @@ const useTimesStopsTableData = ( invalidPathStep: !matchingOp, scheduleNotHonored, marginNotHonored, + powerRestriction, location: pathStep.location, isPathStep: true, shortSlipDistance, diff --git a/front/src/modules/timesStops/hooks/useUpdateTimesStopsTable.ts b/front/src/modules/timesStops/hooks/useUpdateTimesStopsTable.ts index d9c65a7a213..684977e9c43 100644 --- a/front/src/modules/timesStops/hooks/useUpdateTimesStopsTable.ts +++ b/front/src/modules/timesStops/hooks/useUpdateTimesStopsTable.ts @@ -30,9 +30,15 @@ import { applyScheduleEdit, scheduleStateToApiFields, buildUpdatedOccurrence, + buildPowerRestrictionsFromRows, insertScheduleItemInOrder, } from '../helpers/cellUpdate'; -import type { CellUpdate, OptimisticEdit, TimesStopsRowNew } from '../types'; +import type { + CellUpdate, + OptimisticEdit, + PowerRestrictionUpdate, + TimesStopsRowNew, +} from '../types'; /** * Hook that provides a callback to update times/stops cell values. @@ -55,12 +61,20 @@ const useUpdateTimesStopsTable = ( ) => { const [updateTrainSchedule] = osrdEditoastApi.endpoints.putTrainSchedulesById.useMutation(); + const persistTrain = async (train: TimetableItem) => { + await updateTrainSchedule({ + id: train.id, + trainSchedule: train, + }).unwrap(); + upsertTimetableItems([train]); + }; + /** * Compute the updated path and schedule based on the cell update. */ const computeUpdatedPathAndSchedule = useCallback( ( - update: CellUpdate + update: Exclude ): { updatedPath: PathItem[]; updatedSchedule: ScheduleItem[] } | undefined => { const { pathStepId, updatedPath } = upsertPathStep(update.row, selectedTrain.path, allRows); const currentSchedule = selectedTrain.schedule ?? []; @@ -79,7 +93,7 @@ const useUpdateTimesStopsTable = ( } // Convert CellUpdate to OptimisticEdit (stopDuration: number → Duration) - let edit: OptimisticEdit; + let edit: Exclude; if (update.field === 'stopDuration') { edit = { field: 'stopDuration', @@ -159,29 +173,43 @@ const useUpdateTimesStopsTable = ( // Build updated occurrence based on update type let updatedOccurrence: TrainSchedule; - if (update.field === 'requestedArrival' && update.row.opOnPathIndex === 0) { + if (update.field === 'powerRestriction') { + const { pathStepId, updatedPath } = upsertPathStep(update.row, selectedTrain.path, allRows); + const modifiedRows = allRows.map((r) => + r.id === update.row.id + ? { ...r, id: pathStepId, isPathStep: true, powerRestriction: update.value } + : r + ); + updatedOccurrence = buildUpdatedOccurrence({ + selectedTrain, + updatedPath, + updatedSchedule: selectedTrain.schedule ?? [], + trainName: occurrenceTrainName, + powerRestrictions: buildPowerRestrictionsFromRows(modifiedRows), + }); + } else if (update.field === 'requestedArrival' && update.row.opOnPathIndex === 0) { if (!update.value) { console.error('Cannot clear start time on the origin'); return; } updatedOccurrence = { - ...buildUpdatedOccurrence( + ...buildUpdatedOccurrence({ selectedTrain, - selectedTrain.path, - selectedTrain.schedule ?? [], - occurrenceTrainName - ), + updatedPath: selectedTrain.path, + updatedSchedule: selectedTrain.schedule ?? [], + trainName: occurrenceTrainName, + }), start_time: update.value.toISOString(), }; } else { const result = computeUpdatedPathAndSchedule(update); if (!result) return; - updatedOccurrence = buildUpdatedOccurrence( + updatedOccurrence = buildUpdatedOccurrence({ selectedTrain, - result.updatedPath, - result.updatedSchedule, - occurrenceTrainName - ); + updatedPath: result.updatedPath, + updatedSchedule: result.updatedSchedule, + trainName: occurrenceTrainName, + }); } const updatedPacedTrain = buildPacedTrainWithUpdatedException( @@ -190,11 +218,7 @@ const useUpdateTimesStopsTable = ( occurrenceId ); - await updateTrainSchedule({ - id: pacedTrainId, - trainSchedule: updatedPacedTrain, - }).unwrap(); - upsertTimetableItems([{ ...updatedPacedTrain, id: pacedTrainId }]); + await persistTrain({ ...updatedPacedTrain, id: pacedTrainId }); }, [selectedTrain, timetableItemsWithDetails, computeUpdatedPathAndSchedule] ); @@ -209,37 +233,37 @@ const useUpdateTimesStopsTable = ( // Handle first row if (update.field === 'requestedArrival' && update.row.opOnPathIndex === 0) { if (!update.value) return; - - const train: TimetableItem = { + return persistTrain({ ...selectedTrain, id: editoastId, start_time: update.value.toISOString(), - }; + }); + } - await updateTrainSchedule({ + if (update.field === 'powerRestriction') { + const { pathStepId, updatedPath } = upsertPathStep(update.row, selectedTrain.path, allRows); + const modifiedRows = allRows.map((r) => + r.id === update.row.id + ? { ...r, id: pathStepId, isPathStep: true, powerRestriction: update.value } + : r + ); + return persistTrain({ + ...selectedTrain, id: editoastId, - trainSchedule: train, - }).unwrap(); - upsertTimetableItems([train]); - return; + path: updatedPath, + power_restrictions: buildPowerRestrictionsFromRows(modifiedRows), + }); } const result = computeUpdatedPathAndSchedule(update); if (!result) return; - const { updatedPath, updatedSchedule } = result; - const train: TimetableItem = { + return persistTrain({ ...selectedTrain, - id: extractEditoastIdFromPacedTrainId(trainId), - path: updatedPath, - schedule: updatedSchedule, - }; - - await updateTrainSchedule({ id: editoastId, - trainSchedule: train, - }).unwrap(); - upsertTimetableItems([train]); + path: result.updatedPath, + schedule: result.updatedSchedule, + }); }, [selectedTrain, computeUpdatedPathAndSchedule] ); @@ -288,11 +312,18 @@ const useUpdateTimesStopsTable = ( [updateCell] ); + const updatePowerRestrictions = useCallback( + (row: TimesStopsRowNew, value: string | null) => + updateCell({ row, field: 'powerRestriction', value }), + [updateCell] + ); + return { updateArrival, updateStopDuration, updateDeparture, updateReceptionSignal, + updatePowerRestrictions, }; }; diff --git a/front/src/modules/timesStops/styles/_timesStopsTable.scss b/front/src/modules/timesStops/styles/_timesStopsTable.scss index 9e4526a7692..e6540a92ec7 100644 --- a/front/src/modules/timesStops/styles/_timesStopsTable.scss +++ b/front/src/modules/timesStops/styles/_timesStopsTable.scss @@ -8,6 +8,23 @@ pointer-events: none; } + .power-restriction-warning { + display: flex; + align-items: center; + gap: 8px; + height: 47px; + padding-inline: 16px; + background: var(--warning5); + color: var(--warning60); + font-size: 0.875rem; + border-bottom: 1px solid var(--black25); + + svg { + flex-shrink: 0; + color: var(--warning30); + } + } + .table-container { position: relative; thead { @@ -196,6 +213,54 @@ max-width: 36px; } + td.col-power-restriction { + min-width: 94px; + padding-inline: 0; + + .power-restriction-select-wrapper { + position: relative; + display: flex; + align-items: center; + width: 100%; + height: 100%; + + select { + appearance: none; + background: transparent; + border: 0; + border-radius: 0; + width: 100%; + padding: 0 22px 0 12px; + font-size: 0.875rem; + color: var(--primary60); + + option { + color: initial; + } + } + + .power-restriction-arrow { + position: absolute; + right: 5px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: var(--black25); + } + + } + + &.power-restriction-incompatible { + background: var(--warning5); + box-shadow: inset 0 0 0 0.5px var(--warning30), 0 0 0 0.5px var(--warning30); + + select { + color: var(--warning60); + } + } + } + + /* comfortable padding */ @media (min-width: 784px) { th[class^='col-'] { @@ -239,7 +304,8 @@ .col-index, .col-step-status, .col-closed-signal, - .col-short-slip-distance + .col-short-slip-distance, + .col-power-restriction ) { padding-inline: 12px; } diff --git a/front/src/modules/timesStops/types.ts b/front/src/modules/timesStops/types.ts index ae3a88b3202..ea0eb33e400 100644 --- a/front/src/modules/timesStops/types.ts +++ b/front/src/modules/timesStops/types.ts @@ -127,16 +127,24 @@ export type ReceptionSignalUpdate = { value: ReceptionSignal | undefined; }; +export type PowerRestrictionUpdate = { + row: TimesStopsRowNew; + field: 'powerRestriction'; + value: string | null; +}; + export type CellUpdate = | ArrivalUpdate | StopDurationUpdate | DepartureUpdate - | ReceptionSignalUpdate; + | ReceptionSignalUpdate + | PowerRestrictionUpdate; export type OptimisticEdit = | { field: 'requestedArrival'; value: Date | null } | { field: 'requestedDeparture'; value: Date | null } | { field: 'stopDuration'; value: Duration | null } - | { field: 'receptionSignal'; value: ReceptionSignal | undefined }; + | { field: 'receptionSignal'; value: ReceptionSignal | undefined } + | { field: 'powerRestriction'; value: string | null }; export type PendingEdit = OptimisticEdit & { rowId: string };
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}