fix(marketplace): код получения — диалог из шапки + единый виджет карточки партии

- «Получить заказ» вернул из переадресации в диалог (ReceiveCodeDialog),
  кнопка в шапке и на «Моих заказах», и на детали заказа — в одном месте;
  отдельная страница меню остаётся. Общий ReceiveCodeContent (DRY).
- OfferGallery: точки-навигация выключены по умолчанию (убраны со всех экранов).
- Вынес SupplyPartyCard — единая карточка партии для заказчика
  («Коллективный заказ») и поставщика («Входящие заказы»), вместо дублей.
- Подчистил мёртвые стили карусели/плейсхолдера в CatalogOfferCard и
  странице оферты.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ant
2026-06-01 10:48:37 +00:00
parent b01652f8ae
commit ec1ca903a5
13 changed files with 390 additions and 359 deletions
@@ -201,13 +201,6 @@ q-page.offer-detail(role="region", aria-label="Описание предложе
background: var(--p-surface-2);
}
&__placeholder {
height: 360px;
display: flex;
align-items: center;
justify-content: center;
}
&__summary {
display: flex;
flex-direction: column;
@@ -8,6 +8,7 @@ import { RefreshButton } from 'src/widgets/Marketplace/RefreshButton';
import { BaseButton, EmptyState } from 'src/shared/ui/base';
import { PageHint } from 'src/shared/ui/domain';
import { PageTabs, type PageTab } from 'src/shared/ui/layout';
import { ReceiveCodeDialog } from 'src/widgets/Marketplace/ReceiveCode';
import { cancelOrder, fetchMyOrders } from '../api';
import type { MarketplaceOrderStatusView, MarketplaceOrderView } from '../types';
import OrdererFinalizeIssuanceDialog from './OrdererFinalizeIssuanceDialog.vue';
@@ -21,8 +22,8 @@ import OrdererFinalizeIssuanceDialog from './OrdererFinalizeIssuanceDialog.vue';
* выдачу, статус READY_TO_RECEIVE) — отдельной страницы «Готово к получению»
* больше нет. Клик по карточке открывает детальную страницу заказа.
*
* Код получения (account-bound QR) — на отдельной странице меню «Получить
* заказ» (OrdererReceiveCode), а не здесь: так он очевидно findable.
* Код получения (account-bound QR) — кнопка «Получить заказ» в шапке: открывает
* диалог с тем же QR, что и на отдельной странице меню (OrdererReceiveCode).
*
* Live-обновления — polling каждые 10s.
*/
@@ -47,6 +48,9 @@ const hasMore = computed(() => currentPage.value < totalPages.value);
const finalizeDialogOpen = ref(false);
const selectedOrder = ref<MarketplaceOrderView | null>(null);
// Код получения (account-bound QR) — диалогом из шапки, в одном месте.
const receiveDialogOpen = ref(false);
// Фильтр по этапу. Покрытие ИСЧЕРПЫВАЮЩЕЕ по enum'у MarketplaceOrderStatusView:
// каждый статус заказа попадает хотя бы в одну вкладку. Иначе заказ молча
// «растекается» — пропадает из всех вкладок, кроме «Все» (баг: молоко в статусе
@@ -160,7 +164,7 @@ function openDetail(order: OrderCardModel): void {
}
function goReceive(): void {
void router.push({ name: 'marketplace-receive-code', params: { coopname: coopname.value } });
receiveDialogOpen.value = true;
}
function onCardAction(payload: { key: string; order: OrderCardModel }): void {
@@ -230,6 +234,8 @@ q-page.orders(role="region", aria-label="Мои заказы")
:order="selectedOrder",
@finalized="onFinalized"
)
ReceiveCodeDialog(v-model="receiveDialogOpen", :coopname="coopname")
</template>
<style scoped lang="scss">
@@ -3,10 +3,11 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
import { Dialog } from 'quasar';
import { SuccessAlert, FailAlert, NotifyAlert } from 'src/shared/api';
import { useRoute, useRouter } from 'vue-router';
import { BaseBadge, BaseButton, EmptyState } from 'src/shared/ui/base';
import { BaseButton, EmptyState } from 'src/shared/ui/base';
import { PageHint } from 'src/shared/ui/domain';
import { PageTabs, type PageTab } from 'src/shared/ui/layout';
import { orderStatusDisplay } from 'src/widgets/Marketplace/OrderCard';
import { SupplyPartyCard } from 'src/widgets/Marketplace/SupplyPartyCard';
import { RefreshButton } from 'src/widgets/Marketplace/RefreshButton';
import { marketplaceUnitShort } from 'src/shared/lib/consts/marketplace-units';
import { formatShortFio } from 'src/shared/lib/utils/getNameFromCertificate';
@@ -203,6 +204,17 @@ function formatCost(value: number): string {
}).format(value);
}
// Состав партии строками для общего виджета SupplyPartyCard. Поставщику важен
// общий объём, не кто заказал — имена показываем справочно, без дробления.
function memberRows(p: SupplierParty): Array<{ id: string; who: string; qty: string; cost: string }> {
return p.orders.map((o) => ({
id: o.id,
who: o.orderer_name ? formatShortFio(o.orderer_name) : o.orderer_account,
qty: `${o.quantity} ${p.unitLabel}`,
cost: formatCost(parseFloat(o.total_cost) || 0),
}));
}
async function load(page: number, append: boolean): Promise<void> {
loading.value = true;
try {
@@ -320,61 +332,35 @@ q-page.incoming-orders(role='region', aria-label='Входящие заказы
q-icon(name='inbox', size='48px')
.incoming-orders__list(v-if='hasParties')
.incoming-orders__party(
//- ЕДИНАЯ карточка партии (канон-виджет SupplyPartyCard) — та же, что у
//- заказчика. Действия (Принять/Отклонить) — только пока партия копится.
SupplyPartyCard(
v-for='p in parties',
:key='p.key',
:class='`incoming-orders__party--${p.kind}`'
:product-name='p.productName',
:pvz-name='p.pvzName',
:stage-status='p.stageStatus',
:order-count='p.orders.length',
:volume-label='`Объём партии: ${p.totalUnits} ${p.unitLabel}`',
:target-label='hasTarget(p) ? `цель — от ${p.minVolume} ${p.unitLabel}` : ""',
:progress='progressRatio(p)',
:bar-color='barColor(p)',
:members='memberRows(p)',
total-label='Итого партии',
:total-value='`${formatCost(p.totalCost)} · ${p.totalUnits} ${p.unitLabel}`'
)
.incoming-orders__party-head
.row.items-center.q-gutter-sm.no-wrap
div.col
.t-h3 {{ p.productName }}
.t-muted.incoming-orders__party-sub
q-icon(name='place', size='14px')
| КУ «{{ p.pvzName }}»
BaseBadge(:variant='orderStatusDisplay(p.stageStatus).variant') {{ orderStatusDisplay(p.stageStatus).label }}
span.chip.chip--accent
q-icon(name='layers', size='14px')
| {{ p.orders.length }} зак.
//- ЕДИНАЯ карточка партии на всех стадиях: прогресс всегда, состав
//- компактными строками, действия — только пока партия копится.
.incoming-orders__progress
.incoming-orders__progress-row
span Объём партии: {{ p.totalUnits }} {{ p.unitLabel }}
span.t-muted(v-if='hasTarget(p)') цель от {{ p.minVolume }} {{ p.unitLabel }}
q-linear-progress.incoming-orders__progress-bar(
:value='progressRatio(p)',
rounded,
size='10px',
:color='barColor(p)',
track-color='grey-3'
)
.t-muted.incoming-orders__progress-hint(v-if='p.kind === "collecting" && hasTarget(p) && reachedMin(p)')
template(#hint)
template(v-if='p.kind === "collecting" && hasTarget(p) && reachedMin(p)')
q-icon(name='check_circle', size='14px', color='positive')
| Минимальный объём набран можно принимать партию.
.t-muted.incoming-orders__progress-hint(v-else-if='p.kind === "collecting" && hasTarget(p)')
| Можно принять и сейчас минимум лишь ориентир сбора.
.t-muted.incoming-orders__progress-hint(v-else-if='p.kind === "collecting"')
| Поштучный приём каждый заказ можно принять сразу.
.t-muted.incoming-orders__progress-hint(v-else)
span Минимальный объём набран можно принимать партию.
template(v-else-if='p.kind === "collecting" && hasTarget(p)')
span Можно принять и сейчас минимум лишь ориентир сбора.
template(v-else-if='p.kind === "collecting"')
span Поштучный приём каждый заказ можно принять сразу.
template(v-else)
q-icon(name='local_shipping', size='14px')
| Партия принята. Этап: {{ orderStatusDisplay(p.stageStatus).label }}.
//- Состав партии — компактные строки (поставщику важен общий объём, не
//- кто конкретно заказал; имена показываем справочно, без дробления на
//- отдельные карточки).
.incoming-orders__members
.incoming-orders__member(v-for='o in p.orders', :key='o.id')
span.incoming-orders__member-who {{ o.orderer_name ? formatShortFio(o.orderer_name) : o.orderer_account }}
span.incoming-orders__member-qty {{ o.quantity }} {{ p.unitLabel }}
span.incoming-orders__member-cost {{ formatCost(parseFloat(o.total_cost) || 0) }}
.incoming-orders__party-foot
.incoming-orders__party-total
span.t-muted Итого партии
span.incoming-orders__party-total-val {{ formatCost(p.totalCost) }} · {{ p.totalUnits }} {{ p.unitLabel }}
q-space
span Партия принята. Этап: {{ orderStatusDisplay(p.stageStatus).label }}.
template(#actions)
template(v-if='p.kind === "collecting"')
BaseButton(variant='ghost', size='sm', @click='onDeclineParty(p)') Отклонить
BaseButton(variant='primary', size='sm', :loading='loading', @click='onAcceptParty(p)')
@@ -412,100 +398,6 @@ q-page.incoming-orders(role='region', aria-label='Входящие заказы
gap: var(--p-4, 16px);
}
&__party {
border: 1px solid var(--p-line);
border-radius: var(--p-r-md, 12px);
background: var(--p-surface);
padding: var(--p-4, 16px);
display: flex;
flex-direction: column;
gap: var(--p-3, 12px);
}
&__party-sub {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 2px;
}
&__progress {
display: flex;
flex-direction: column;
gap: var(--p-2, 8px);
}
&__progress-row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: var(--p-fs-body);
}
&__progress-bar {
border-radius: var(--p-r-sm, 8px);
}
&__progress-hint {
display: inline-flex;
align-items: center;
gap: 4px;
}
&__members {
display: flex;
flex-direction: column;
border: 1px solid var(--p-line);
border-radius: var(--p-r-sm, 8px);
overflow: hidden;
}
&__member {
display: grid;
grid-template-columns: 1fr auto auto;
gap: var(--p-4, 16px);
align-items: center;
padding: var(--p-2, 8px) var(--p-3, 12px);
border-top: 1px solid var(--p-line);
&:first-child {
border-top: none;
}
}
&__member-who {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__member-qty {
color: var(--p-ink-2);
}
&__member-cost {
font-variant-numeric: tabular-nums;
}
&__party-foot {
display: flex;
align-items: center;
gap: var(--p-2, 8px);
flex-wrap: wrap;
padding-top: var(--p-2, 8px);
border-top: 1px solid var(--p-line);
}
&__party-total {
display: flex;
flex-direction: column;
}
&__party-total-val {
font-size: var(--p-fs-body);
color: var(--p-ink-1);
}
&__skel {
border: 1px solid var(--p-line);
border-radius: var(--p-r-md, 12px);
@@ -3,7 +3,8 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
import { FailAlert } from 'src/shared/api';
import { orderStatusDisplay } from 'src/widgets/Marketplace/OrderCard';
import { RefreshButton } from 'src/widgets/Marketplace/RefreshButton';
import { BaseBadge, EmptyState } from 'src/shared/ui/base';
import { SupplyPartyCard } from 'src/widgets/Marketplace/SupplyPartyCard';
import { EmptyState } from 'src/shared/ui/base';
import { PageHint } from 'src/shared/ui/domain';
import { marketplaceUnitShort } from 'src/shared/lib/consts/marketplace-units';
import { fetchMyOrders } from '../../MyOrders/api';
@@ -158,6 +159,17 @@ function formatCost(value: number): string {
}).format(value);
}
// Состав партии строками для общего виджета SupplyPartyCard. У заказчика «кто» —
// его собственные заказы (товар/КУ уже в шапке карточки).
function memberRows(p: CollectiveParty): Array<{ id: string; who: string; qty: string; cost: string }> {
return p.orders.map((o) => ({
id: o.id,
who: `Ваш заказ № ${o.id.slice(0, 8)}`,
qty: `${o.quantity} ${p.unitLabel}`,
cost: formatCost(parseFloat(o.total_cost) || 0),
}));
}
async function load(): Promise<void> {
loading.value = true;
try {
@@ -215,51 +227,32 @@ q-page.collective(role="region", aria-label="Коллективный заказ
q-icon(name="inventory_2", size="48px")
.collective__list(v-if="hasParties")
//- ЕДИНАЯ карточка для любой партии (сбор/принята) — отличается только
//- заполнением прогресса и бейджем этапа, не вёрсткой.
.collective__party(v-for="p in parties", :key="p.key")
.collective__party-head
.row.items-center.q-gutter-sm.no-wrap
div.col
.t-h3 {{ p.productName }}
.t-muted.collective__party-sub
q-icon(name="place", size="14px")
| КУ «{{ p.pvzName }}»
BaseBadge(:variant="orderStatusDisplay(p.stageStatus).variant") {{ orderStatusDisplay(p.stageStatus).label }}
span.chip.chip--accent
q-icon(name="layers", size="14px")
| {{ p.orders.length }} зак.
.collective__progress
.collective__progress-row
span Накоплено всеми: {{ accumulated(p) }} {{ p.unitLabel }}
span.t-muted(v-if="hasTarget(p)") цель от {{ p.groupMinVolume }} {{ p.unitLabel }}
q-linear-progress.collective__progress-bar(
:value="progressRatio(p)",
rounded,
size="10px",
:color="barColor(p)",
track-color="grey-3"
)
.t-muted.collective__progress-hint(v-if="p.collecting && reachedMin(p)")
//- ЕДИНАЯ карточка партии (канон-виджет SupplyPartyCard) — та же, что у
//- поставщика. Отличаются только данные и тексты, не вёрстка.
SupplyPartyCard(
v-for="p in parties",
:key="p.key",
:product-name="p.productName",
:pvz-name="p.pvzName",
:stage-status="p.stageStatus",
:order-count="p.orders.length",
:volume-label="`Накоплено всеми: ${accumulated(p)} ${p.unitLabel}`",
:target-label="hasTarget(p) ? `цель — от ${p.groupMinVolume} ${p.unitLabel}` : ''",
:progress="progressRatio(p)",
:bar-color="barColor(p)",
:members="memberRows(p)",
total-label="Ваш вклад в партию",
:total-value="`${formatCost(p.ownCost)} · ${p.ownUnits} ${p.unitLabel}`"
)
template(#hint)
template(v-if="p.collecting && reachedMin(p)")
q-icon(name="check_circle", size="14px", color="positive")
| Минимальный объём набран поставщик может принять партию.
.t-muted.collective__progress-hint(v-else-if="p.collecting")
| Сбор продолжается. Поставщик может принять партию и раньше минимум лишь ориентир.
.t-muted.collective__progress-hint(v-else)
span Минимальный объём набран поставщик может принять партию.
template(v-else-if="p.collecting")
span Сбор продолжается. Поставщик может принять партию и раньше минимум лишь ориентир.
template(v-else)
q-icon(name="local_shipping", size="14px")
| Партия набрана. Этап: {{ orderStatusDisplay(p.stageStatus).label }}.
//- Свои заказы в партии — компактными строками (товар/КУ уже в шапке).
.collective__own-orders
.collective__own-order(v-for="o in p.orders", :key="o.id")
span.collective__own-order-who Ваш заказ {{ o.id.slice(0, 8) }}
span.collective__own-order-qty {{ o.quantity }} {{ p.unitLabel }}
span.collective__own-order-cost {{ formatCost(parseFloat(o.total_cost) || 0) }}
.collective__own
span.t-muted Ваш вклад в партию
span.collective__own-val {{ formatCost(p.ownCost) }} · {{ p.ownUnits }} {{ p.unitLabel }}
span Партия набрана. Этап: {{ orderStatusDisplay(p.stageStatus).label }}.
</template>
<style scoped lang="scss">
@@ -279,96 +272,6 @@ q-page.collective(role="region", aria-label="Коллективный заказ
flex-direction: column;
gap: var(--p-4, 16px);
}
&__party {
border: 1px solid var(--p-line);
border-radius: var(--p-r-md, 12px);
background: var(--p-surface);
padding: var(--p-4, 16px);
display: flex;
flex-direction: column;
gap: var(--p-3, 12px);
}
&__party-sub {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 2px;
}
&__progress {
display: flex;
flex-direction: column;
gap: var(--p-2, 8px);
}
&__progress-row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: var(--p-fs-body);
}
&__progress-bar {
border-radius: var(--p-r-sm, 8px);
}
&__progress-hint {
display: inline-flex;
align-items: center;
gap: 4px;
}
&__own-orders {
display: flex;
flex-direction: column;
border: 1px solid var(--p-line);
border-radius: var(--p-r-sm, 8px);
overflow: hidden;
}
&__own-order {
display: grid;
grid-template-columns: 1fr auto auto;
gap: var(--p-4, 16px);
align-items: center;
padding: var(--p-2, 8px) var(--p-3, 12px);
border-top: 1px solid var(--p-line);
&:first-child {
border-top: none;
}
}
&__own-order-who {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__own-order-qty {
color: var(--p-ink-2);
}
&__own-order-cost {
font-variant-numeric: tabular-nums;
}
&__own {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--p-2, 8px);
padding-top: var(--p-2, 8px);
border-top: 1px solid var(--p-line);
}
&__own-val {
font-size: var(--p-fs-body);
color: var(--p-ink-1);
font-variant-numeric: tabular-nums;
}
}
@media (max-width: 768px) {
@@ -7,6 +7,7 @@ import { BaseBadge, BaseButton, BaseCard } from 'src/shared/ui/base';
import { ActivityTimeline, type ActivityEvent } from 'src/shared/ui/domain';
import { OfferGallery } from 'src/widgets/Marketplace/OfferGallery';
import { RefreshButton } from 'src/widgets/Marketplace/RefreshButton';
import { ReceiveCodeDialog } from 'src/widgets/Marketplace/ReceiveCode';
import { orderStatusDisplay } from 'src/widgets/Marketplace/OrderCard';
import { marketplaceUnitShort } from 'src/shared/lib/consts/marketplace-units';
import { marketplaceOfferImageUrls } from 'src/shared/lib/utils';
@@ -37,6 +38,7 @@ const offerImages = ref<string[]>([]);
const loading = ref(false);
const finalizeDialogOpen = ref(false);
const receiveDialogOpen = ref(false);
const status = computed(() => (order.value ? orderStatusDisplay(order.value.status) : null));
const unitShort = computed(() => marketplaceUnitShort(order.value?.unit_of_measure));
@@ -108,7 +110,7 @@ function goBack(): void {
}
function goReceive(): void {
void router.push({ name: 'marketplace-receive-code', params: { coopname: coopname.value } });
receiveDialogOpen.value = true;
}
function confirmCancel(): void {
@@ -155,6 +157,10 @@ onBeforeUnmount(() => {
<template lang="pug">
q-page.order-detail(role="region", aria-label="Заказ")
Teleport(to="#header-actions-host", defer)
BaseButton(variant="secondary", size="sm", @click="goReceive")
template(#icon-left)
q-icon(name="qr_code_2", size="16px")
| Получить заказ
RefreshButton(:loading="loading", @refresh="load")
.order-detail__col
@@ -222,11 +228,7 @@ q-page.order-detail(role="region", aria-label="Заказ")
.t-h3 Хронология
ActivityTimeline(:events="timelineEvents", group-by-date)
.order-detail__actions
BaseButton(variant="secondary", @click="goReceive")
template(#icon-left)
q-icon(name="qr_code_2", size="16px")
| Получить заказ
.order-detail__actions(v-if="receivable || cancellable")
BaseButton(v-if="receivable", variant="primary", @click="finalizeDialogOpen = true")
template(#icon-left)
q-icon(name="draw", size="16px")
@@ -238,6 +240,8 @@ q-page.order-detail(role="region", aria-label="Заказ")
:order="order",
@finalized="onFinalized"
)
ReceiveCodeDialog(v-model="receiveDialogOpen", :coopname="coopname")
</template>
<style scoped lang="scss">
@@ -1,10 +1,7 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useSessionStore } from 'src/entities/Session';
import { HandoffQr } from 'src/widgets/Marketplace/HandoffQr';
import { EmptyState } from 'src/shared/ui/base';
import { encodeHandoffToken, HandoffTokenKind } from 'src/shared/lib/marketplace';
import { ReceiveCodeContent } from 'src/widgets/Marketplace/ReceiveCode';
/**
* Стол заказчика, страница «Получить заказ».
@@ -15,39 +12,17 @@ import { encodeHandoffToken, HandoffTokenKind } from 'src/shared/lib/marketplace
*
* Сделано отдельным пунктом меню (а не действием в шапке), чтобы код был
* очевидно findable: пайщику не нужно объяснять, где его искать — пункт
* «Получить заказ» всегда виден в меню стола.
* «Получить заказ» всегда виден в меню стола. Тот же QR доступен диалогом из
* шапки «Моих заказов» и детали заказа (общий `ReceiveCodeContent`).
*/
const route = useRoute();
const session = useSessionStore();
const coopname = computed(() => String(route.params.coopname ?? ''));
const receiveCode = computed(() =>
session.username
? encodeHandoffToken({
kind: HandoffTokenKind.Receive,
coopname: coopname.value,
account: session.username,
})
: '',
);
</script>
<template lang="pug">
q-page.receive(role="region", aria-label="Получить заказ")
.receive__inner(v-if="receiveCode")
HandoffQr(
:value="receiveCode",
:size="320",
caption="Покажите этот код оператору на том пункте выдачи, куда пришёл ваш заказ — он выдаст по нему всё, что готово к получению. Код можно показать с экрана телефона или с распечатки."
)
EmptyState(
v-else,
title="Код недоступен",
body="Войдите в кооператив, чтобы получить персональный код выдачи."
)
template(#icon)
q-icon(name="qr_code_2", size="48px")
ReceiveCodeContent(:coopname="coopname")
</template>
<style scoped lang="scss">
@@ -56,14 +31,5 @@ q-page.receive(role="region", aria-label="Получить заказ")
align-items: center;
justify-content: center;
padding: var(--p-6, 24px);
&__inner {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--p-4, 16px);
max-width: 480px;
text-align: center;
}
}
</style>
@@ -169,35 +169,10 @@ function onClick() {
}
}
&__carousel {
width: 100%;
height: 100%;
background: var(--mp-surface-1);
// Слайд q-carousel по умолчанию имеет внутренний padding — обнуляем,
// чтобы изображение шло во всю ширину карточки.
:deep(.q-carousel__slide) {
padding: 0;
}
.q-img {
width: 100%;
height: 100%;
}
}
&.mp-card--interactive:hover &__media :deep(img) {
transform: scale(1.02);
}
&__placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
&__status {
position: absolute;
top: var(--mp-space-sm);
@@ -50,6 +50,10 @@ withDefaults(
/** Высота карусели: фикс ('360px') или '100%' под размер обёртки. */
height?: string
arrows?: boolean
/**
* Точки-навигация снизу. По умолчанию выключены — точки в галерее товара
* не нужны (листается свайпом/стрелками), их явно убрали по всем экранам.
*/
navigation?: boolean
alt?: string
fit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
@@ -58,7 +62,7 @@ withDefaults(
{
height: '100%',
arrows: true,
navigation: true,
navigation: false,
alt: '',
fit: 'cover',
placeholderIconSize: '48px',
@@ -0,0 +1,63 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useSessionStore } from 'src/entities/Session';
import { HandoffQr } from 'src/widgets/Marketplace/HandoffQr';
import { EmptyState } from 'src/shared/ui/base';
import { encodeHandoffToken, HandoffTokenKind } from 'src/shared/lib/marketplace';
/**
* Содержимое экрана «Получить заказ» — account-bound QR-код заказчика плюс
* пояснение. Единый источник на три точки показа: отдельная страница меню
* (`OrdererReceiveCode`), диалог из шапки «Моих заказов» и диалог из шапки
* детали заказа. Раньше QR верстали отдельно в каждой — нарушение DRY; теперь
* один компонент, которому передают `coopname` (аккаунт берётся из сессии).
*/
const props = withDefaults(
defineProps<{
coopname: string;
size?: number;
}>(),
{ size: 320 },
);
const session = useSessionStore();
const receiveCode = computed(() =>
session.username
? encodeHandoffToken({
kind: HandoffTokenKind.Receive,
coopname: props.coopname,
account: session.username,
})
: '',
);
</script>
<template lang="pug">
.receive-code(v-if="receiveCode")
HandoffQr(
:value="receiveCode",
:size="size",
caption="Покажите этот код оператору на том пункте выдачи, куда пришёл ваш заказ — он выдаст по нему всё, что готово к получению. Код можно показать с экрана телефона или с распечатки."
)
EmptyState(
v-else,
title="Код недоступен",
body="Войдите в кооператив, чтобы получить персональный код выдачи."
)
template(#icon)
q-icon(name="qr_code_2", size="48px")
</template>
<style scoped lang="scss">
.receive-code {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--p-4, 16px);
max-width: 480px;
margin: 0 auto;
text-align: center;
}
</style>
@@ -0,0 +1,38 @@
<script lang="ts" setup>
import { BaseDialog } from 'src/shared/ui/base';
import ReceiveCodeContent from './ReceiveCodeContent.vue';
/**
* Диалог «Получить заказ» — тот же QR-код, что и на отдельной странице, но
* всплывающим окном. Открывается кнопкой из шапки «Моих заказов» и из шапки
* детали заказа: код всегда под рукой в одном месте (шапка), без перехода на
* отдельную страницу и проблемы «как вернуться назад».
*/
defineProps<{
modelValue: boolean;
coopname: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
}>();
</script>
<template lang="pug">
BaseDialog(
:model-value="modelValue",
title="Получить заказ",
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
)
.receive-code-dialog
ReceiveCodeContent(:coopname="coopname")
</template>
<style scoped lang="scss">
.receive-code-dialog {
display: flex;
justify-content: center;
padding: var(--p-4, 16px) 0;
}
</style>
@@ -0,0 +1,2 @@
export { default as ReceiveCodeContent } from './ReceiveCodeContent.vue';
export { default as ReceiveCodeDialog } from './ReceiveCodeDialog.vue';
@@ -0,0 +1,184 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { BaseBadge } from 'src/shared/ui/base';
import { orderStatusDisplay, type DomainOrderStatus } from 'src/widgets/Marketplace/OrderCard';
/**
* Единая карточка партии Стола заказов — одна вёрстка на всех столах и стадиях.
*
* Партия (заказы по паре оферта × КУ) показывается одинаково и у заказчика
* («Коллективный заказ»), и у поставщика («Входящие заказы»), на любом этапе
* (копится / принята / в работе): шапка (товар + КУ + бейдж этапа + счётчик),
* прогресс-бар сбора, состав партии строками, подвал (итог + действия).
* Меняются только данные и тексты — не вёрстка. Раньше каждая страница
* рисовала свою карточку (накопитель ≠ принятая) — путало и нарушало DRY.
*
* Вариативные части — слотами:
* - `#hint` — пояснение под прогресс-баром (тексты у столов разные);
* - `#actions` — кнопки в подвале (у поставщика «Принять/Отклонить», у
* заказчика пусто).
*/
const props = defineProps<{
productName: string;
pvzName: string;
/** Доменный статус-этап партии (минимальный по рангу среди заказов). */
stageStatus: DomainOrderStatus | string;
orderCount: number;
/** Левая подпись прогресса целиком, напр. «Объём партии: 5 кг». */
volumeLabel: string;
/** Правая muted-подпись (цель сбора), напр. «цель — от 10 кг». Пусто — скрыта. */
targetLabel?: string;
/** Заполнение прогресс-бара 0..1. */
progress: number;
/** Цвет бара (Quasar color): primary пока копится, positive когда набрано/принято. */
barColor: string;
/** Состав партии строками: кто · сколько · стоимость. */
members: Array<{ id: string; who: string; qty: string; cost: string }>;
/** Подпись итога, напр. «Итого партии» / «Ваш вклад в партию». */
totalLabel: string;
/** Значение итога, напр. «1 200 ₽ · 5 кг». */
totalValue: string;
}>();
const statusDisplay = computed(() => orderStatusDisplay(props.stageStatus));
</script>
<template lang="pug">
.supply-party
.supply-party__head
.row.items-center.q-gutter-sm.no-wrap
div.col
.t-h3 {{ productName }}
.t-muted.supply-party__sub
q-icon(name="place", size="14px")
| КУ «{{ pvzName }}»
BaseBadge(:variant="statusDisplay.variant") {{ statusDisplay.label }}
span.chip.chip--accent
q-icon(name="layers", size="14px")
| {{ orderCount }} зак.
.supply-party__progress
.supply-party__progress-row
span {{ volumeLabel }}
span.t-muted(v-if="targetLabel") {{ targetLabel }}
q-linear-progress.supply-party__progress-bar(
:value="progress",
rounded,
size="10px",
:color="barColor",
track-color="grey-3"
)
.t-muted.supply-party__progress-hint
slot(name="hint")
.supply-party__members
.supply-party__member(v-for="m in members", :key="m.id")
span.supply-party__member-who {{ m.who }}
span.supply-party__member-qty {{ m.qty }}
span.supply-party__member-cost {{ m.cost }}
.supply-party__foot
.supply-party__total
span.t-muted {{ totalLabel }}
span.supply-party__total-val {{ totalValue }}
q-space
slot(name="actions")
</template>
<style scoped lang="scss">
.supply-party {
border: 1px solid var(--p-line);
border-radius: var(--p-r-md, 12px);
background: var(--p-surface);
padding: var(--p-4, 16px);
display: flex;
flex-direction: column;
gap: var(--p-3, 12px);
&__sub {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 2px;
}
&__progress {
display: flex;
flex-direction: column;
gap: var(--p-2, 8px);
}
&__progress-row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: var(--p-fs-body);
}
&__progress-bar {
border-radius: var(--p-r-sm, 8px);
}
&__progress-hint {
display: inline-flex;
align-items: center;
gap: 4px;
}
&__members {
display: flex;
flex-direction: column;
border: 1px solid var(--p-line);
border-radius: var(--p-r-sm, 8px);
overflow: hidden;
}
&__member {
display: grid;
grid-template-columns: 1fr auto auto;
gap: var(--p-4, 16px);
align-items: center;
padding: var(--p-2, 8px) var(--p-3, 12px);
border-top: 1px solid var(--p-line);
&:first-child {
border-top: none;
}
}
&__member-who {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__member-qty {
color: var(--p-ink-2);
}
&__member-cost {
font-variant-numeric: tabular-nums;
}
&__foot {
display: flex;
align-items: center;
gap: var(--p-2, 8px);
flex-wrap: wrap;
padding-top: var(--p-2, 8px);
border-top: 1px solid var(--p-line);
}
&__total {
display: flex;
flex-direction: column;
}
&__total-val {
font-size: var(--p-fs-body);
color: var(--p-ink-1);
font-variant-numeric: tabular-nums;
}
}
</style>
@@ -0,0 +1 @@
export { default as SupplyPartyCard } from './SupplyPartyCard.vue';