fix(desktop): каталог и фильтры заказов по канону + общий RefreshButton

- Каталог: чип-фильтр категорий + плавающая сортировка → канон PageTabs
  (категории как вкладки со счётчиком) + BaseSelect в слоте #actions.
- «Мои заказы»: вкладки покрывают ВСЕ статусы заказа (был баг — заказ в
  SUPPLY_PREPARED не попадал ни в одну вкладку, кроме «Все»). Добавлена
  вкладка «Готовятся» (ACCEPTED/SUPPLY_PREPARED/ACCEPTED_TO_COOP), «Отменены»
  расширена истёкшими циклами и возвратами.
- «Входящие заказы» поставщика: аналогичное исчерпывающее покрытие статусов.
- Кнопка «Обновить» вынесена в общий виджет widgets/Marketplace/RefreshButton
  (дублировалась ~15× по столам, по-разному свёрстана) и поставлена в шапку:
  на «Сводный заказ» и «Готово к получению» — через канон Teleport в
  #header-actions-host, у поставщика — в слот вкладок PageTabs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ant
2026-05-30 09:22:18 +00:00
parent 46ce429d3b
commit 59db799051
7 changed files with 132 additions and 97 deletions
@@ -9,6 +9,7 @@ import {
} from 'src/widgets/Marketplace/CatalogOfferCard';
import { BaseSelect, BaseButton, EmptyState } from 'src/shared/ui/base';
import { PageHint } from 'src/shared/ui/domain';
import { PageTabs, type PageTab } from 'src/shared/ui/layout';
import { marketplaceUnitShort } from 'src/shared/lib/consts';
import { marketplaceOfferImageUrls } from 'src/shared/lib/utils';
import {
@@ -49,6 +50,25 @@ const totalActiveCount = computed(() =>
Array.from(counts.value.values()).reduce((acc, v) => acc + v, 0)
);
// Категории как канон-вкладки (`PageTabs` со счётчиком). `key` — строковый id
// категории (или 'all'), счётчик — число активных предложений в категории.
const categoryTabs = computed<PageTab[]>(() => [
{ key: 'all', label: 'Все', count: totalActiveCount.value },
...categories.value.map((cat) => ({
key: String(cat.id),
label: cat.display_name,
count: counts.value.get(cat.id) ?? 0,
})),
]);
const activeCategoryKey = computed(() =>
selectedCategoryId.value === ALL_KEY ? 'all' : String(selectedCategoryId.value)
);
function onSelectCategory(tab: PageTab): void {
selectCategory(tab.key === 'all' ? ALL_KEY : Number(tab.key));
}
const sortOptions: Array<{ label: string; value: CatalogSort }> = [
{ label: 'Свежие сначала', value: 'created_at_desc' },
{ label: 'Цена ↑', value: 'price_asc' },
@@ -159,37 +179,18 @@ q-page.catalog(role="region", aria-label="Каталог Стола заказо
PageHint(storage-key="mp:catalog:banner-dismissed")
| Предложения поставщиков кооператива. Выберите товар или оформите заказ на ваш пункт выдачи.
.catalog__toolbar
.catalog__filters(role="tablist", aria-label="Фильтр по категориям")
.chip.catalog__chip(
:class="selectedCategoryId === ALL_KEY ? 'chip--accent' : 'chip--neutral'",
role="tab",
:aria-selected="selectedCategoryId === ALL_KEY",
tabindex="0",
@click="selectCategory(ALL_KEY)",
@keydown.enter="selectCategory(ALL_KEY)"
PageTabs.catalog__tabs(
:tabs="categoryTabs",
:active-key="activeCategoryKey",
@select="onSelectCategory"
)
template(#actions)
BaseSelect.catalog__sort(
:model-value="sort",
:options="sortOptions",
label="Сортировка",
@update:model-value="onSortChange"
)
span Все
span.catalog__count {{ totalActiveCount }}
.chip.catalog__chip(
v-for="cat in categories",
:key="cat.id",
:class="selectedCategoryId === cat.id ? 'chip--accent' : 'chip--neutral'",
role="tab",
:aria-selected="selectedCategoryId === cat.id",
tabindex="0",
@click="selectCategory(cat.id)",
@keydown.enter="selectCategory(cat.id)"
)
span {{ cat.display_name }}
span.catalog__count {{ counts.get(cat.id) ?? 0 }}
q-space
BaseSelect.catalog__sort(
:model-value="sort",
:options="sortOptions",
label="Сортировка",
@update:model-value="onSortChange"
)
q-inner-loading(:showing="loading && items.length === 0")
q-spinner(color="primary", size="2em")
@@ -232,32 +233,14 @@ q-page.catalog(role="region", aria-label="Каталог Стола заказо
flex-direction: column;
gap: var(--p-4, 16px);
&__toolbar {
display: flex;
align-items: center;
gap: var(--p-3, 12px);
flex-wrap: wrap;
}
// Канон-`.tabbar` тянется во всю ширину; гасим его внутренний горизонтальный
// паддинг, чтобы вкладки шли от края страницы (у страницы свои отступы).
&__tabs {
margin: 0 calc(-1 * var(--p-6, 24px));
&__filters {
display: flex;
gap: var(--p-2, 8px);
overflow-x: auto;
flex-wrap: nowrap;
padding-bottom: var(--p-1, 4px);
flex: 1 1 auto;
min-width: 0;
}
&__chip {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
&__count {
color: var(--p-ink-3);
font-variant-numeric: tabular-nums;
:deep(.tabbar__tabs) {
padding: 0 var(--p-6, 24px);
}
}
&__sort {
@@ -33,9 +33,13 @@ const activeKey = ref('all');
const hasMore = computed(() => currentPage.value < totalPages.value);
// Фильтр по этапу. Вкладка может покрывать несколько статусов: «Ждут
// поставщика» — и сводные, и индивидуальные заказы (оба ждут акцепта
// поставщика), иначе индивидуальный заказ в неё не попадал.
// Фильтр по этапу. Покрытие ИСЧЕРПЫВАЮЩЕЕ по enum'у MarketplaceOrderStatusView:
// каждый статус заказа попадает хотя бы в одну вкладку. Иначе заказ молча
// «растекается» — пропадает из всех вкладок, кроме «Все» (баг: молоко в статусе
// SUPPLY_PREPARED не показывалось нигде). Вкладка может покрывать несколько
// статусов: «Ждут поставщика» — сводные и индивидуальные (оба ждут акцепта);
// «Готовятся» — принят → готовится → принят кооперативом (для заказчика это
// единый этап «в работе»); «Отменены» — отмены, истёкшие циклы и возвраты.
const FILTERS: Array<{ key: string; label: string; statuses: MarketplaceOrderStatusView[] | null }> = [
{ key: 'all', label: 'Все', statuses: null },
{ key: 'active', label: 'Активные', statuses: ['ACTIVE'] },
@@ -44,13 +48,23 @@ const FILTERS: Array<{ key: string; label: string; statuses: MarketplaceOrderSta
label: 'Ждут поставщика',
statuses: ['ACCEPTED_PENDING_SUPPLIER', 'ACCEPTED_PENDING_SUPPLIER_INDIVIDUAL'],
},
{ key: 'accepted', label: 'Приняты', statuses: ['ACCEPTED'] },
{
key: 'in-progress',
label: 'Готовятся',
statuses: ['ACCEPTED', 'SUPPLY_PREPARED', 'ACCEPTED_TO_COOP'],
},
{ key: 'ready', label: 'Готовы к выдаче', statuses: ['READY_TO_RECEIVE'] },
{ key: 'received', label: 'Получены', statuses: ['RECEIVED'] },
{
key: 'cancelled',
key: 'closed',
label: 'Отменены',
statuses: ['CANCELLED_BY_ORDERER', 'CANCELLED_BY_SUPPLIER'],
statuses: [
'CANCELLED_BY_ORDERER',
'CANCELLED_BY_SUPPLIER',
'EXPIRED_NO_THRESHOLD',
'EXPIRED_NO_VOLUME',
'RETURNED',
],
},
];
@@ -10,6 +10,7 @@ import {
toOrderCardModel,
type Order as OrderCardModel,
} from 'src/widgets/Marketplace/OrderCard';
import { RefreshButton } from 'src/widgets/Marketplace/RefreshButton';
import {
acceptConsolidatedRequest,
acceptIndividualOrder,
@@ -59,6 +60,11 @@ const showSkeleton = computed(() => loading.value && items.value.length === 0);
// фильтровала только сводный статус, и индивидуальный заказ с кнопкой
// «Принять» в неё не попадал (отдельная вкладка «Индивидуальные» путала).
// `key` — стабильный слаг в URL (`?status=pending-accept`) для прямых ссылок.
// Покрытие исчерпывающее по enum'у MarketplaceOrderStatusView: каждый статус
// заказа попадает хотя бы в одну вкладку, иначе при фильтрации заказ молча
// «растекается» — пропадает из всех вкладок, кроме «Все». READY_TO_RECEIVE для
// поставщика — продолжение «у кооператива»; EXPIRED_*/RETURNED — терминальные
// вместе с отменами.
const FILTERS: Array<{ key: string; label: string; statuses: MarketplaceOrderStatusView[] | null }> = [
{ key: 'all', label: 'Все', statuses: null },
{
@@ -68,9 +74,23 @@ const FILTERS: Array<{ key: string; label: string; statuses: MarketplaceOrderSta
},
{ key: 'accepted', label: 'Приняты', statuses: ['ACCEPTED'] },
{ key: 'supply-prepared', label: 'Поставка готова', statuses: ['SUPPLY_PREPARED'] },
{ key: 'accepted-to-coop', label: 'Приняты кооперативом', statuses: ['ACCEPTED_TO_COOP'] },
{
key: 'accepted-to-coop',
label: 'У кооператива',
statuses: ['ACCEPTED_TO_COOP', 'READY_TO_RECEIVE'],
},
{ key: 'received', label: 'Получены', statuses: ['RECEIVED'] },
{ key: 'cancelled', label: 'Отменены', statuses: ['CANCELLED_BY_ORDERER', 'CANCELLED_BY_SUPPLIER'] },
{
key: 'closed',
label: 'Отменены',
statuses: [
'CANCELLED_BY_ORDERER',
'CANCELLED_BY_SUPPLIER',
'EXPIRED_NO_THRESHOLD',
'EXPIRED_NO_VOLUME',
'RETURNED',
],
},
];
const tabs = computed<PageTab[]>(() => FILTERS.map((f) => ({ key: f.key, label: f.label })));
@@ -216,15 +236,7 @@ q-page.incoming-orders(role='region', aria-label='Входящие заказы
PageTabs.incoming-orders__tabs(:tabs='tabs', :active-key='activeKey', @select='onSelectTab')
template(#actions)
BaseButton(
variant='ghost',
icon-only,
aria-label='Обновить',
:loading='loading',
@click='load(1, false)'
)
template(#icon-left)
q-icon(name='refresh', size='20px')
RefreshButton(:loading='loading', @refresh='load(1, false)')
//- Скелетон вместо спиннера — каркас карточек проявляется сразу.
.row.q-col-gutter-md(v-if='showSkeleton')
@@ -6,7 +6,8 @@ import {
orderStatusDisplay,
toOrderCardModel,
} from 'src/widgets/Marketplace/OrderCard';
import { BaseButton, EmptyState } from 'src/shared/ui/base';
import { RefreshButton } from 'src/widgets/Marketplace/RefreshButton';
import { EmptyState } from 'src/shared/ui/base';
import { PageHint } from 'src/shared/ui/domain';
import { fetchMyOrders } from '../../MyOrders/api';
import type {
@@ -169,15 +170,13 @@ onUnmounted(() => {
<template lang="pug">
q-page.consolidated(role="region", aria-label="Сводный заказ")
//- Действие страницы — в шапку, где стоят общие действия (канон Teleport).
Teleport(to="#header-actions-host", defer)
RefreshButton(:loading="loading", @refresh="load")
PageHint(storage-key="mp:consolidated:banner-dismissed")
| Партии заказов, сгруппированные по циклу. Несколько ваших заказов в одной партии обслуживаются совместно на одном цикле, с одной поставкой.
.consolidated__toolbar
q-space
BaseButton(variant="ghost", iconOnly, ariaLabel="Обновить", :loading="loading", @click="load")
template(#icon-left)
q-icon(name="refresh", size="18px")
q-inner-loading(:showing="loading && items.length === 0")
q-spinner(color="primary", size="2em")
@@ -236,11 +235,6 @@ q-page.consolidated(role="region", aria-label="Сводный заказ")
flex-direction: column;
gap: var(--p-4, 16px);
&__toolbar {
display: flex;
align-items: center;
}
&__list {
display: flex;
flex-direction: column;
@@ -2,7 +2,8 @@
import type { QTableProps } from 'quasar';
import { onMounted, ref } from 'vue';
import { FailAlert } from 'src/shared/api';
import { BaseButton, EmptyState } from 'src/shared/ui/base';
import { EmptyState } from 'src/shared/ui/base';
import { RefreshButton } from 'src/widgets/Marketplace/RefreshButton';
import { PageHint } from 'src/shared/ui/domain';
import { marketplaceUnitShort } from 'src/shared/lib/consts/marketplace-units';
import { listMyReadyToReceive, type MarketplaceOrderIssuanceView } from '../api';
@@ -77,16 +78,13 @@ onMounted(load);
<template lang="pug">
q-page.ready(role="region", aria-label="Готово к получению")
//- Действие страницы — в шапку, где стоят общие действия (канон Teleport).
Teleport(to="#header-actions-host", defer)
RefreshButton(:loading="loading", @refresh="load")
PageHint(storage-key="mp:ready-to-receive:banner-dismissed")
| Оператор открыл выдачу этих заказов на пункте. Приходите на участок для сверки имущества и финальной подписи.
.ready__toolbar
q-space
BaseButton(variant="ghost", :loading="loading", @click="load")
template(#icon-left)
q-icon(name="refresh", size="18px")
| Обновить
q-table(
:rows="items",
:columns="columns",
@@ -110,11 +108,6 @@ q-page.ready(role="region", aria-label="Готово к получению")
display: flex;
flex-direction: column;
gap: var(--p-4, 16px);
&__toolbar {
display: flex;
align-items: center;
}
}
@media (max-width: 768px) {
@@ -0,0 +1,38 @@
<template lang="pug">
BaseButton(
variant='ghost',
icon-only,
:aria-label='ariaLabel',
:loading='loading',
@click="emit('refresh')"
)
template(#icon-left)
q-icon(name='refresh', :size='size')
q-tooltip(anchor='bottom middle', self='top middle') {{ ariaLabel }}
</template>
<script setup lang="ts">
import { BaseButton } from 'src/shared/ui/base';
/**
* Канон-кнопка «Обновить» для столов Marketplace. Вынесена в общий виджет —
* ручная перезагрузка ленты повторялась в ~15 страницах столов (входящие
* заказы, сводный заказ, готово к получению, склад и т.д.), каждый раз слегка
* по-разному (то icon-only, то с подписью, разный размер иконки) и «выпадала»
* из общего вида. Единый ghost-icon согласует внешний вид и место (под-навигация
* `PageTabs #actions` либо шапка через `Teleport to="#header-actions-host"`).
*/
withDefaults(
defineProps<{
/** Идёт ли загрузка — крутит спиннер вместо иконки. */
loading?: boolean;
/** Размер иконки (по умолчанию 20px — как у глобальных действий шапки). */
size?: string;
/** Подпись для скринридера и подсказки. */
ariaLabel?: string;
}>(),
{ loading: false, size: '20px', ariaLabel: 'Обновить' },
);
const emit = defineEmits<{ refresh: [] }>();
</script>
@@ -0,0 +1 @@
export { default as RefreshButton } from './RefreshButton.vue';