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:
+54
@@ -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) {
|
||||
|
||||
+348
-112
@@ -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>
|
||||
|
||||
+22
-43
@@ -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'
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user