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}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+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);
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;