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