feat(marketplace): склад участка переосмыслен как доска полок

Плоский список «позиция + поле Полка + кнопки» был непонятен: не было
ощущения, ГДЕ что лежит и зачем раскладка. Переделано в доску:

- колонка «Поступило» — принятое, ещё не разложенное;
- колонки-полки — что физически лежит на каждой полке;
- раскладка = перетащить карточку на полку (нативный DnD) или меню «⋮»;
- «+ Полка» создаёт новую полку; снять с полки = вернуть в «Поступило»;
- этикетка и раскладка по количеству — в меню карточки;
- штрих-код показан в карточке; отдельный лист — только для печати.

Цель страницы теперь читается: разложить так, чтобы при выдаче
заказчику сразу найти, на какой полке заказ. Backend без изменений
(assignInventoryShelf/splitInventory/generateInventoryLabel), без новых
зависимостей (DnD на HTML5). Канон: токены --p-*, убран хардкод #b3261e.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ant
2026-06-01 17:29:45 +00:00
parent c89ca2784b
commit 4ead387131
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Loading } from 'quasar'
import { Zeus } from '@coopenomics/sdk'
@@ -18,11 +18,16 @@ import {
} from '../api'
/**
* Стол ПВЗ, «Раскладка и маркировка». Имущество попадает на склад на приёмке
* (статус RECEIVED) — здесь оператор организует его физически: назначает полку
* (свободная строка), при необходимости раскладывает одну принятую позицию по
* нескольким полкам (split), и опционально наклеивает штрих-код для быстрого
* поиска. Штрих-код не обязателен — склад работает и без него (один холодильник).
* Стол ПВЗ, «Склад участка». Имущество, принятое кооперативом (RECEIVED), лежит
* на складе КУ — здесь оператор организует его физически как доску полок:
*
* - колонка «Поступило» — принятое, ещё не разложенное;
* - колонки-полки — что лежит на каждой полке;
* - раскладка = перетащить карточку на полку (DnD) или через меню «⋮»;
* - «Разложить» дробит позицию по количеству на несколько полок;
* - «Этикетка» наклеивает штрих-код для поиска сканером (по желанию).
*
* Цель — при выдаче заказчику за секунды найти, на какой полке лежит заказ.
*/
const route = useRoute()
@@ -33,26 +38,59 @@ const braname = computed(() => store.activeBraname ?? '')
const items = ref<MarketplaceInventoryItemView[]>([])
const loading = ref(false)
// Сначала непромаркированные/без полки (нужно разложить), затем остальное.
const RECEIVED = Zeus.MarketplaceInventoryStatus.RECEIVED
const LABELED = Zeus.MarketplaceInventoryStatus.LABELED
const onWarehouse = computed(() =>
[...items.value]
.filter((i) => i.status === RECEIVED || i.status === LABELED)
.sort((a, b) => {
// Сперва то, чем ещё нужно заняться: без полки, затем без штрих-кода.
const aw = (a.shelf ? 0 : 2) + (a.barcode_value ? 0 : 1)
const bw = (b.shelf ? 0 : 2) + (b.barcode_value ? 0 : 1)
return bw - aw
}),
// Имущество на складе (принятое/промаркированное) — то, что раскладываем.
const boardItems = computed(() =>
items.value.filter((i) => i.status === RECEIVED || i.status === LABELED),
)
const labeledItems = computed(() => items.value.filter((i) => !!i.barcode_value))
// Непромаркированный RECEIVED-пул одного заказа — куски, которые можно собрать
// с полок и разложить заново (merge/split/перенос). Промаркированные единицы
// (со штрих-кодом) фиксированы и в пул не входят.
// Полки-плейсхолдеры, созданные оператором, но пока пустые (нет позиций).
// Живут до перезагрузки — после reload остаются лишь полки с позициями.
const extraShelves = ref<string[]>([])
const shelfNames = computed(() => {
const set = new Set<string>()
for (const i of boardItems.value) if (i.shelf) set.add(i.shelf)
for (const s of extraShelves.value) set.add(s)
return [...set].sort((a, b) => a.localeCompare(b, 'ru'))
})
interface BoardColumn {
key: string
title: string
icon: string
shelf: string | null
items: MarketplaceInventoryItemView[]
}
const INBOX_KEY = '__inbox__'
const columns = computed<BoardColumn[]>(() => {
const cols: BoardColumn[] = [
{
key: INBOX_KEY,
title: 'Поступило',
icon: 'inbox',
shelf: null,
items: boardItems.value.filter((i) => !i.shelf),
},
]
for (const name of shelfNames.value) {
cols.push({
key: name,
title: name,
icon: 'shelves',
shelf: name,
items: boardItems.value.filter((i) => i.shelf === name),
})
}
return cols
})
// ── Перераскладка (split) по количеству: непромаркированный пул заказа ──
function orderPool(item: MarketplaceInventoryItemView): MarketplaceInventoryItemView[] {
return items.value.filter(
(i) => i.order_id === item.order_id && i.status === RECEIVED && !i.barcode_value,
@@ -61,15 +99,10 @@ function orderPool(item: MarketplaceInventoryItemView): MarketplaceInventoryItem
function orderPoolTotal(item: MarketplaceInventoryItemView): number {
return orderPool(item).reduce((a, p) => a + p.quantity_per_label, 0)
}
// Перераскладка доступна для непромаркированной позиции, если в заказе ≥2 ед.
// (есть что распределить или собрать обратно).
function canRedistribute(item: MarketplaceInventoryItemView): boolean {
return !item.barcode_value && orderPoolTotal(item) >= 2
}
// Черновик полки на строку — чтобы сохранять только по явному действию.
const shelfDraft = reactive<Record<string, string>>({})
async function load(): Promise<void> {
if (!braname.value.trim()) {
items.value = []
@@ -78,7 +111,6 @@ async function load(): Promise<void> {
loading.value = true
try {
items.value = await fetchInventoryByBraname(braname.value.trim())
for (const i of items.value) shelfDraft[i.id] = i.shelf ?? ''
} catch (e) {
FailAlert(e, 'Не удалось загрузить склад участка')
} finally {
@@ -86,18 +118,54 @@ async function load(): Promise<void> {
}
}
async function saveShelf(item: MarketplaceInventoryItemView): Promise<void> {
const next = (shelfDraft[item.id] ?? '').trim()
if (next === (item.shelf ?? '')) return
// ── Перекладка позиции на полку (и снятие — shelf=null) ──
async function moveToShelf(item: MarketplaceInventoryItemView, shelf: string | null): Promise<void> {
const next = shelf?.trim() ? shelf.trim() : null
if ((item.shelf ?? null) === next) return
try {
await assignInventoryShelf({ inventory_id: item.id, shelf: next || null })
SuccessAlert(next ? `Полка «${next}» сохранена` : 'Полка очищена')
await assignInventoryShelf({ inventory_id: item.id, shelf: next })
SuccessAlert(next ? `Переложено на полку «${next}»` : 'Снято с полки')
await load()
} catch (e) {
FailAlert(e, 'Не удалось сохранить полку')
FailAlert(e, 'Не удалось переложить позицию')
}
}
// ── Drag & drop карточек между колонками ──
const dragId = ref<string | null>(null)
const dragOverKey = ref<string | null>(null)
function onDragStart(item: MarketplaceInventoryItemView): void {
dragId.value = item.id
}
function onDragEnd(): void {
dragId.value = null
dragOverKey.value = null
}
function onDrop(col: BoardColumn): void {
const item = items.value.find((i) => i.id === dragId.value)
dragOverKey.value = null
dragId.value = null
if (item) void moveToShelf(item, col.shelf)
}
// ── Создание новой (пустой) полки ──
const newShelfOpen = ref(false)
const newShelfName = ref('')
function openNewShelf(): void {
newShelfName.value = ''
newShelfOpen.value = true
}
function createShelf(): void {
const name = newShelfName.value.trim()
if (!name) return
if (!extraShelves.value.includes(name) && !shelfNames.value.includes(name)) {
extraShelves.value.push(name)
}
newShelfOpen.value = false
}
// ── Маркировка штрих-кодом ──
async function makeLabel(item: MarketplaceInventoryItemView): Promise<void> {
Loading.show({ message: 'Генерирую этикетку…' })
try {
@@ -111,7 +179,7 @@ async function makeLabel(item: MarketplaceInventoryItemView): Promise<void> {
}
}
// ── Раскладка по полкам (split) ──
// ── Раскладка по количеству на несколько полок (split/merge/move) ──
const splitDialogOpen = ref(false)
const splitTarget = ref<MarketplaceInventoryItemView | null>(null)
const splitRows = ref<{ quantity: number | null; shelf: string }[]>([])
@@ -119,7 +187,6 @@ const splitRows = ref<{ quantity: number | null; shelf: string }[]>([])
const splitTotal = computed(() =>
splitRows.value.reduce((a, r) => a + (Number(r.quantity) || 0), 0),
)
// Цель раскладки — суммарное количество непромаркированного пула заказа.
const splitPoolTotal = computed(() =>
splitTarget.value ? orderPoolTotal(splitTarget.value) : 0,
)
@@ -133,9 +200,6 @@ const splitValid = computed(
function openSplit(item: MarketplaceInventoryItemView): void {
splitTarget.value = item
// Предзаполняем строками из текущих кусков пула (кол-во + полка) — оператор
// видит, как заказ сейчас лежит на полках, и правит как нужно. Один кусок —
// добавляем пустую строку, чтобы предложить раскладку.
const pool = orderPool(item)
splitRows.value = pool.map((p) => ({
quantity: p.quantity_per_label as number | null,
@@ -191,7 +255,7 @@ onMounted(async () => {
</script>
<template lang="pug">
q-page.place(role='region', aria-label='Раскладка и маркировка')
q-page.place(role='region', aria-label='Склад участка')
OperatorBranchBar
EmptyState(
@@ -211,60 +275,114 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
RefreshButton(:loading='loading', @refresh='load')
PageHint.no-print(storage-key='mp:operator-labeling:banner-dismissed')
| Принятое имущество лежит на складе сразу. Назначьте каждой позиции полку
| (свободная строка), при необходимости разложите одну позицию по нескольким
| полкам и по желанию наклейте штрих-код для быстрого поиска. Штрих-код
| не обязателен: если у вас один холодильник, можно обойтись без него.
| Разложите принятое имущество по полкам чтобы при выдаче заказчику сразу
| найти, где что лежит. Перетащите карточку на полку (или меню « на полку»).
| Штрих-код по желанию, для поиска сканером.
.place__loading.no-print(v-if='loading && !items.length')
q-spinner(size='28px', color='primary')
EmptyState.no-print(
v-if='!onWarehouse.length',
v-else-if='!boardItems.length',
title='На складе пусто',
body='Здесь появятся принятые позиции — после приёмки партии на столе «Приёмка партии».'
)
template(#icon)
q-icon(name='inventory_2', size='48px')
.place__list.no-print(v-else)
.place__item(v-for='item in onWarehouse', :key='item.id')
.place__item-info
.place__item-name {{ item.product_name_snapshot || 'Товар по предложению' }}
.place__item-meta
| Принято {{ item.quantity_per_label }} ед. · {{ item.orderer_account_snapshot }}
BaseBadge(v-if='item.barcode_value', variant='pos') Промаркировано
BaseBadge(v-else, variant='neutral') Без штрих-кода
//- Штрих-код в той же строке, что и имущество с полкой — не оторван.
.place__item-barcode(v-if='item.barcode_value')
BarcodeDisplay(:code='item.barcode_value', size='sm')
.place__item-controls
BaseInput.place__shelf(
v-model='shelfDraft[item.id]',
label='Полка',
placeholder='A-12',
dense,
@blur='saveShelf(item)',
@keydown.enter='saveShelf(item)'
)
BaseButton(
v-if='!item.barcode_value',
variant='ghost',
size='sm',
:disabled='!canRedistribute(item)',
@click='openSplit(item)'
)
template(#icon-left)
q-icon(name='call_split', size='16px')
| Разложить
BaseButton(
v-if='!item.barcode_value',
variant='primary',
size='sm',
@click='makeLabel(item)'
)
template(#icon-left)
q-icon(name='qr_code_2', size='16px')
| Этикетка
.place__board.no-print(v-else)
.place__col(
v-for='col in columns',
:key='col.key',
:class='{ "place__col--inbox": col.key === "__inbox__", "is-over": dragOverKey === col.key }',
@dragover.prevent='dragOverKey = col.key',
@dragleave='dragOverKey = (dragOverKey === col.key ? null : dragOverKey)',
@drop='onDrop(col)'
)
.place__col-head
q-icon(:name='col.icon', size='18px')
span.place__col-title {{ col.title }}
BaseBadge(variant='neutral') {{ col.items.length }}
//- Печатный лист этикеток (только при печати; на экране штрих-код в строке).
.place__col-body
.place__empty-drop(v-if='!col.items.length')
| {{ col.key === '__inbox__' ? 'Всё разложено' : 'Перетащите сюда' }}
.place__card(
v-for='item in col.items',
:key='item.id',
draggable='true',
:class='{ "is-dragging": dragId === item.id }',
@dragstart='onDragStart(item)',
@dragend='onDragEnd'
)
.place__card-top
.place__card-info
.place__card-name {{ item.product_name_snapshot || 'Товар по предложению' }}
.place__card-meta {{ item.quantity_per_label }} ед. · {{ item.orderer_account_snapshot }}
BaseButton(variant='ghost', size='sm', icon-only, aria-label='Действия')
template(#icon-left)
q-icon(name='more_vert', size='18px')
q-menu(anchor='bottom right', self='top right')
q-list(dense, style='min-width: 200px')
q-item-label(header) Переложить на полку
q-item(
v-for='name in shelfNames.filter((n) => n !== item.shelf)',
:key='name',
clickable,
v-close-popup,
@click='moveToShelf(item, name)'
)
q-item-section(avatar)
q-icon(name='shelves', size='18px')
q-item-section {{ name }}
q-item(clickable, v-close-popup, @click='openNewShelf')
q-item-section(avatar)
q-icon(name='add', size='18px')
q-item-section На новую полку
q-item(
v-if='item.shelf',
clickable,
v-close-popup,
@click='moveToShelf(item, null)'
)
q-item-section(avatar)
q-icon(name='inbox', size='18px')
q-item-section Снять с полки
q-separator
q-item(
v-if='canRedistribute(item)',
clickable,
v-close-popup,
@click='openSplit(item)'
)
q-item-section(avatar)
q-icon(name='call_split', size='18px')
q-item-section Разложить по количеству
q-item(
v-if='!item.barcode_value',
clickable,
v-close-popup,
@click='makeLabel(item)'
)
q-item-section(avatar)
q-icon(name='qr_code_2', size='18px')
q-item-section Наклеить этикетку
.place__card-badges
BaseBadge(v-if='item.barcode_value', variant='pos') Промаркировано
BaseBadge(v-else, variant='neutral') Без штрих-кода
BarcodeDisplay(v-if='item.barcode_value', :code='item.barcode_value', size='sm')
//- Колонка-кнопка создания новой полки.
.place__col.place__col--add
BaseButton(variant='ghost', @click='openNewShelf')
template(#icon-left)
q-icon(name='add', size='18px')
| Полка
//- Печатный лист этикеток (только при печати; на экране штрих-код в карточке).
.place__grid.print-only(v-if='labeledItems.length')
.place__label(v-for='item in labeledItems', :key='item.id')
.place__label-name.ellipsis {{ item.product_name_snapshot }}
@@ -273,6 +391,21 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
template(v-if='item.shelf') · Полка: {{ item.shelf }}
BarcodeDisplay(v-if='item.barcode_value', :code='item.barcode_value', size='md')
//- Новая полка.
BaseDialog(v-model='newShelfOpen', title='Новая полка', size='sm')
.place__new-shelf
BaseInput(
v-model='newShelfName',
label='Название полки',
placeholder='A-12',
autofocus,
@keydown.enter='createShelf'
)
template(#footer)
BaseButton(variant='ghost', size='sm', @click='newShelfOpen = false') Отмена
BaseButton(variant='primary', size='sm', :disabled='!newShelfName.trim()', @click='createShelf') Создать
//- Раскладка по количеству.
BaseDialog(v-model='splitDialogOpen', title='Разложить по полкам', size='md')
.place__split(v-if='splitTarget')
.place__split-head
@@ -322,52 +455,134 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
flex-direction: column;
gap: var(--p-4, 16px);
&__list {
&__loading {
display: flex;
justify-content: center;
padding: var(--p-6, 24px);
}
// Доска полок — горизонтальный ряд колонок с прокруткой.
&__board {
display: flex;
gap: var(--p-3, 12px);
align-items: flex-start;
overflow-x: auto;
padding-bottom: var(--p-2, 8px);
}
&__col {
flex: 0 0 280px;
display: flex;
flex-direction: column;
gap: var(--p-2, 8px);
}
&__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--p-3, 12px);
flex-wrap: wrap;
border: 1px solid var(--p-line);
border-radius: var(--p-r-md, 12px);
background: var(--p-surface);
padding: var(--p-3, 12px) var(--p-4, 16px);
background: var(--p-surface-2);
padding: var(--p-3, 12px);
max-height: calc(100vh - 220px);
transition: border-color 0.12s ease, background 0.12s ease;
&.is-over {
border-color: var(--p-primary);
background: var(--p-surface);
}
&--inbox {
background: var(--p-surface);
}
&--add {
border-style: dashed;
background: transparent;
align-items: stretch;
justify-content: flex-start;
}
}
&__item-info {
min-width: 0;
&__col-head {
display: flex;
flex-direction: column;
gap: var(--p-1, 4px);
}
&__item-name {
font-size: var(--p-fs-body, 14px);
font-weight: 600;
align-items: center;
gap: var(--p-2, 8px);
color: var(--p-ink);
}
&__item-meta {
&__col-title {
flex: 1 1 auto;
font-weight: 600;
font-size: var(--p-fs-body, 14px);
overflow-wrap: anywhere;
}
&__col-body {
display: flex;
flex-direction: column;
gap: var(--p-2, 8px);
overflow-y: auto;
}
&__empty-drop {
border: 1px dashed var(--p-line);
border-radius: var(--p-r-sm, 8px);
padding: var(--p-4, 16px);
text-align: center;
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-3);
}
&__card {
border: 1px solid var(--p-line);
border-radius: var(--p-r-sm, 8px);
background: var(--p-surface);
padding: var(--p-3, 12px);
display: flex;
flex-direction: column;
gap: var(--p-2, 8px);
cursor: grab;
&.is-dragging {
opacity: 0.5;
}
&:active {
cursor: grabbing;
}
}
&__card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--p-2, 8px);
}
&__card-info {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
&__card-name {
font-size: var(--p-fs-body, 14px);
font-weight: 600;
color: var(--p-ink);
overflow-wrap: anywhere;
}
&__card-meta {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-2);
font-variant-numeric: tabular-nums;
}
&__item-controls {
&__card-badges {
display: flex;
align-items: flex-end;
gap: var(--p-2, 8px);
flex-wrap: wrap;
gap: var(--p-1, 4px);
}
&__shelf {
width: 120px;
&__new-shelf {
padding-top: var(--p-2, 8px);
}
&__split {
@@ -387,10 +602,6 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
color: var(--p-ink-2);
}
&__item-barcode {
flex: 0 0 auto;
}
&__split-row {
display: flex;
align-items: flex-end;
@@ -417,7 +628,7 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
color: var(--p-ink-2);
&--bad {
color: var(--p-danger, #b3261e);
color: var(--p-neg);
font-weight: 600;
}
}
@@ -451,6 +662,10 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
@media (max-width: 768px) {
.place {
padding: var(--p-4, 16px);
&__col {
flex-basis: 240px;
}
}
}