feat(marketplace): S3 — редизайн «Подготовки отгрузки» (глобальная кнопка + dual-list)

Стол подготовки отгрузки переведён на модель брифа E14:
- Глобальная кнопка «Сформировать партию» в шапке (Teleport #header-actions-host)
  вместо per-cycle кнопок. Лист сформированных партий — основной вид стола.
- Новый диалог формирования: способ доставки (самовывоз / экспедитор по ТТН) →
  один кооперативный участок → dual-list заказов («Переместить всё» + откат
  отдельных строк назад). Грузим всё, что справа; невыбранное остаётся ACCEPTED.
  Гранулярность целая — количество в заказе не дробим.
- Submit группирует выбранные заказы по заявке (cycle_id) и шлёт createShipment
  по каждой заявке с order_ids-подмножеством (backend S1). Для экспедитора —
  поля ТТН (на каждую партию печатается QR приёмки, S2).
- groupAcceptedByKu(): группировка акцептованных заказов по КУ через все заявки.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ant
2026-05-31 10:36:10 +00:00
parent a700422dd1
commit 9f7173fc26
3 changed files with 424 additions and 155 deletions
@@ -27,6 +27,60 @@ export interface ShipmentFormationCycle {
sum: number;
}
/**
* E14: одна строка заказа для dual-list формирования партии. Гранулярность —
* целый заказ (не дробим количество): строка целиком грузится в партию или нет.
*/
export interface ShipmentOrderLine {
id: string;
cycle_id: string;
title: string;
quantity: number;
unit: string;
sum: number;
}
/** E14: акцептованные заказы одного КУ (через все заявки) — корзина для dual-list. */
export interface ShipmentKuBucket {
braname: string;
kuName: string;
kuAddress?: string;
lines: ShipmentOrderLine[];
}
/**
* E14: группировка акцептованных заказов поставщика по КУ (через все заявки) —
* для нового диалога формирования: поставщик выбирает один КУ и переносит
* заказы-строки в партию (dual-list). Заказы без cycle_id опускаем — без заявки
* партию не сформировать. На submit выбранные строки группируются по cycle_id
* (одна партия = один cycle × КУ × вариант).
*/
export function groupAcceptedByKu(orders: MarketplaceOrderView[]): ShipmentKuBucket[] {
const byKu = new Map<string, ShipmentKuBucket>();
for (const o of orders) {
if (!o.cycle_id) continue;
let bucket = byKu.get(o.delivery_braname);
if (!bucket) {
bucket = {
braname: o.delivery_braname,
kuName: o.delivery_point_name || o.delivery_braname,
kuAddress: o.delivery_point_address || undefined,
lines: [],
};
byKu.set(o.delivery_braname, bucket);
}
bucket.lines.push({
id: o.id,
cycle_id: o.cycle_id,
title: o.product_name || 'Товар по предложению',
quantity: o.quantity,
unit: marketplaceUnitShort(o.unit_of_measure),
sum: parseFloat(o.total_cost) || 0,
});
}
return [...byKu.values()];
}
export function groupAcceptedOrders(orders: MarketplaceOrderView[]): ShipmentFormationCycle[] {
const byCycle = new Map<string, MarketplaceOrderView[]>();
for (const o of orders) {
@@ -4,17 +4,26 @@ import { Zeus } from '@coopenomics/sdk';
import { SuccessAlert, FailAlert } from 'src/shared/api';
import { BaseButton, BaseDialog, BaseInput, BaseRadioCard } from 'src/shared/ui/base';
import { createShipment } from '../api';
import type { ShipmentFormationCycle } from '../lib/shipmentFormation';
import { groupAcceptedByKu, type ShipmentKuBucket } from '../lib/shipmentFormation';
import type { MarketplaceOrderView } from '../../MyOrders/types';
/**
* Story 14.1 / 14.5: явное формирование партии поставщиком.
* E14: явное формирование партии поставщиком — «отделить акцептованное от
* реально погруженного».
*
* Поставщик выбирает вариант доставки ПО КАЖДОМУ КУ заявки (А — самовывоз,
* Б — экспедитор + ТТН) и формирует партию через `marketplaceCreateShipment`.
* Backend требует группу на каждый КУ заявки (1:1), поэтому диалог показывает
* все КУ заявки сразу. Для Варианта Б обязательны поля ТТН.
* Поток: (1) способ доставки — самовывоз / экспедитор по ТТН; (2) один
* кооперативный участок; (3) dual-list заказов этого КУ — «переместить всё» +
* откат отдельных строк назад (гранулярность целая, количество не дробим).
* Грузим всё, что в правой колонке; невыбранное остаётся ACCEPTED для следующей
* партии. Выбранные заказы группируются по заявке (cycle_id) — на каждую заявку
* создаётся отдельная партия (backend: один shipment = один cycle × КУ × вариант).
*/
// Значения GraphQL-enum'а передаются ПО ИМЕНИ (SELF/EXPEDITOR) — backend мапит в код.
const SELF = Zeus.MarketplaceShipmentDeliveryVariant.SELF;
const EXPEDITOR = Zeus.MarketplaceShipmentDeliveryVariant.EXPEDITOR;
type DeliveryVariant = Zeus.MarketplaceShipmentDeliveryVariant;
interface TtnData {
expeditor_full_name: string;
expeditor_phone: string;
@@ -25,15 +34,10 @@ interface TtnData {
delivery_datetime_estimate: string;
}
// Значения GraphQL-enum'а передаются ПО ИМЕНИ (SELF/EXPEDITOR), не по
// внутреннему коду ('A'/'B') — backend-NestJS мапит имя в код сам.
const SELF = Zeus.MarketplaceShipmentDeliveryVariant.SELF;
const EXPEDITOR = Zeus.MarketplaceShipmentDeliveryVariant.EXPEDITOR;
type DeliveryVariant = Zeus.MarketplaceShipmentDeliveryVariant;
const props = defineProps<{
modelValue: boolean;
cycle: ShipmentFormationCycle | null;
/** Акцептованные заказы поставщика (источник для dual-list по КУ). */
orders: MarketplaceOrderView[];
}>();
const emit = defineEmits<{
@@ -42,9 +46,11 @@ const emit = defineEmits<{
}>();
const submitting = ref(false);
// Вариант доставки и данные ТТН — по каждому КУ (ключ = braname).
const variants = ref<Record<string, DeliveryVariant>>({});
const ttn = ref<Record<string, TtnData>>({});
const variant = ref<DeliveryVariant>(SELF);
const selectedKu = ref<string | null>(null);
// Заказы, перемещённые в партию (id строк). Невыбранное остаётся ACCEPTED.
const included = ref<Set<string>>(new Set());
const ttn = ref<TtnData>(emptyTtn());
function emptyTtn(): TtnData {
return {
@@ -58,22 +64,6 @@ function emptyTtn(): TtnData {
};
}
// При открытии диалога — инициализируем выбор «самовывоз» для всех КУ.
watch(
() => props.cycle,
(cycle) => {
const v: Record<string, DeliveryVariant> = {};
const t: Record<string, TtnData> = {};
for (const g of cycle?.groups ?? []) {
v[g.braname] = SELF;
t[g.braname] = emptyTtn();
}
variants.value = v;
ttn.value = t;
},
{ immediate: true },
);
const TTN_FIELDS: Array<{ key: keyof TtnData; label: string; type?: 'text' | 'tel' | 'date' }> = [
{ key: 'expeditor_full_name', label: 'ФИО экспедитора' },
{ key: 'expeditor_phone', label: 'Телефон экспедитора', type: 'tel' },
@@ -84,35 +74,112 @@ const TTN_FIELDS: Array<{ key: keyof TtnData; label: string; type?: 'text' | 'te
{ key: 'delivery_datetime_estimate', label: 'Ожидаемая дата доставки', type: 'date' },
];
function isExpeditor(braname: string): boolean {
return variants.value[braname] === EXPEDITOR;
const buckets = computed<ShipmentKuBucket[]>(() => groupAcceptedByKu(props.orders));
const activeBucket = computed<ShipmentKuBucket | null>(
() => buckets.value.find((b) => b.braname === selectedKu.value) ?? null,
);
// Левая колонка — доступно к погрузке; правая — в партии.
const availableLines = computed(() =>
(activeBucket.value?.lines ?? []).filter((l) => !included.value.has(l.id)),
);
const includedLines = computed(() =>
(activeBucket.value?.lines ?? []).filter((l) => included.value.has(l.id)),
);
const includedSum = computed(() => includedLines.value.reduce((acc, l) => acc + l.sum, 0));
// Сброс выбора при открытии/смене диалога.
watch(
() => props.modelValue,
(open) => {
if (open) {
variant.value = SELF;
selectedKu.value = buckets.value.length === 1 ? buckets.value[0].braname : null;
included.value = new Set();
ttn.value = emptyTtn();
}
},
{ immediate: true },
);
// Смена КУ — заново пустой выбор (заказы у каждого КУ свои).
watch(selectedKu, () => {
included.value = new Set();
});
function selectKu(braname: string): void {
selectedKu.value = braname;
}
// Все поля ТТН для каждого Варианта Б должны быть заполнены.
function include(id: string): void {
const next = new Set(included.value);
next.add(id);
included.value = next;
}
function exclude(id: string): void {
const next = new Set(included.value);
next.delete(id);
included.value = next;
}
function includeAll(): void {
included.value = new Set((activeBucket.value?.lines ?? []).map((l) => l.id));
}
function excludeAll(): void {
included.value = new Set();
}
const isExpeditor = computed(() => variant.value === EXPEDITOR);
const ttnComplete = computed(() =>
TTN_FIELDS.every((f) => String(ttn.value[f.key]).trim().length > 0),
);
const canSubmit = computed(() => {
if (!props.cycle) return false;
return props.cycle.groups.every((g) => {
if (variants.value[g.braname] !== EXPEDITOR) return true;
const t = ttn.value[g.braname];
return t && TTN_FIELDS.every((f) => String(t[f.key]).trim().length > 0);
});
if (!selectedKu.value || included.value.size === 0) return false;
if (isExpeditor.value && !ttnComplete.value) return false;
return true;
});
function formatPrice(v: number): string {
return new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 0 }).format(v) + ' ₽';
}
function close(): void {
emit('update:modelValue', false);
}
async function submit(): Promise<void> {
if (!props.cycle || !canSubmit.value) return;
if (!selectedKu.value || !canSubmit.value) return;
submitting.value = true;
try {
const groups = props.cycle.groups.map((g) => ({
braname: g.braname,
delivery_variant: variants.value[g.braname],
ttn_data: variants.value[g.braname] === EXPEDITOR ? ttn.value[g.braname] : null,
}));
await createShipment({ cycle_id: props.cycle.cycle_id, groups });
SuccessAlert('Партия сформирована');
// Выбранные строки → группируем по заявке (одна партия = один cycle × КУ).
const byCycle = new Map<string, string[]>();
for (const line of includedLines.value) {
const arr = byCycle.get(line.cycle_id) ?? [];
arr.push(line.id);
byCycle.set(line.cycle_id, arr);
}
const ttn_data = isExpeditor.value ? { ...ttn.value } : null;
let created = 0;
for (const [cycle_id, order_ids] of byCycle) {
const result = await createShipment({
cycle_id,
groups: [
{
braname: selectedKu.value,
delivery_variant: variant.value,
order_ids,
ttn_data,
},
],
});
created += result.shipments.length;
}
SuccessAlert(created > 1 ? `Сформировано партий: ${created}` : 'Партия сформирована');
emit('created');
close();
} catch (e) {
@@ -130,42 +197,98 @@ BaseDialog(
size='lg',
@update:model-value='emit("update:modelValue", $event)'
)
.create-shipment(v-if='cycle')
.create-shipment__intro
| Выберите способ доставки по каждому пункту выдачи. Самовывоз привезёте
| сами, оператор откроет приёмку на КУ. Через экспедитора заполните ТТН.
.create-shipment__group(v-for='g in cycle.groups', :key='g.braname')
.create-shipment__ku
.create-shipment__ku-name {{ g.kuName }}
.create-shipment__ku-addr(v-if='g.kuAddress') {{ g.kuAddress }}
.create-shipment__ku-meta {{ g.ordersCount }} заказ(ов) · {{ g.units }} ед. · {{ g.sum }}
.create-shipment(v-if='buckets.length')
//- Шаг 1: способ доставки.
.create-shipment__step
.create-shipment__step-title Способ доставки
.create-shipment__variants
BaseRadioCard(
v-model='variants[g.braname]',
v-model='variant',
:value='SELF',
title='Самовывоз',
description='Привезу сам на пункт выдачи'
description='Привезу сам на пункт выдачи — без ТТН'
)
BaseRadioCard(
v-model='variants[g.braname]',
v-model='variant',
:value='EXPEDITOR',
title='Через экспедитора',
description='Передам по товарно-транспортной накладной'
description='Передам по товарно-транспортной накладной (с QR приёмки)'
)
.create-shipment__ttn(v-if='isExpeditor(g.braname)')
.create-shipment__ttn-title Данные ТТН
.create-shipment__ttn-grid
BaseInput(
v-for='f in TTN_FIELDS',
:key='f.key',
v-model='ttn[g.braname][f.key]',
:label='f.label',
:type='f.type ?? "text"',
required
)
//- Шаг 2: кооперативный участок.
.create-shipment__step
.create-shipment__step-title Кооперативный участок
.create-shipment__ku-list
.create-shipment__ku-row(
v-for='b in buckets',
:key='b.braname',
:class='{ "create-shipment__ku-row--active": selectedKu === b.braname }',
role='button',
tabindex='0',
@click='selectKu(b.braname)',
@keydown.enter='selectKu(b.braname)'
)
q-icon.create-shipment__ku-icon(name='place', size='18px')
.create-shipment__ku-text
.create-shipment__ku-name {{ b.kuName }}
.create-shipment__ku-addr(v-if='b.kuAddress') {{ b.kuAddress }}
.create-shipment__ku-meta {{ b.lines.length }} заказ(ов)
//- Шаг 3: dual-list заказов выбранного КУ.
.create-shipment__step(v-if='activeBucket')
.create-shipment__step-title Что грузим в партию
.create-shipment__hint
| Перенесите заказы в партию. Грузим всё, что справа; остальное останется
| акцептованным для следующей партии. Количество в заказе не дробим.
.create-shipment__transfer
.create-shipment__col
.create-shipment__col-head
span Доступно ({{ availableLines.length }})
BaseButton(variant='ghost', size='sm', :disabled='!availableLines.length', @click='includeAll')
| Переместить всё
.create-shipment__col-body
.create-shipment__empty(v-if='!availableLines.length') Все заказы в партии
.create-shipment__line(v-for='l in availableLines', :key='l.id')
.create-shipment__line-info
.create-shipment__line-title {{ l.title }}
.create-shipment__line-meta {{ l.quantity }} {{ l.unit }} · {{ formatPrice(l.sum) }}
BaseButton(variant='ghost', size='sm', icon-only, aria-label='В партию', @click='include(l.id)')
template(#icon-left)
q-icon(name='chevron_right', size='18px')
.create-shipment__col
.create-shipment__col-head
span В партии ({{ includedLines.length }})
BaseButton(variant='ghost', size='sm', :disabled='!includedLines.length', @click='excludeAll')
| Убрать всё
.create-shipment__col-body
.create-shipment__empty(v-if='!includedLines.length') Перенесите заказы сюда
.create-shipment__line.create-shipment__line--in(v-for='l in includedLines', :key='l.id')
BaseButton(variant='ghost', size='sm', icon-only, aria-label='Откатить', @click='exclude(l.id)')
template(#icon-left)
q-icon(name='chevron_left', size='18px')
.create-shipment__line-info
.create-shipment__line-title {{ l.title }}
.create-shipment__line-meta {{ l.quantity }} {{ l.unit }} · {{ formatPrice(l.sum) }}
.create-shipment__total(v-if='includedLines.length')
| В партии: {{ includedLines.length }} заказ(ов) · {{ formatPrice(includedSum) }}
//- Шаг 4: данные ТТН (только экспедитор).
.create-shipment__step(v-if='activeBucket && isExpeditor')
.create-shipment__step-title Данные ТТН
.create-shipment__ttn-grid
BaseInput(
v-for='f in TTN_FIELDS',
:key='f.key',
v-model='ttn[f.key]',
:label='f.label',
:type='f.type ?? "text"',
required
)
.create-shipment__nodata(v-else)
| Нет акцептованных заказов для формирования партии. Примите заказы во
| «Входящих заказах» они появятся здесь.
template(#footer)
BaseButton(variant='ghost', :disabled='submitting', @click='close') Отмена
@@ -181,58 +304,165 @@ BaseDialog(
.create-shipment {
display: flex;
flex-direction: column;
gap: var(--p-4, 16px);
gap: var(--p-5, 20px);
&__intro {
&__step {
display: flex;
flex-direction: column;
gap: var(--p-2, 8px);
}
&__step-title {
font-size: var(--p-fs-eyebrow, 11px);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--p-ink-3);
}
&__hint {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-3);
line-height: 1.4;
}
&__group {
border: 1px solid var(--p-line);
border-radius: var(--p-r-md, 12px);
padding: var(--p-4, 16px);
display: flex;
flex-direction: column;
gap: var(--p-3, 12px);
}
&__ku-name {
font-size: var(--p-fs-body, 14px);
font-weight: 600;
color: var(--p-ink);
}
&__ku-addr {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-3);
}
&__ku-meta {
margin-top: 2px;
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-2);
font-variant-numeric: tabular-nums;
}
&__variants {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--p-2, 8px);
}
&__ttn {
border-top: 1px solid var(--p-line);
padding-top: var(--p-3, 12px);
&__ku-list {
display: flex;
flex-direction: column;
gap: var(--p-1, 4px);
}
&__ttn-title {
font-size: var(--p-fs-eyebrow, 11px);
text-transform: uppercase;
letter-spacing: 0.04em;
&__ku-row {
display: flex;
align-items: center;
gap: var(--p-2, 8px);
padding: var(--p-2, 8px) var(--p-3, 12px);
border: 1px solid var(--p-line);
border-radius: var(--p-r-md, 12px);
cursor: pointer;
&--active {
border-color: var(--q-primary);
background: var(--p-surface-2, rgba(15, 118, 110, 0.06));
}
}
&__ku-icon {
color: var(--p-ink-3);
margin-bottom: var(--p-2, 8px);
flex-shrink: 0;
}
&__ku-text {
flex: 1 1 auto;
min-width: 0;
}
&__ku-name {
font-size: var(--p-fs-body, 14px);
color: var(--p-ink);
overflow-wrap: anywhere;
}
&__ku-addr {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-3);
overflow-wrap: anywhere;
}
&__ku-meta {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-2);
white-space: nowrap;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
&__transfer {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--p-3, 12px);
}
&__col {
display: flex;
flex-direction: column;
border: 1px solid var(--p-line);
border-radius: var(--p-r-md, 12px);
min-height: 120px;
}
&__col-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--p-2, 8px);
padding: var(--p-2, 8px) var(--p-3, 12px);
border-bottom: 1px solid var(--p-line);
font-size: var(--p-fs-body-sm, 13px);
font-weight: 600;
color: var(--p-ink-2);
}
&__col-body {
display: flex;
flex-direction: column;
gap: var(--p-1, 4px);
padding: var(--p-2, 8px);
max-height: 280px;
overflow-y: auto;
}
&__empty {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-3);
padding: var(--p-3, 12px);
text-align: center;
}
&__line {
display: flex;
align-items: center;
gap: var(--p-2, 8px);
padding: var(--p-1, 4px) var(--p-2, 8px);
border-radius: var(--p-r-sm, 8px);
&:hover {
background: var(--p-surface-2, rgba(0, 0, 0, 0.03));
}
&--in {
flex-direction: row;
}
}
&__line-info {
flex: 1 1 auto;
min-width: 0;
}
&__line-title {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink);
overflow-wrap: anywhere;
}
&__line-meta {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-3);
font-variant-numeric: tabular-nums;
}
&__total {
font-size: var(--p-fs-body-sm, 13px);
font-weight: 600;
color: var(--p-ink);
text-align: right;
font-variant-numeric: tabular-nums;
}
&__ttn-grid {
@@ -240,5 +470,11 @@ BaseDialog(
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--p-3, 12px);
}
&__nodata {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-3);
padding: var(--p-4, 16px);
}
}
</style>
@@ -13,7 +13,6 @@ import { TTNPrintPreview, type TTNData } from 'src/widgets/Marketplace/TTNPrintP
import { listShipments, type MarketplaceShipmentView } from '../api';
import { fetchSupplierOrders } from '../../OffererIncomingOrders/api';
import type { MarketplaceOrderView } from '../../MyOrders/types';
import { groupAcceptedOrders, type ShipmentFormationCycle } from '../lib/shipmentFormation';
import { buildTtnData } from '../lib/ttn';
import CreateShipmentDialog from './CreateShipmentDialog.vue';
@@ -43,22 +42,16 @@ const acceptedOrders = ref<MarketplaceOrderView[]>([]);
const preparedOrders = ref<MarketplaceOrderView[]>([]);
const loading = ref(false);
const formationCycles = computed<ShipmentFormationCycle[]>(() =>
groupAcceptedOrders(acceptedOrders.value),
);
// Есть ли акцептованные заказы (привязанные к заявке), из которых можно
// сформировать партию — управляет доступностью глобальной кнопки.
const hasFormable = computed(() => acceptedOrders.value.some((o) => o.cycle_id));
const isEmpty = computed(
() => !loading.value && formationCycles.value.length === 0 && shipments.value.length === 0,
() => !loading.value && !hasFormable.value && shipments.value.length === 0,
);
// Диалог формирования партии.
// Диалог формирования партии — глобальный, открывается из шапки.
const dialogOpen = ref(false);
const selectedCycle = ref<ShipmentFormationCycle | null>(null);
function openFormation(cycle: ShipmentFormationCycle): void {
selectedCycle.value = cycle;
dialogOpen.value = true;
}
// Story 14.3: один account-bound код на весь стол. Поставщик показывает его
// оператору приёмки — тот резолвит аккаунт против ленты своего КУ и принимает
@@ -178,47 +171,33 @@ onMounted(() => {
<template lang="pug">
q-page.offerer-supply
PageHint(storage-key='mp:offerer-supply:banner-dismissed')
| Принятые заказы в разделе «К формированию» выберите способ доставки
| (самовывоз или экспедитор по ТТН) и сформируйте партию. Сформированные
| партии и их следующий шаг ниже. После приёмки на ПВЗ партия перейдёт
| кооперативу.
.offerer-supply__toolbar
//- Действия страницы — в шапку (канон Teleport): глобальная «Сформировать
//- партию», код для ПВЗ, обновление.
Teleport(to="#header-actions-host", defer)
BaseButton(variant='primary', size='sm', :disabled='!hasFormable', @click='dialogOpen = true')
template(#icon-left)
q-icon(name='local_shipping', size='16px')
| Сформировать партию
BaseButton(variant='secondary', size='sm', :disabled='!myPickupCode', @click='myCodeDialogOpen = true')
template(#icon-left)
q-icon(name='qr_code_2', size='16px')
| Мой код для ПВЗ
RefreshButton(:loading='loading', @refresh='load')
PageHint(storage-key='mp:offerer-supply:banner-dismissed')
| Нажмите «Сформировать партию» в шапке: выберите способ доставки (самовывоз
| или экспедитор по ТТН), кооперативный участок и перенесите в партию заказы,
| которые реально грузите. Невыбранное останется акцептованным для следующей
| партии. Сформированные партии и их следующий шаг ниже.
TableSkeleton(
v-if='loading && !shipments.length && !formationCycles.length',
v-if='loading && !shipments.length',
:columns='skeletonColumns',
:rows='6',
min-width="970px"
)
//- Раздел 1: заявки, ожидающие явного формирования партии.
template(v-if='formationCycles.length')
.offerer-supply__section-title К формированию
.offerer-supply__formation
.offerer-supply__cycle(v-for='c in formationCycles', :key='c.cycle_id')
.offerer-supply__cycle-head
.offerer-supply__cycle-title {{ c.title }}
.offerer-supply__cycle-meta {{ c.ordersCount }} заказ(ов) · {{ c.sum }}
.offerer-supply__ku(v-for='g in c.groups', :key='g.braname')
q-icon.offerer-supply__ku-icon(name='place', size='16px')
.offerer-supply__ku-text
.offerer-supply__ku-name {{ g.kuName }}
.offerer-supply__ku-addr(v-if='g.kuAddress') {{ g.kuAddress }}
.offerer-supply__ku-meta {{ g.ordersCount }} · {{ g.units }} ед. · {{ g.sum }}
.offerer-supply__cycle-foot
BaseButton(variant='primary', size='sm', @click='openFormation(c)')
template(#icon-left)
q-icon(name='local_shipping', size='16px')
| Сформировать партию
//- Раздел 2: уже сформированные партии.
//- Сформированные партии — основной список стола.
template(v-if='shipments.length')
.offerer-supply__section-title Сформированные партии
.table-wrap
@@ -253,14 +232,14 @@ q-page.offerer-supply
EmptyState(
v-if='isEmpty',
title='Партий пока нет',
body='Когда вы примете заказ во «Входящих заказах» — он появится здесь для формирования партии.'
body='Примите заказы во «Входящих заказах» — затем нажмите «Сформировать партию» в шапке, чтобы собрать отгрузку.'
)
template(#icon)
q-icon(name='local_shipping', size='48px')
CreateShipmentDialog(
v-model='dialogOpen',
:cycle='selectedCycle',
:orders='acceptedOrders',
@created='onCreated'
)