Data from the server
Pagination, sorting and filters are handled on the backend: the change event gives you the current state of the table, the handler requests a page of data, and the loading prop shows the request in progress.
vue
<script setup lang="ts">
import type { ITableProps } from '@central-design-system/components';
import { CdsTable } from '@central-design-system/components';
import { computed, onMounted, ref } from 'vue';
type APIResponse = {
results: {
gender: 'female' | 'male';
name: { first: string; last: string };
email: string;
login: { uuid: string };
location: { city: string; country: string };
}[];
};
const columns = [
{ title: 'Имя', dataIndex: 'name', sorter: true, width: '20%' },
{
title: 'Пол',
dataIndex: 'gender',
filters: [
{ text: 'Мужчина', value: 'male' },
{ text: 'Женщина', value: 'female' }
],
width: '20%'
},
{ title: 'Email', dataIndex: 'email' },
{ title: 'Адрес', dataIndex: 'location', sorter: true }
];
const dataSource = ref<APIResponse['results']>([]);
const FIRST_PAGE = 1;
const PER_PAGE = 10;
const TOTAL = 100;
const pagination = computed(() => ({
total: TOTAL,
length: PER_PAGE
}));
const isLoading = ref(false);
async function queryData(params: Record<string, any>) {
try {
isLoading.value = true;
const query = new URLSearchParams(
Object.entries(params)
.filter(([, value]) => value !== undefined && value !== null)
.map(([key, value]) => [key, String(value)])
);
const response = await fetch(`https://randomuser.me/api?${query}`);
const data: APIResponse = await response.json();
dataSource.value = data.results ?? [];
} catch (error) {
console.error(error);
} finally {
isLoading.value = false;
}
}
onMounted(() => {
queryData({ results: PER_PAGE, page: FIRST_PAGE });
});
const handleTableChange: ITableProps['onChange'] = (paginationInfo, filters: any, sorter: any) => {
queryData({
results: paginationInfo.length as number,
page: paginationInfo.modelValue,
sortField: sorter.field,
sortOrder: sorter.order,
...filters
});
};
</script>
<template>
<CdsTable
:data="dataSource"
:columns="columns"
:row-key="(record) => record.login.uuid"
:pagination="pagination"
:loading="isLoading"
@change="handleTableChange"
>
<template #bodyCell="{ column, text }">
<template v-if="column.dataIndex === 'name'">{{ text.first }} {{ text.last }}</template>
<template v-if="column.dataIndex === 'location'">{{ text.country }}, {{ text.city }}</template>
</template>
</CdsTable>
</template>Product moderation
A header with tabs, a toolbar with search and filters, an empty state with an action and external pagination in the footer — a typical table for a moderation section.
vue
<script setup lang="ts">
import type { NTable } from '@central-design-system/components';
import {
CdsBadge,
CdsButton,
CdsEmptyState,
CdsFunctionalButton,
CdsIcon,
CdsLoader,
CdsPagination,
CdsSelect,
CdsSkeletonLoader,
CdsTab,
CdsTable,
CdsTabs,
CdsTag,
CdsTextInput,
CdsTooltip,
utils
} from '@central-design-system/components';
import { computed, onMounted, ref, unref, watch } from 'vue';
const { debounce, plurals } = utils;
type TDataItem = {
key: number;
sku: string;
date: string;
name: string;
category: string;
tolerance: number;
count: string;
base_price: number | string;
percent: number;
price: number | string;
};
const columns: NTable.NColumn.TColumns<TDataItem> = [
{ title: 'Артикул', dataIndex: 'sku', width: 140 },
{ title: 'Дата', dataIndex: 'date', width: 140 },
{ title: 'Наименование', dataIndex: 'name', width: 350 },
{ title: 'Толеранс, %', dataIndex: 'tolerance', align: 'right', width: 110 },
{ title: 'Единица', dataIndex: 'count', width: 110 },
{ title: 'Базовая цена, ₽', dataIndex: 'base_price', align: 'right', width: 140 },
{ title: '%', dataIndex: 'percent', align: 'right', width: 70 },
{ title: 'Новая цена, ₽', dataIndex: 'price', align: 'right', width: 140 }
];
const data = ref<TDataItem[]>([]);
const filtersData = ['Узлы, устройства и инструменты', 'Изделия производственные', 'Машины установки и оборудование'];
const isLoading = ref(false);
const tableDataSwitcher = ref(0);
const isActiveFilters = ref(false);
const currentPage = ref(1);
const totalData = ref(100);
const perPage = ref(10);
const pagesLength = computed(() => Math.ceil(unref(totalData) / unref(perPage)));
const pageSizeItems = Array.from(new Set([5, 10, 15, 20, 30, 50]), (item) => ({
value: item,
title: item.toString()
}));
const queryParams = computed(() => ({
page: unref(currentPage),
perPage: unref(perPage)
}));
const formatPrice = (price: number | string) =>
new Intl.NumberFormat('ru-RU', {
style: 'decimal',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(+price);
const queryData = async (params?: Record<string, any>) => {
try {
isLoading.value = true;
const query = new URLSearchParams({ example: 'example1', ...params });
const response = await fetch(`https://cds-api.b2b-center.ru/api/v1/examples/data-table?${query}`);
const { data: examples, meta: metaInfo } = await response.json();
// Принудительная задержка для примера загрузки
await new Promise((resolve) => setTimeout(resolve, 1000));
totalData.value = metaInfo.total;
perPage.value = metaInfo.perPage;
data.value =
examples.map((item: TDataItem, idx: number) => ({
...item,
base_price: formatPrice(item.base_price),
price: formatPrice(item.price),
key: idx
})) ?? [];
} catch (error) {
console.error(error);
} finally {
isLoading.value = false;
}
};
const getDataTableExample = () => {
queryData(unref(queryParams));
};
const clearDataTableExample = () => {
data.value = [];
};
const isPublishLoading = ref(false);
const publishData = async () => {
isPublishLoading.value = true;
await new Promise((resolve) => setTimeout(resolve, 1000));
selectedRowKeys.value = [];
isPublishLoading.value = false;
};
const selectedRowKeys = ref<NTable.TKey[]>([]);
const selectedRowText = computed(() => plurals(unref(selectedRowKeys).length, ['строка', 'строки', 'строк']));
const cancelSelected = () => {
selectedRowKeys.value = [];
};
const onSelectChange = (selectedKeys: NTable.TKey[]) => {
selectedRowKeys.value = selectedKeys;
};
const handleSearch = debounce(async () => {
await queryData(unref(queryParams));
}, 500);
onMounted(() => {
queryData(unref(queryParams));
});
watch([currentPage, perPage], () => {
queryData(unref(queryParams));
});
watch(tableDataSwitcher, () => {
queryData(unref(queryParams));
});
</script>
<template>
<CdsTable
class="min-w-[860px]"
:columns="columns"
:data="data"
:row-selection="{ selectedRowKeys, onChange: onSelectChange }"
:pagination="false"
>
<template #header>
<div class="flex justify-between">
<div>
<h4>Список товаров, поступивших на модерацию</h4>
<p class="mb-4 text-content-secondary">
Согласуйте или отклоните товары поставщика для их доступности в каталоге
</p>
<div class="flex">
<CdsTabs v-model="tableDataSwitcher">
<CdsTab>Все 654</CdsTab>
<CdsTab>Новые 340</CdsTab>
<CdsTab>Обновленные 314</CdsTab>
</CdsTabs>
</div>
</div>
<div>
<CdsButton variant="soft" color="brand" text="Удалить данные" @click="clearDataTableExample" />
</div>
</div>
</template>
<template #toolbar>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<p>
Выбрано: <strong>{{ selectedRowKeys.length }}</strong> {{ selectedRowText }}
</p>
<CdsTextInput
prepend-inner-icon="search"
placeholder="Артикул, наименование"
hide-messages
class="w-[250px]"
@update:model-value="handleSearch"
/>
<CdsButton
variant="soft"
:color="isActiveFilters ? 'brand' : 'neutral'"
prepend-icon="adjust"
@click="isActiveFilters = !isActiveFilters"
>
Фильтры
<CdsBadge v-if="isActiveFilters" content="10" inline />
</CdsButton>
</div>
<div class="flex gap-2">
<CdsButton variant="soft" color="brand" text="Отклонить" @click="cancelSelected" />
<CdsButton text="Опубликовать" :loading="isPublishLoading" @click="publishData" />
</div>
</div>
<div v-if="isActiveFilters" class="mt-4">
<h6 class="mb-2">Активные фильтры:</h6>
<div class="flex flex-col gap-2">
<div class="flex">
<div class="flex items-start">
<CdsFunctionalButton icon="remove" @click="isActiveFilters = false" />
<div class="mx-1 flex h-[24px] items-center">Категории:</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<CdsTag v-for="item in filtersData" :key="item">{{ item }}</CdsTag>
</div>
</div>
</div>
</div>
</template>
<template #headerCell="{ title, column }">
<div class="flex items-center" :class="{ 'justify-end': column.align === 'right' }">
<span>{{ title }}</span>
<CdsTooltip v-if="['count', 'percent'].includes(String(column.dataIndex))">
<template #activator="{ props }">
<CdsIcon name="info" class="ml-2" v-bind="props" />
</template>
Информация о столбце
</CdsTooltip>
</div>
</template>
<template #bodyCell>
<template v-if="isLoading">
<CdsSkeletonLoader type="text" />
</template>
</template>
<template #footer>
<div class="flex items-center justify-between">
<CdsPagination v-model="currentPage" :length="pagesLength" class="w-full" />
<div class="flex w-full justify-center gap-1">
Отображено <strong>{{ perPage }}</strong> из <strong>{{ totalData }}</strong>
</div>
<div class="flex w-full items-center justify-end gap-2">
<span class="text-content-tertiary">Строк на странице:</span>
<CdsSelect v-model="perPage" :items="pageSizeItems" hide-messages class="max-w-[70px]" />
</div>
</div>
</template>
<template #emptyText>
<div class="flex h-[500px] items-center justify-center">
<CdsLoader v-if="isLoading" fixed overlay />
<CdsEmptyState
title="Нет данных"
description="Нажмите кнопку ниже чтобы добавить данные в таблицу"
illustration="documents"
>
<template #actions>
<CdsButton text="Добавить данные" @click="getDataTableExample" />
</template>
</CdsEmptyState>
</div>
</template>
</CdsTable>
</template>Items and prices
Built-in pagination with a counter and a page size selector, a skeleton while loading and composite cells with an extra line of text.
vue
<script setup lang="ts">
import type { ITableProps, NTable } from '@central-design-system/components';
import {
CdsButton,
CdsEmptyState,
CdsLoader,
CdsSkeletonLoader,
CdsTab,
CdsTable,
CdsTabs,
CdsTextInput,
utils
} from '@central-design-system/components';
import { computed, onMounted, ref, unref, watch } from 'vue';
const { plurals } = utils;
type TDataItem = {
key: number;
id: number;
name: string;
best_price: number | string;
qty: number;
price: number | string;
tax: number;
delay: number;
download: boolean;
};
const columns: NTable.NColumn.TColumns<TDataItem> = [
{ title: '№', dataIndex: 'id', width: 40 },
{ title: 'Позиция', dataIndex: 'name', width: 350 },
{ title: 'Лучшая цена с НДС, ₽', dataIndex: 'best_price', width: 200, align: 'right' },
{ title: 'Количество', dataIndex: 'qty', align: 'right', width: 80 },
{ title: 'Цена за единицу с НДС, ₽', dataIndex: 'price', align: 'right', width: 200 },
{ title: 'НДС, %', dataIndex: 'tax', align: 'right', width: 80 },
{ title: 'Срок поставки, дней', dataIndex: 'delay', align: 'right', width: 180 },
{ title: 'Документация', dataIndex: 'docs', align: 'right', width: 80 }
];
const data = ref<TDataItem[]>([]);
const total = ref(10);
const isLoading = ref(false);
const tableDataSwitcher = ref(0);
const formatPrice = (price: number | string) =>
new Intl.NumberFormat('ru-RU', {
style: 'decimal',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(+price);
const queryDataTableExample = async () => {
try {
isLoading.value = true;
const response = await fetch('https://cds-api.b2b-center.ru/api/v1/examples/data-table?example=example2');
const { data: examples } = await response.json();
// Принудительная задержка для примера загрузки
await new Promise((resolve) => setTimeout(resolve, 1000));
data.value =
examples.map((item: TDataItem, idx: number) => ({
...item,
best_price: formatPrice(item.best_price),
price: formatPrice(item.price),
key: idx
})) ?? [];
} catch (error) {
console.error(error);
} finally {
isLoading.value = false;
}
};
const selectedRowKeys = ref<NTable.TKey[]>([]);
const selectedRowText = computed(() => plurals(unref(selectedRowKeys).length, ['строка', 'строки', 'строк']));
const onSelectChange = (selectedKeys: NTable.TKey[]) => {
selectedRowKeys.value = selectedKeys;
};
const onTableChange: ITableProps<TDataItem>['onChange'] = (pagination) => {
total.value = pagination.total!;
};
onMounted(() => {
queryDataTableExample();
});
watch(tableDataSwitcher, () => {
queryDataTableExample();
});
</script>
<template>
<CdsTable
class="min-w-[860px]"
:columns="columns"
:data="data"
:pagination="{ total, position: ['bottomLeft'], counter: true, size: true }"
:row-selection="{ selectedRowKeys, onChange: onSelectChange }"
@change="onTableChange"
>
<template #header>
<div class="flex justify-between">
<div>
<h4 class="mb-4">Позиции и цены</h4>
<CdsTabs v-model="tableDataSwitcher">
<CdsTab>Заявки на позиции</CdsTab>
<CdsTab>Позиции без заявки</CdsTab>
</CdsTabs>
</div>
<div>
<CdsButton prepend-icon="upload" variant="soft" color="brand" text="Загрузить из файла" />
</div>
</div>
</template>
<template #toolbar>
<div class="flex items-end justify-between">
<div>
<p>
Выбрано: <strong>{{ selectedRowKeys.length }}</strong> {{ selectedRowText }}
</p>
</div>
<div class="flex gap-2">
<CdsTextInput prefix="на" suffix="%" model-value="5" hide-messages class="mr-2 w-[127px]" />
<CdsButton variant="soft" color="brand" text="Пересчитать" />
<CdsButton text="Сохранить цены" />
</div>
</div>
</template>
<template #bodyCell="{ column, record, text }">
<template v-if="isLoading">
<CdsSkeletonLoader type="text@4" />
</template>
<template v-else>
<div v-if="column.dataIndex === 'name'" class="flex flex-col">
<p>{{ text }}</p>
<span class="text-content-secondary">Количество: 100 шт</span>
<span class="text-content-secondary">Максимальная цена: 150.00 ₽ с НДС 20%</span>
</div>
<div v-if="column.dataIndex === 'docs'">
<CdsButton
size="sm"
:prepend-icon="record.download ? 'download' : 'upload'"
variant="soft"
:color="record.download ? 'neutral' : 'brand'"
:text="record.download ? 'Скачать' : 'Добавить'"
/>
</div>
</template>
</template>
<template #emptyText>
<div class="flex h-[500px] items-center justify-center">
<CdsLoader v-if="isLoading" fixed overlay />
<CdsEmptyState
title="Нет данных"
description="Нажмите кнопку ниже чтобы добавить данные в таблицу"
illustration="documents"
>
<template #actions>
<CdsButton text="Добавить данные" @click="queryDataTableExample" />
</template>
</CdsEmptyState>
</div>
</template>
</CdsTable>
</template>Item list
A nested table inside an expanded row and a toolbar with fields and a dropdown calendar — a recipe for documents with line items.
vue
<script setup lang="ts">
import type { ITableProps, NTable } from '@central-design-system/components';
import {
CdsButton,
CdsDatePicker,
CdsEmptyState,
CdsFunctionalButton,
CdsLoader,
CdsMenu,
CdsSkeletonLoader,
CdsTab,
CdsTable,
CdsTabs,
CdsTextInput,
utils
} from '@central-design-system/components';
import { computed, onMounted, ref, unref, watch } from 'vue';
const { plurals } = utils;
type TInnerDataItem = {
key: number | string;
factory: string;
firm: number;
place: string;
date: string;
price_one: number | string;
qty: number;
price: number | string;
};
type TDataItem = {
key: number | string;
id: number;
name: string;
group: number | string;
size: number;
qty: number | string;
price: number | string;
innerData: TInnerDataItem[];
};
const columns: NTable.NColumn.TColumns<TDataItem> = [
{ title: '№', dataIndex: 'id', width: 59 },
{ title: 'Код ОЗМ', dataIndex: 'code', width: 91 },
{ title: 'Позиция', dataIndex: 'name', width: 545 },
{ title: 'Группа закупок', dataIndex: 'group', width: 187 },
{ title: 'Единица', dataIndex: 'size', width: 89 },
{ title: 'Количество', dataIndex: 'qty', align: 'right', width: 107 },
{ title: 'Плановая стоимость, ₽', dataIndex: 'price', align: 'right', width: 187 },
{ title: '', dataIndex: 'action', width: 45 }
];
const innerColumns: NTable.NColumn.TColumns<TInnerDataItem> = [
{ title: 'Завод', dataIndex: 'factory', width: 98 },
{ title: 'Склад', dataIndex: 'firm', width: 92 },
{ title: 'Место поставки', dataIndex: 'place', width: 218 },
{ title: 'Плановая дата поставки', dataIndex: 'date', width: 280 },
{ title: 'Цена за единицу, ₽', dataIndex: 'price_one', align: 'right', width: 290 },
{ title: 'Количество', dataIndex: 'qty', align: 'right', width: 107 },
{ title: 'Плановая стоимость, ₽', dataIndex: 'price', align: 'right', width: 185 },
{ title: '', dataIndex: 'action', width: 44 }
];
const data = ref<TDataItem[]>([]);
const total = ref(10);
const isLoading = ref(false);
const tableDataSwitcher = ref(0);
const date = ref('');
const formatPrice = (price: number | string) =>
new Intl.NumberFormat('ru-RU', {
style: 'decimal',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(+price);
const queryDataTableExample = async () => {
try {
isLoading.value = true;
const response = await fetch('https://cds-api.b2b-center.ru/api/v1/examples/data-table?example=example3');
const { data: examples } = await response.json();
// Принудительная задержка для примера загрузки
await new Promise((resolve) => setTimeout(resolve, 1000));
data.value =
examples.map((item: TDataItem, idx: number) => ({
...item,
price: formatPrice(item.price),
innerData: item.innerData.map((innerItem, innerIdx) => ({
...innerItem,
price_one: formatPrice(innerItem.price_one),
key: `${idx}.${innerIdx}`
})),
key: idx
})) ?? [];
} catch (error) {
console.error(error);
} finally {
isLoading.value = false;
}
};
const selectedRowKeys = ref<NTable.TKey[]>([]);
const selectedRowText = computed(() => plurals(unref(selectedRowKeys).length, ['строка', 'строки', 'строк']));
const onSelectChange = (selectedKeys: NTable.TKey[]) => {
selectedRowKeys.value = selectedKeys;
};
const onTableChange: ITableProps<TDataItem>['onChange'] = (pagination) => {
total.value = pagination.total!;
};
onMounted(() => {
queryDataTableExample();
});
watch(tableDataSwitcher, () => {
queryDataTableExample();
});
</script>
<template>
<CdsTable
class="min-w-[860px]"
:columns="columns"
:data="data"
:pagination="{ total, position: ['bottomLeft'], counter: true, size: true }"
:row-selection="{ selectedRowKeys, onChange: onSelectChange }"
@change="onTableChange"
>
<template #header>
<div class="flex justify-between">
<div>
<h4 class="mb-4">Список позиций</h4>
<CdsTabs v-model="tableDataSwitcher">
<CdsTab text="Позиции" />
<CdsTab text="Адреса" />
</CdsTabs>
</div>
<div class="flex gap-2">
<CdsButton prepend-icon="download" variant="soft" color="brand" text="Скачать шаблон" />
<CdsButton prepend-icon="upload" variant="soft" color="brand" text="Загрузить из файла" />
</div>
</div>
</template>
<template #toolbar>
<div class="flex flex-wrap items-end justify-between gap-4">
<div class="flex items-end gap-4">
<p class="min-w-[121px]">
Выбрано: <strong>{{ selectedRowKeys.length }}</strong> {{ selectedRowText }}
</p>
<CdsTextInput label="Все адреса" placeholder="Введите адрес" hide-messages class="w-[250px]" />
<CdsTextInput
v-model="date"
append-inner-icon="calendar"
label="Дата"
placeholder="Выберите дату"
hide-messages
class="w-[250px]"
>
<CdsMenu activator="parent" :close-on-content-click="false">
<CdsDatePicker v-model="date" />
</CdsMenu>
</CdsTextInput>
</div>
<div class="flex gap-2">
<CdsButton variant="soft" color="brand" prepend-icon="add" text="Добавить позицию" />
<CdsButton variant="soft" color="brand" prepend-icon="add" text="Добавить адреса и позиции" />
</div>
</div>
</template>
<template #bodyCell="{ column }">
<template v-if="isLoading">
<CdsSkeletonLoader type="text" />
</template>
<template v-else>
<CdsFunctionalButton v-if="column.dataIndex === 'action'" icon="delete" />
</template>
</template>
<template #expandedRowRender="{ record }">
<CdsTable :columns="innerColumns" :data="record.innerData" :pagination="false">
<template #bodyCell="{ column }">
<template v-if="column.dataIndex === 'action'">
<CdsFunctionalButton icon="delete" />
</template>
</template>
</CdsTable>
</template>
<template #emptyText>
<div class="flex h-[500px] items-center justify-center">
<CdsLoader v-if="isLoading" fixed overlay />
<CdsEmptyState
title="Нет данных"
description="Нажмите кнопку ниже чтобы добавить данные в таблицу"
illustration="documents"
>
<template #actions>
<CdsButton text="Добавить данные" @click="queryDataTableExample" />
</template>
</CdsEmptyState>
</div>
</template>
</CdsTable>
</template>