From 16d0eaa0e82e20d5f4c5f9f5f08e31de16cf63d0 Mon Sep 17 00:00:00 2001 From: "Stanislas Signoud (Signez)" Date: Tue, 12 May 2026 12:25:47 +0200 Subject: [PATCH 1/2] front: ui: add proper IBM Plex italic version Until now, we didn't use the italic version of our main UI font that often, but relying on the browser creating an italic version on the fly when we have the proper version (with the proper glyphs) is not a good way to go forward. Signed-off-by: Stanislas Signoud (Signez) --- front/ui/ui-core/src/styles/main.css | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/front/ui/ui-core/src/styles/main.css b/front/ui/ui-core/src/styles/main.css index 6bd6dc4a076..0414d55403b 100644 --- a/front/ui/ui-core/src/styles/main.css +++ b/front/ui/ui-core/src/styles/main.css @@ -26,6 +26,28 @@ font-weight: 900; } +/* ITALIC */ +@font-face { + font-family: 'IBM Plex Sans'; + src: inline('./fonts/IBMPlexSans-Italic.ttf') format('truetype'); + font-style: italic; + font-weight: 400; +} + +@font-face { + font-family: 'IBM Plex Sans'; + src: inline('./fonts/IBMPlexSans-SemiBoldItalic.ttf') format('truetype'); + font-style: italic; + font-weight: 600; +} + +@font-face { + font-family: 'IBM Plex Sans'; + src: inline('./fonts/IBMPlexSans-BoldItalic.ttf') format('truetype'); + font-style: italic; + font-weight: 900; +} + /* SERIF */ @font-face { font-family: 'IBM Plex Serif', serif; From 0f75f3b8866f8de09a68fe9cd5c17f4b168fca62 Mon Sep 17 00:00:00 2001 From: "Stanislas Signoud (Signez)" Date: Wed, 13 May 2026 17:31:18 +0200 Subject: [PATCH 2/2] front: add a logic-less train header As a first step of our "train header" initiative, this commit add a simple collapsable form that allow users to set key properties of a train directly on top of the train stops times table. For now, all the inputs are disabled, as they will be updated with properly wired fields of the right kind later. Signed-off-by: Stanislas Signoud (Signez) --- .../locales/en/operational-studies.json | 20 ++ .../locales/fr/operational-studies.json | 20 ++ .../Scenario/components/BoardWrapper.tsx | 3 + .../SimulationResults/SimulationResults.tsx | 4 + front/src/modules/modules.scss | 1 + .../trainHeader/CollapsedTrainOverview.tsx | 77 +++++++ .../modules/trainHeader/ExpandedTrainForm.tsx | 199 ++++++++++++++++++ front/src/modules/trainHeader/TrainHeader.tsx | 41 ++++ .../modules/trainHeader/styles/collapsed.scss | 65 ++++++ .../modules/trainHeader/styles/expanded.scss | 127 +++++++++++ .../trainHeader/styles/trainHeader.scss | 32 +++ .../trainHeader/utils/trainProperties.ts | 28 +++ .../operationalStudies/_boardWrapper.scss | 3 + 13 files changed, 620 insertions(+) create mode 100644 front/src/modules/trainHeader/CollapsedTrainOverview.tsx create mode 100644 front/src/modules/trainHeader/ExpandedTrainForm.tsx create mode 100644 front/src/modules/trainHeader/TrainHeader.tsx create mode 100644 front/src/modules/trainHeader/styles/collapsed.scss create mode 100644 front/src/modules/trainHeader/styles/expanded.scss create mode 100644 front/src/modules/trainHeader/styles/trainHeader.scss create mode 100644 front/src/modules/trainHeader/utils/trainProperties.ts diff --git a/front/public/locales/en/operational-studies.json b/front/public/locales/en/operational-studies.json index dd17bc0cbe6..93d9a550816 100644 --- a/front/public/locales/en/operational-studies.json +++ b/front/public/locales/en/operational-studies.json @@ -496,6 +496,26 @@ }, "toggleTrigramSearch": "Toggle trigram search", "trainAdded": "Train added", + "trainHeader": { + "form": { + "comfort": "Comfort", + "compositionCode": "Composition code", + "departureDate": "Departure date", + "electricProfiles": "Electric profiles", + "initialVelocity": "Initial velocity", + "manageExtraOccurrences": "Manage extra occurrences", + "recoveryMargin": "Recovery margin", + "rollingStock": "Rolling stock", + "serviceCadence": "Service cadence", + "serviceWindow": "Service window", + "tags": "Tags", + "trainCategory": "Train category", + "trainName": "Train name" + }, + "itinerary": "Itinerary", + "serviceModelTrain": "Service model train", + "serviceOccurrence": "Service occurrence" + }, "trainLabels": "Tags", "trainScheduleDepartureTime": "Departure time", "trainScheduleInitialSpeed": "Initial velocity", diff --git a/front/public/locales/fr/operational-studies.json b/front/public/locales/fr/operational-studies.json index f63f47f1d3b..709f9d0eede 100644 --- a/front/public/locales/fr/operational-studies.json +++ b/front/public/locales/fr/operational-studies.json @@ -496,6 +496,26 @@ }, "toggleTrigramSearch": "Basculer l'affichage de la recherche par trigramme", "trainAdded": "Train ajouté", + "trainHeader": { + "form": { + "comfort": "Confort", + "compositionCode": "Code composition", + "departureDate": "Date de départ", + "electricProfiles": "Profils électriques", + "initialVelocity": "Vitesse initiale", + "manageExtraOccurrences": "Gérer les occurrences hors cadence", + "recoveryMargin": "Marge de régularité", + "rollingStock": "Matériel roulant", + "serviceCadence": "Cadence de la mission", + "serviceWindow": "Durée de la plage de répétition", + "tags": "Étiquettes", + "trainCategory": "Catégorie de train", + "trainName": "Nom du train" + }, + "itinerary": "Itinéraire", + "serviceModelTrain": "Train modèle de mission", + "serviceOccurrence": "Occurrence de mission" + }, "trainLabels": "Étiquettes", "trainScheduleDepartureTime": "Heure de départ", "trainScheduleInitialSpeed": "Vitesse initiale", diff --git a/front/src/applications/operationalStudies/views/Scenario/components/BoardWrapper.tsx b/front/src/applications/operationalStudies/views/Scenario/components/BoardWrapper.tsx index a6761104530..0deee60f43f 100644 --- a/front/src/applications/operationalStudies/views/Scenario/components/BoardWrapper.tsx +++ b/front/src/applications/operationalStudies/views/Scenario/components/BoardWrapper.tsx @@ -14,6 +14,7 @@ type ResizableProps = { type BoardWrapperProps = { children: React.ReactNode; + customHeader?: React.ReactNode; customFooter?: React.ReactNode; hidden?: boolean; name: string; @@ -34,6 +35,7 @@ const BoardWrapper = ({ withFooter = false, footerClass, dataTestId, + customHeader, customFooter, resizable, }: BoardWrapperProps) => { @@ -55,6 +57,7 @@ const BoardWrapper = ({ menuProps={{ items }} /> + {customHeader}
+ } customFooter={ simulationResults?.isValid && (
diff --git a/front/src/modules/modules.scss b/front/src/modules/modules.scss index 7c6340fdc97..8f7097ae3ce 100644 --- a/front/src/modules/modules.scss +++ b/front/src/modules/modules.scss @@ -6,3 +6,4 @@ @use './scenario/styles/scenario.scss'; @use './trainSchedule/styles/trainSchedule.scss'; @use './timesStops/styles/timesStops.scss'; +@use './trainHeader/styles/trainHeader.scss'; diff --git a/front/src/modules/trainHeader/CollapsedTrainOverview.tsx b/front/src/modules/trainHeader/CollapsedTrainOverview.tsx new file mode 100644 index 00000000000..f6886721af1 --- /dev/null +++ b/front/src/modules/trainHeader/CollapsedTrainOverview.tsx @@ -0,0 +1,77 @@ +import { Button } from '@osrd-project/ui-core'; +import { ChevronDown } from '@osrd-project/ui-icons'; +import { useTranslation } from 'react-i18next'; + +import type { PacedTrainWithPaced } from 'applications/operationalStudies/types'; +import type { Train } from 'reducers/osrdconf/types'; +import { isOccurrenceId } from 'utils/trainId'; + +import { + getCategoryName, + getServiceInterval, + getServiceWindow, + getShortDepartureDate, +} from './utils/trainProperties'; + +export type CollapsedTrainOverviewProps = { + train: Train; + onExpand: () => void; +}; + +/** + * A simple line that shows an overview of the key properties of a train. + */ +const CollapsedTrainOverview = ({ train, onExpand }: CollapsedTrainOverviewProps) => { + const { t } = useTranslation(['operational-studies', 'translation']); + + const pacedTrain = train.paced ? (train as PacedTrainWithPaced) : null; + const isOccurrence = isOccurrenceId(train.id); + const isException = isOccurrence && 'exception' in train; + + return ( +
+ {pacedTrain && ( +
+ {isOccurrence + ? t('manageTrainSchedule.trainHeader.serviceOccurrence') + : t('manageTrainSchedule.trainHeader.serviceModelTrain')} + {isException && '≠'} +
+ )} +
+ {pacedTrain && !isOccurrence && ( +
+ {getServiceInterval(pacedTrain)}’ — {getServiceWindow(pacedTrain)}’ +
+ )} +
{getShortDepartureDate(train)}
+
{getCategoryName(train, t)}
+ {train.rolling_stock_name && ( +
{train.rolling_stock_name}
+ )} + {train.speed_limit_tag && ( +
{train.speed_limit_tag}
+ )} +
+ {train.constraint_distribution === 'MARECO' + ? t('manageTrainSchedule.allowances.distribution-mareco') + : t('manageTrainSchedule.allowances.distribution-linear')} +
+
+
+
+ +
+ ); +}; + +export default CollapsedTrainOverview; diff --git a/front/src/modules/trainHeader/ExpandedTrainForm.tsx b/front/src/modules/trainHeader/ExpandedTrainForm.tsx new file mode 100644 index 00000000000..616f8f276b6 --- /dev/null +++ b/front/src/modules/trainHeader/ExpandedTrainForm.tsx @@ -0,0 +1,199 @@ +import { Button, Checkbox, Input } from '@osrd-project/ui-core'; +import { ChevronUp } from '@osrd-project/ui-icons'; +import { useTranslation } from 'react-i18next'; + +import type { Train } from 'reducers/osrdconf/types'; +import { Duration } from 'utils/duration'; +import { isOccurrenceId } from 'utils/trainId'; + +import { getCategoryName, getComfortType, getShortDepartureDate } from './utils/trainProperties'; + +export type ExpandedTrainFormProps = { + train: Train; + onCollapse: () => void; +}; + +/** + * A header-shaped form that allow users to set most of the properties of a train, beside the itinerary itself. + */ +const ExpandedTrainForm = ({ train, onCollapse }: ExpandedTrainFormProps) => { + const { t } = useTranslation(['operational-studies', 'translation']); + + const isPaced = !!train.paced; + const isOccurrence = isOccurrenceId(train.id); + + const toggleBand = ( +
+ +
+ ); + + return ( +
+ {isPaced && ( +
+ {toggleBand} + {isOccurrence ? ( +
+
+ {t('manageTrainSchedule.trainHeader.serviceOccurrence')} +
+
+ ) : ( +
+
+ {t('manageTrainSchedule.trainHeader.serviceModelTrain')} +
+
+ +
+
+ +
+
+
+
+ )} +
+ )} + {!isPaced && toggleBand} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + {t('manageTrainSchedule.trainHeader.form.electricProfiles')} + +
+
+ +
+ +
+
+
+
+ ); +}; + +export default ExpandedTrainForm; diff --git a/front/src/modules/trainHeader/TrainHeader.tsx b/front/src/modules/trainHeader/TrainHeader.tsx new file mode 100644 index 00000000000..f3e8b3e4866 --- /dev/null +++ b/front/src/modules/trainHeader/TrainHeader.tsx @@ -0,0 +1,41 @@ +import { useState } from 'react'; + +import type { Train } from 'reducers/osrdconf/types'; + +import CollapsedTrainOverview from './CollapsedTrainOverview'; +import ExpandedTrainForm from './ExpandedTrainForm'; + +export type TrainHeaderProps = { + train: Train; +}; + +/** + * A dual-purpose header that shows either a collapsed overview on some key train characteristics, + * or an expanded form that allow the user to edit every data about the train outside of the train + * stops themselves or its itinerary. + */ +const TrainHeader = ({ train }: TrainHeaderProps) => { + const [expanded, setExpanded] = useState(false); + + if (expanded) { + return ( + { + setExpanded(false); + }} + /> + ); + } + + return ( + { + setExpanded(true); + }} + /> + ); +}; + +export default TrainHeader; diff --git a/front/src/modules/trainHeader/styles/collapsed.scss b/front/src/modules/trainHeader/styles/collapsed.scss new file mode 100644 index 00000000000..98456547a67 --- /dev/null +++ b/front/src/modules/trainHeader/styles/collapsed.scss @@ -0,0 +1,65 @@ +.collapsed-train-summary { + display: grid; + grid-template-areas: 'metadata actions toggle'; + grid-template-columns: 1fr auto auto; + font-size: 0.875rem; + color: var(--grey50); + padding: 2px 8px 4px 12px; + min-height: 32px; + border-bottom: 1px solid var(--black25); + background-color: var(--ambientB5); + align-items: center; + box-shadow: inset 0 1px 0 var(--white100); + + &:has(.train-kind-header) { + grid-template-areas: 'kind metadata actions toggle'; + grid-template-columns: auto 1fr auto auto; + } + + .train-metadata { + grid-area: metadata; + display: flex; + flex-flow: row wrap; + column-gap: 32px; + row-gap: 4px; + margin-top: -2px; + justify-self: start; + } + + .train-kind-header { + grid-area: kind; + font-style: italic; + color: var(--info60); + margin-left: 12px; + margin-right: 32px; + margin-top: -2px; + } + + .header-toggle { + grid-area: toggle; + margin-left: 24px; + } + + .actions { + grid-area: actions; + justify-self: end; + margin-left: 24px; + } +} + +@container board-wrapper (width < 800px) { + .collapsed-train-summary { + grid-template-areas: + 'actions actions toggle' + 'metadata metadata metadata'; + + &:has(.train-kind-header) { + grid-template-areas: 'kind metadata actions toggle'; + grid-template-columns: auto 1fr auto auto; + } + + .train-kind-header ~ .train-metadata { + margin-left: 12px; + } + } +} diff --git a/front/src/modules/trainHeader/styles/expanded.scss b/front/src/modules/trainHeader/styles/expanded.scss new file mode 100644 index 00000000000..5b1f5d9589f --- /dev/null +++ b/front/src/modules/trainHeader/styles/expanded.scss @@ -0,0 +1,127 @@ +.expanded-train-form { + background-color: var(--white100); + border-bottom: 1px solid var(--black25); + font-size: 14px; + display: grid; + grid-template-columns: repeat(3, 200px) 1fr [end-column]; + + :where(& > *) { + grid-column: 1 / end-column; + } + + .toggle-band { + display: grid; + place-items: start end; + padding-top: 3px; + margin-right: 8px; + margin-bottom: -28px; + } + + .train-form { + display: grid; + grid-template-columns: subgrid; + grid-auto-flow: column; + padding: 24px 16px 16px 16px; + + :where(& > *) { + margin-top: -8px; + } + + label { + margin-bottom: unset; + } + + .loose-field { + align-content: center; + padding: 32px 16px 16px 16px; + } + + .actions { + place-self: end end; + margin-bottom: 16px; + margin-right: 8px; + grid-row: 3; + grid-column: end-column; + } + } + + .train-service { + background-color: var(--ambientB5); + display: grid; + grid-template-columns: subgrid; + padding: 0 16px 16px 16px; + border-bottom: 1px solid var(--black10); + + .toggle-band { + justify-self: end; + z-index: 1; + margin-right: -8px; + } + + :where(& > *) { + grid-column: 1 / end-column; + } + } + + .train-paced-kind { + background-color: var(--black5); + color: var(--info60); + font-weight: 600; + margin: 16px 14px 0 16px; + align-self: center; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-style: italic; + border-radius: 3px; + white-space: nowrap; + padding-inline: 16px; + } + + .train-occurrence { + display: grid; + grid-template-columns: subgrid; + + .train-paced-kind { + background-color: var(--white50); + } + } + + .train-service-form { + display: grid; + grid-template-columns: subgrid; + margin-bottom: -16px; + + label { + margin-bottom: unset; + } + + .actions { + align-self: end; + margin-left: 16px; + margin-bottom: 18px; + } + } +} + +@container board-wrapper (width < 800px) { + .expanded-train-form { + overflow-x: auto; + grid-template-columns: repeat(3, 200px) [end-column]; + justify-content: space-between; + + .train-form { + .train-tags { + grid-row: 4; + grid-column: 1 / 3; + } + + .actions { + grid-row: 4; + grid-column: end-column; + } + } + } +} diff --git a/front/src/modules/trainHeader/styles/trainHeader.scss b/front/src/modules/trainHeader/styles/trainHeader.scss new file mode 100644 index 00000000000..c046cb8e959 --- /dev/null +++ b/front/src/modules/trainHeader/styles/trainHeader.scss @@ -0,0 +1,32 @@ +@use './collapsed.scss'; +@use './expanded.scss'; + +.train-header { + .header-toggle { + width: 24px; + height: 24px; + display: grid; + place-content: center; + border-radius: 4px; + outline: none; + color: var(--grey50); + z-index: 1; + } + + .header-toggle:hover, + .header-toggle:focus-visible { + color: var(--color-grey-80); + background-color: var(--white100); + box-shadow: + 0 1px 2px 0 rgba(0, 0, 0, 0.16), + 0 2px 2px -1px rgba(255, 171, 88, 0.27); + } + + .header-toggle:focus-visible { + outline: auto; + } + + .header-toggle span { + display: contents; + } +} diff --git a/front/src/modules/trainHeader/utils/trainProperties.ts b/front/src/modules/trainHeader/utils/trainProperties.ts new file mode 100644 index 00000000000..6440d4295a1 --- /dev/null +++ b/front/src/modules/trainHeader/utils/trainProperties.ts @@ -0,0 +1,28 @@ +import type { TFunction } from 'i18next'; + +import type { PacedTrainWithPaced } from 'applications/operationalStudies/types'; +import type { Train } from 'reducers/osrdconf/types'; +import { Duration } from 'utils/duration'; + +export const getShortDepartureDate = (train: Train) => + train && train.start_time + ? new Date(train.start_time).toLocaleDateString(undefined, { + day: 'numeric', + month: 'numeric', + year: 'numeric', + }) + : null; + +export const getCategoryName = (train: Train, t: TFunction<'translation'>): string | null => + train.category && 'main_category' in train.category + ? t(`translation:rollingStock.categoriesOptions.${train.category.main_category}`) + : null; + +export const getComfortType = (train: Train, t: TFunction<'translation'>): string | null => + train.comfort ? t(`translation:rollingStock.comfortTypes.${train.comfort}`) : ''; + +export const getServiceInterval = (train: PacedTrainWithPaced): number | null => + train?.paced ? Duration.parse(train.paced.interval).total('minute') : null; + +export const getServiceWindow = (train: PacedTrainWithPaced): number | null => + train?.paced ? Duration.parse(train.paced.time_window).total('minute') : null; diff --git a/front/src/styles/scss/applications/operationalStudies/_boardWrapper.scss b/front/src/styles/scss/applications/operationalStudies/_boardWrapper.scss index aa25cc944a7..0fbc528d4da 100644 --- a/front/src/styles/scss/applications/operationalStudies/_boardWrapper.scss +++ b/front/src/styles/scss/applications/operationalStudies/_boardWrapper.scss @@ -1,4 +1,7 @@ .board-wrapper { + container-name: board-wrapper; + container-type: inline-size; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.16), 0 2px 2px -1px rgba(255, 171, 88, 0.27);