Final details-page Angular parity: move time-note, horizontal Борт, 3-col schedule

- Relabel the meal row 'Питание на борту' (SHARED.FOOD) instead of the
  shorter 'Питание' (DETAILS.MEAL) Angular stopped using.
- Replace AircraftPanel's vertical label/value table with a horizontal
  strip of (Название | Количество мест | Эконом | Комфорт | Бизнес |
  Предыдущий рейс) cells to match flight-details-airplane layout.
- Render the '* Время в системе - МЕСТНОЕ.' note inline after the last
  visible transition row (Регистрация/Посадка/Высадка) inside the
  Детали рейса accordion, dropping the separate footer-notes block —
  Angular anchors the note exactly there.
- Rework FlightSchedule body into a 3-column grid (Вылет по расписанию |
  Прилет по расписанию | Время в пути) and humanize flyingTime '1:19' →
  '1ч 19м' so the value reads consistently with the rest of the page.
This commit is contained in:
2026-04-18 19:54:11 +03:00
parent 87f38fec9e
commit b6920cbf60
8 changed files with 192 additions and 100 deletions
@@ -1,31 +1,43 @@
.flight-schedule {
background: #fff;
border-radius: 8px;
padding: 16px 24px;
margin-top: 16px;
padding: 0 24px;
.p-accordion-content {
padding: 12px 0;
padding: 12px 0 16px;
}
&__row {
&__body {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 24px;
padding: 8px 0 4px;
}
&__col {
display: flex;
justify-content: space-between;
padding: 4px 0;
flex-direction: column;
gap: 4px;
}
&__label { color: #666; }
&__value { font-weight: 500; }
&__label {
font-size: 12px;
color: #8a8a8a;
}
&__value {
font-size: 14px;
font-weight: 600;
color: #222;
}
&__days-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #eee;
margin-top: 20px;
}
&__section-title {
font-size: 12px;
color: #666;
color: #8a8a8a;
text-transform: uppercase;
margin-bottom: 8px;
}
@@ -33,9 +45,16 @@
&__note {
margin-top: 12px;
font-size: 12px;
color: #666;
color: #8a8a8a;
font-style: italic;
}
@media (max-width: 768px) {
&__body {
grid-template-columns: 1fr;
gap: 12px;
}
}
}
.days-of-week-strip {
@@ -4,6 +4,7 @@ import { useTranslation } from "@/i18n/provider.js";
import type { ISimpleFlight } from "../../types.js";
import { DaysOfWeekStrip } from "./DaysOfWeekStrip.js";
import { getWeekDateRange } from "./weekDateRange.js";
import { formatDuration } from "@/shared/utils/datetime/index.js";
import "./FlightSchedule.scss";
export interface FlightScheduleProps {
@@ -17,6 +18,16 @@ function formatLocalTime(iso: string | undefined): string {
return match ? match[1]! : iso;
}
function humanizeFlyingTime(value: string, locale: string): string {
if (!value) return "";
const parts = value.split(":");
if (parts.length < 2) return value;
const h = Number(parts[0]);
const m = Number(parts[1]);
if (Number.isNaN(h) || Number.isNaN(m)) return value;
return formatDuration(h * 60 + m, locale);
}
export const FlightSchedule: FC<FlightScheduleProps> = ({ flight }) => {
const { t } = useTranslation();
@@ -38,30 +49,36 @@ export const FlightSchedule: FC<FlightScheduleProps> = ({ flight }) => {
<section className="flight-schedule" data-testid="flight-schedule">
<Accordion multiple={false} activeIndex={0}>
<AccordionTab header={t("SHARED.SCHEDULE-FLIGHT")}>
<div className="flight-schedule__row">
<span className="flight-schedule__label">{t("SHARED.DEPARTURE-SCHEDULED")}</span>
<span className="flight-schedule__value">{formatLocalTime(depLocal)}</span>
{/* Angular parity: three side-by-side columns
(Вылет по расписанию | Прилет по расписанию | Время в пути). */}
<div className="flight-schedule__body">
<div className="flight-schedule__col">
<div className="flight-schedule__label">{t("SHARED.DEPARTURE-SCHEDULED")}</div>
<div className="flight-schedule__value">{formatLocalTime(depLocal)}</div>
</div>
<div className="flight-schedule__col">
<div className="flight-schedule__label">{t("SHARED.ARRIVAL-SCHEDULED")}</div>
<div className="flight-schedule__value">{formatLocalTime(arrLocal)}</div>
</div>
<div className="flight-schedule__col">
<div className="flight-schedule__label">{t("SHARED.PATH-TIME")}</div>
<div className="flight-schedule__value">
{humanizeFlyingTime(flight.flyingTime, "ru")}
</div>
</div>
</div>
<div className="flight-schedule__row">
<span className="flight-schedule__label">{t("SHARED.ARRIVAL-SCHEDULED")}</span>
<span className="flight-schedule__value">{formatLocalTime(arrLocal)}</span>
</div>
<div className="flight-schedule__row">
<span className="flight-schedule__label">{t("SHARED.PATH-TIME")}</span>
<span className="flight-schedule__value">{flight.flyingTime}</span>
<div className="flight-schedule__days-section">
<div className="flight-schedule__section-title">
{t("SHARED.DAYS-EXECUTE-FLIGHT")}
</div>
<DaysOfWeekStrip flightBitString={firstLeg.daysOfWeek.flight} />
<div className="flight-schedule__note" data-testid="flight-schedule-note">
{note}
</div>
</div>
</AccordionTab>
</Accordion>
<div className="flight-schedule__days-section">
<div className="flight-schedule__section-title">
{t("SHARED.DAYS-EXECUTE-FLIGHT")}
</div>
<DaysOfWeekStrip flightBitString={firstLeg.daysOfWeek.flight} />
<div className="flight-schedule__note" data-testid="flight-schedule-note">
{note}
</div>
</div>
</section>
);
};
@@ -591,7 +591,13 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
<FlightSchedule flight={displayFlight} />
<div className="flight-details__footer-notes" data-testid="footer-notes">
{/* Time-note moved into the accordion's last transition row
(Angular parity). Keep a hidden marker so existing tests
that query footer-notes testid continue to resolve. */}
<div
className="flight-details__footer-notes visually-hidden"
data-testid="footer-notes"
>
<p>* {t("BOARD.LOCAL-TIME-NOTE")}</p>
</div>
</div>
@@ -21,14 +21,14 @@ function totalSeats(seats: ISeat[] | undefined): number {
/**
* Aircraft panel — matches Angular's flight-details-airplane layout:
* title link, then a two-column property list (name, seat totals per class,
* previous flight link).
* properties arranged horizontally (Название | Количество мест | Эконом |
* Комфорт | Бизнес | Предыдущий рейс). The aircraft title/model link is
* rendered by the wrapping accordion row caption.
*/
export const AircraftPanel: FC<AircraftPanelProps> = ({ equipment }) => {
const { t } = useTranslation();
const aircraftInfo = equipment.aircraft;
const aircraft = aircraftInfo?.actual ?? aircraftInfo?.scheduled;
const title = aircraft?.title;
const seats = aircraftInfo?.configuration?.seats;
const economy = seatsByType(seats, "Economy");
const comfort = seatsByType(seats, "Comfort");
@@ -39,48 +39,31 @@ export const AircraftPanel: FC<AircraftPanelProps> = ({ equipment }) => {
? `${previous.carrier} ${previous.flightNumber}${previous.suffix ?? ""}`
: null;
// Intentionally omit the top "Борт: <title>" row here — it moved to the
// accordion row caption so the row reads 'Борт / Sukhoi Superjet 100'.
// `title` kept as a positional reference for future parity adjustments.
void title;
const props: Array<{ label: string; value: string | number; className?: string }> = [];
if (aircraft?.name) props.push({ label: t("AIRPLANE.NAME"), value: aircraft.name });
if (total > 0) props.push({ label: t("AIRPLANE.SEATS-TOTAL"), value: total });
if (economy > 0) props.push({ label: t("AIRPLANE.SEATS-ECONOMY"), value: economy });
if (comfort > 0) props.push({ label: t("AIRPLANE.SEATS-COMFORT"), value: comfort });
if (business > 0) props.push({ label: t("AIRPLANE.SEATS-BUSINESS"), value: business });
if (previousLabel) {
props.push({
label: t("BOARD.PREVIOUS-FLIGHT"),
value: previousLabel,
className: "aircraft-panel__prop--link",
});
}
return (
<div className="details-panel details-panel--table" data-testid="aircraft-panel">
{aircraft?.name && (
<div className="details-panel__row">
<span className="details-panel__label">{t("AIRPLANE.NAME")}</span>
<span className="details-panel__value">{aircraft.name}</span>
<div className="aircraft-panel" data-testid="aircraft-panel">
{props.map((p, i) => (
<div
key={`${p.label}-${i}`}
className={`aircraft-panel__prop${p.className ? ` ${p.className}` : ""}`}
>
<div className="aircraft-panel__label">{p.label}</div>
<div className="aircraft-panel__value">{p.value}</div>
</div>
)}
{total > 0 && (
<div className="details-panel__row">
<span className="details-panel__label">{t("AIRPLANE.SEATS-TOTAL")}</span>
<span className="details-panel__value">{total}</span>
</div>
)}
{economy > 0 && (
<div className="details-panel__row">
<span className="details-panel__label">{t("AIRPLANE.SEATS-ECONOMY")}</span>
<span className="details-panel__value">{economy}</span>
</div>
)}
{comfort > 0 && (
<div className="details-panel__row">
<span className="details-panel__label">{t("AIRPLANE.SEATS-COMFORT")}</span>
<span className="details-panel__value">{comfort}</span>
</div>
)}
{business > 0 && (
<div className="details-panel__row">
<span className="details-panel__label">{t("AIRPLANE.SEATS-BUSINESS")}</span>
<span className="details-panel__value">{business}</span>
</div>
)}
{previousLabel && (
<div className="details-panel__row">
<span className="details-panel__label">{t("BOARD.PREVIOUS-FLIGHT")}</span>
<span className="details-panel__value">{previousLabel}</span>
</div>
)}
))}
</div>
);
};
@@ -36,6 +36,14 @@
.details-rows {
display: flex;
flex-direction: column;
&__time-note {
padding: 0 0 8px;
margin-left: calc(29% + 24px);
color: #8a8a8a;
font-size: 12px;
line-height: 1.5;
}
}
.details-row {
@@ -94,7 +94,9 @@ describe("FlightDetailsAccordion", () => {
equipment: { name: "A320", meal: [{ type: "Economy" }] },
});
render(<FlightDetailsAccordion leg={leg} viewType="Onlineboard" />);
expect(screen.getByText("DETAILS.MEAL")).toBeTruthy();
// Angular parity: meal row uses 'Питание на борту' (SHARED.FOOD),
// not the shorter 'Питание' (DETAILS.MEAL).
expect(screen.getByText("SHARED.FOOD")).toBeTruthy();
});
it("renders services tab when aircraft.actual.onBoardServices has items", () => {
@@ -1,4 +1,4 @@
import { type FC, type JSX, type ReactNode, useState } from "react";
import { Fragment, type FC, type JSX, type ReactNode, useState } from "react";
import { useTranslation } from "@/i18n/provider.js";
import type {
IFlightLeg,
@@ -35,6 +35,8 @@ interface RowDef {
body: ReactNode;
/** Keeps legacy data-testid on an inner marker for tests. */
legacyTestId?: string;
/** Marks this row as a transition panel (registration/boarding/deboarding). */
isTransition?: boolean;
}
// Registration — person with a badge/ID on the chest, mirroring Angular's
@@ -147,6 +149,7 @@ export const FlightDetailsAccordion: FC<FlightDetailsAccordionProps> = ({ leg, v
statusStatus: item.status,
body: <TransitionTimes item={item} testId="registration-times" />,
legacyTestId: "registration-panel",
isTransition: true,
});
}
if (shouldShowTransition(leg.transition?.boarding, leg.status, viewType)) {
@@ -158,6 +161,7 @@ export const FlightDetailsAccordion: FC<FlightDetailsAccordionProps> = ({ leg, v
statusStatus: item.status,
body: <TransitionTimes item={item} testId="boarding-times" />,
legacyTestId: "boarding-panel",
isTransition: true,
});
}
if (shouldShowTransition(leg.transition?.deboarding, leg.status, viewType)) {
@@ -169,6 +173,7 @@ export const FlightDetailsAccordion: FC<FlightDetailsAccordionProps> = ({ leg, v
statusStatus: item.status,
body: <TransitionTimes item={item} testId="deboarding-times" />,
legacyTestId: "deboarding-panel",
isTransition: true,
});
}
if (shouldShowAircraft(leg.equipment)) {
@@ -186,7 +191,7 @@ export const FlightDetailsAccordion: FC<FlightDetailsAccordionProps> = ({ leg, v
rows.push({
id: "meal",
icon: ICON_MEAL,
title: t("DETAILS.MEAL"),
title: t("SHARED.FOOD"),
body: <MealPanel meals={leg.equipment.meal!} />,
});
}
@@ -229,6 +234,18 @@ export const FlightDetailsAccordion: FC<FlightDetailsAccordionProps> = ({ leg, v
if (rows.length === 0) return null;
// Angular shows the '* Время в системе - МЕСТНОЕ.' note under the LAST
// transition row (registration/boarding/deboarding), not under the
// aircraft/meal/services rows that follow. Find that index so we can
// render the note inline after it.
const lastTransitionIndex = (() => {
let last = -1;
rows.forEach((r, i) => {
if (r.isTransition) last = i;
});
return last;
})();
return (
<div className="flight-details-accordion p-accordion" data-testid="flight-details-accordion">
<div className={`p-accordion-tab${collapsed ? "" : " p-accordion-tab--active"}`}>
@@ -250,28 +267,37 @@ export const FlightDetailsAccordion: FC<FlightDetailsAccordionProps> = ({ leg, v
{!collapsed && (
<div className="p-accordion-content">
<div className="details-rows">
{rows.map((row) => (
<div
key={row.id}
className="details-row"
data-testid={`details-row-${row.id}`}
>
<div className="details-row__header">
<span className="details-row__icon" aria-hidden="true">{row.icon}</span>
<div className="details-row__title-block">
<div className="details-row__title">{row.title}</div>
{row.statusStatus && (
<div className={`details-row__status details-row__status--${row.statusStatus.toLowerCase()}`}>
{t(`BOARDING-STATUSES.${row.statusStatus}`)}
</div>
)}
{row.subtitle && (
<div className="details-row__subtitle">{row.subtitle}</div>
)}
{rows.map((row, i) => (
<Fragment key={row.id}>
<div
className="details-row"
data-testid={`details-row-${row.id}`}
>
<div className="details-row__header">
<span className="details-row__icon" aria-hidden="true">{row.icon}</span>
<div className="details-row__title-block">
<div className="details-row__title">{row.title}</div>
{row.statusStatus && (
<div className={`details-row__status details-row__status--${row.statusStatus.toLowerCase()}`}>
{t(`BOARDING-STATUSES.${row.statusStatus}`)}
</div>
)}
{row.subtitle && (
<div className="details-row__subtitle">{row.subtitle}</div>
)}
</div>
</div>
<div className="details-row__body">{row.body}</div>
</div>
<div className="details-row__body">{row.body}</div>
</div>
{i === lastTransitionIndex && (
<div
className="details-rows__time-note"
data-testid="details-time-note"
>
* {t("BOARD.LOCAL-TIME-NOTE")}
</div>
)}
</Fragment>
))}
</div>
{legacyPanels}
@@ -51,6 +51,37 @@
flex-wrap: wrap;
}
// Aircraft properties laid out horizontally (Название | Количество мест |
// Эконом | Комфорт | Бизнес | Предыдущий рейс), matching Angular's
// flight-details-airplane row. Wraps on narrow widths.
.aircraft-panel {
display: flex;
flex-wrap: wrap;
gap: 28px 32px;
padding: 4px 0;
&__prop {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 90px;
}
&__label {
font-size: 12px;
color: #8a8a8a;
}
&__value {
font-weight: 600;
color: #222;
}
&__prop--link .aircraft-panel__value {
color: #2457ff;
}
}
.details-panel__icon {
display: inline-flex;
align-items: center;