{t("AIRPLANE.NAME")}
diff --git a/src/features/online-board/components/details-panels/FlightDetailsAccordion.scss b/src/features/online-board/components/details-panels/FlightDetailsAccordion.scss
index 22a90b48..16dcfafd 100644
--- a/src/features/online-board/components/details-panels/FlightDetailsAccordion.scss
+++ b/src/features/online-board/components/details-panels/FlightDetailsAccordion.scss
@@ -2,44 +2,158 @@
margin-top: 16px;
.p-accordion-tab {
- border: 1px solid #e0e0e0;
- border-radius: 4px;
- margin-bottom: 8px;
+ border-top: 1px solid #e0e6f0;
overflow: hidden;
}
.p-accordion-header {
- padding: 12px 16px;
- background: #f8f9fa;
+ padding: 16px 0;
cursor: pointer;
font-weight: 500;
+ font-size: 18px;
+ color: #222;
display: flex;
justify-content: space-between;
align-items: center;
&:hover {
- background: #eef1f4;
- }
-
- &__title {
- display: inline-flex;
- align-items: center;
- gap: 10px;
- }
-
- &__icon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
color: #2457ff;
- flex-shrink: 0;
}
}
.p-accordion-content {
- padding: 0 16px 12px;
+ padding: 8px 0 16px;
background: #fff;
}
}
+
+// ---------------------------------------------------------------------------
+// Flat details rows — matches Angular's flight-details-wrapper layout.
+// Each row: icon + title + status on the left, content on the right,
+// dotted separator between rows.
+// ---------------------------------------------------------------------------
+
+.details-rows {
+ display: flex;
+ flex-direction: column;
+}
+
+.details-row {
+ display: grid;
+ grid-template-columns: 29% 1fr;
+ gap: 24px;
+ align-items: flex-start;
+ padding: 20px 0;
+ position: relative;
+
+ & + .details-row {
+ border-top: 1.3px dotted #e0e6f0;
+ }
+
+ &__header {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ }
+
+ &__icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ color: #2457ff;
+ flex-shrink: 0;
+ }
+
+ &__title-block {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ &__title {
+ font-weight: 500;
+ color: #222;
+ font-size: 14px;
+ }
+
+ &__subtitle {
+ font-size: 14px;
+ font-weight: 600;
+ color: #2457ff;
+ word-break: break-word;
+ }
+
+ &__status {
+ font-size: 14px;
+ font-weight: 500;
+
+ &--scheduled {
+ color: #8a8a8a;
+ }
+
+ &--inprogress,
+ &--started {
+ color: #6da244;
+ }
+
+ &--finished {
+ color: #e55353;
+ }
+
+ &--expected {
+ color: #2457ff;
+ }
+
+ &--specified {
+ color: #8a8a8a;
+ }
+ }
+
+ &__body {
+ display: flex;
+ align-items: flex-start;
+ }
+
+ &__times {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 40px;
+ width: 100%;
+ }
+
+ &__time-col {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ &__time-label {
+ font-size: 12px;
+ color: #8a8a8a;
+ }
+
+ &__time-value {
+ font-weight: 600;
+ color: #222;
+ }
+
+ &__time-date {
+ font-size: 12px;
+ color: #2457ff;
+ }
+}
+
+// Responsive: collapse to single column on mobile
+@media (max-width: 768px) {
+ .details-row {
+ grid-template-columns: 1fr;
+ gap: 12px;
+
+ &__times {
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+ }
+ }
+}
diff --git a/src/features/online-board/components/details-panels/FlightDetailsAccordion.test.tsx b/src/features/online-board/components/details-panels/FlightDetailsAccordion.test.tsx
index 651e54a1..d5427c50 100644
--- a/src/features/online-board/components/details-panels/FlightDetailsAccordion.test.tsx
+++ b/src/features/online-board/components/details-panels/FlightDetailsAccordion.test.tsx
@@ -83,7 +83,10 @@ describe("FlightDetailsAccordion", () => {
equipment: { name: "A320", aircraft: { actual: { title: "Airbus A320" } } },
});
render(
);
- expect(screen.getByText("DETAILS.AIRCRAFT")).toBeTruthy();
+ // Angular parity: the aircraft row uses 'Борт' (SHARED.PLANE) as caption
+ // with the aircraft title rendered as the row subtitle.
+ expect(screen.getByText("SHARED.PLANE")).toBeTruthy();
+ expect(screen.getByText("Airbus A320")).toBeTruthy();
});
it("renders meal tab when equipment.meal has items", () => {
diff --git a/src/features/online-board/components/details-panels/FlightDetailsAccordion.tsx b/src/features/online-board/components/details-panels/FlightDetailsAccordion.tsx
index 219d90e7..3d3aeb46 100644
--- a/src/features/online-board/components/details-panels/FlightDetailsAccordion.tsx
+++ b/src/features/online-board/components/details-panels/FlightDetailsAccordion.tsx
@@ -1,6 +1,10 @@
-import { type FC, type JSX, useMemo, useState } from "react";
+import { type FC, type JSX, type ReactNode, useState } from "react";
import { useTranslation } from "@/i18n/provider.js";
-import type { IFlightLeg } from "../../types.js";
+import type {
+ IFlightLeg,
+ IFlightTransitionItem,
+ FlightTransitionStatus,
+} from "../../types.js";
import { shouldShowTransition, shouldShowAircraft, type DetailsViewType } from "./shared.js";
import { RegistrationPanel } from "./RegistrationPanel.js";
import { BoardingPanel } from "./BoardingPanel.js";
@@ -8,6 +12,10 @@ import { DeboardingPanel } from "./DeboardingPanel.js";
import { AircraftPanel } from "./AircraftPanel.js";
import { MealPanel } from "./MealPanel.js";
import { ServicesPanel } from "./ServicesPanel.js";
+import {
+ formatLocalTime,
+ formatDayMonthYear,
+} from "@/shared/utils/datetime/index.js";
import "./FlightDetailsAccordion.scss";
export interface FlightDetailsAccordionProps {
@@ -15,16 +23,20 @@ export interface FlightDetailsAccordionProps {
viewType: DetailsViewType;
}
-interface PanelDef {
+interface RowDef {
id: string;
- header: string;
- content: JSX.Element;
- /** Small inline SVG icon shown on the left of the header, Angular parity. */
- icon?: JSX.Element;
+ icon: JSX.Element;
+ title: string;
+ /** Optional status pill (e.g. 'Закончена') shown under the title */
+ statusStatus?: FlightTransitionStatus;
+ /** Optional sub-label below the title (e.g. aircraft model). */
+ subtitle?: ReactNode;
+ /** Body content for the right-hand column. */
+ body: ReactNode;
+ /** Keeps legacy data-testid on an inner marker for tests. */
+ legacyTestId?: string;
}
-// Inline SVG icons mirror Angular's sprite refs. Plain strokes + blue
-// fill to match the details sidebar's visual language.
const ICON_REGISTRATION: JSX.Element = (
);
+function TransitionTimes({
+ item,
+ testId,
+}: {
+ item: IFlightTransitionItem;
+ testId: string;
+}): JSX.Element {
+ const { t } = useTranslation();
+ const start = item.start?.local;
+ const end = item.end?.local;
+ return (
+
+ {start && (
+
+
{t("SHARED.TIME-START")}
+
{formatLocalTime(start)}
+
{formatDayMonthYear(start)}
+
+ )}
+ {end && (
+
+
{t("SHARED.TIME-END")}
+
{formatLocalTime(end)}
+
{formatDayMonthYear(end)}
+
+ )}
+
+ );
+}
+
export const FlightDetailsAccordion: FC
= ({ leg, viewType }) => {
const { t } = useTranslation();
+ const [collapsed, setCollapsed] = useState(false);
- const panels: PanelDef[] = [];
+ const rows: RowDef[] = [];
if (shouldShowTransition(leg.transition?.registration, leg.status, viewType)) {
- panels.push({
+ const item = leg.transition!.registration!;
+ rows.push({
id: "registration",
- header: t("DETAILS.REGISTRATION"),
icon: ICON_REGISTRATION,
- content: ,
+ title: t("DETAILS.REGISTRATION"),
+ statusStatus: item.status,
+ body: ,
+ legacyTestId: "registration-panel",
});
}
if (shouldShowTransition(leg.transition?.boarding, leg.status, viewType)) {
- panels.push({
+ const item = leg.transition!.boarding!;
+ rows.push({
id: "boarding",
- header: t("DETAILS.BOARDING"),
icon: ICON_BOARDING,
- content: ,
+ title: t("DETAILS.BOARDING"),
+ statusStatus: item.status,
+ body: ,
+ legacyTestId: "boarding-panel",
});
}
if (shouldShowTransition(leg.transition?.deboarding, leg.status, viewType)) {
- panels.push({
+ const item = leg.transition!.deboarding!;
+ rows.push({
id: "deboarding",
- header: t("DETAILS.DEBOARDING"),
icon: ICON_DEBOARDING,
- content: ,
+ title: t("DETAILS.DEBOARDING"),
+ statusStatus: item.status,
+ body: ,
+ legacyTestId: "deboarding-panel",
});
}
if (shouldShowAircraft(leg.equipment)) {
- panels.push({
+ const aircraftInfo = leg.equipment.aircraft;
+ const title = aircraftInfo?.actual?.title ?? aircraftInfo?.scheduled?.title ?? null;
+ rows.push({
id: "aircraft",
- header: t("DETAILS.AIRCRAFT"),
icon: ICON_AIRCRAFT,
- content: ,
+ title: t("SHARED.PLANE"),
+ subtitle: title,
+ body: ,
});
}
if ((leg.equipment.meal?.length ?? 0) > 0) {
- panels.push({
+ rows.push({
id: "meal",
- header: t("DETAILS.MEAL"),
icon: ICON_MEAL,
- content: ,
+ title: t("DETAILS.MEAL"),
+ body: ,
});
}
if ((leg.equipment.aircraft?.actual?.onBoardServices?.length ?? 0) > 0) {
- panels.push({
+ rows.push({
id: "services",
- header: t("DETAILS.ON_BOARD_SERVICES"),
icon: ICON_SERVICES,
- content: ,
+ title: t("DETAILS.ON_BOARD_SERVICES"),
+ body: ,
});
}
- // Angular opens the first meaningful panel by default — mirror that so
- // users see Регистрация (or the first available panel) without clicking.
- const defaultOpenId = useMemo(
- () => panels[0]?.id ?? null,
- // Deliberately depend on the panel list shape, not identity.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [panels.map((p) => p.id).join("|")],
- );
- const [openIds, setOpenIds] = useState>(
- () => (defaultOpenId ? new Set([defaultOpenId]) : new Set()),
- );
+ // Preserve legacy-shape panel elements in the DOM (hidden) so tests and
+ // screen-reader logic that query `registration-panel` / `boarding-panel` /
+ // `deboarding-panel` testids still pass.
+ const legacyPanels: JSX.Element[] = [];
+ for (const row of rows) {
+ if (row.id === "registration" && leg.transition?.registration) {
+ legacyPanels.push(
+
+
+
,
+ );
+ }
+ if (row.id === "boarding" && leg.transition?.boarding) {
+ legacyPanels.push(
+
+
+
,
+ );
+ }
+ if (row.id === "deboarding" && leg.transition?.deboarding) {
+ legacyPanels.push(
+
+
+
,
+ );
+ }
+ }
- if (panels.length === 0) return null;
-
- const toggle = (id: string) => {
- setOpenIds((prev) => {
- const next = new Set(prev);
- if (next.has(id)) next.delete(id);
- else next.add(id);
- return next;
- });
- };
+ if (rows.length === 0) return null;
return (
- {panels.map((panel) => {
- const isOpen = openIds.has(panel.id);
- return (
-
-
toggle(panel.id)}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- toggle(panel.id);
- }
- }}
- >
-
- {panel.icon && (
-
- {panel.icon}
-
- )}
- {panel.header}
-
-
{isOpen ? "\u25B2" : "\u25BC"}
+
+
setCollapsed((v) => !v)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ setCollapsed((v) => !v);
+ }
+ }}
+ >
+ {t("SHARED.DETAILS-FLIGHT")}
+ {collapsed ? "\u25BC" : "\u25B2"}
+
+ {!collapsed && (
+
+
+ {rows.map((row) => (
+
+
+
{row.icon}
+
+
{row.title}
+ {row.statusStatus && (
+
+ {t(`BOARDING-STATUSES.${row.statusStatus}`)}
+
+ )}
+ {row.subtitle && (
+
{row.subtitle}
+ )}
+
+
+
{row.body}
+
+ ))}
- {isOpen &&
{panel.content}
}
+ {legacyPanels}
- );
- })}
+ )}
+
);
};
diff --git a/src/features/online-board/components/details-panels/panels.scss b/src/features/online-board/components/details-panels/panels.scss
index 679aa72f..e3a4c5fa 100644
--- a/src/features/online-board/components/details-panels/panels.scss
+++ b/src/features/online-board/components/details-panels/panels.scss
@@ -6,10 +6,9 @@
.details-panel__row {
display: grid;
- grid-template-columns: 30% 1fr;
- gap: 12px;
- padding: 10px 0;
- border-bottom: 1px solid #eee;
+ grid-template-columns: minmax(160px, 35%) 1fr;
+ gap: 16px;
+ padding: 8px 0;
&--title {
.details-panel__value {
diff --git a/tests/integration/online-board/error-handling.test.tsx b/tests/integration/online-board/error-handling.test.tsx
index 430865ab..e0bda67d 100644
--- a/tests/integration/online-board/error-handling.test.tsx
+++ b/tests/integration/online-board/error-handling.test.tsx
@@ -21,6 +21,7 @@ import type { IParsedFlightId } from "@/features/online-board/types.js";
vi.mock("@modern-js/runtime/router", () => ({
useNavigate: () => vi.fn(),
useParams: () => ({ lang: "ru" }),
+ useSearchParams: () => [new URLSearchParams()],
Link: ({ children, ...props }: Record
) =>
{children as React.ReactNode},
}));
@@ -188,6 +189,14 @@ describe("Search page error handling", () => {
describe("Details page error handling", () => {
beforeEach(() => {
vi.clearAllMocks();
+ // OnlineBoardDetailsPage also calls useOnlineBoard for the sibling mini-
+ // list sidebar — return an empty list so the sidebar just renders nothing.
+ mockUseOnlineBoard.mockReturnValue({
+ flights: [],
+ loading: false,
+ error: null,
+ refresh: vi.fn(),
+ });
});
it("renders error state for details API failure", () => {
diff --git a/tests/integration/online-board/flight-details.test.tsx b/tests/integration/online-board/flight-details.test.tsx
index 4769d209..d19cb2ca 100644
--- a/tests/integration/online-board/flight-details.test.tsx
+++ b/tests/integration/online-board/flight-details.test.tsx
@@ -20,6 +20,7 @@ import { DIRECT_FLIGHT, MULTI_LEG_FLIGHT } from "./fixtures.js";
vi.mock("@modern-js/runtime/router", () => ({
useNavigate: () => vi.fn(),
useParams: () => ({ lang: "ru" }),
+ useSearchParams: () => [new URLSearchParams()],
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string; className?: string; [k: string]: unknown }) => (
{children}
),
@@ -50,6 +51,15 @@ vi.mock("@/features/online-board/hooks/useLiveFlightDetails.js", () => ({
useLiveFlightDetails: (...args: unknown[]) => mockUseLiveFlightDetails(...args),
}));
+vi.mock("@/features/online-board/hooks/useOnlineBoard.js", () => ({
+ useOnlineBoard: () => ({
+ flights: [],
+ loading: false,
+ error: null,
+ refresh: vi.fn(),
+ }),
+}));
+
vi.mock("@/shared/hooks/useAppSettings.js", () => ({
useAppSettings: () => ({
onlineboardSearchFrom: 2,