@sberbusiness/triplex-next-mcp-server 1.43.0-beta → 1.44.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2294 @@
1
+ ---
2
+ component: TableBasic
3
+ category: Table
4
+ related: [MasterTable, ListTableItem, LoaderScreen, LoaderMiddle, Text]
5
+ tokens:
6
+ - --triplex-next-TableBasic-Header_Background
7
+ - --triplex-next-TableBasic-Color
8
+ - --triplex-next-TableBasic-Background_Hover
9
+ - --triplex-next-TableBasic-Background_Selected
10
+ stories: stories/TableBasic/TableBasic.stories.tsx
11
+ version: "1.0"
12
+ ---
13
+
14
+ # TableBasic
15
+
16
+ ## Назначение
17
+
18
+ Табличное представление данных: `<table>` с шапкой из `columns`, строками из
19
+ `data` и подвалом, в котором показывается заглушка пустого состояния или лоадер.
20
+ Обвязку вокруг таблицы — панель чипов, фильтры, настройки колонок, футер
21
+ выделения и пагинацию — добавляет родительский `MasterTable`; из его контекста
22
+ `TableBasic` берёт состояние загрузки и публикует в него свой набор колонок.
23
+
24
+ Используй когда: нужен структурированный набор данных в строках и столбцах —
25
+ реестр документов, выписка, список операций.
26
+
27
+ Не используй когда:
28
+ - нужен список карточек с собственной вёрсткой строки, свайпом или
29
+ drag-and-drop — бери `List` / `ListTableItem`;
30
+ - таблица должна работать без `MasterTable`. Формально это возможно (контекст
31
+ имеет значения по умолчанию), но тогда не работают `loading` и настройки
32
+ колонок — это не поддерживаемый сценарий.
33
+
34
+ ---
35
+
36
+ ## Варианты и props
37
+
38
+ `TableBasic` доступен и напрямую, и как `MasterTable.TableBasic` — это один и
39
+ тот же компонент.
40
+
41
+ ### Обязательные props
42
+
43
+ | Prop | Тип | Описание |
44
+ |---|---|---|
45
+ | `columns` | `ITableBasicColumn[]` | Описание столбцов: ключ поля, заголовок, выравнивание, ширина, сортировка |
46
+ | `data` | `ITableBasicRow[]` | Строки таблицы. Пустой массив — рендерится `renderNoData` |
47
+ | `renderNoData` | `() => React.JSX.Element` | Заглушка для пустой таблицы. Обязателен даже там, где данные всегда есть |
48
+
49
+ ### Опциональные props
50
+
51
+ | Prop | Тип | По умолчанию | Описание |
52
+ |---|---|---|---|
53
+ | `renderNoColumns` | `() => React.ReactNode` | — | Заглушка вместо таблицы, когда все `columns` скрыты (`hidden`) |
54
+ | `highlightRowOnHover` | `boolean` | `false` | Подсветка строки под курсором |
55
+ | `onOrderBy` | `(order: ISortOrder) => void` | — | Обработчик сортировки. Включает кнопку сортировки у колонок с `orderDirection` |
56
+ | `onClickRow` | `(rowKey: string) => void` | — | Клик по строке. Включает `cursor: pointer` и подсветку при наведении |
57
+ | `headless` | `boolean` | `false` | Скрыть шапку таблицы |
58
+ | `...HTMLTableElementAttributes` | — | — | Стандартные атрибуты `<table>`, включая `className`; `ref` — на `<table>` (в ветке `renderNoColumns` таблицы нет, ref пустой) |
59
+
60
+ ### `ITableBasicColumn`
61
+
62
+ | Поле | Тип | По умолчанию | Описание |
63
+ |---|---|---|---|
64
+ | `fieldKey` | `string` | — | Ключ в `rowData`, значение которого выводится в ячейке |
65
+ | `label` | `string \| React.JSX.Element` | — | Содержимое заголовка |
66
+ | `title` | `string` | — | Нативный `title` у `<th>` |
67
+ | `orderDirection` | `EOrderDirection` | — | Текущее направление. Наличие значения + `onOrderBy` включают сортировку |
68
+ | `horizontalAlign` | `EHorizontalAlign` | `LEFT` | Горизонтальное выравнивание |
69
+ | `verticalAlign` | `EVerticalAlign` | `BASELINE` для `TEXT`, `TOP` для остальных | Вертикальное выравнивание |
70
+ | `cellType` | `ECellType` | `TEXT` | Отступы ячейки и способ вывода контента |
71
+ | `width` | `string \| number` | — | У `<th>` задаёт `min/max/width`, у `<td>` — только `width` |
72
+ | `hidden` | `boolean` | `false` | Столбец не рендерится ни в шапке, ни в строках |
73
+ | `renderCell` | `(param: any) => React.ReactNode` | — | Кастомный рендер значения ячейки |
74
+ | `dataAttributes` / `ariaAttributes` | `TDataHTMLAttributes` / `TAriaHTMLAttributes` | — | Ключи без префикса: `{label: "..."}` → `aria-label="..."` |
75
+
76
+ ### `ITableBasicRow`
77
+
78
+ | Поле | Тип | По умолчанию | Описание |
79
+ |---|---|---|---|
80
+ | `rowKey` | `string` | — | React-key строки, он же аргумент `onClickRow` |
81
+ | `rowData` | `any` | — | Объект «ключ колонки → значение ячейки» |
82
+ | `rowLayout` | `Record<string, ITableRowCellSpanProps>` | — | `rowSpan` / `colSpan` по ключу колонки |
83
+ | `selected` | `boolean` | `false` | Подсветка выбранной строки. Компонент сам флаг не проставляет |
84
+ | `dataAttributes` / `ariaAttributes` | `TDataHTMLAttributes` / `TAriaHTMLAttributes` | — | Атрибуты `<tr>`; `test-id` дополнительно строит `data-test-id` ячеек |
85
+
86
+ ### Ограничения по типам ячеек
87
+
88
+ - `ECellType.TEXT` (по умолчанию) — значение оборачивается в `Text` размера `B3`.
89
+ - `ECellType.COMPONENTS` и `ECellType.CHECKBOX` — разметка выводится как есть,
90
+ меняются только внутренние отступы ячейки.
91
+
92
+ ---
93
+
94
+ ## Дизайн-токены
95
+
96
+ ```
97
+ --triplex-next-TableBasic-Header_Background
98
+ --triplex-next-TableBasic-Color
99
+ --triplex-next-TableBasic-Background_Hover
100
+ --triplex-next-TableBasic-Background_Selected
101
+ ```
102
+
103
+ Остальные токены группы `TableBasic-*` (`TableFooter_*`, `TableBasicSettings_*`)
104
+ принадлежат соседним компонентам семейства, а не самому `TableBasic`.
105
+
106
+ ---
107
+
108
+ ## Инварианты
109
+
110
+ - **Корневой DOM — `<div>` со стилями таблицы, а `ref` и остальные HTML-атрибуты
111
+ идут на вложенный `<table>`.** Так исторически ведёт себя `className`, и
112
+ `forwardRef` добавлен туда же. Переносить их на корневой `<div>` — breaking
113
+ change. Проброс не безусловный: когда все колонки скрыты и задан
114
+ `renderNoColumns`, вместо таблицы рендерится заглушка и `ref.current` остаётся
115
+ `null` — измерять таблицу по ref в этом состоянии нельзя.
116
+ - **`renderNoData` обязателен.** Сделать его опциональным можно только вместе с
117
+ решением, что рендерится при пустых данных без него.
118
+ - **Ячейка рендерится, только если ключ колонки есть в `rowData`.** Колонка без
119
+ соответствующего ключа не даёт `<td>` вовсе — строка окажется короче шапки.
120
+ Для пустой ячейки передавай `null`, а не отсутствие ключа.
121
+ - **Пустое значение ячейки заменяется плейсхолдером `---`.** Проверка falsy,
122
+ поэтому `0`, `""` и `false` тоже дают `---`. Менять это правило —
123
+ наблюдаемое изменение поведения.
124
+ - **Компонент не сортирует данные.** `onOrderBy` вызывается со **следующим**
125
+ направлением по циклу `none → asc → desc → none`; сортировку выполняет
126
+ потребитель и возвращает новый `orderDirection` в колонке.
127
+ - **Колонки публикуются в `MasterTableContext`** через `useEffect` с
128
+ `isEqual`-сравнением — на нём держатся настройки колонок
129
+ (`TableBasicSettings` / `ColumnSettings`). Собирай `columns` стабильно
130
+ (по возможности вне рендера), иначе таблица будет лишний раз обновлять
131
+ контекст.
132
+ - **`loading` приходит только из контекста `MasterTable`**, собственного prop'а
133
+ у таблицы нет.
134
+ - **Класс `selected` на `<tr>` выставляется дважды** — хешированный из
135
+ CSS-модуля и глобальный `"selected"`. Глобальный оставлен для внешних стилей
136
+ и тестов, удалять нельзя.
137
+
138
+ ---
139
+
140
+ ## Accessibility
141
+
142
+ - Разметка нативная: `<table>` / `<thead>` / `<tbody>` / `<th>` / `<td>`,
143
+ скринридер читает её как таблицу без дополнительных ролей.
144
+ - Собственных `aria-*` компонент не хардкодит — библиотека мультиязычная.
145
+ `aria-label` таблицы передаётся через props (уходит на `<table>`), атрибуты
146
+ столбца и строки — через `ariaAttributes` (ключи без префикса `aria-`).
147
+ - **Сортировка не доступна с клавиатуры.** Заголовок с сортировкой — это
148
+ `<span>` с `onClick`, без `role="button"`, `tabIndex` и обработки
149
+ `Enter` / `Space`; `aria-sort` не выставляется. То же с `onClickRow`: клик по
150
+ `<tr>` не имеет клавиатурного эквивалента. Если нужен доступный вариант —
151
+ клади в ячейку интерактивный элемент через `renderCell`.
152
+ - Иконка направления сортировки в состоянии `NONE` видна только при наведении на
153
+ заголовок — состояние сортировки передаётся только цветом и формой иконки.
154
+
155
+ ---
156
+
157
+ ## Связанные компоненты
158
+
159
+ - `MasterTable` — родитель семейства: провайдер контекста (`loading`, `columns`)
160
+ и контейнер для панелей. Предоставляет `MasterTable.TableBasic`,
161
+ `MasterTable.FilterPanel`, `MasterTable.ChipPanel`,
162
+ `MasterTable.TableBasicSettings`, `MasterTable.TableFooter`,
163
+ `MasterTable.PaginationPanel`, `MasterTable.NoColumns`.
164
+ - `ListTableItem` — альтернатива для табличного списка, когда строка это
165
+ карточка со своей вёрсткой, свайпом и контролами.
166
+ - `LoaderScreen` — рендерится поверх таблицы, когда `loading` пришёл из
167
+ контекста, а данные уже есть.
168
+ - `LoaderMiddle` — рендерится в подвале, когда идёт загрузка и данных ещё нет.
169
+ - `Text` — в него оборачивается содержимое заголовков и ячеек типа
170
+ `ECellType.TEXT`.
171
+
172
+ ---
173
+
174
+ ## Stories
175
+
176
+ Основные истории: `stories/TableBasic/TableBasic.stories.tsx`
177
+ Файлы примеров: `stories/TableBasic/examples/`
178
+
179
+ | Story | Example file | Что демонстрирует |
180
+ |---|---|---|
181
+ | `Playground` | `Playground.tsx` | Интерактивный контроль загрузки, шапки, подсветки, данных, скролла и кнопок |
182
+ | `Default` | `Default.tsx` | Минимальная таблица: `columns`, `data`, `renderNoData` |
183
+ | `Alignments` | `Alignments.tsx` | `horizontalAlign` и `verticalAlign` и их значения по умолчанию |
184
+ | `CellTypes` | `CellTypes.tsx` | `ECellType.TEXT` / `COMPONENTS` / `CHECKBOX` |
185
+ | `Sorting` | `Sorting.tsx` | Цикл `none → asc → desc → none`, сортировка на стороне потребителя |
186
+ | `Headless` | `Headless.tsx` | Таблица без шапки |
187
+ | `HighlightRowOnHover` | `HighlightRowOnHover.tsx` | Подсветка строки под курсором |
188
+ | `ClickableRows` | `ClickableRows.tsx` | `onClickRow` и флаг `selected` в данных строки |
189
+ | `NoData` | `NoData.tsx` | Заглушка пустой таблицы |
190
+ | `NoColumns` | `NoColumns.tsx` | Заглушка, когда все колонки скрыты |
191
+ | `Loading` | `Loading.tsx` | Загрузка поверх данных и загрузка при пустой таблице |
192
+ | `TableSpan` | `TableSpan.tsx` | Объединение ячеек через `rowLayout` |
193
+ | `TableSettingsColumn` | `TableSettingsColumn.tsx` | Настройка видимости и порядка колонок |
194
+ | `TableSettingsColumnExtended` | `TableSettingsColumnExtended.tsx` | Настройка не только колонок, но и блоков внутри колонки |
195
+ | `TableWithPagination` | `TableWithPagination.tsx` | Таблица с `Pagination` при известном количестве данных |
196
+ | `TableWithPaginationLoading` | `TableWithPaginationLoading.tsx` | Фильтры, таблица и пагинация в состоянии загрузки |
197
+ | `TableWithPaginationExtended` | `TableWithPaginationExtended.tsx` | Таблица с `PaginationExtended` при неизвестном количестве данных |
198
+ | `Example: production` | `ExampleProduction.tsx` | Композиция, приближённая к production: чипы-сегменты, фильтры, теги |
199
+ | `VisualTests` | `VisualTests.tsx` | Статичные визуальные состояния в одном кадре |
200
+
201
+ ---
202
+
203
+ ## История изменений
204
+
205
+ | Дата | Изменение |
206
+ |---|---|
207
+ | 2026-08-27 | Создан документ. AI-рефакторинг: `TableBasic` получил `forwardRef` на `<table>`, JSDoc на всех публичных полях, разбор подвала по состояниям, удалён неиспользуемый `TableBasicAdaptive.module.less`. Stories переведены на modern pattern, добавлены unit-тесты (46) |
208
+
209
+ ---
210
+
211
+ ## Примеры использования
212
+
213
+ ### Default
214
+
215
+ `stories/TableBasic/examples/Default.tsx`
216
+
217
+ ```tsx
218
+ import React from "react";
219
+ import { ITableBasicColumn, ITableBasicRow, MasterTable } from "@sberbusiness/triplex-next";
220
+
221
+ const columns: ITableBasicColumn[] = [
222
+ { fieldKey: "number", label: "Номер", title: "Номер", width: 100 },
223
+ { fieldKey: "recipient", label: "Получатель", title: "Получатель" },
224
+ { fieldKey: "sum", label: "Сумма", title: "Сумма", width: 140 },
225
+ { fieldKey: "status", label: "Статус", title: "Статус", width: 140 },
226
+ ];
227
+
228
+ const data: ITableBasicRow[] = [
229
+ { rowKey: "1350", rowData: { number: "1350", recipient: "ООО Ромашка", sum: "9 450,00", status: "Создан" } },
230
+ { rowKey: "1351", rowData: { number: "1351", recipient: "ИП Иванов И. И.", sum: "18 914,00", status: "Подписан" } },
231
+ { rowKey: "1352", rowData: { number: "1352", recipient: "ООО Росинка", sum: "28 392,00", status: "Оплачен" } },
232
+ ];
233
+
234
+ export const Default = () => (
235
+ <MasterTable>
236
+ <MasterTable.TableBasic columns={columns} data={data} renderNoData={() => <div>Нет данных</div>} />
237
+ </MasterTable>
238
+ );
239
+ ```
240
+
241
+ ### Alignments
242
+
243
+ `stories/TableBasic/examples/Alignments.tsx`
244
+
245
+ ```tsx
246
+ import React from "react";
247
+ import {
248
+ EHorizontalAlign,
249
+ EVerticalAlign,
250
+ ITableBasicColumn,
251
+ ITableBasicRow,
252
+ MasterTable,
253
+ } from "@sberbusiness/triplex-next";
254
+
255
+ const columns: ITableBasicColumn[] = [
256
+ {
257
+ fieldKey: "left",
258
+ label: "Слева (по умолчанию)",
259
+ horizontalAlign: EHorizontalAlign.LEFT,
260
+ verticalAlign: EVerticalAlign.TOP,
261
+ },
262
+ {
263
+ fieldKey: "center",
264
+ label: "По центру",
265
+ horizontalAlign: EHorizontalAlign.CENTER,
266
+ verticalAlign: EVerticalAlign.BOTTOM,
267
+ },
268
+ ];
269
+
270
+ const data: ITableBasicRow[] = [
271
+ {
272
+ rowKey: "1",
273
+ rowData: {
274
+ left: "Выравнивание по верхнему краю ячейки. Текст занимает несколько строк, чтобы разница по вертикали была видна.",
275
+ center: "По нижнему краю ячейки",
276
+ },
277
+ },
278
+ {
279
+ rowKey: "2",
280
+ rowData: {
281
+ left: "Ещё одна многострочная ячейка, задающая высоту строки таблицы для наглядности примера.",
282
+ center: "По нижнему краю ячейки",
283
+ },
284
+ },
285
+ ];
286
+
287
+ export const Alignments = () => (
288
+ <MasterTable>
289
+ <MasterTable.TableBasic columns={columns} data={data} renderNoData={() => <div>Нет данных</div>} />
290
+ </MasterTable>
291
+ );
292
+ ```
293
+
294
+ ### CellTypes
295
+
296
+ `stories/TableBasic/examples/CellTypes.tsx`
297
+
298
+ ```tsx
299
+ import React, { useState } from "react";
300
+ import {
301
+ Button,
302
+ Checkbox,
303
+ ECellType,
304
+ EButtonTheme,
305
+ EComponentSize,
306
+ ITableBasicColumn,
307
+ ITableBasicRow,
308
+ MasterTable,
309
+ } from "@sberbusiness/triplex-next";
310
+
311
+ const rows = [
312
+ { rowKey: "1350", number: "1350", recipient: "ООО Ромашка" },
313
+ { rowKey: "1351", number: "1351", recipient: "ИП Иванов И. И." },
314
+ ];
315
+
316
+ // Колонки собираются вне рендера, чтобы таблица не обновляла контекст MasterTable на каждый рендер.
317
+ const columns: ITableBasicColumn[] = [
318
+ { fieldKey: "checkbox", cellType: ECellType.CHECKBOX, label: "", width: 48 },
319
+ { fieldKey: "number", cellType: ECellType.TEXT, label: "Номер (text)", width: 120 },
320
+ { fieldKey: "recipient", cellType: ECellType.TEXT, label: "Получатель (text)" },
321
+ { fieldKey: "action", cellType: ECellType.COMPONENTS, label: "Действие (components)", width: 180 },
322
+ ];
323
+
324
+ export const CellTypes = () => {
325
+ const [checkedRows, setCheckedRows] = useState<string[]>([]);
326
+
327
+ const toggleRow = (rowKey: string, checked: boolean) => {
328
+ setCheckedRows((prev) => (checked ? [...prev, rowKey] : prev.filter((key) => key !== rowKey)));
329
+ };
330
+
331
+ const data: ITableBasicRow[] = rows.map((row) => ({
332
+ rowKey: row.rowKey,
333
+ selected: checkedRows.includes(row.rowKey),
334
+ rowData: {
335
+ checkbox: (
336
+ <Checkbox
337
+ checked={checkedRows.includes(row.rowKey)}
338
+ onChange={(event) => toggleRow(row.rowKey, event.target.checked)}
339
+ aria-label={`Выбрать строку ${row.number}`}
340
+ />
341
+ ),
342
+ number: row.number,
343
+ recipient: row.recipient,
344
+ action: (
345
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD} onClick={() => {}}>
346
+ Напечатать
347
+ </Button>
348
+ ),
349
+ },
350
+ }));
351
+
352
+ return (
353
+ <MasterTable>
354
+ <MasterTable.TableBasic columns={columns} data={data} renderNoData={() => <div>Нет данных</div>} />
355
+ </MasterTable>
356
+ );
357
+ };
358
+ ```
359
+
360
+ ### Sorting
361
+
362
+ `stories/TableBasic/examples/Sorting.tsx`
363
+
364
+ ```tsx
365
+ import React, { useState } from "react";
366
+ import {
367
+ EHorizontalAlign,
368
+ EOrderDirection,
369
+ ISortOrder,
370
+ ITableBasicColumn,
371
+ ITableBasicRow,
372
+ MasterTable,
373
+ } from "@sberbusiness/triplex-next";
374
+
375
+ const rows = [
376
+ { rowKey: "1350", number: "1350", recipient: "ООО Ромашка", sum: 9450 },
377
+ { rowKey: "1351", number: "1351", recipient: "ИП Иванов И. И.", sum: 18914 },
378
+ { rowKey: "1352", number: "1352", recipient: "ООО Росинка", sum: 4200 },
379
+ ];
380
+
381
+ export const Sorting = () => {
382
+ // Направление переключается по циклу none → asc → desc → none, следующее значение приходит в onOrderBy.
383
+ const [order, setOrder] = useState<ISortOrder>({ fieldKey: "sum", direction: EOrderDirection.NONE });
384
+
385
+ const getDirection = (fieldKey: string) => (order.fieldKey === fieldKey ? order.direction : EOrderDirection.NONE);
386
+
387
+ const columns: ITableBasicColumn[] = [
388
+ { fieldKey: "number", label: "Номер", width: 120, orderDirection: getDirection("number") },
389
+ { fieldKey: "recipient", label: "Получатель (без сортировки)" },
390
+ {
391
+ fieldKey: "sum",
392
+ label: "Сумма",
393
+ width: 160,
394
+ horizontalAlign: EHorizontalAlign.RIGHT,
395
+ orderDirection: getDirection("sum"),
396
+ },
397
+ ];
398
+
399
+ const sortedRows = [...rows].sort((a, b) => {
400
+ if (order.direction === EOrderDirection.NONE) {
401
+ return 0;
402
+ }
403
+
404
+ const sign = order.direction === EOrderDirection.ASC ? 1 : -1;
405
+
406
+ return order.fieldKey === "sum" ? sign * (a.sum - b.sum) : sign * a.number.localeCompare(b.number);
407
+ });
408
+
409
+ const data: ITableBasicRow[] = sortedRows.map((row) => ({
410
+ rowKey: row.rowKey,
411
+ rowData: { number: row.number, recipient: row.recipient, sum: row.sum.toLocaleString("ru-RU") },
412
+ }));
413
+
414
+ return (
415
+ <MasterTable>
416
+ <MasterTable.TableBasic
417
+ columns={columns}
418
+ data={data}
419
+ onOrderBy={setOrder}
420
+ renderNoData={() => <div>Нет данных</div>}
421
+ />
422
+ </MasterTable>
423
+ );
424
+ };
425
+ ```
426
+
427
+ ### Headless
428
+
429
+ `stories/TableBasic/examples/Headless.tsx`
430
+
431
+ ```tsx
432
+ import React from "react";
433
+ import { ITableBasicColumn, ITableBasicRow, MasterTable } from "@sberbusiness/triplex-next";
434
+
435
+ const columns: ITableBasicColumn[] = [
436
+ { fieldKey: "number", label: "Номер", width: 100 },
437
+ { fieldKey: "recipient", label: "Получатель" },
438
+ { fieldKey: "sum", label: "Сумма", width: 140 },
439
+ ];
440
+
441
+ const data: ITableBasicRow[] = [
442
+ { rowKey: "1350", rowData: { number: "1350", recipient: "ООО Ромашка", sum: "9 450,00" } },
443
+ { rowKey: "1351", rowData: { number: "1351", recipient: "ИП Иванов И. И.", sum: "18 914,00" } },
444
+ ];
445
+
446
+ export const Headless = () => (
447
+ <MasterTable>
448
+ <MasterTable.TableBasic headless columns={columns} data={data} renderNoData={() => <div>Нет данных</div>} />
449
+ </MasterTable>
450
+ );
451
+ ```
452
+
453
+ ### HighlightRowOnHover
454
+
455
+ `stories/TableBasic/examples/HighlightRowOnHover.tsx`
456
+
457
+ ```tsx
458
+ import React from "react";
459
+ import { ITableBasicColumn, ITableBasicRow, MasterTable } from "@sberbusiness/triplex-next";
460
+
461
+ const columns: ITableBasicColumn[] = [
462
+ { fieldKey: "number", label: "Номер", width: 100 },
463
+ { fieldKey: "recipient", label: "Получатель" },
464
+ { fieldKey: "sum", label: "Сумма", width: 140 },
465
+ ];
466
+
467
+ const data: ITableBasicRow[] = [
468
+ { rowKey: "1350", rowData: { number: "1350", recipient: "ООО Ромашка", sum: "9 450,00" } },
469
+ { rowKey: "1351", rowData: { number: "1351", recipient: "ИП Иванов И. И.", sum: "18 914,00" } },
470
+ { rowKey: "1352", rowData: { number: "1352", recipient: "ООО Росинка", sum: "28 392,00" } },
471
+ ];
472
+
473
+ export const HighlightRowOnHover = () => (
474
+ <MasterTable>
475
+ <MasterTable.TableBasic
476
+ highlightRowOnHover
477
+ columns={columns}
478
+ data={data}
479
+ renderNoData={() => <div>Нет данных</div>}
480
+ />
481
+ </MasterTable>
482
+ );
483
+ ```
484
+
485
+ ### ClickableRows
486
+
487
+ `stories/TableBasic/examples/ClickableRows.tsx`
488
+
489
+ ```tsx
490
+ import React, { useState } from "react";
491
+ import { ITableBasicColumn, ITableBasicRow, MasterTable } from "@sberbusiness/triplex-next";
492
+
493
+ const columns: ITableBasicColumn[] = [
494
+ { fieldKey: "number", label: "Номер", width: 100 },
495
+ { fieldKey: "recipient", label: "Получатель" },
496
+ { fieldKey: "sum", label: "Сумма", width: 140 },
497
+ ];
498
+
499
+ const rows = [
500
+ { rowKey: "1350", number: "1350", recipient: "ООО Ромашка", sum: "9 450,00" },
501
+ { rowKey: "1351", number: "1351", recipient: "ИП Иванов И. И.", sum: "18 914,00" },
502
+ { rowKey: "1352", number: "1352", recipient: "ООО Росинка", sum: "28 392,00" },
503
+ ];
504
+
505
+ export const ClickableRows = () => {
506
+ const [selectedRowKey, setSelectedRowKey] = useState("1351");
507
+
508
+ // Заданный onClickRow сам включает подсветку строки при наведении, highlightRowOnHover не нужен.
509
+ const data: ITableBasicRow[] = rows.map((row) => ({
510
+ rowKey: row.rowKey,
511
+ selected: row.rowKey === selectedRowKey,
512
+ rowData: { number: row.number, recipient: row.recipient, sum: row.sum },
513
+ }));
514
+
515
+ return (
516
+ <MasterTable>
517
+ <MasterTable.TableBasic
518
+ columns={columns}
519
+ data={data}
520
+ onClickRow={setSelectedRowKey}
521
+ renderNoData={() => <div>Нет данных</div>}
522
+ />
523
+ </MasterTable>
524
+ );
525
+ };
526
+ ```
527
+
528
+ ### NoData
529
+
530
+ `stories/TableBasic/examples/NoData.tsx`
531
+
532
+ ```tsx
533
+ import React from "react";
534
+ import { EmptytableSysIcon96 } from "@sberbusiness/icons-next";
535
+ import {
536
+ EFontType,
537
+ EFontWeightTitle,
538
+ ETextSize,
539
+ ETitleSize,
540
+ Gap,
541
+ ITableBasicColumn,
542
+ MasterTable,
543
+ Text,
544
+ Title,
545
+ } from "@sberbusiness/triplex-next";
546
+
547
+ const columns: ITableBasicColumn[] = [
548
+ { fieldKey: "number", label: "Номер", width: 100 },
549
+ { fieldKey: "recipient", label: "Получатель" },
550
+ { fieldKey: "sum", label: "Сумма", width: 140 },
551
+ ];
552
+
553
+ const renderNoData = () => (
554
+ <>
555
+ <EmptytableSysIcon96 />
556
+ <Gap size={8} />
557
+ <Title size={ETitleSize.H3} weight={EFontWeightTitle.REGULAR}>
558
+ Пока нет документов
559
+ </Title>
560
+ <Gap size={12} />
561
+ <Text tag="div" size={ETextSize.B3} type={EFontType.SECONDARY}>
562
+ Здесь появятся платёжные поручения, когда вы их создадите.
563
+ </Text>
564
+ </>
565
+ );
566
+
567
+ export const NoData = () => (
568
+ <MasterTable>
569
+ <MasterTable.TableBasic columns={columns} data={[]} renderNoData={renderNoData} />
570
+ </MasterTable>
571
+ );
572
+ ```
573
+
574
+ ### NoColumns
575
+
576
+ `stories/TableBasic/examples/NoColumns.tsx`
577
+
578
+ ```tsx
579
+ import React, { useState } from "react";
580
+ import {
581
+ Button,
582
+ EButtonTheme,
583
+ EComponentSize,
584
+ EFontType,
585
+ EFontWeightTitle,
586
+ ETextSize,
587
+ ETitleSize,
588
+ Gap,
589
+ ITableBasicColumn,
590
+ ITableBasicRow,
591
+ MasterTable,
592
+ Text,
593
+ Title,
594
+ } from "@sberbusiness/triplex-next";
595
+
596
+ const defaultColumns: ITableBasicColumn[] = [
597
+ { fieldKey: "number", label: "Номер", width: 100 },
598
+ { fieldKey: "recipient", label: "Получатель" },
599
+ { fieldKey: "sum", label: "Сумма", width: 140 },
600
+ ];
601
+
602
+ const data: ITableBasicRow[] = [
603
+ { rowKey: "1350", rowData: { number: "1350", recipient: "ООО Ромашка", sum: "9 450,00" } },
604
+ ];
605
+
606
+ export const NoColumns = () => {
607
+ // Все колонки скрыты: renderNoColumns вызывается вместо таблицы.
608
+ const [columns, setColumns] = useState<ITableBasicColumn[]>(
609
+ defaultColumns.map((column) => ({ ...column, hidden: true })),
610
+ );
611
+
612
+ const renderNoColumns = () => (
613
+ <MasterTable.NoColumns>
614
+ <Title size={ETitleSize.H3} weight={EFontWeightTitle.REGULAR}>
615
+ Все колонки таблицы скрыты
616
+ </Title>
617
+ <Gap size={12} />
618
+ <Text tag="div" size={ETextSize.B3} type={EFontType.SECONDARY}>
619
+ Выберите нужные вам для отображения колонки в настройках таблицы.
620
+ </Text>
621
+ <Gap size={24} />
622
+ <Button theme={EButtonTheme.GENERAL} size={EComponentSize.MD} onClick={() => setColumns(defaultColumns)}>
623
+ Сбросить настройки
624
+ </Button>
625
+ </MasterTable.NoColumns>
626
+ );
627
+
628
+ return (
629
+ <MasterTable>
630
+ <MasterTable.TableBasic
631
+ columns={columns}
632
+ data={data}
633
+ renderNoData={() => <div>Нет данных</div>}
634
+ renderNoColumns={renderNoColumns}
635
+ />
636
+ </MasterTable>
637
+ );
638
+ };
639
+ ```
640
+
641
+ ### Loading
642
+
643
+ `stories/TableBasic/examples/Loading.tsx`
644
+
645
+ ```tsx
646
+ import React from "react";
647
+ import { ETextSize, Gap, ITableBasicColumn, ITableBasicRow, MasterTable, Text } from "@sberbusiness/triplex-next";
648
+
649
+ const columns: ITableBasicColumn[] = [
650
+ { fieldKey: "number", label: "Номер", width: 100 },
651
+ { fieldKey: "recipient", label: "Получатель" },
652
+ { fieldKey: "sum", label: "Сумма", width: 140 },
653
+ ];
654
+
655
+ const data: ITableBasicRow[] = [
656
+ { rowKey: "1350", rowData: { number: "1350", recipient: "ООО Ромашка", sum: "9 450,00" } },
657
+ { rowKey: "1351", rowData: { number: "1351", recipient: "ИП Иванов И. И.", sum: "18 914,00" } },
658
+ ];
659
+
660
+ // Состояние загрузки задаётся на MasterTable, TableBasic берёт его из контекста.
661
+ export const Loading = () => (
662
+ <>
663
+ <Text tag="div" size={ETextSize.B3}>
664
+ Загрузка поверх уже показанных данных
665
+ </Text>
666
+ <Gap size={12} />
667
+ <MasterTable loading>
668
+ <MasterTable.TableBasic columns={columns} data={data} renderNoData={() => <div>Нет данных</div>} />
669
+ </MasterTable>
670
+ <Gap size={24} />
671
+ <Text tag="div" size={ETextSize.B3}>
672
+ Загрузка при пустой таблице
673
+ </Text>
674
+ <Gap size={12} />
675
+ <MasterTable loading>
676
+ <MasterTable.TableBasic columns={columns} data={[]} renderNoData={() => <div>Нет данных</div>} />
677
+ </MasterTable>
678
+ </>
679
+ );
680
+ ```
681
+
682
+ ### TableSpan
683
+
684
+ `stories/TableBasic/examples/TableSpan.tsx`
685
+
686
+ ```tsx
687
+ import React from "react";
688
+ import {
689
+ Amount,
690
+ EHorizontalAlign,
691
+ EVerticalAlign,
692
+ MasterTable,
693
+ ITableBasicColumn,
694
+ ITableBasicRow,
695
+ } from "@sberbusiness/triplex-next";
696
+
697
+ // Колонки и данные собираются вне рендера, чтобы таблица не обновляла контекст MasterTable на каждый рендер.
698
+ const backendData: ITableBasicRow[] = [
699
+ {
700
+ rowKey: "637",
701
+ rowData: {
702
+ number: "637",
703
+ sender: "ООО Ромашка",
704
+ sum: "1337,00",
705
+ status: "Ячейки первой и второй строки объединены по вертикали.",
706
+ },
707
+ rowLayout: {
708
+ status: { rowSpan: 2 },
709
+ },
710
+ },
711
+ {
712
+ rowKey: "638",
713
+ rowData: {
714
+ number: "638",
715
+ sender: "ООО Росинка",
716
+ sum: "420,00",
717
+ },
718
+ },
719
+ {
720
+ rowKey: "639",
721
+ rowData: {
722
+ number: "639",
723
+ sender: "Ячейки второго и третьего столбца объединены по горизонтали.",
724
+ status: "Исполнено",
725
+ },
726
+ rowLayout: {
727
+ sender: { colSpan: 2 },
728
+ },
729
+ },
730
+ ];
731
+
732
+ const columns: ITableBasicColumn[] = [
733
+ {
734
+ fieldKey: "number",
735
+ label: "Номер",
736
+ width: 65,
737
+ },
738
+ {
739
+ fieldKey: "sender",
740
+ label: "Отправитель",
741
+ },
742
+ {
743
+ fieldKey: "sum",
744
+ label: "Сумма",
745
+ horizontalAlign: EHorizontalAlign.RIGHT,
746
+ renderCell: (fieldValue) => fieldValue && <Amount value={fieldValue} currency={"RUB"} />,
747
+ },
748
+ {
749
+ fieldKey: "status",
750
+ label: "Статус",
751
+ verticalAlign: EVerticalAlign.MIDDLE,
752
+ width: 150,
753
+ },
754
+ ];
755
+
756
+ export const TableSpan = () => (
757
+ <div style={{ maxWidth: "600px" }}>
758
+ <MasterTable>
759
+ <MasterTable.TableBasic columns={columns} data={backendData} renderNoData={() => <div>Нет данных</div>} />
760
+ </MasterTable>
761
+ </div>
762
+ );
763
+ ```
764
+
765
+ ### TableSettingsColumn
766
+
767
+ `stories/TableBasic/examples/TableSettingsColumn.tsx`
768
+
769
+ ```tsx
770
+ import React, { useState } from "react";
771
+ import { EmptytableSysIcon96 } from "@sberbusiness/icons-next";
772
+ import {
773
+ Amount,
774
+ Button,
775
+ Checkbox,
776
+ ColumnSettings,
777
+ EButtonTheme,
778
+ EComponentSize,
779
+ EFontType,
780
+ EFontWeightTitle,
781
+ EHorizontalAlign,
782
+ ETextSize,
783
+ ETitleSize,
784
+ Gap,
785
+ MasterTable,
786
+ Text,
787
+ Title,
788
+ ITableBasicColumn,
789
+ ITableBasicRow,
790
+ } from "@sberbusiness/triplex-next";
791
+
792
+ const renderCounterpartyDetails = (purpose: string, account: string, tax: string) => (
793
+ <>
794
+ <div>
795
+ {purpose}
796
+ <br />
797
+ {account}
798
+ </div>
799
+ <Gap size={4} />
800
+ <Text tag="div" size={ETextSize.B4} type={EFontType.SECONDARY}>
801
+ {tax}
802
+ </Text>
803
+ </>
804
+ );
805
+
806
+ const renderNoData = () => (
807
+ <>
808
+ <EmptytableSysIcon96 />
809
+ <Gap size={8} />
810
+ <Title size={ETitleSize.H3} weight={EFontWeightTitle.REGULAR}>
811
+ Текст заголовка
812
+ </Title>
813
+ <Gap size={12} />
814
+ <Text tag="div" size={ETextSize.B3} type={EFontType.SECONDARY}>
815
+ Нет данных, но можно предложить какие-то действия для заполнения таблицы
816
+ </Text>
817
+ <Gap size={24} />
818
+ <div>
819
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD}>
820
+ Button text
821
+ </Button>
822
+ <Button theme={EButtonTheme.GENERAL} size={EComponentSize.MD}>
823
+ Button text
824
+ </Button>
825
+ </div>
826
+ </>
827
+ );
828
+
829
+ const renderNoColumns = (onReset: () => void) => (
830
+ <MasterTable.NoColumns>
831
+ <Title size={ETitleSize.H3} weight={EFontWeightTitle.REGULAR}>
832
+ Все колонки таблицы скрыты
833
+ </Title>
834
+ <Gap size={12} />
835
+ <Text tag="div" size={ETextSize.B3} type={EFontType.SECONDARY}>
836
+ Выберите нужные вам для отображения колонки в настройках таблицы.
837
+ </Text>
838
+ <Gap size={24} />
839
+ <Button theme={EButtonTheme.GENERAL} size={EComponentSize.MD} onClick={onReset}>
840
+ Сбросить настройки
841
+ </Button>
842
+ </MasterTable.NoColumns>
843
+ );
844
+
845
+ // Колонки и данные собираются вне рендера, чтобы таблица не обновляла контекст MasterTable на каждый рендер.
846
+ const defaultColumns: ITableBasicColumn[] = [
847
+ {
848
+ fieldKey: "number",
849
+ label: "Номер",
850
+ },
851
+ {
852
+ fieldKey: "value",
853
+ label: "Получатель",
854
+ },
855
+ {
856
+ fieldKey: "sum",
857
+ horizontalAlign: EHorizontalAlign.RIGHT,
858
+ label: "Сумма",
859
+ renderCell: (fieldValue) => fieldValue && <Amount value={fieldValue} currency="RUB" />,
860
+ },
861
+ {
862
+ fieldKey: "status",
863
+ label: "Статус",
864
+ },
865
+ ];
866
+
867
+ const data: ITableBasicRow[] = Array.from({ length: 5 }, (_, index) => ({
868
+ rowData: {
869
+ number: 1397450 + index,
870
+ status: "Исполнено",
871
+ sum: "1220000000",
872
+ value: renderCounterpartyDetails(
873
+ "Платежное поручение ООО Ромашка",
874
+ "40702 810 2 0527 5000000",
875
+ "В том числе НДС 20%",
876
+ ),
877
+ },
878
+ rowKey: `table-basic-row-${index}`,
879
+ }));
880
+
881
+ export const TableSettingsColumn = () => {
882
+ const [columns, setColumns] = useState(defaultColumns);
883
+ const [settingsDropdownOpened, setSettingsDropdownOpened] = useState(false);
884
+
885
+ const resetColumns = () => {
886
+ setColumns(defaultColumns);
887
+ };
888
+
889
+ const showAllColumns = () => {
890
+ setColumns(
891
+ columns.map((column) => {
892
+ if (column.hidden) {
893
+ return { ...column, hidden: false };
894
+ }
895
+ return column;
896
+ }),
897
+ );
898
+ };
899
+
900
+ const toggleColumn = (fieldKey: string, checked: boolean) => {
901
+ setColumns(
902
+ columns.map((column) => {
903
+ if (column.fieldKey === fieldKey) {
904
+ return { ...column, hidden: !checked };
905
+ }
906
+ return column;
907
+ }),
908
+ );
909
+ };
910
+
911
+ const renderColumns = () => (
912
+ <ColumnSettings.SortableList columns={columns} onColumnsChange={setColumns}>
913
+ {columns.map((column) => (
914
+ <ColumnSettings.SortableList.Item key={column.fieldKey} id={column.fieldKey}>
915
+ <Checkbox
916
+ checked={column.hidden !== true}
917
+ onChange={(event) => toggleColumn(column.fieldKey, event.target.checked)}
918
+ >
919
+ {column.label}
920
+ </Checkbox>
921
+ </ColumnSettings.SortableList.Item>
922
+ ))}
923
+ </ColumnSettings.SortableList>
924
+ );
925
+
926
+ return (
927
+ <MasterTable>
928
+ <MasterTable.ChipPanel>
929
+ <MasterTable.ChipPanel.Links>
930
+ <MasterTable.TableBasicSettings
931
+ linkTitle={settingsDropdownOpened ? "Скрыть настройки" : "Настройки"}
932
+ opened={settingsDropdownOpened}
933
+ setOpened={setSettingsDropdownOpened}
934
+ >
935
+ <MasterTable.TableBasicSettings.Header>Показать столбцы</MasterTable.TableBasicSettings.Header>
936
+
937
+ <MasterTable.TableBasicSettings.Body>
938
+ <ColumnSettings>{renderColumns()}</ColumnSettings>
939
+ </MasterTable.TableBasicSettings.Body>
940
+
941
+ <MasterTable.TableBasicSettings.Footer>
942
+ <Button theme={EButtonTheme.LINK} size={EComponentSize.MD} onClick={showAllColumns}>
943
+ Выбрать все
944
+ </Button>
945
+ <Button theme={EButtonTheme.LINK} size={EComponentSize.MD} onClick={resetColumns}>
946
+ Сбросить настройки
947
+ </Button>
948
+ </MasterTable.TableBasicSettings.Footer>
949
+ </MasterTable.TableBasicSettings>
950
+ </MasterTable.ChipPanel.Links>
951
+ </MasterTable.ChipPanel>
952
+ <MasterTable.TableBasic
953
+ columns={columns}
954
+ data={data}
955
+ renderNoData={renderNoData}
956
+ renderNoColumns={() => renderNoColumns(resetColumns)}
957
+ />
958
+ </MasterTable>
959
+ );
960
+ };
961
+ ```
962
+
963
+ ### TableSettingsColumnExtended
964
+
965
+ `stories/TableBasic/examples/TableSettingsColumnExtended.tsx`
966
+
967
+ ```tsx
968
+ import React, { useState } from "react";
969
+ import { EmptytableSysIcon96 } from "@sberbusiness/icons-next";
970
+ import {
971
+ Amount,
972
+ Button,
973
+ Checkbox,
974
+ ColumnSettings,
975
+ EButtonTheme,
976
+ EComponentSize,
977
+ EFontType,
978
+ EFontWeightTitle,
979
+ EHorizontalAlign,
980
+ ETextSize,
981
+ ETitleSize,
982
+ Gap,
983
+ MasterTable,
984
+ Text,
985
+ Title,
986
+ ITableBasicColumn,
987
+ ITableBasicRow,
988
+ } from "@sberbusiness/triplex-next";
989
+
990
+ const renderNoData = () => (
991
+ <>
992
+ <EmptytableSysIcon96 />
993
+ <Gap size={8} />
994
+ <Title size={ETitleSize.H3} weight={EFontWeightTitle.REGULAR}>
995
+ Текст заголовка
996
+ </Title>
997
+ <Gap size={12} />
998
+ <Text tag="div" size={ETextSize.B3} type={EFontType.SECONDARY}>
999
+ Нет данных, но можно предложить какие-то действия для заполнения таблицы
1000
+ </Text>
1001
+ <Gap size={24} />
1002
+ <div>
1003
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD}>
1004
+ Button text
1005
+ </Button>
1006
+ <Button theme={EButtonTheme.GENERAL} size={EComponentSize.MD}>
1007
+ Button text
1008
+ </Button>
1009
+ </div>
1010
+ </>
1011
+ );
1012
+
1013
+ const renderNoColumns = (onReset: () => void) => (
1014
+ <MasterTable.NoColumns>
1015
+ <Title size={ETitleSize.H3} weight={EFontWeightTitle.REGULAR}>
1016
+ Все колонки таблицы скрыты
1017
+ </Title>
1018
+ <Gap size={12} />
1019
+ <Text tag="div" size={ETextSize.B3} type={EFontType.SECONDARY}>
1020
+ Выберите нужные вам для отображения колонки в настройках таблицы.
1021
+ </Text>
1022
+ <Gap size={24} />
1023
+ <Button theme={EButtonTheme.GENERAL} size={EComponentSize.MD} onClick={onReset}>
1024
+ Сбросить настройки
1025
+ </Button>
1026
+ </MasterTable.NoColumns>
1027
+ );
1028
+
1029
+ // Колонки собираются вне рендера, чтобы таблица не обновляла контекст MasterTable на каждый рендер.
1030
+ const defaultColumns: ITableBasicColumn[] = [
1031
+ {
1032
+ fieldKey: "number",
1033
+ label: "Номер",
1034
+ },
1035
+ {
1036
+ fieldKey: "value",
1037
+ label: "Получатель",
1038
+ },
1039
+ {
1040
+ fieldKey: "sum",
1041
+ horizontalAlign: EHorizontalAlign.RIGHT,
1042
+ label: "Сумма",
1043
+ renderCell: (fieldValue) => fieldValue && <Amount value={fieldValue} currency="RUB" />,
1044
+ },
1045
+ {
1046
+ fieldKey: "status",
1047
+ label: "Статус",
1048
+ },
1049
+ ];
1050
+
1051
+ interface IValueColumnOptions {
1052
+ showCounterparty: boolean;
1053
+ showNumber: boolean;
1054
+ showNDS: boolean;
1055
+ }
1056
+
1057
+ const initiateValueColumnOptions = (defaultChecked: boolean): IValueColumnOptions => ({
1058
+ showCounterparty: defaultChecked,
1059
+ showNumber: defaultChecked,
1060
+ showNDS: defaultChecked,
1061
+ });
1062
+
1063
+ const defaultValueColumnOptions = initiateValueColumnOptions(true);
1064
+
1065
+ const mapValueColumnOptionKeyToName: Record<string, string> = {
1066
+ showCounterparty: "Получатель",
1067
+ showNumber: "Номер счета",
1068
+ showNDS: "НДС",
1069
+ };
1070
+
1071
+ /**
1072
+ * Возвращает данные для таблицы.
1073
+ * @param showCounterparty - показать блок Получатель в столбце Значение.
1074
+ * @param showNumber - показать блок Номер счета в столбце Значение.
1075
+ * @param showNDS - показать блок НДС счета в столбце Значение.
1076
+ */
1077
+ const getData = ({ showCounterparty, showNumber, showNDS }: IValueColumnOptions): ITableBasicRow[] =>
1078
+ Array.from({ length: 5 }, (_, index) => ({
1079
+ rowData: {
1080
+ number: 1397450 + index,
1081
+ status: "Исполнено",
1082
+ sum: "1220000000",
1083
+ value: (
1084
+ <>
1085
+ {(showCounterparty || showNumber) && (
1086
+ <>
1087
+ <div>
1088
+ {showCounterparty && (
1089
+ <>
1090
+ Платежное поручение ООО Ромашка <br />
1091
+ </>
1092
+ )}
1093
+ {showNumber && "40702 810 2 0527 5000000"}
1094
+ </div>
1095
+ <Gap size={4} />
1096
+ </>
1097
+ )}
1098
+
1099
+ {showNDS && (
1100
+ <Text tag="div" size={ETextSize.B4} type={EFontType.SECONDARY}>
1101
+ В том числе НДС 20%
1102
+ </Text>
1103
+ )}
1104
+ </>
1105
+ ),
1106
+ },
1107
+ rowKey: `table-basic-row-${index}`,
1108
+ }));
1109
+
1110
+ interface IValueColumnSettingsProps {
1111
+ column: ITableBasicColumn;
1112
+ options: IValueColumnOptions;
1113
+ setColumn: (checked: boolean) => void;
1114
+ setOptions: (option: IValueColumnOptions) => void;
1115
+ }
1116
+
1117
+ const ValueColumnSettings = ({ column, options, setColumn, setOptions }: IValueColumnSettingsProps) => {
1118
+ const handleChildCheckboxChange = (event: React.ChangeEvent<HTMLInputElement>, columnKey: string) => {
1119
+ const newOptions = { ...options, [columnKey]: event.target.checked };
1120
+
1121
+ if (event.target.checked !== !column.hidden) {
1122
+ if (event.target.checked) {
1123
+ if (Object.values(options).every((value) => value === false)) {
1124
+ setColumn(event.target.checked);
1125
+ }
1126
+ } else {
1127
+ if (Object.values(newOptions).every((value) => value === false)) {
1128
+ setColumn(event.target.checked);
1129
+ }
1130
+ }
1131
+ }
1132
+
1133
+ setOptions(newOptions);
1134
+ };
1135
+
1136
+ const handleParentCheckboxChange = (event: React.ChangeEvent<HTMLInputElement>) => {
1137
+ setOptions(initiateValueColumnOptions(event.target.checked));
1138
+ setColumn(event.target.checked);
1139
+ };
1140
+
1141
+ const renderOptions = () => (
1142
+ <ColumnSettings.StaticList depth={1}>
1143
+ {Object.entries(options).map(([key, value], index) => (
1144
+ <ColumnSettings.StaticList.Item key={index}>
1145
+ <Checkbox checked={Boolean(value)} onChange={(event) => handleChildCheckboxChange(event, key)}>
1146
+ {mapValueColumnOptionKeyToName[key]}
1147
+ </Checkbox>
1148
+ </ColumnSettings.StaticList.Item>
1149
+ ))}
1150
+ </ColumnSettings.StaticList>
1151
+ );
1152
+
1153
+ return (
1154
+ <ColumnSettings.SortableList.Item id={column.fieldKey} staticContent={renderOptions()}>
1155
+ <Checkbox
1156
+ checked={!column.hidden}
1157
+ bulk={Object.values(options).some((value) => value !== !column.hidden)}
1158
+ onChange={handleParentCheckboxChange}
1159
+ >
1160
+ {column.label}
1161
+ </Checkbox>
1162
+ </ColumnSettings.SortableList.Item>
1163
+ );
1164
+ };
1165
+
1166
+ export const TableSettingsColumnExtended = () => {
1167
+ const [columns, setColumns] = useState(defaultColumns);
1168
+ const [valueColumnOptions, setValueColumnOptions] = useState(defaultValueColumnOptions);
1169
+ const [settingsDropdownOpened, setSettingsDropdownOpened] = useState(false);
1170
+
1171
+ // Содержимое колонки «Получатель» выводится из настроек — отдельного состояния для данных не нужно.
1172
+ const data = getData(valueColumnOptions);
1173
+
1174
+ const resetColumns = () => {
1175
+ setColumns(defaultColumns);
1176
+ setValueColumnOptions(defaultValueColumnOptions);
1177
+ };
1178
+
1179
+ const showAllColumns = () => {
1180
+ setColumns(
1181
+ columns.map((column) => {
1182
+ if (column.hidden) {
1183
+ return { ...column, hidden: false };
1184
+ }
1185
+ return column;
1186
+ }),
1187
+ );
1188
+ setValueColumnOptions(defaultValueColumnOptions);
1189
+ };
1190
+
1191
+ const toggleColumn = (fieldKey: string, checked: boolean): void => {
1192
+ setColumns(
1193
+ columns.map((column) => {
1194
+ if (column.fieldKey === fieldKey) {
1195
+ return { ...column, hidden: !checked };
1196
+ }
1197
+ return column;
1198
+ }),
1199
+ );
1200
+ };
1201
+
1202
+ const renderColumns = () => (
1203
+ <ColumnSettings.SortableList columns={columns} onColumnsChange={setColumns}>
1204
+ {columns.map((column) => {
1205
+ if (column.fieldKey === "value") {
1206
+ return (
1207
+ <ValueColumnSettings
1208
+ key={column.fieldKey}
1209
+ column={column}
1210
+ options={valueColumnOptions}
1211
+ setColumn={(checked: boolean) => toggleColumn(column.fieldKey, checked)}
1212
+ setOptions={(options) => {
1213
+ setValueColumnOptions(options);
1214
+ }}
1215
+ />
1216
+ );
1217
+ }
1218
+
1219
+ return (
1220
+ <ColumnSettings.SortableList.Item key={column.fieldKey} id={column.fieldKey}>
1221
+ <Checkbox
1222
+ checked={column.hidden !== true}
1223
+ onChange={(event) => toggleColumn(column.fieldKey, event.target.checked)}
1224
+ >
1225
+ {column.label}
1226
+ </Checkbox>
1227
+ </ColumnSettings.SortableList.Item>
1228
+ );
1229
+ })}
1230
+ </ColumnSettings.SortableList>
1231
+ );
1232
+
1233
+ return (
1234
+ <MasterTable>
1235
+ <MasterTable.ChipPanel>
1236
+ <MasterTable.ChipPanel.Links>
1237
+ <MasterTable.TableBasicSettings
1238
+ linkTitle={settingsDropdownOpened ? "Скрыть настройки" : "Настройки"}
1239
+ opened={settingsDropdownOpened}
1240
+ setOpened={setSettingsDropdownOpened}
1241
+ >
1242
+ <MasterTable.TableBasicSettings.Header>Показать столбцы</MasterTable.TableBasicSettings.Header>
1243
+
1244
+ <MasterTable.TableBasicSettings.Body>
1245
+ <ColumnSettings>{renderColumns()}</ColumnSettings>
1246
+ </MasterTable.TableBasicSettings.Body>
1247
+
1248
+ <MasterTable.TableBasicSettings.Footer>
1249
+ <Button theme={EButtonTheme.LINK} size={EComponentSize.MD} onClick={showAllColumns}>
1250
+ Выбрать все
1251
+ </Button>
1252
+ <Button theme={EButtonTheme.LINK} size={EComponentSize.MD} onClick={resetColumns}>
1253
+ Сбросить настройки
1254
+ </Button>
1255
+ </MasterTable.TableBasicSettings.Footer>
1256
+ </MasterTable.TableBasicSettings>
1257
+ </MasterTable.ChipPanel.Links>
1258
+ </MasterTable.ChipPanel>
1259
+ <MasterTable.TableBasic
1260
+ columns={columns}
1261
+ data={data}
1262
+ renderNoData={renderNoData}
1263
+ renderNoColumns={() => renderNoColumns(resetColumns)}
1264
+ />
1265
+ </MasterTable>
1266
+ );
1267
+ };
1268
+ ```
1269
+
1270
+ ### TableWithPagination
1271
+
1272
+ `stories/TableBasic/examples/TableWithPagination.tsx`
1273
+
1274
+ ```tsx
1275
+ import React, { useState } from "react";
1276
+ import {
1277
+ Amount,
1278
+ EFontType,
1279
+ EHorizontalAlign,
1280
+ ETextSize,
1281
+ Gap,
1282
+ MasterTable,
1283
+ Pagination,
1284
+ Text,
1285
+ ISelectExtendedFieldDefaultOption,
1286
+ ITableBasicColumn,
1287
+ ITableBasicRow,
1288
+ } from "@sberbusiness/triplex-next";
1289
+
1290
+ const renderCounterpartyDetails = (purpose: string, account: string, tax: string) => (
1291
+ <>
1292
+ <div>
1293
+ {purpose}
1294
+ <br />
1295
+ {account}
1296
+ </div>
1297
+ <Gap size={4} />
1298
+ <Text tag="div" size={ETextSize.B4} type={EFontType.SECONDARY}>
1299
+ {tax}
1300
+ </Text>
1301
+ </>
1302
+ );
1303
+
1304
+ // Колонки и данные собираются вне рендера, чтобы таблица не обновляла контекст MasterTable на каждый рендер.
1305
+ const columns: ITableBasicColumn[] = [
1306
+ {
1307
+ fieldKey: "number",
1308
+ label: "Номер",
1309
+ },
1310
+ {
1311
+ fieldKey: "value",
1312
+ label: "Получатель",
1313
+ },
1314
+ {
1315
+ fieldKey: "sum",
1316
+ horizontalAlign: EHorizontalAlign.RIGHT,
1317
+ label: "Сумма",
1318
+ renderCell: (fieldValue) => fieldValue && <Amount value={fieldValue} currency="RUB" />,
1319
+ },
1320
+ {
1321
+ fieldKey: "status",
1322
+ label: "Статус",
1323
+ },
1324
+ ];
1325
+
1326
+ const data: ITableBasicRow[] = Array.from({ length: 300 }, (_, index) => ({
1327
+ rowData: {
1328
+ number: 1000 + index,
1329
+ status: "Исполнено",
1330
+ sum: "1220000000",
1331
+ value: renderCounterpartyDetails(
1332
+ "Платежное поручение ООО Ромашка",
1333
+ "40702 810 2 0527 5000000",
1334
+ "В том числе НДС 20%",
1335
+ ),
1336
+ },
1337
+ rowKey: `table-basic-row-${index}`,
1338
+ }));
1339
+
1340
+ const options: ISelectExtendedFieldDefaultOption[] = [
1341
+ { id: "0", value: "10", label: "10" },
1342
+ { id: "1", value: "20", label: "20" },
1343
+ { id: "2", value: "50", label: "50" },
1344
+ { id: "3", value: "100", label: "100" },
1345
+ ];
1346
+
1347
+ export const TableWithPagination = () => {
1348
+ const [page, setPage] = useState(1);
1349
+ const [pageSize, setPageSize] = useState(10);
1350
+ const totalItems = data.length;
1351
+ const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
1352
+
1353
+ const handlePageSizeChange = (option: ISelectExtendedFieldDefaultOption) => {
1354
+ setPage(1);
1355
+ setPageSize(Number(option.value));
1356
+ };
1357
+
1358
+ const selectedOption = options.find((option) => option.value === String(pageSize));
1359
+
1360
+ const getPaginatedData = () => {
1361
+ return data.slice((page - 1) * pageSize, page * pageSize);
1362
+ };
1363
+
1364
+ return (
1365
+ <MasterTable>
1366
+ <MasterTable.TableBasic
1367
+ columns={columns}
1368
+ data={getPaginatedData()}
1369
+ renderNoData={() => <div>Нет данных</div>}
1370
+ />
1371
+ <MasterTable.PaginationPanel>
1372
+ <Pagination
1373
+ paginationNavigationProps={{
1374
+ currentPage: page,
1375
+ totalPages,
1376
+ boundaryCount: 1,
1377
+ siblingCount: 1,
1378
+ onCurrentPageChange: setPage,
1379
+ }}
1380
+ paginationSelectProps={{
1381
+ paginationLabel: "Показать на странице:",
1382
+ options,
1383
+ value: selectedOption || options[0],
1384
+ onChange: handlePageSizeChange,
1385
+ targetProps: {
1386
+ fieldLabel: "",
1387
+ },
1388
+ }}
1389
+ />
1390
+ </MasterTable.PaginationPanel>
1391
+ </MasterTable>
1392
+ );
1393
+ };
1394
+ ```
1395
+
1396
+ ### TableWithPaginationLoading
1397
+
1398
+ `stories/TableBasic/examples/TableWithPaginationLoading.tsx`
1399
+
1400
+ ```tsx
1401
+ import React, { useState } from "react";
1402
+ import { EmptytableSysIcon96, NotfoundSysIcon96 } from "@sberbusiness/icons-next";
1403
+ import {
1404
+ Amount,
1405
+ Button,
1406
+ Col,
1407
+ EButtonTheme,
1408
+ EComponentSize,
1409
+ EFontType,
1410
+ EFontWeightTitle,
1411
+ EHorizontalAlign,
1412
+ ETextSize,
1413
+ ETitleSize,
1414
+ Gap,
1415
+ MasterTable,
1416
+ Pagination,
1417
+ Row,
1418
+ Text,
1419
+ TextField,
1420
+ Title,
1421
+ ISelectExtendedFieldDefaultOption,
1422
+ ITableBasicColumn,
1423
+ ITableBasicRow,
1424
+ } from "@sberbusiness/triplex-next";
1425
+
1426
+ const renderCounterpartyDetails = (purpose: string, account: string, tax: string) => (
1427
+ <>
1428
+ <div>
1429
+ {purpose}
1430
+ <br />
1431
+ {account}
1432
+ </div>
1433
+ <Gap size={4} />
1434
+ <Text tag="div" size={ETextSize.B4} type={EFontType.SECONDARY}>
1435
+ {tax}
1436
+ </Text>
1437
+ </>
1438
+ );
1439
+
1440
+ const renderNoData = (isFiltered: boolean) => (
1441
+ <>
1442
+ {isFiltered ? <NotfoundSysIcon96 /> : <EmptytableSysIcon96 />}
1443
+ <Gap size={8} />
1444
+ <Title size={ETitleSize.H3} weight={EFontWeightTitle.REGULAR}>
1445
+ Текст заголовка
1446
+ </Title>
1447
+ <Gap size={12} />
1448
+ <Text tag="div" size={ETextSize.B3} type={EFontType.SECONDARY}>
1449
+ Нет данных, но можно предложить какие-то действия для заполнения таблицы
1450
+ </Text>
1451
+ <Gap size={24} />
1452
+ <div>
1453
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD}>
1454
+ Button text
1455
+ </Button>
1456
+ <Button theme={EButtonTheme.GENERAL} size={EComponentSize.MD}>
1457
+ Button text
1458
+ </Button>
1459
+ </div>
1460
+ </>
1461
+ );
1462
+
1463
+ // Колонки собираются вне рендера, чтобы таблица не обновляла контекст MasterTable на каждый рендер.
1464
+ const columns: ITableBasicColumn[] = [
1465
+ {
1466
+ fieldKey: "number",
1467
+ label: "Номер",
1468
+ },
1469
+ {
1470
+ fieldKey: "value",
1471
+ label: "Получатель",
1472
+ },
1473
+ {
1474
+ fieldKey: "sum",
1475
+ horizontalAlign: EHorizontalAlign.RIGHT,
1476
+ label: "Сумма",
1477
+ renderCell: (fieldValue) => fieldValue && <Amount value={fieldValue} currency="RUB" />,
1478
+ },
1479
+ {
1480
+ fieldKey: "status",
1481
+ label: "Статус",
1482
+ },
1483
+ ];
1484
+
1485
+ const filtersAndPaginationRows = Array.from({ length: 30 }, (_, index) => ({
1486
+ docNumber: String(1000 + index),
1487
+ recipient: index % 2 === 0 ? "ООО Ромашка" : "ИП Иванов Иван Иванович",
1488
+ status: ["Создан", "Подписан", "Оплачен"][index % 3],
1489
+ sum: String((index + 1) * 10000),
1490
+ }));
1491
+
1492
+ const pageSizeOptions: ISelectExtendedFieldDefaultOption[] = [
1493
+ { id: "0", value: "10", label: "10" },
1494
+ { id: "1", value: "20", label: "20" },
1495
+ { id: "2", value: "50", label: "50" },
1496
+ ];
1497
+
1498
+ export const TableWithPaginationLoading = () => {
1499
+ const [docNumberFilter, setDocNumberFilter] = useState("");
1500
+ const [recipientFilter, setRecipientFilter] = useState("");
1501
+ const [page, setPage] = useState(1);
1502
+ const [pageSize, setPageSize] = useState(10);
1503
+
1504
+ const isFiltered = docNumberFilter !== "" || recipientFilter !== "";
1505
+
1506
+ const filteredRows = filtersAndPaginationRows.filter(
1507
+ (row) =>
1508
+ row.docNumber.includes(docNumberFilter) &&
1509
+ row.recipient.toLowerCase().includes(recipientFilter.toLowerCase()),
1510
+ );
1511
+
1512
+ const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
1513
+ // Фильтр мог сократить выборку сильнее, чем текущая страница — страховка от выхода за диапазон.
1514
+ const currentPage = Math.min(page, totalPages);
1515
+
1516
+ const data: ITableBasicRow[] = filteredRows
1517
+ .slice((currentPage - 1) * pageSize, currentPage * pageSize)
1518
+ .map((row) => ({
1519
+ rowData: {
1520
+ number: row.docNumber,
1521
+ status: row.status,
1522
+ sum: row.sum,
1523
+ value: renderCounterpartyDetails(
1524
+ `Платежное поручение ${row.recipient}`,
1525
+ "40702 810 2 0527 5000000",
1526
+ "В том числе НДС 20%",
1527
+ ),
1528
+ },
1529
+ rowKey: `table-with-filters-row-${row.docNumber}`,
1530
+ }));
1531
+
1532
+ const handleChangeDocNumber = (event: React.ChangeEvent<HTMLInputElement>) => {
1533
+ setDocNumberFilter(event.target.value);
1534
+ setPage(1);
1535
+ };
1536
+
1537
+ const handleChangeRecipient = (event: React.ChangeEvent<HTMLInputElement>) => {
1538
+ setRecipientFilter(event.target.value);
1539
+ setPage(1);
1540
+ };
1541
+
1542
+ const handlePageSizeChange = (option: ISelectExtendedFieldDefaultOption) => {
1543
+ setPageSize(Number(option.value));
1544
+ setPage(1);
1545
+ };
1546
+
1547
+ return (
1548
+ <MasterTable loading>
1549
+ <MasterTable.FilterPanel>
1550
+ <Row paddingBottom={false}>
1551
+ <Col size={6}>
1552
+ <TextField
1553
+ size={EComponentSize.MD}
1554
+ inputProps={{
1555
+ value: docNumberFilter,
1556
+ onChange: handleChangeDocNumber,
1557
+ placeholder: "Введите номер документа",
1558
+ }}
1559
+ label="Номер документа"
1560
+ />
1561
+ </Col>
1562
+ <Col size={6}>
1563
+ <TextField
1564
+ size={EComponentSize.MD}
1565
+ inputProps={{
1566
+ value: recipientFilter,
1567
+ onChange: handleChangeRecipient,
1568
+ placeholder: "Введите получателя",
1569
+ }}
1570
+ label="Получатель"
1571
+ />
1572
+ </Col>
1573
+ </Row>
1574
+ </MasterTable.FilterPanel>
1575
+ <MasterTable.TableBasic columns={columns} data={data} renderNoData={() => renderNoData(isFiltered)} />
1576
+ <MasterTable.PaginationPanel>
1577
+ <Pagination
1578
+ paginationNavigationProps={{
1579
+ currentPage,
1580
+ totalPages,
1581
+ boundaryCount: 1,
1582
+ siblingCount: 1,
1583
+ onCurrentPageChange: setPage,
1584
+ }}
1585
+ paginationSelectProps={{
1586
+ paginationLabel: "Показать на странице:",
1587
+ options: pageSizeOptions,
1588
+ value:
1589
+ pageSizeOptions.find((option) => option.value === String(pageSize)) || pageSizeOptions[0],
1590
+ onChange: handlePageSizeChange,
1591
+ targetProps: {
1592
+ fieldLabel: "",
1593
+ },
1594
+ }}
1595
+ />
1596
+ </MasterTable.PaginationPanel>
1597
+ </MasterTable>
1598
+ );
1599
+ };
1600
+ ```
1601
+
1602
+ ### TableWithPaginationExtended
1603
+
1604
+ `stories/TableBasic/examples/TableWithPaginationExtended.tsx`
1605
+
1606
+ ```tsx
1607
+ import React, { useState } from "react";
1608
+ import {
1609
+ Amount,
1610
+ EFontType,
1611
+ EHorizontalAlign,
1612
+ EPaginationNavigationIconDirection,
1613
+ ETextSize,
1614
+ Gap,
1615
+ MasterTable,
1616
+ PaginationExtended,
1617
+ PaginationNavigationButton,
1618
+ PaginationNavigationExtended,
1619
+ PaginationNavigationExtendedItem,
1620
+ PaginationPageButton,
1621
+ PaginationSelect,
1622
+ Text,
1623
+ ISelectExtendedFieldDefaultOption,
1624
+ ITableBasicColumn,
1625
+ ITableBasicRow,
1626
+ } from "@sberbusiness/triplex-next";
1627
+
1628
+ const renderCounterpartyDetails = (purpose: string, account: string, tax: string) => (
1629
+ <>
1630
+ <div>
1631
+ {purpose}
1632
+ <br />
1633
+ {account}
1634
+ </div>
1635
+ <Gap size={4} />
1636
+ <Text tag="div" size={ETextSize.B4} type={EFontType.SECONDARY}>
1637
+ {tax}
1638
+ </Text>
1639
+ </>
1640
+ );
1641
+
1642
+ // Колонки и данные собираются вне рендера, чтобы таблица не обновляла контекст MasterTable на каждый рендер.
1643
+ const columns: ITableBasicColumn[] = [
1644
+ {
1645
+ fieldKey: "number",
1646
+ label: "Номер",
1647
+ },
1648
+ {
1649
+ fieldKey: "value",
1650
+ label: "Получатель",
1651
+ },
1652
+ {
1653
+ fieldKey: "sum",
1654
+ horizontalAlign: EHorizontalAlign.RIGHT,
1655
+ label: "Сумма",
1656
+ renderCell: (fieldValue) => fieldValue && <Amount value={fieldValue} currency="RUB" />,
1657
+ },
1658
+ {
1659
+ fieldKey: "status",
1660
+ label: "Статус",
1661
+ },
1662
+ ];
1663
+
1664
+ const data: ITableBasicRow[] = Array.from({ length: 300 }, (_, index) => ({
1665
+ rowData: {
1666
+ number: 1000 + index,
1667
+ status: "Исполнено",
1668
+ sum: "1220000000",
1669
+ value: renderCounterpartyDetails(
1670
+ "Платежное поручение ООО Ромашка",
1671
+ "40702 810 2 0527 5000000",
1672
+ "В том числе НДС 20%",
1673
+ ),
1674
+ },
1675
+ rowKey: `table-basic-row-${index}`,
1676
+ }));
1677
+
1678
+ const options: ISelectExtendedFieldDefaultOption[] = [
1679
+ { id: "0", value: "10", label: "10" },
1680
+ { id: "1", value: "20", label: "20" },
1681
+ { id: "2", value: "50", label: "50" },
1682
+ { id: "3", value: "100", label: "100" },
1683
+ ];
1684
+
1685
+ export const TableWithPaginationExtended = () => {
1686
+ const [page, setPage] = useState(1);
1687
+ const [pageSize, setPageSize] = useState(10);
1688
+ const totalItems = data.length;
1689
+ const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
1690
+
1691
+ const handlePageSizeChange = (option: ISelectExtendedFieldDefaultOption) => {
1692
+ setPage(1);
1693
+ setPageSize(Number(option.value));
1694
+ };
1695
+
1696
+ const selectedOption = options.find((option) => option.value === String(pageSize));
1697
+
1698
+ const getPaginatedData = () => {
1699
+ return data.slice((page - 1) * pageSize, page * pageSize);
1700
+ };
1701
+
1702
+ return (
1703
+ <MasterTable>
1704
+ <MasterTable.TableBasic
1705
+ columns={columns}
1706
+ data={getPaginatedData()}
1707
+ renderNoData={() => <div>Нет данных</div>}
1708
+ />
1709
+ <MasterTable.PaginationPanel>
1710
+ <PaginationExtended>
1711
+ {totalPages > 1 && (
1712
+ <PaginationNavigationExtended>
1713
+ <PaginationNavigationExtendedItem>
1714
+ <PaginationNavigationButton
1715
+ direction={EPaginationNavigationIconDirection.BACK}
1716
+ onClick={() => setPage((prevPage) => Math.max(1, prevPage - 1))}
1717
+ disabled={page === 1}
1718
+ />
1719
+ </PaginationNavigationExtendedItem>
1720
+
1721
+ <PaginationNavigationExtendedItem key="current-page">
1722
+ <PaginationPageButton isCurrent onClick={() => {}}>
1723
+ {page}
1724
+ </PaginationPageButton>
1725
+ </PaginationNavigationExtendedItem>
1726
+
1727
+ <PaginationNavigationExtendedItem>
1728
+ <PaginationNavigationButton
1729
+ direction={EPaginationNavigationIconDirection.NEXT}
1730
+ onClick={() => setPage((prevPage) => Math.min(totalPages, prevPage + 1))}
1731
+ disabled={page === totalPages}
1732
+ />
1733
+ </PaginationNavigationExtendedItem>
1734
+ </PaginationNavigationExtended>
1735
+ )}
1736
+ <PaginationSelect
1737
+ paginationLabel="Показать на странице:"
1738
+ value={selectedOption || options[0]}
1739
+ options={options}
1740
+ onChange={handlePageSizeChange}
1741
+ targetProps={{
1742
+ fieldLabel: "",
1743
+ }}
1744
+ />
1745
+ </PaginationExtended>
1746
+ </MasterTable.PaginationPanel>
1747
+ </MasterTable>
1748
+ );
1749
+ };
1750
+ ```
1751
+
1752
+ ### Example: production
1753
+
1754
+ `stories/TableBasic/examples/ExampleProduction.tsx`
1755
+
1756
+ ```tsx
1757
+ import React, { useState } from "react";
1758
+ import { EmptytableSysIcon96, NotfoundSysIcon96 } from "@sberbusiness/icons-next";
1759
+ import {
1760
+ Amount,
1761
+ Button,
1762
+ Checkbox,
1763
+ CheckboxYGroup,
1764
+ Chip,
1765
+ ChipGroup,
1766
+ ChipMultiselect,
1767
+ Col,
1768
+ DropdownMobileBody,
1769
+ DropdownMobileClose,
1770
+ DropdownMobileFooter,
1771
+ DropdownMobileHeader,
1772
+ DropdownMobileInput,
1773
+ EButtonTheme,
1774
+ EComponentSize,
1775
+ EDropdownAlignment,
1776
+ EFontType,
1777
+ EFontWeightTitle,
1778
+ EHorizontalAlign,
1779
+ EMarkerStatus,
1780
+ ETextSize,
1781
+ ETitleSize,
1782
+ FormField,
1783
+ FormFieldClear,
1784
+ FormFieldInput,
1785
+ FormFieldLabel,
1786
+ FormFieldPostfix,
1787
+ Gap,
1788
+ Link,
1789
+ MarkerStatus,
1790
+ MasterTable,
1791
+ MultiselectField,
1792
+ Row,
1793
+ SelectField,
1794
+ Tag,
1795
+ TagGroup,
1796
+ Text,
1797
+ TextField,
1798
+ Title,
1799
+ ISelectFieldOption,
1800
+ ITableBasicColumn,
1801
+ ITableBasicRow,
1802
+ } from "@sberbusiness/triplex-next";
1803
+
1804
+ /** Ключи полей таблицы. */
1805
+ enum ETableField {
1806
+ Number = "Number",
1807
+ Recipient = "Recipient",
1808
+ Sum = "Sum",
1809
+ Status = "Status",
1810
+ }
1811
+
1812
+ interface IExampleExtraFilters {
1813
+ recipientOption: ISelectFieldOption | undefined;
1814
+ docNumber: string;
1815
+ }
1816
+
1817
+ const renderCounterpartyDetails = (purpose: string, account: string, tax: string) => (
1818
+ <>
1819
+ <div>
1820
+ {purpose}
1821
+ <br />
1822
+ {account}
1823
+ </div>
1824
+ <Gap size={4} />
1825
+ <Text tag="div" size={ETextSize.B4} type={EFontType.SECONDARY}>
1826
+ {tax}
1827
+ </Text>
1828
+ </>
1829
+ );
1830
+
1831
+ const renderNoData = (isFiltered: boolean) => (
1832
+ <>
1833
+ {isFiltered ? <NotfoundSysIcon96 /> : <EmptytableSysIcon96 />}
1834
+ <Gap size={8} />
1835
+ <Title size={ETitleSize.H3} weight={EFontWeightTitle.REGULAR}>
1836
+ Текст заголовка
1837
+ </Title>
1838
+ <Gap size={12} />
1839
+ <Text tag="div" size={ETextSize.B3} type={EFontType.SECONDARY}>
1840
+ Нет данных, но можно предложить какие-то действия для заполнения таблицы
1841
+ </Text>
1842
+ <Gap size={24} />
1843
+ <div>
1844
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD}>
1845
+ Button text
1846
+ </Button>
1847
+ <Button theme={EButtonTheme.GENERAL} size={EComponentSize.MD}>
1848
+ Button text
1849
+ </Button>
1850
+ </div>
1851
+ </>
1852
+ );
1853
+
1854
+ const segments: { id: string; label: string; status: string | null; withNotification?: boolean }[] = [
1855
+ { id: "all", label: "Все", status: null },
1856
+ { id: "drafts", label: "Черновики", status: "Создан" },
1857
+ { id: "toSign", label: "На подпись и отправку", status: "Подписан", withNotification: true },
1858
+ { id: "executed", label: "Исполненные", status: "Оплачен" },
1859
+ ];
1860
+
1861
+ const statusFilterOptions = [
1862
+ { id: "created", label: "Создан" },
1863
+ { id: "signed", label: "Подписан" },
1864
+ { id: "paid", label: "Оплачен" },
1865
+ ];
1866
+
1867
+ const statusToMarker: Record<string, EMarkerStatus> = {
1868
+ Создан: EMarkerStatus.WAITING,
1869
+ Подписан: EMarkerStatus.WARNING,
1870
+ Оплачен: EMarkerStatus.SUCCESS,
1871
+ };
1872
+
1873
+ const counterpartyOptions: ISelectFieldOption[] = [
1874
+ { id: "0", value: "", label: "Не указан" },
1875
+ { id: "1", value: "ООО Ромашка", label: "ООО Ромашка" },
1876
+ { id: "2", value: "ИП Иванов Иван Иванович", label: "ИП Иванов Иван Иванович" },
1877
+ ];
1878
+
1879
+ const documents = [
1880
+ {
1881
+ docNumber: "1350",
1882
+ recipient: { account: "40702 810 2 9045 8100009", name: "ИП Иванов Иван Иванович" },
1883
+ purpose: "Платёжное поручение ИП Иванов Иван Иванович за оказание услуг по акту №67834259-ТНГК2356-345",
1884
+ tax: "В том числе НДС 20%",
1885
+ sum: "9450.00",
1886
+ status: "Создан",
1887
+ },
1888
+ {
1889
+ docNumber: "1351",
1890
+ recipient: { account: "40702 810 2 0527 5520080", name: "ООО Ромашка" },
1891
+ purpose: "Платёжное поручение ООО Ромашка за оказание услуг по акту №67834259-ТНГК2356-345",
1892
+ tax: "В том числе НДС 20%",
1893
+ sum: "18914.00",
1894
+ status: "Создан",
1895
+ },
1896
+ {
1897
+ docNumber: "1352",
1898
+ recipient: { account: "40702 810 2 2581 6403700", name: "ИП Иванов Иван Иванович" },
1899
+ purpose: "Платёжное поручение ИП Иванов Иван Иванович за оказание услуг по акту №67834259-ТНГК2356-345",
1900
+ tax: "В том числе НДС 20%",
1901
+ sum: "28392.00",
1902
+ status: "Подписан",
1903
+ },
1904
+ {
1905
+ docNumber: "1353",
1906
+ recipient: { account: "40702 810 2 8302 7306400", name: "ООО Ромашка" },
1907
+ purpose: "Платёжное поручение ООО Ромашка за оказание услуг по акту №67834259-ТНГК2356-345",
1908
+ tax: "В том числе НДС 20%",
1909
+ sum: "37884.00",
1910
+ status: "Подписан",
1911
+ },
1912
+ {
1913
+ docNumber: "1354",
1914
+ recipient: { account: "40702 810 2 9947 8250050", name: "ИП Иванов Иван Иванович" },
1915
+ purpose: "Платёжное поручение ИП Иванов Иван Иванович за оказание услуг по акту №67834259-ТНГК2356-345",
1916
+ tax: "В том числе НДС 20%",
1917
+ sum: "47390.00",
1918
+ status: "Оплачен",
1919
+ },
1920
+ {
1921
+ docNumber: "1355",
1922
+ recipient: { account: "40702 810 2 0290 9100406", name: "ООО Ромашка" },
1923
+ purpose: "Платёжное поручение ООО Ромашка за оказание услуг по акту №67834259-ТНГК2356-345",
1924
+ tax: "В том числе НДС 20%",
1925
+ sum: "56910.00",
1926
+ status: "Оплачен",
1927
+ },
1928
+ ];
1929
+
1930
+ // Колонки собираются вне рендера, чтобы таблица не обновляла контекст MasterTable на каждый рендер.
1931
+ const columns: ITableBasicColumn[] = [
1932
+ { fieldKey: ETableField.Number, label: "Номер", title: "Номер", width: 100 },
1933
+ { fieldKey: ETableField.Recipient, label: "Получатель", title: "Получатель", width: 320 },
1934
+ {
1935
+ fieldKey: ETableField.Sum,
1936
+ label: "Сумма",
1937
+ title: "Сумма",
1938
+ horizontalAlign: EHorizontalAlign.RIGHT,
1939
+ renderCell: (fieldValue) => fieldValue && <Amount value={fieldValue} currency="RUB" />,
1940
+ },
1941
+ {
1942
+ fieldKey: ETableField.Status,
1943
+ label: "Статус",
1944
+ title: "Статус",
1945
+ width: 140,
1946
+ renderCell: (status: string) => (
1947
+ <MarkerStatus size={EComponentSize.LG} status={statusToMarker[status] ?? EMarkerStatus.WAITING}>
1948
+ {status}
1949
+ </MarkerStatus>
1950
+ ),
1951
+ },
1952
+ ];
1953
+
1954
+ const defaultExtraFilters: IExampleExtraFilters = { recipientOption: undefined, docNumber: "" };
1955
+
1956
+ export const ExampleProduction = () => {
1957
+ const [activeSegment, setActiveSegment] = useState("all");
1958
+ const [selectedStatusIds, setSelectedStatusIds] = useState<string[]>([]);
1959
+ const [statusFilter, setStatusFilter] = useState("");
1960
+ const [recipientQuery, setRecipientQuery] = useState("");
1961
+ const [showExtraFilters, setShowExtraFilters] = useState(false);
1962
+ const [appliedExtraFilters, setAppliedExtraFilters] = useState<IExampleExtraFilters>(defaultExtraFilters);
1963
+ const [tempExtraFilters, setTempExtraFilters] = useState<IExampleExtraFilters>(defaultExtraFilters);
1964
+
1965
+ const selectedStatusLabels = statusFilterOptions
1966
+ .filter((option) => selectedStatusIds.includes(option.id))
1967
+ .map((option) => option.label);
1968
+
1969
+ const segmentStatus = segments.find((segment) => segment.id === activeSegment)?.status ?? null;
1970
+
1971
+ const isExtraFilterApplied =
1972
+ Boolean(appliedExtraFilters.recipientOption?.value) || Boolean(appliedExtraFilters.docNumber);
1973
+
1974
+ const isAnyFilterApplied = selectedStatusLabels.length > 0 || Boolean(recipientQuery) || isExtraFilterApplied;
1975
+
1976
+ const filteredData = documents.filter((doc) => {
1977
+ if (segmentStatus && doc.status !== segmentStatus) {
1978
+ return false;
1979
+ }
1980
+ if (selectedStatusLabels.length > 0 && !selectedStatusLabels.includes(doc.status)) {
1981
+ return false;
1982
+ }
1983
+ if (recipientQuery && !doc.recipient.name.toLowerCase().includes(recipientQuery.toLowerCase())) {
1984
+ return false;
1985
+ }
1986
+ if (
1987
+ appliedExtraFilters.recipientOption?.value &&
1988
+ doc.recipient.name !== appliedExtraFilters.recipientOption.value
1989
+ ) {
1990
+ return false;
1991
+ }
1992
+ if (appliedExtraFilters.docNumber && !doc.docNumber.includes(appliedExtraFilters.docNumber)) {
1993
+ return false;
1994
+ }
1995
+ return true;
1996
+ });
1997
+
1998
+ const data: ITableBasicRow[] = filteredData.map((doc) => ({
1999
+ rowKey: doc.docNumber,
2000
+ rowData: {
2001
+ [ETableField.Number]: doc.docNumber,
2002
+ [ETableField.Recipient]: renderCounterpartyDetails(doc.purpose, doc.recipient.account, doc.tax),
2003
+ [ETableField.Sum]: doc.sum,
2004
+ [ETableField.Status]: doc.status,
2005
+ },
2006
+ }));
2007
+
2008
+ const visibleStatusOptions = statusFilterOptions.filter((option) =>
2009
+ option.label.toLowerCase().includes(statusFilter.trim().toLowerCase()),
2010
+ );
2011
+
2012
+ const toggleStatus = (optionId: string, checked: boolean) => {
2013
+ setSelectedStatusIds((prev) => (checked ? [...prev, optionId] : prev.filter((id) => id !== optionId)));
2014
+ };
2015
+
2016
+ const unselectAllStatuses = () => {
2017
+ setSelectedStatusIds([]);
2018
+ setStatusFilter("");
2019
+ };
2020
+
2021
+ const handleStatusFilterChange = (event: React.ChangeEvent<HTMLInputElement>) => {
2022
+ setStatusFilter(event.target.value);
2023
+ };
2024
+
2025
+ const handleClickToggleExtraFilters = () => {
2026
+ setTempExtraFilters(appliedExtraFilters);
2027
+ setShowExtraFilters((prev) => !prev);
2028
+ };
2029
+
2030
+ const handleClickResetTempFilters = () => {
2031
+ setTempExtraFilters(defaultExtraFilters);
2032
+ };
2033
+
2034
+ const handleClickApplyTempFilters = () => {
2035
+ setAppliedExtraFilters(tempExtraFilters);
2036
+ setShowExtraFilters(false);
2037
+ };
2038
+
2039
+ const handleRemoveTag = (newFilters: IExampleExtraFilters) => () => {
2040
+ setTempExtraFilters(newFilters);
2041
+ setAppliedExtraFilters(newFilters);
2042
+ };
2043
+
2044
+ const handleClickResetAllFilters = () => {
2045
+ unselectAllStatuses();
2046
+ setRecipientQuery("");
2047
+ setTempExtraFilters(defaultExtraFilters);
2048
+ setAppliedExtraFilters(defaultExtraFilters);
2049
+ };
2050
+
2051
+ const renderStatusCheckboxList = () => (
2052
+ <CheckboxYGroup aria-label="Статус">
2053
+ {visibleStatusOptions.map((option) => (
2054
+ <Checkbox
2055
+ key={option.id}
2056
+ id={option.id}
2057
+ size={EComponentSize.MD}
2058
+ checked={selectedStatusIds.includes(option.id)}
2059
+ onChange={(event) => toggleStatus(option.id, event.target.checked)}
2060
+ >
2061
+ {option.label}
2062
+ </Checkbox>
2063
+ ))}
2064
+ </CheckboxYGroup>
2065
+ );
2066
+
2067
+ const renderStatusDropdownContent = () => {
2068
+ const hasResults = !statusFilter.trim() || visibleStatusOptions.length > 0;
2069
+
2070
+ return hasResults ? (
2071
+ renderStatusCheckboxList()
2072
+ ) : (
2073
+ <div style={{ padding: "8px 16px" }}>
2074
+ <Text size={ETextSize.B3}>Ничего не найдено</Text>
2075
+ </div>
2076
+ );
2077
+ };
2078
+
2079
+ const renderStatusMultiselect = () => (
2080
+ <ChipMultiselect
2081
+ size={EComponentSize.MD}
2082
+ clearSelected={unselectAllStatuses}
2083
+ selected={selectedStatusIds.length > 0}
2084
+ label="Статус"
2085
+ displayedValue={`Статус: ${selectedStatusLabels.join(", ")}`}
2086
+ >
2087
+ {({ opened, setOpened, targetRef, dropdownRef }) => (
2088
+ <MultiselectField.Dropdown
2089
+ opened={opened}
2090
+ setOpened={setOpened}
2091
+ targetRef={targetRef}
2092
+ ref={dropdownRef}
2093
+ alignment={EDropdownAlignment.LEFT}
2094
+ focusTrapProps={{ focusTrapOptions: { initialFocus: false } }}
2095
+ mobileViewProps={{
2096
+ children: (
2097
+ <>
2098
+ <DropdownMobileHeader
2099
+ controlButtons={<DropdownMobileClose onClick={() => setOpened(false)} />}
2100
+ >
2101
+ <DropdownMobileInput
2102
+ autoFocus
2103
+ placeholder="Поиск по статусу"
2104
+ value={statusFilter}
2105
+ onChange={handleStatusFilterChange}
2106
+ />
2107
+ </DropdownMobileHeader>
2108
+ <DropdownMobileBody>{renderStatusDropdownContent()}</DropdownMobileBody>
2109
+ <DropdownMobileFooter>
2110
+ <Button
2111
+ theme={EButtonTheme.GENERAL}
2112
+ size={EComponentSize.SM}
2113
+ onClick={() => setOpened(false)}
2114
+ >
2115
+ Применить
2116
+ </Button>
2117
+ <Button
2118
+ theme={EButtonTheme.LINK}
2119
+ size={EComponentSize.SM}
2120
+ onClick={unselectAllStatuses}
2121
+ >
2122
+ Сбросить
2123
+ </Button>
2124
+ </DropdownMobileFooter>
2125
+ </>
2126
+ ),
2127
+ }}
2128
+ >
2129
+ <MultiselectField.Dropdown.Header>
2130
+ <FormField size={EComponentSize.SM}>
2131
+ <FormFieldLabel>Поиск по статусу</FormFieldLabel>
2132
+ <FormFieldInput value={statusFilter} onChange={handleStatusFilterChange} />
2133
+ <FormFieldPostfix>
2134
+ <FormFieldClear onClick={() => setStatusFilter("")} />
2135
+ </FormFieldPostfix>
2136
+ </FormField>
2137
+ </MultiselectField.Dropdown.Header>
2138
+ <MultiselectField.Dropdown.Content>
2139
+ {renderStatusDropdownContent()}
2140
+ </MultiselectField.Dropdown.Content>
2141
+ <MultiselectField.Dropdown.Footer>
2142
+ <Button
2143
+ theme={EButtonTheme.SECONDARY}
2144
+ size={EComponentSize.SM}
2145
+ onClick={() => setOpened(false)}
2146
+ >
2147
+ Применить
2148
+ </Button>
2149
+ <Button theme={EButtonTheme.LINK} size={EComponentSize.SM} onClick={unselectAllStatuses}>
2150
+ Сбросить
2151
+ </Button>
2152
+ </MultiselectField.Dropdown.Footer>
2153
+ </MultiselectField.Dropdown>
2154
+ )}
2155
+ </ChipMultiselect>
2156
+ );
2157
+
2158
+ const renderExtraFilters = () => (
2159
+ <>
2160
+ <Gap size={12} />
2161
+ <Row paddingBottom={false}>
2162
+ <Col size={6}>
2163
+ <SelectField
2164
+ size={EComponentSize.MD}
2165
+ value={tempExtraFilters.recipientOption}
2166
+ options={counterpartyOptions}
2167
+ onChange={(option) =>
2168
+ setTempExtraFilters((prev) => ({
2169
+ ...prev,
2170
+ recipientOption: option.value ? option : undefined,
2171
+ }))
2172
+ }
2173
+ placeholder="Выберите получателя из списка"
2174
+ targetProps={{ fieldLabel: "Получатель" }}
2175
+ />
2176
+ </Col>
2177
+ <Col size={6}>
2178
+ <TextField
2179
+ size={EComponentSize.MD}
2180
+ inputProps={{
2181
+ value: tempExtraFilters.docNumber,
2182
+ onChange: (event) =>
2183
+ setTempExtraFilters((prev) => ({ ...prev, docNumber: event.target.value })),
2184
+ placeholder: "Введите номер документа",
2185
+ }}
2186
+ label="Номер документа"
2187
+ />
2188
+ </Col>
2189
+ </Row>
2190
+ <Gap size={12} />
2191
+ <div style={{ display: "flex", justifyContent: "end" }}>
2192
+ <Button theme={EButtonTheme.SECONDARY} size={EComponentSize.MD} onClick={handleClickResetTempFilters}>
2193
+ Сбросить
2194
+ </Button>
2195
+ <Button theme={EButtonTheme.GENERAL} size={EComponentSize.MD} onClick={handleClickApplyTempFilters}>
2196
+ Применить
2197
+ </Button>
2198
+ </div>
2199
+ </>
2200
+ );
2201
+
2202
+ const renderExtraFilterTags = () => {
2203
+ const tags: React.JSX.Element[] = [];
2204
+
2205
+ if (appliedExtraFilters.recipientOption) {
2206
+ tags.push(
2207
+ <Tag
2208
+ key="tag-filter-recipient"
2209
+ id="tag-filter-recipient"
2210
+ size={EComponentSize.MD}
2211
+ onRemove={handleRemoveTag({ ...appliedExtraFilters, recipientOption: undefined })}
2212
+ >
2213
+ {`Получатель: ${appliedExtraFilters.recipientOption.value}`}
2214
+ </Tag>,
2215
+ );
2216
+ }
2217
+
2218
+ if (appliedExtraFilters.docNumber) {
2219
+ tags.push(
2220
+ <Tag
2221
+ key="tag-filter-doc-number"
2222
+ id="tag-filter-doc-number"
2223
+ size={EComponentSize.MD}
2224
+ onRemove={handleRemoveTag({ ...appliedExtraFilters, docNumber: "" })}
2225
+ >
2226
+ {`Номер документа: ${appliedExtraFilters.docNumber}`}
2227
+ </Tag>,
2228
+ );
2229
+ }
2230
+
2231
+ return (
2232
+ <>
2233
+ <Gap size={12} />
2234
+ <TagGroup size={EComponentSize.MD}>{tags}</TagGroup>
2235
+ </>
2236
+ );
2237
+ };
2238
+
2239
+ const renderAdditionalFilterArea = () => {
2240
+ if (showExtraFilters) {
2241
+ return renderExtraFilters();
2242
+ }
2243
+
2244
+ return isExtraFilterApplied ? renderExtraFilterTags() : null;
2245
+ };
2246
+
2247
+ return (
2248
+ <MasterTable>
2249
+ <MasterTable.ChipPanel>
2250
+ <ChipGroup size={EComponentSize.SM} oneLine>
2251
+ {segments.map((segment) => (
2252
+ <Chip
2253
+ key={segment.id}
2254
+ size={EComponentSize.SM}
2255
+ selected={activeSegment === segment.id}
2256
+ showNotificationIcon={segment.withNotification}
2257
+ onClick={() => setActiveSegment(segment.id)}
2258
+ >
2259
+ {segment.label}
2260
+ </Chip>
2261
+ ))}
2262
+ </ChipGroup>
2263
+ <MasterTable.ChipPanel.Links>
2264
+ {isExtraFilterApplied && <Link onClick={handleClickResetAllFilters}>Сбросить всё</Link>}
2265
+ <Link onClick={handleClickToggleExtraFilters}>
2266
+ {showExtraFilters ? "Скрыть фильтры" : isExtraFilterApplied ? "Изменить фильтры" : "Фильтры"}
2267
+ </Link>
2268
+ </MasterTable.ChipPanel.Links>
2269
+ </MasterTable.ChipPanel>
2270
+ <MasterTable.FilterPanel>
2271
+ <div style={{ display: "flex", alignItems: "flex-start", gap: 12, flexWrap: "wrap" }}>
2272
+ {renderStatusMultiselect()}
2273
+ <TextField
2274
+ size={EComponentSize.MD}
2275
+ inputProps={{
2276
+ value: recipientQuery,
2277
+ onChange: (event) => setRecipientQuery(event.target.value),
2278
+ placeholder: "Введите получателя",
2279
+ }}
2280
+ label="Получатель"
2281
+ />
2282
+ </div>
2283
+ {renderAdditionalFilterArea()}
2284
+ </MasterTable.FilterPanel>
2285
+ <MasterTable.TableBasic
2286
+ columns={columns}
2287
+ data={data}
2288
+ renderNoData={() => renderNoData(isAnyFilterApplied)}
2289
+ />
2290
+ </MasterTable>
2291
+ );
2292
+ };
2293
+ ```
2294
+