feat(marketplace): Эпик 10 — Дизайн-система Стола Заказов (Stories 10.1+10.2+10.3) (#383)

* [598-13][@ant] feat: добавить токены marketplace и каркас витрины дизайн-системы — Story 10.1 даёт владельцу продукта единую страницу /market/design-system для утверждения 13 UI-компонентов до их применения в функциональных эпиках 1-9 без локального запуска

- src/app/styles/marketplace-tokens.scss — spacing (UX-DR22), touch-targets 44/48 (UX-DR23), per-role layout (UX-DR31), statusbar (UX-DR20), helper-классы
- src/pages/Marketplace/DesignSystem/ — каркас витрины: aside-навигация по 13 компонентам, переключатели роли/темы/breakpoint в URL query, заглушки Placeholder для будущих секций
- TokensSection — превью палитры, типографики, spacing-scale, touch-targets, per-role layout
- extensions/market/install.ts — route marketplace-design-system под /market/design-system (roles: chairman, member)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [598-13][@ant] feat: добавить CatalogOfferCard (UX-DR10) — карточка предложения в каталоге Витрины становится каноническим компонентом Стола Заказов, обёртка над существующим RequestCard с расширенным API status (draft/moderation/published/paused/sold-out/completed) + slot actions + fallback изображения для применения в эпиках 3 (Витрина) и 4 (Заказ)

- widgets/Marketplace/CatalogOfferCard — типизированный CatalogOffer + 6 статусов + slot actions + per-role POS-увеличение title
- секция CatalogOfferCard в витрине дизайн-системы со всеми 6 статусами + edge cases (нет preview, длинный текст, slot actions)
- статус в каталоге: imported → ready

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [598-13][@ant] feat: добавить OrderCard (UX-DR9) — карточка заказа с 10 статусами жизненного цикла и per-роль actions для применения в эпиках 4 (Заказ), 5 (Поставка), 6 (Выдача), 9 (Склад) — Стол Заказов получает единый компонент списка заказов вместо ad-hoc вёрстки

- widgets/Marketplace/OrderCard — Order interface с 10 status (draft → issued + dispute/returned) + 4 роли (orderer/offerer/operator/admin) с собственным набором actions
- секция витрины переключает симуляцию роли и показывает все 10 статусов в одной сетке
- статус в каталоге: planned → ready

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [598-13][@ant] feat: добавить TakeoverDialog (UX-DR7) — full-screen takeover с 4 kind для критических действий выдачи (Эпик 6), отмены заказа (Эпик 4), спора (Эпик 7), списания скоропорта (Эпик 8) даёт единый паттерн необратимых операций со встроенным confirm-loader и slot actions

- widgets/Marketplace/TakeoverDialog — TakeoverKind info/success/warning/danger + цветной header-bar + slot для body и actions + confirm-loader
- секция витрины с 4 пресетами (open by kind) + комментарий + последнее событие
- статус в каталоге: planned → ready

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [598-13][@ant] feat: добавить WalletTimeline (UX-DR8) — лента движений кошелька с 6 типами операций (пополнение, блокировка, разблокировка, списание, возврат, выплата) для применения в эпиках 4 (Заказ и блокировка средств) и 9 (Склад и отчётность), даёт пайщику читаемую историю и связку с конкретными заказами

- widgets/Marketplace/WalletTimeline — WalletEntry + WalletEntryKind (6 типов) + цветовая семантика + знак суммы + связь с orderId
- секция витрины с фильтром по типам + рендер бок-о-бок normal vs empty state
- статус в каталоге: planned → ready

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [598-13][@ant] feat: добавить BarcodeScanner (UX-DR11) и BarcodeDisplay (UX-DR12) — mock-сканер с visual feedback вместо звукового пика (UX-DR26) и SVG-рендер штрих-кода для печати на ТТН — операторский стол ПВЗ (Эпик 5 Поставка, Эпик 6 Выдача) получает согласованный паттерн ввода/вывода штрих-кодов до подключения jsbarcode/quagga в функциональной реализации

- widgets/Marketplace/BarcodeScanner — 5 состояний (idle/requesting/scanning/success/error) с lazer-анимацией и flash-эффектом
- widgets/Marketplace/BarcodeDisplay — SVG-генерация с 3 size (sm/md/lg) и опцией без подписи
- секции витрины: успех vs ошибка камеры; sizes-grid + кастомный код в инпуте
- статусы в каталоге: planned → ready (для обоих)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [598-13][@ant] feat: добавить CorrectionTable (UX-DR13) и TTNPrintPreview (UX-DR15) — таблица корректировки приёмки факт vs план и предпросмотр печатной ТТН А5 со встроенным штрих-кодом — операторский стол ПВЗ (Эпики 5 Поставка и 6 Выдача) получает согласованный паттерн приёмки имущества и сопровождающего печатного документа

- widgets/Marketplace/CorrectionTable — inline-input факт, цветовая подсветка дельты, счётчики совпадений/недостач/избытков
- widgets/Marketplace/TTNPrintPreview — формат А5 (148×210 мм), BarcodeDisplay в шапке, таблица позиций, две подписи, @media print
- секции витрины: CorrectionTable с 5 примерами (совпадение/недостача/избыток/полное отсутствие); TTNPrintPreview с реалистичным mock-документом
- статусы в каталоге: planned → ready

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [598-13][@ant] feat: добавить ExpeditorGroupingBoard (UX-DR14) WarehouseSummaryGrid (UX-DR16) OnboardingCPPGate (UX-DR17) MultiChannelStatus (UX-DR18) и секцию KUMapWithList — закрыть оставшиеся 5 custom-компонентов Эпика 10, чтобы все 13 имели визуальный канон для эпиков 1 (Онбординг), 5 (Доставка/группировка), 9 (Склад), и cross-cutting NotificationCenter

- ExpeditorGroupingBoard — drag-n-drop карточек между маршрутами + empty-state, событие move
- WarehouseSummaryGrid — admin-плотная таблица приход/расход/остаток с фильтром и сортировкой
- OnboardingCPPGate — L3-gate с required/optional/locked документами, два примера (orderer vs offerer)
- MultiChannelStatus — chip-row push/email/SMS с tooltip-details и 6 status
- KUMapWithList — секция-обёртка над существующим widgets/KUMapWithList с пояснением что реальная карта работает на /market-pvz/list
- все 13 статусов в каталоге переведены в ready

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [598-13][@ant] feat: добавить Story 10.3 performance baseline и правила для агентов — закрыть Эпик 10 секцией performance в витрине, инструкцией PERFORMANCE.md с эталонными страницами P1-P4 и целевыми метриками MVP/Phase 2, плюс CLAUDE.md в extensions/market/ как канон-директива для всех агентов работающих с marketplace UI

- pages/Marketplace/DesignSystem/PERFORMANCE.md — manual Lighthouse workflow, NFR-P1/P2/P3, целевые метрики
- pages/Marketplace/DesignSystem/ui/sections/PerformanceSection.vue — секция витрины с эталонными страницами и таблицей метрик
- extensions/market/CLAUDE.md — таблица 13 канонических компонентов, 5 жёстких правил для агентов (inline-вёрстка запрещена, токены только через CSS-переменные, per-роль через корневой класс, новый компонент сначала в витрине, расширение API через витрину)
- статус в каталоге: добавлена секция performance (ready)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [598-13][@ant] fix: восстановить плоские реэкспорты в entities/MarketplaceKUDetails — индекс экспортировал ./model только через namespace MarketplaceKUDetailsModel, из-за чего useMarketplaceKUDetailsStore / IMarketplaceKUDetails / IWorkingHours не резолвились в PvzListPage, KUMapWithList и MarketplaceDetailKUDialog (vue-tsc валился на всех ветках marketplace2) — добавлен `export * from './model'` параллельно с namespace, обратной совместимости не теряем

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [598-13][@ant] fix: починить ESLint и pug-парсер на marketplace2 — pug-парсер в ShipmentsPage и DisputePage падал на `#{{ id }}` (символ # после текста = id-селектор Pug), из-за чего ESLint считал весь скрипт unused; заменены на `№{{ id }}`; параллельно убраны мелкие unused в WarehousePage, PvzListPage, useDesignSystemState, ExpeditorGroupingBoard — финально npm run lint чист на src/extensions

- pages/Marketplace/ShipmentsPage/ui/ShipmentsPage.vue — `Перевозка #{{ id }}` → `Перевозка №{{ id }}` (pug syntax fix)
- pages/Marketplace/DisputePage/ui/DisputePage.vue — `Претензия #{{ id }}` → `Претензия №{{ id }}` (pug syntax fix)
- pages/Marketplace/WarehousePage/ui/WarehousePage.vue — удалён неиспользуемый import client; параметр _request заглушен через void (ESLint не учитывает underscore-prefix)
- pages/Marketplace/PvzList/ui/PvzListPage.vue — _unused-driver и _pvz-параметр заглушены через void
- pages/Marketplace/DesignSystem/composables/useDesignSystemState.ts — убран лишний import ref
- widgets/Marketplace/ExpeditorGroupingBoard — убрано неиспользуемое `const props =`, defineProps без присваивания

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [598-13][@ant] refactor: минимализм Айва в токенах и каталоге — нейтральная шкала + статус-чипы с точкой + терминология «заказать/закончилось» — фидбэк владельца продукта 2026-05-15 о попугайных бэйджах, выпадающей кнопке «Купить» и расхождении со словарём ЦПП (это не торговля, а заказы)

* [598-13][@ant] refactor: адаптив карточек на xs/sm — meta-grid в OrderCard и карточный fallback в CorrectionTable с touch-friendly полем «факт» — фидбэк 2026-05-15 что на узких экранах верстка просто сжимается и в поле «факт» невозможно попасть пальцем; теперь meta переносится через auto-fit grid а на <600px CorrectionTable рендерится карточками

* [598-13][@ant] refactor: единый стиль кнопок и статуса в OrderCard — primary unelevated без тени + flat second-level + mp-status-chip вместо разноцветного q-badge — фидбэк 2026-05-15 что «Открыть» (с тенью) и «Подробнее» (flat) выглядят попугайно и не из одного дизайна; теперь все actions одной формы с единым радиусом

* [598-13][@ant] refactor: убрать «попугайную» цветную шапку TakeoverDialog — нейтральный header + 4px accent-stripe слева + цветная иконка — фидбэк 2026-05-15 что info-карточка с залитой primary-шапкой выглядит броско и одинаково для info и success; теперь акцент через 4px полосу (primary/positive/warning/negative)

* [598-13][@ant] fix: контраст BarcodeScanner на тёмной теме — явный rgba-overlay вместо опоры на text-grey-7 + flash-анимация возвращается в полупрозрачный чёрный а не в transparent — фидбэк 2026-05-15 что на dark внизу мелькает белая полоска и текст «Сканер готов» теряет фон

* [598-13][@ant] feat: ExpeditorGroupingBoard — снять инлайн text-color/bg + добавить move-all для оптом-переноса — фидбэк 2026-05-15 что на dark черный текст «3 заявки ожидают группировки» уходит в чёрный фон и что перетаскивать 10 единиц одного заказа по одной — неудобно; теперь токены поверхности + кнопка «Переместить всё из N» рядом с droptarget

* [598-13][@ant] refactor: WarehouseSummaryGrid — снять колонку статус «достаточно/мало» — фидбэк 2026-05-15 что КУ кооператива работает транзитом (пришло — ушло), это не торговая точка с минимальным остатком; ввели подсветку остатка серым при нуле — этого достаточно

* [598-13][@ant] refactor: OnboardingCPPGate — одна оферта ЦПП per-роль и нейтральный «обязательно»-чип — фидбэк 2026-05-15 что в архитектуре нет ПВЗ-логистики/ценовой политики/режима самозанятого и подписки на рассылку; L2 на регистрации уже принимает обработку данных и базовые правила платформы — здесь дублировать нельзя

* [598-13][@ant] refactor: MultiChannelStatus — нейтральные чипы с точкой состояния и нормальные отступы — фидбэк 2026-05-15 что разноцветные q-chip (info/positive/primary/negative) выглядят попугайно и прижаты друг к другу; теперь surface-1 фон + 1px-граница + цветная точка-индикатор + gap 16px

* [598-13][@ant] fix: безопасное переключение light/dark — снять хардкод-фоны на CatalogOfferCard статус-чипе, DesignSystemPage preview-обёртке и заменить bg-grey-2 в секциях на mp-event-banner поверх токенов — фидбэк 2026-05-15 что статус-чип «Распродано» сливается на dark и часть баннеров не реагирует на тему; теперь все surface — через --mp-surface-* и --mp-border-subtle, переключение работает автоматически

---------

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Ant
2026-05-15 15:04:58 +05:00
committed by GitHub
parent 173014f712
commit cc694dedcc
55 changed files with 3946 additions and 5 deletions
@@ -0,0 +1,66 @@
# Marketplace extension — правила для агентов
Этот файл загружается при работе агентов в директории `components/desktop/extensions/market/`,
`components/desktop/src/pages/Marketplace/`, `components/desktop/src/widgets/Marketplace/`.
## Дизайн-система Стола Заказов (Эпик 10)
**Канон UI-компонентов**`src/widgets/Marketplace/*`. На 2026-05-15 в каноне:
| Компонент | UX-DR | Story | Назначение |
|------------------------|-------|--------------|-----------------------------------------------------|
| `CatalogOfferCard` | DR10 | Story 10.2.4 | Карточка Offer в каталоге (Витрина, Эпик 3) |
| `OrderCard` | DR9 | Story 10.2.3 | Карточка заказа, per-роль actions (Эпики 4-9) |
| `TakeoverDialog` | DR7 | Story 10.2.1 | Full-screen takeover для критических действий |
| `WalletTimeline` | DR8 | Story 10.2.2 | Лента движений кошелька (Эпики 4, 9) |
| `BarcodeScanner` | DR11 | Story 10.2.5 | Сканер штрих-кода (operator-стол) |
| `BarcodeDisplay` | DR12 | Story 10.2.6 | SVG-рендер штрих-кода для печати |
| `CorrectionTable` | DR13 | Story 10.2.7 | Таблица корректировки факт vs план |
| `ExpeditorGroupingBoard` | DR14 | Story 10.2.8 | Доска группировки заявок (drag-n-drop) |
| `TTNPrintPreview` | DR15 | Story 10.2.9 | Печатная ТТН А5 со штрих-кодом |
| `WarehouseSummaryGrid` | DR16 | Story 10.2.10| Сводный склад admin-стола |
| `OnboardingCPPGate` | DR17 | Story 10.2.11| L3-gate онбординга со списком документов |
| `MultiChannelStatus` | DR18 | Story 10.2.12| Статус push/email/SMS |
| `KUMapWithList` | Эпик 2.3 | Story 10.2.13 | Карта ПВЗ + список (canon из widgets/KUMapWithList) |
## Жёсткие правила
1. **Inline-вёрстка кастомного UI-элемента запрещена.** Если в story нужно отобразить карточку
заказа, статус доставки, диалог подтверждения и т.д. — импортируйте соответствующий
компонент из `widgets/Marketplace/*`. Не дублируйте вёрстку под другим именем.
2. **Дизайн-токены — только через `marketplace-tokens.scss`.** Не хардкодьте цвета,
spacing, touch-targets, font-size. Используйте CSS-переменные:
`var(--mp-space-md)`, `var(--mp-touch-target-pos)`, `var(--mp-font-body)` и т.д.
Палитра `$primary` / `$secondary` / `$accent` приходит из Quasar variables.
3. **Per-роль вариация через корневой класс.** На странице каждого стола обёртка
получает `mp-role-orderer` / `mp-role-offerer` / `mp-role-operator` / `mp-role-admin`
токены автоматически переключаются (плотность, touch-target, font-size).
4. **Новый UI-компонент сначала появляется в витрине.** Если в текущей story
возникла необходимость в новом reusable-компоненте, его сначала добавляют в
`widgets/Marketplace/<Name>/` + секцию в `pages/Marketplace/DesignSystem/ui/sections/`.
Только после визуального одобрения владельцем продукта компонент используется
в функциональных экранах.
5. **Расширение API существующего компонента — через витрину.** Если функциональной
story не хватает props/slot — добавьте их в компонент, обновите секцию витрины
со всеми состояниями, и только после этого используйте в экране. Не создавайте
wrapper'ы с дополнительной логикой «снаружи».
## Производительность (Story 10.3)
См. `src/pages/Marketplace/DesignSystem/PERFORMANCE.md` — целевые метрики, эталонные
страницы (P1..P4), manual workflow Lighthouse перед релизом.
## Где смотреть витрину
`/<coopname>/market/design-system` (доступ: chairman, member). Все 13 компонентов
со всеми состояниями, переключатели темы / роли / breakpoint в URL query.
## Связанные документы
- Спецификация Эпика 10: `_blago/production/1-prilozhenie-stol-zakazov/components/3-minimalnyy-produkt/issues/598-13-epik-10-*-requirements/`
- UX-спецификация MVP: `_blago/production/1-prilozhenie-stol-zakazov/components/3-minimalnyy-produkt/requirements/7e-uxui-spetsifikatsiya-stol-zakazov-mvp.md`
- Архитектура MVP: `_blago/production/1-prilozhenie-stol-zakazov/components/3-minimalnyy-produkt/requirements/d6-arkhitektura-stol-zakazov-mvp.md`
@@ -10,6 +10,7 @@ import { WarehousePage } from 'src/pages/Marketplace/WarehousePage'
import { ShipmentsPage } from 'src/pages/Marketplace/ShipmentsPage'
import { DisputePage } from 'src/pages/Marketplace/DisputePage'
import { PvzListPage } from 'src/pages/Marketplace/PvzList'
import { DesignSystemPage } from 'src/pages/Marketplace/DesignSystem'
import type { IWorkspaceConfig } from 'src/shared/lib/types/workspace'
import { agreementsBase } from 'src/shared/lib/consts/workspaces'
@@ -167,6 +168,21 @@ export default async function (): Promise<IWorkspaceConfig[]> {
agreements: agreementsBase,
},
},
{
// Витрина дизайн-системы (Эпик 10): открыта председателю и членам совета
// для утверждения 13 custom-компонентов до их применения в эпиках 1-9.
// Скрыта от обычных пайщиков, не выходит в production-меню для них.
path: 'design-system',
name: 'marketplace-design-system',
component: markRaw(DesignSystemPage),
meta: {
title: 'Дизайн-система',
icon: 'fa-solid fa-palette',
roles: ['chairman', 'member'],
requiresAuth: true,
agreements: agreementsBase,
},
},
],
},
],
@@ -1,3 +1,6 @@
// Marketplace design tokens (Story 10.1, Эпик 10)
@import './marketplace-tokens.scss';
.bg-dark {
background: #1d1d1d !important;
}
@@ -0,0 +1,215 @@
// Story 10.1 — дизайн-токены Стола Заказов (Эпик 10 MVP)
// Источник: requirements/4f-story-101-dizayn-tokeny-* + UX-DR19..23, DR27..32
//
// SCSS-переменные `$primary` / `$secondary` / `$accent` / `$dark` определяются
// в Quasar через `quasar.variables.scss` и `variables.sass` — кастомизация
// per-coop работает через них (UX-DR19). Здесь — только marketplace-specific
// слой: spacing, touch-targets, per-role layout, statusbar.
//
// Стиль (фидбэк 2026-05-15): минимализм Айва — один акцент primary,
// нейтральная шкала surface, тонкие 1px-границы вместо заливок и теней,
// статусы — точка + текст (не "попугайный" залитый чип).
// === Spacing (UX-DR22) ============================================================
// 4/8/16/24/32/48 — единая шкала; основная плотность маркетплейса = 24/32 (воздух)
:root {
--mp-space-xs: 4px;
--mp-space-sm: 8px;
--mp-space-md: 16px;
--mp-space-lg: 24px;
--mp-space-xl: 32px;
--mp-space-xxl: 48px;
}
// === Touch-targets (UX-DR23) ======================================================
// 44px на мобильных + orderer/offerer/admin; 48px на operator-POS (перчатки)
:root {
--mp-touch-target: 44px;
--mp-touch-target-pos: 48px;
}
// === Typography (UX-DR21) =========================================================
// 16px body на orderer/offerer/operator; 14px в admin-таблицах (через .mp-admin-dense)
:root {
--mp-font-body: 16px;
--mp-font-admin-table: 14px;
}
// === Радиусы ======================================================================
// Один радиус для карточек/кнопок/инпутов — даёт спокойный ритм формы.
:root {
--mp-radius-sm: 6px;
--mp-radius-md: 10px;
--mp-radius-lg: 14px;
}
// === Surface / границы (минимализм Айва) ==========================================
// Сетка нейтралей; на dark-теме инвертируется через body.body--dark переопределение.
// Принцип: фон и поверхности нейтральные; цвет — точечный акцент.
:root {
--mp-surface-0: #ffffff; // фон страницы
--mp-surface-1: #fafafa; // фон секций (subtle)
--mp-surface-2: #f4f4f5; // фон карточек hover / inset
--mp-on-surface: #111111; // основной текст
--mp-on-surface-muted: #6b6b6f; // вспомогательный текст
--mp-border-subtle: rgba(0, 0, 0, .08); // 1px-граница карточек/таблиц
--mp-border-strong: rgba(0, 0, 0, .14); // 1px-граница активных полей
--mp-elevation-none: none; // карточки без теней — только границы
}
body.body--dark {
--mp-surface-0: #0e0e10;
--mp-surface-1: #161618;
--mp-surface-2: #1d1d20;
--mp-on-surface: #f3f3f5;
--mp-on-surface-muted: #a0a0a7;
--mp-border-subtle: rgba(255, 255, 255, .10);
--mp-border-strong: rgba(255, 255, 255, .18);
}
// === Statusbar (UX-DR20) ==========================================================
// Минималистичные статус-чипы: нейтральный фон + цветная точка + текст.
// Применение: `<span class="mp-status-chip mp-status-chip--success">…</span>`.
.mp-status-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
background: var(--mp-surface-2);
color: var(--mp-on-surface);
font-size: 12px;
line-height: 1;
white-space: nowrap;
border: 1px solid var(--mp-border-subtle);
&::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--mp-on-surface-muted);
flex-shrink: 0;
}
&--info::before { background: var(--q-info); }
&--success::before { background: var(--q-positive); }
&--warning::before { background: var(--q-warning); }
&--error::before { background: var(--q-negative); }
&--neutral::before { background: var(--mp-on-surface-muted); }
}
// Legacy alias на случай уже написанной разметки до перехода на mp-status-chip.
.mp-status-badge {
&.mp-status-info,
&.mp-status-success,
&.mp-status-warning,
&.mp-status-error,
&.mp-status-neutral {
background: var(--mp-surface-2) !important;
color: var(--mp-on-surface) !important;
border: 1px solid var(--mp-border-subtle);
}
}
// === Per-role layout (UX-DR31) ====================================================
// Корневой класс на странице каждого стола — определяет плотность и touch-target.
// Применяется обёрткой `<div :class="`mp-role-${role}`">` в layout marketplace.
// Orderer/Offerer — просторный layout пайщика
.mp-role-orderer,
.mp-role-offerer {
--mp-density: 1;
--mp-row-padding: var(--mp-space-md);
--mp-card-padding: var(--mp-space-lg);
--mp-min-tap: var(--mp-touch-target);
font-size: var(--mp-font-body);
}
// Operator (POS-режим, председатель за кассой ПВЗ) — просторный + крупные touch
.mp-role-operator {
--mp-density: 1;
--mp-row-padding: var(--mp-space-lg);
--mp-card-padding: var(--mp-space-xl);
--mp-min-tap: var(--mp-touch-target-pos);
font-size: var(--mp-font-body);
.q-btn { min-height: var(--mp-touch-target-pos); }
.q-input .q-field__control { min-height: var(--mp-touch-target-pos); }
}
// Admin — плотный layout, таблицы 14px
.mp-role-admin {
--mp-density: 0;
--mp-row-padding: var(--mp-space-sm);
--mp-card-padding: var(--mp-space-md);
--mp-min-tap: var(--mp-touch-target);
font-size: var(--mp-font-body);
.q-table { font-size: var(--mp-font-admin-table); }
.mp-admin-dense .q-table tbody td { padding: 6px 12px; }
}
// === Helper-классы для spacing ====================================================
.mp-gap-xs { gap: var(--mp-space-xs); }
.mp-gap-sm { gap: var(--mp-space-sm); }
.mp-gap-md { gap: var(--mp-space-md); }
.mp-gap-lg { gap: var(--mp-space-lg); }
.mp-gap-xl { gap: var(--mp-space-xl); }
.mp-pa-xs { padding: var(--mp-space-xs); }
.mp-pa-sm { padding: var(--mp-space-sm); }
.mp-pa-md { padding: var(--mp-space-md); }
.mp-pa-lg { padding: var(--mp-space-lg); }
.mp-pa-xl { padding: var(--mp-space-xl); }
// === Emotional design (UX-DR32) ===================================================
// Минимализм: нейтральная поверхность + 1px-граница, без теней, единый радиус.
.mp-card {
background: var(--mp-surface-0);
border: 1px solid var(--mp-border-subtle);
border-radius: var(--mp-radius-md);
padding: var(--mp-card-padding, var(--mp-space-md));
box-shadow: var(--mp-elevation-none);
transition: border-color .15s ease, background-color .15s ease;
&--interactive {
cursor: pointer;
&:hover {
border-color: var(--mp-border-strong);
background: var(--mp-surface-1);
}
}
}
// Общая прибивка Quasar-карточек, использованных как marketplace-canvas:
// убираем дефолтные тени, оставляем чистую 1px-границу.
.mp-card,
.mp-flat-card {
box-shadow: none !important;
}
// Универсальный «event-banner» для витрин и логов событий —
// нейтральная поверхность + 1px-граница, корректно переключается light/dark.
.mp-event-banner {
background: var(--mp-surface-1) !important;
color: var(--mp-on-surface) !important;
border: 1px solid var(--mp-border-subtle);
border-radius: var(--mp-radius-md);
}
// Универсальные кнопки маркетплейса — единый стиль без теней
.mp-btn-primary {
background: var(--q-primary);
color: #fff;
border-radius: var(--mp-radius-sm);
min-height: var(--mp-min-tap, var(--mp-touch-target));
box-shadow: none;
}
.mp-btn-ghost {
background: transparent;
color: var(--mp-on-surface);
border: 1px solid var(--mp-border-subtle);
border-radius: var(--mp-radius-sm);
min-height: var(--mp-min-tap, var(--mp-touch-target));
}
@@ -1,2 +1,3 @@
export * as MarketplaceKUDetailsModel from './model'
export * from './model'
export * from './api'
@@ -0,0 +1,57 @@
# Performance baseline (Story 10.3 · NFR-P1/P2/P3)
Эталонная инструкция для измерения и контроля производительности marketplace-frontend.
В MVP — manual через Chrome DevTools / Lighthouse. CI-gate (Lighthouse CI + Web Vitals) — Phase 2.
## Эталонные страницы
| ID | Стол / роль | Путь | Story |
|-----|-------------|-------------------------------|------------------------|
| P1 | orderer | `/:coopname/market/showcase` | Story 3.5 (каталог) |
| P2 | orderer | `/:coopname/market/my-orders` | Story 4.6 (Мои заказы) |
| P3 | operator | `/:coopname/market/warehouse` | Story 9.1 (Склад КУ) |
| P4 | admin | `/:coopname/market/warehouse` | Story 9.2 (Сводный) |
## Целевые показатели (NFR-P1)
| Метрика | Цель MVP | Phase 2 (CI-gate) |
|----------------------------------|---------------------|--------------------|
| Отзывчивость на действие | ≤ 1 сек | ≤ 800 мс |
| FCP (First Contentful Paint) | ≤ 1.5 сек | ≤ 1.2 сек |
| TTI (Time to Interactive) | ≤ 3 сек | ≤ 2.5 сек |
| Bundle size (gzipped, per-стол) | ≤ 500 КБ | ≤ 400 КБ |
| CLS (Cumulative Layout Shift) | ≤ 0.1 | ≤ 0.05 |
## NFR-P2 — отсутствие realtime-требований
- Real-time **не требуется**: цикл отсечки часами/сутками.
- NestJS handlers без sub-ms timing optimization.
- SDK-подписки платформы (AR37) используются вместо собственного WebSocket transport.
## NFR-P3 — экономия RAM в контракте
- Тяжёлые данные (ТТН-документы, фото, сканы) — в Backend Marketplace и Bucket (Story 7.1).
- On-chain только инварианты учёта.
## Manual workflow перед релизом
1. Запустить production-сборку:
```bash
cd components/desktop
npm run build
```
2. Запустить serve:
```bash
npm run start
```
3. Открыть Chrome DevTools → Lighthouse → выбрать категории `Performance`, `Best Practices`, `Accessibility`.
4. Прогнать на каждой из 4 эталонных страниц (P1..P4) с эмуляцией `Slow 4G`.
5. Зафиксировать результаты в blago issue `598-13` под лейблом `performance-baseline-YYYY-MM-DD`.
6. Если значения хуже целевых — открыть отдельный issue с проф-задачами (lazy-loading, image optimization, code splitting).
## Phase 2 — CI gate
После стабилизации MVP подключается:
- `@lhci/cli` в GitHub Actions с budget-файлом на эти же метрики;
- `webpack-bundle-analyzer` в build-step с budget на bundle-size per-стол;
- `@axe-core/cli` для WCAG 2.1 AA (отдельная задача — NFR-A1 Phase 2).
@@ -0,0 +1,74 @@
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
export type MarketplaceRole = 'orderer' | 'offerer' | 'operator' | 'admin'
export type Breakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
export type ThemeMode = 'light' | 'dark'
/**
* Состояние витрины дизайн-системы.
* Хранится в URL query-параметрах, чтобы пользователь мог расшарить ссылку
* на конкретное состояние компонента и роль/тему/breakpoint.
*/
export function useDesignSystemState() {
const route = useRoute()
const router = useRouter()
const role = computed<MarketplaceRole>({
get: () => (route.query.role as MarketplaceRole) || 'orderer',
set: (val) => router.replace({ query: { ...route.query, role: val } }),
})
const theme = computed<ThemeMode>({
get: () => (route.query.theme as ThemeMode) || 'light',
set: (val) => router.replace({ query: { ...route.query, theme: val } }),
})
const breakpoint = computed<Breakpoint>({
get: () => (route.query.bp as Breakpoint) || 'lg',
set: (val) => router.replace({ query: { ...route.query, bp: val } }),
})
const section = computed<string>({
get: () => (route.query.section as string) || 'tokens',
set: (val) => router.replace({ query: { ...route.query, section: val } }),
})
// Симуляция breakpoint через max-width на превью-контейнере
const previewMaxWidth = computed(() => {
switch (breakpoint.value) {
case 'xs': return '375px' // mobile
case 'sm': return '600px'
case 'md': return '1024px'
case 'lg': return '1440px'
case 'xl': return '100%'
default: return '1440px'
}
})
const roleClass = computed(() => `mp-role-${role.value}`)
return {
role,
theme,
breakpoint,
section,
previewMaxWidth,
roleClass,
}
}
export const MARKETPLACE_ROLES: Array<{ value: MarketplaceRole; label: string; hint: string }> = [
{ value: 'orderer', label: 'Стол заказчика', hint: 'просторный пайщик' },
{ value: 'offerer', label: 'Стол поставщика', hint: 'просторный пайщик' },
{ value: 'operator', label: 'Стол ПВЗ (POS)', hint: 'POS, touch 48px' },
{ value: 'admin', label: 'Стол админа', hint: 'плотный, таблицы 14px' },
]
export const BREAKPOINTS: Array<{ value: Breakpoint; label: string }> = [
{ value: 'xs', label: 'xs · 375' },
{ value: 'sm', label: 'sm · 600' },
{ value: 'md', label: 'md · 1024' },
{ value: 'lg', label: 'lg · 1440' },
{ value: 'xl', label: 'xl · ∞' },
]
@@ -0,0 +1 @@
export * from './ui'
@@ -0,0 +1,163 @@
// Каталог 13 компонентов дизайн-системы (Story 10.2) + Tokens (Story 10.1).
// Каждый компонент — отдельная секция витрины. Принадлежность к UX-DR / FR
// зафиксирована в спецификации Эпика 10 (issue 598-13 в blago).
export interface DesignSystemSection {
key: string
title: string
uxDr: string // ссылка на UX-DR# (или Эпик)
story: string // Story 10.X.N
primaryRole: string[] // на каких столах используется
status: 'planned' | 'imported' | 'ready'
description: string
}
export const SECTIONS: DesignSystemSection[] = [
{
key: 'tokens',
title: 'Дизайн-токены',
uxDr: 'UX-DR19..23, DR27..32',
story: 'Story 10.1',
primaryRole: ['все'],
status: 'ready',
description: 'Colors, typography, spacing, touch-targets, per-роль layout, statusbar.',
},
{
key: 'takeover-dialog',
title: 'TakeoverDialog',
uxDr: 'UX-DR7',
story: 'Story 10.2.1',
primaryRole: ['все'],
status: 'ready',
description: 'Full-screen takeover для критических действий: 4 kind (info / success / warning / danger), confirm-loader, slot actions.',
},
{
key: 'wallet-timeline',
title: 'WalletTimeline',
uxDr: 'UX-DR8',
story: 'Story 10.2.2',
primaryRole: ['orderer', 'offerer'],
status: 'ready',
description: 'Лента движений кошелька: 6 типов (deposit/block/unblock/charge/refund/payout) + empty state + фильтр.',
},
{
key: 'order-card',
title: 'OrderCard',
uxDr: 'UX-DR9',
story: 'Story 10.2.3',
primaryRole: ['orderer', 'offerer', 'operator', 'admin'],
status: 'ready',
description: 'Карточка заказа со статусом жизненного цикла (10 status) и actions per-роль (orderer/offerer/operator/admin).',
},
{
key: 'catalog-offer-card',
title: 'CatalogOfferCard',
uxDr: 'UX-DR10',
story: 'Story 10.2.4',
primaryRole: ['orderer'],
status: 'ready',
description: 'Карточка Offer в каталоге Витрины. Канон Стола Заказов, обёртка над widgets/Marketplace/RequestCard с расширенным API status / actions / fallback.',
},
{
key: 'barcode-scanner',
title: 'BarcodeScanner',
uxDr: 'UX-DR11',
story: 'Story 10.2.5',
primaryRole: ['operator'],
status: 'ready',
description: 'Mock-сканер штрих-кода: idle → requesting → viewfinder → success-flash / error. Visual feedback вместо «пик» (UX-DR26).',
},
{
key: 'barcode-display',
title: 'BarcodeDisplay',
uxDr: 'UX-DR12',
story: 'Story 10.2.6',
primaryRole: ['operator', 'admin'],
status: 'ready',
description: 'SVG-рендер штрих-кода для печати на ТТН/упаковке. 3 size + опция без подписи. jsbarcode подключается в функциональной реализации.',
},
{
key: 'correction-table',
title: 'CorrectionTable',
uxDr: 'UX-DR13',
story: 'Story 10.2.7',
primaryRole: ['operator'],
status: 'ready',
description: 'Таблица корректировки факт vs план: inline-input на факт, дельта подсвечивается цветом, счётчики совпадений/недостач/избытков снизу.',
},
{
key: 'expeditor-grouping-board',
title: 'ExpeditorGroupingBoard',
uxDr: 'UX-DR14',
story: 'Story 10.2.8',
primaryRole: ['operator'],
status: 'ready',
description: 'Kanban-доска экспедитора с drag-n-drop карточек заявок между маршрутами + empty-state в пустой колонке.',
},
{
key: 'ttn-print-preview',
title: 'TTNPrintPreview',
uxDr: 'UX-DR15',
story: 'Story 10.2.9',
primaryRole: ['operator', 'admin'],
status: 'ready',
description: 'Предпросмотр ТТН А5 со встроенным BarcodeDisplay в шапке + таблица позиций + две подписи + @media print.',
},
{
key: 'warehouse-summary-grid',
title: 'WarehouseSummaryGrid',
uxDr: 'UX-DR16',
story: 'Story 10.2.10',
primaryRole: ['admin'],
status: 'ready',
description: 'Сводный склад: приход/расход/остаток/статус, фильтр по SKU/названию, плотный admin-layout 14px.',
},
{
key: 'onboarding-cpp-gate',
title: 'OnboardingCPPGate',
uxDr: 'UX-DR17',
story: 'Story 10.2.11',
primaryRole: ['все'],
status: 'ready',
description: 'L3-gate трёхуровневого онбординга: пакет документов per-стол, чекбоксы Required/Optional, locked-документы (унаследованные из L2), кнопка "Открыть" для каждого.',
},
{
key: 'multi-channel-status',
title: 'MultiChannelStatus',
uxDr: 'UX-DR18',
story: 'Story 10.2.12',
primaryRole: ['все'],
status: 'ready',
description: 'Статус push / email / SMS chip-row с tooltip-details и 6 status (sent / delivered / read / failed / pending / disabled).',
},
{
key: 'ku-map-with-list',
title: 'KUMapWithList',
uxDr: 'Эпик 2 Story 2.3',
story: 'Story 10.2.13',
primaryRole: ['orderer', 'admin'],
status: 'ready',
description: 'Карта ПВЗ + синхронный список. Канон Эпика 2. Витрина показывает layout-превью без подключения Yandex Maps SDK (реальная карта — на /market-pvz/list).',
},
{
key: 'performance',
title: 'Performance baseline',
uxDr: 'NFR-P1/P2/P3',
story: 'Story 10.3',
primaryRole: ['все'],
status: 'ready',
description: 'Эталонные страницы P1..P4 + целевые метрики MVP / Phase 2. Manual Lighthouse перед релизом; CI-gate — Phase 2.',
},
]
export const STATUS_LABEL: Record<DesignSystemSection['status'], string> = {
planned: 'План',
imported: 'Импорт',
ready: 'Готово',
}
export const STATUS_COLOR: Record<DesignSystemSection['status'], string> = {
planned: 'grey',
imported: 'warning',
ready: 'positive',
}
@@ -0,0 +1,194 @@
<template>
<q-page class="mp-design-system">
<div class="row">
<!-- Левая колонка навигация -->
<aside class="col-12 col-md-3 mp-ds-aside q-pa-md">
<div class="text-h6 q-mb-sm">Дизайн-система Стола Заказов</div>
<div class="text-caption text-grey-7 q-mb-md">
Эпик 10 · MVP · 13 custom-компонентов + токены + perf
</div>
<q-list dense bordered separator class="rounded-borders">
<q-item
v-for="s in sections"
:key="s.key"
clickable
:active="state.section.value === s.key"
active-class="bg-primary text-white"
@click="state.section.value = s.key"
>
<q-item-section>
<q-item-label>{{ s.title }}</q-item-label>
<q-item-label caption>{{ s.story }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-badge :color="statusColor[s.status]">
{{ statusLabel[s.status] }}
</q-badge>
</q-item-section>
</q-item>
</q-list>
</aside>
<!-- Правая колонка секция компонента -->
<main class="col-12 col-md-9 q-pa-md">
<!-- Глобальные переключатели -->
<div class="row q-gutter-md q-mb-lg items-center mp-ds-controls">
<q-select
v-model="state.role.value"
:options="roles"
option-value="value"
option-label="label"
emit-value
map-options
dense
outlined
label="Стол / роль"
style="min-width: 220px"
>
<template #option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section>
<q-item-label>{{ scope.opt.label }}</q-item-label>
<q-item-label caption>{{ scope.opt.hint }}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-select>
<q-btn-toggle
v-model="state.theme.value"
:options="[
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
]"
unelevated
toggle-color="primary"
no-caps
dense
/>
<q-btn-toggle
v-model="state.breakpoint.value"
:options="breakpoints"
unelevated
toggle-color="primary"
no-caps
dense
/>
<q-space />
<span class="mp-status-chip mp-status-chip--neutral">
<q-icon name="fa-solid fa-code" size="12px" class="q-mr-xs" />
{{ state.section.value }}
</span>
</div>
<!-- Контейнер превью с роль-классом и breakpoint-симуляцией -->
<div
:class="['mp-ds-preview', state.roleClass.value, themeClass]"
:style="`max-width: ${state.previewMaxWidth.value}`"
>
<component :is="currentSectionComponent" :section="currentSection" />
</div>
</main>
</div>
</q-page>
</template>
<script setup lang="ts">
import { computed, watch, onMounted, onUnmounted } from 'vue'
import { useQuasar } from 'quasar'
import { SECTIONS, STATUS_LABEL, STATUS_COLOR } from '../lib/sections'
import { useDesignSystemState, MARKETPLACE_ROLES, BREAKPOINTS } from '../composables/useDesignSystemState'
import TokensSection from './sections/TokensSection.vue'
import PlaceholderSection from './sections/PlaceholderSection.vue'
import CatalogOfferCardSection from './sections/CatalogOfferCardSection.vue'
import OrderCardSection from './sections/OrderCardSection.vue'
import TakeoverDialogSection from './sections/TakeoverDialogSection.vue'
import WalletTimelineSection from './sections/WalletTimelineSection.vue'
import BarcodeScannerSection from './sections/BarcodeScannerSection.vue'
import BarcodeDisplaySection from './sections/BarcodeDisplaySection.vue'
import CorrectionTableSection from './sections/CorrectionTableSection.vue'
import TTNPrintPreviewSection from './sections/TTNPrintPreviewSection.vue'
import ExpeditorGroupingBoardSection from './sections/ExpeditorGroupingBoardSection.vue'
import WarehouseSummaryGridSection from './sections/WarehouseSummaryGridSection.vue'
import OnboardingCPPGateSection from './sections/OnboardingCPPGateSection.vue'
import MultiChannelStatusSection from './sections/MultiChannelStatusSection.vue'
import KUMapWithListSection from './sections/KUMapWithListSection.vue'
import PerformanceSection from './sections/PerformanceSection.vue'
const $q = useQuasar()
const state = useDesignSystemState()
const sections = SECTIONS
const roles = MARKETPLACE_ROLES
const breakpoints = BREAKPOINTS
const statusLabel = STATUS_LABEL
const statusColor = STATUS_COLOR
// Реализованные секции: пока только tokens. Остальные — PlaceholderSection.
const SECTION_COMPONENTS: Record<string, unknown> = {
tokens: TokensSection,
'catalog-offer-card': CatalogOfferCardSection,
'order-card': OrderCardSection,
'takeover-dialog': TakeoverDialogSection,
'wallet-timeline': WalletTimelineSection,
'barcode-scanner': BarcodeScannerSection,
'barcode-display': BarcodeDisplaySection,
'correction-table': CorrectionTableSection,
'ttn-print-preview': TTNPrintPreviewSection,
'expeditor-grouping-board': ExpeditorGroupingBoardSection,
'warehouse-summary-grid': WarehouseSummaryGridSection,
'onboarding-cpp-gate': OnboardingCPPGateSection,
'multi-channel-status': MultiChannelStatusSection,
'ku-map-with-list': KUMapWithListSection,
performance: PerformanceSection,
}
const currentSection = computed(() =>
sections.find((s) => s.key === state.section.value) ?? sections[0]
)
const currentSectionComponent = computed(
() => SECTION_COMPONENTS[state.section.value] ?? PlaceholderSection
)
const themeClass = computed(() => (state.theme.value === 'dark' ? 'mp-theme-dark' : 'mp-theme-light'))
// Управляем глобальной темой Quasar по переключателю витрины (только пока открыта страница).
let previousDark = false
onMounted(() => {
previousDark = $q.dark.isActive
$q.dark.set(state.theme.value === 'dark')
})
watch(() => state.theme.value, (v) => { $q.dark.set(v === 'dark') })
onUnmounted(() => { $q.dark.set(previousDark) })
</script>
<style scoped lang="scss">
.mp-design-system {
min-height: 100vh;
}
.mp-ds-aside {
border-right: 1px solid var(--mp-border-subtle);
position: sticky;
top: 0;
align-self: flex-start;
max-height: 100vh;
overflow: auto;
}
.mp-ds-controls {
border-bottom: 1px solid var(--mp-border-subtle);
padding-bottom: var(--mp-space-md);
}
.mp-ds-preview {
margin: 0 auto;
transition: max-width .25s ease;
// Тема страницы целиком переключается через $q.dark.set — больше не
// переопределяем bg/color локально, чтобы не ломать вложенные компоненты.
}
</style>
@@ -0,0 +1 @@
export { default as DesignSystemPage } from './DesignSystemPage.vue'
@@ -0,0 +1,38 @@
<template>
<div>
<div class="text-h5 q-mb-md">BarcodeDisplay · Story 10.2.6 · UX-DR12</div>
<div class="text-body2 text-grey-7 q-mb-lg">
Визуальный рендер штрих-кода для печати на ТТН/упаковке.
В функциональной реализации (Эпик 6) подключается jsbarcode для печатной
пригодности; в витрине псевдо-генерация для согласования макета.
</div>
<q-input v-model="code" outlined dense label="Код" class="q-mb-md" style="max-width: 320px" />
<div class="row q-col-gutter-lg items-center">
<div class="column items-center">
<div class="text-caption q-mb-sm">size=sm</div>
<BarcodeDisplay :code="code" size="sm" />
</div>
<div class="column items-center">
<div class="text-caption q-mb-sm">size=md</div>
<BarcodeDisplay :code="code" size="md" />
</div>
<div class="column items-center">
<div class="text-caption q-mb-sm">size=lg</div>
<BarcodeDisplay :code="code" size="lg" />
</div>
<div class="column items-center">
<div class="text-caption q-mb-sm">без подписи</div>
<BarcodeDisplay :code="code" size="md" :show-text="false" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { BarcodeDisplay } from 'src/widgets/Marketplace/BarcodeDisplay'
const code = ref('MP-1234-A56-B78')
</script>
@@ -0,0 +1,37 @@
<template>
<div>
<div class="text-h5 q-mb-md">BarcodeScanner · Story 10.2.5 · UX-DR11</div>
<div class="text-body2 text-grey-7 q-mb-lg">
Сканер штрих-кода через camera-API или USB-сканер. В витрине mock:
кнопка «Начать» запрос камеры viewfinder с лазером визуальная вспышка
(UX-DR26 заменяет звук «пик»). Используется на operator-столе ПВЗ.
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<div class="text-subtitle1 q-mb-sm">Успешное сканирование</div>
<BarcodeScanner @scanned="onScanned" />
</div>
<div class="col-12 col-md-6">
<div class="text-subtitle1 q-mb-sm">Ошибка камеры</div>
<BarcodeScanner
force-error="Доступ к камере запрещён"
@error="onError"
/>
</div>
</div>
<q-banner v-if="event" class="mp-event-banner q-mt-lg" rounded>
Событие: <strong>{{ event }}</strong>
</q-banner>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { BarcodeScanner } from 'src/widgets/Marketplace/BarcodeScanner'
const event = ref('')
function onScanned(code: string) { event.value = `scanned: ${code}` }
function onError(msg: string) { event.value = `error: ${msg}` }
</script>
@@ -0,0 +1,110 @@
<template>
<div class="mp-catalog-offer-card-section">
<div class="text-h5 q-mb-md">CatalogOfferCard · Story 10.2.4 · UX-DR10</div>
<div class="text-body2 text-grey-7 q-mb-lg">
Карточка предложения в каталоге Витрины. Канон Стола Заказов:
обёртка над существующим <code>widgets/Marketplace/RequestCard</code>
с расширенным API (status, описание, slot actions, fallback изображения).
</div>
<!-- Состояния: все 6 status'ов -->
<div class="text-h6 q-mt-md q-mb-sm">Все состояния (status)</div>
<div class="row q-col-gutter-md">
<div v-for="o in offers" :key="o.id" class="col-12 col-sm-6 col-md-3">
<CatalogOfferCard :offer="o" @click="onClick" />
</div>
</div>
<!-- Без изображения / нет описания -->
<div class="text-h6 q-mt-xl q-mb-sm">Edge cases</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-sm-6 col-md-4">
<div class="text-caption q-mb-xs">Нет preview (placeholder-иконка)</div>
<CatalogOfferCard :offer="noPreview" />
</div>
<div class="col-12 col-sm-6 col-md-4">
<div class="text-caption q-mb-xs">Длинные title и описание (clamp)</div>
<CatalogOfferCard :offer="longText" />
</div>
<div class="col-12 col-sm-6 col-md-4">
<div class="text-caption q-mb-xs">Slot actions (использование в OfferPage)</div>
<CatalogOfferCard :offer="withActions">
<template #actions="{ offer }">
<q-btn flat dense no-caps label="Подробнее" />
<q-btn unelevated dense no-caps color="primary" :label="`Заказать · ${offer.unitCost} ₽`" />
</template>
</CatalogOfferCard>
</div>
</div>
<!-- Last click -->
<q-banner v-if="lastClicked" class="mp-event-banner q-mt-lg" rounded>
Последний клик: <strong>{{ lastClicked.title }}</strong>
</q-banner>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { CatalogOfferCard, type CatalogOffer } from 'src/widgets/Marketplace/CatalogOfferCard'
const lastClicked = ref<CatalogOffer | null>(null)
function onClick(offer: CatalogOffer) { lastClicked.value = offer }
const offers: CatalogOffer[] = [
{
id: 1, title: 'Картофель «Невский» от КФХ Иваново',
preview: 'https://images.unsplash.com/photo-1518977676601-b53f82aba655?w=400',
remainUnits: 240, unitCost: 35, unitLabel: 'кг', status: 'published',
description: 'Сорт «Невский», урожай 2026 г., чистый, без проростков. Поставка через ПВЗ.',
},
{
id: 2, title: 'Мёд гречишный 0.5 кг',
preview: 'https://images.unsplash.com/photo-1587049352846-4a222e784d38?w=400',
remainUnits: 0, unitCost: 850, unitLabel: 'шт', status: 'sold-out',
description: 'Натуральный гречишный мёд с пасеки кооператива «Лето».',
},
{
id: 3, title: 'Молоко цельное (фермерское)',
preview: 'https://images.unsplash.com/photo-1550583724-b2692b85b150?w=400',
remainUnits: 50, unitCost: 120, unitLabel: 'л', status: 'paused',
description: 'Поставка приостановлена: технический перерыв на ферме.',
},
{
id: 4, title: 'Творог фермерский 9% — пробный лот',
preview: 'https://images.unsplash.com/photo-1559561853-08451507cbe7?w=400',
remainUnits: 30, unitCost: 380, unitLabel: 'кг', status: 'moderation',
description: 'Предложение направлено председателю на проверку оферты.',
},
{
id: 5, title: 'Яйца куриные С1 (10 шт.)',
preview: 'https://images.unsplash.com/photo-1582722872445-44dc5f7e3c8f?w=400',
remainUnits: 12, unitCost: 110, unitLabel: 'уп', status: 'draft',
description: 'Заполняется автором, скоро будет опубликовано.',
},
{
id: 6, title: 'Морковь столовая 5 кг',
preview: 'https://images.unsplash.com/photo-1598170845058-32b9d6a5da37?w=400',
remainUnits: 0, unitCost: 220, unitLabel: 'упак', status: 'completed',
description: 'Поставка закрыта, заявки выполнены.',
},
]
const noPreview: CatalogOffer = {
id: 'np', title: 'Картофель в сетке 5 кг', remainUnits: 100, unitCost: 200, unitLabel: 'упак', status: 'published',
}
const longText: CatalogOffer = {
id: 'lt',
title: 'Очень-очень длинное название предложения, которое не помещается в две строки и должно обрезаться',
preview: 'https://images.unsplash.com/photo-1606787366850-de6330128bfc?w=400',
description: 'Описание тоже очень длинное и должно обрезаться по двум строкам через -webkit-line-clamp без вылезания за рамки карточки и без смещения остальных элементов. Описание тоже очень длинное.',
remainUnits: 999, unitCost: 12345.67, unitLabel: 'кг', status: 'published',
}
const withActions: CatalogOffer = {
id: 'wa', title: 'Сыр адыгейский 1 кг',
preview: 'https://images.unsplash.com/photo-1486297678162-eb2a19b0a32d?w=400',
remainUnits: 18, unitCost: 540, unitLabel: 'кг', status: 'published',
}
</script>
@@ -0,0 +1,32 @@
<template>
<div>
<div class="text-h5 q-mb-md">CorrectionTable · Story 10.2.7 · UX-DR13</div>
<div class="text-body2 text-grey-7 q-mb-lg">
Таблица корректировки факт vs план: оператор ПВЗ вбивает фактическое
количество, дельта подсвечивается цветом, низ показывает счётчики
совпадений/недостач/избытков. Используется в Эпике 5 (Приёмка), Эпике 6 (Корректировка при выдаче).
</div>
<CorrectionTable :rows="rows" @change="onChange" />
<q-banner v-if="lastChange" class="mp-event-banner q-mt-lg" rounded>
Изменён факт <strong>{{ lastChange.sku }}</strong> <strong>{{ lastChange.fact }}</strong>
</q-banner>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { CorrectionTable, type CorrectionRow } from 'src/widgets/Marketplace/CorrectionTable'
const rows = ref<CorrectionRow[]>([
{ sku: 'KA-001', title: 'Картофель «Невский»', unit: 'кг', expected: 100, fact: 100 },
{ sku: 'KA-002', title: 'Морковь столовая', unit: 'кг', expected: 50, fact: 47 },
{ sku: 'KA-003', title: 'Капуста б/к', unit: 'кг', expected: 30, fact: 32 },
{ sku: 'KA-004', title: 'Лук репчатый', unit: 'кг', expected: 20, fact: 20 },
{ sku: 'KA-005', title: 'Свёкла столовая', unit: 'кг', expected: 15, fact: 0 },
])
const lastChange = ref<{ sku: string; fact: number } | null>(null)
function onChange(payload: { sku: string; fact: number }) { lastChange.value = payload }
</script>
@@ -0,0 +1,70 @@
<template>
<div>
<div class="text-h5 q-mb-md">ExpeditorGroupingBoard · Story 10.2.8 · UX-DR14</div>
<div class="text-body2 text-grey-7 q-mb-lg">
Доска группировки заявок экспедитором: горизонтальные колонки по маршрутам/ПВЗ
с drag-n-drop карточек заявок между ними. Используется в Эпике 5 (Доставка).
</div>
<ExpeditorGroupingBoard :columns="columns" @move="onMove" @move-all="onMoveAll" />
<q-banner v-if="lastEvent" class="mp-event-banner q-mt-lg" rounded>
{{ lastEvent }}
</q-banner>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ExpeditorGroupingBoard, type GroupingColumn } from 'src/widgets/Marketplace/ExpeditorGroupingBoard'
const columns = ref<GroupingColumn[]>([
{
id: 'unassigned',
title: 'Не распределено',
meta: '3 заявки ожидают группировки',
items: [
{ id: 1, shortId: '1234', title: 'Картофель 10 кг', units: 10, unitLabel: 'кг', pvz: 'ПВЗ Молодёжная' },
{ id: 2, shortId: '1235', title: 'Молоко 5 л', units: 5, unitLabel: 'л', pvz: 'ПВЗ Молодёжная' },
{ id: 3, shortId: '1236', title: 'Морковь 5 кг', units: 5, unitLabel: 'кг', pvz: 'ПВЗ Гагарина' },
],
},
{
id: 'route-1',
title: 'Маршрут 1 (Молодёжная)',
meta: 'Выезд 16.05 09:00',
items: [],
},
{
id: 'route-2',
title: 'Маршрут 2 (Гагарина)',
meta: 'Выезд 16.05 11:00',
items: [],
},
])
const lastEvent = ref<string | null>(null)
function onMove(payload: { itemId: string | number; fromColumnId: string; toColumnId: string }) {
const { itemId, fromColumnId, toColumnId } = payload
const from = columns.value.find((c) => c.id === fromColumnId)
const to = columns.value.find((c) => c.id === toColumnId)
if (!from || !to) return
const idx = from.items.findIndex((i) => i.id === itemId)
if (idx < 0) return
const [item] = from.items.splice(idx, 1)
if (item) to.items.push(item)
lastEvent.value = `Переместили MP-${itemId}: «${fromColumnId}» → «${toColumnId}»`
}
function onMoveAll(payload: { fromColumnId: string; toColumnId: string }) {
const { fromColumnId, toColumnId } = payload
const from = columns.value.find((c) => c.id === fromColumnId)
const to = columns.value.find((c) => c.id === toColumnId)
if (!from || !to) return
const moved = from.items.length
to.items.push(...from.items)
from.items = []
lastEvent.value = `Bulk move: перенесли все ${moved} заявок из «${fromColumnId}» в «${toColumnId}»`
}
</script>
@@ -0,0 +1,77 @@
<template>
<div>
<div class="text-h5 q-mb-md">KUMapWithList · Story 10.2.13 · Эпик 2 Story 2.3</div>
<div class="text-body2 text-grey-7 q-mb-lg">
Карта ПВЗ с синхронным выбором пин/список. Существующий виджет
<code>src/widgets/KUMapWithList</code> подключён к Yandex Maps API.
Здесь превью layout-а и состояний (loading, no-api-key, geocode-warnings).
Реальная карта работает на странице <code>/market-pvz/list</code>.
</div>
<q-banner class="mp-event-banner q-mb-md" rounded>
<template #avatar>
<q-icon name="fa-solid fa-circle-info" color="primary" />
</template>
Витрина не подключает Yandex Maps SDK рендерится только список с
идентичной разметкой; стилевая часть карты тестируется на реальной странице.
</q-banner>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<div class="text-subtitle1 q-mb-sm">Список ПВЗ (layout идентичен виджету)</div>
<q-list bordered separator class="rounded-borders">
<q-item
v-for="pvz in pvzList"
:key="pvz.coreBraname"
clickable
:active="selected === pvz.coreBraname"
active-class="bg-primary text-white"
@click="selected = pvz.coreBraname"
>
<q-item-section>
<q-item-label class="text-weight-medium">{{ pvz.coreBraname }}</q-item-label>
<q-item-label caption>{{ pvz.addressFull }}</q-item-label>
<q-item-label v-if="pvz.geocodeStatus !== 'OK'" caption :class="pvz.geocodeStatus === 'PENDING' ? 'text-warning' : 'text-negative'">
{{ pvz.geocodeStatus === 'PENDING' ? 'Геокодирование выполняется' : 'Ошибка геокодирования' }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
<div class="col-12 col-md-6">
<div class="text-subtitle1 q-mb-sm">Превью места карты</div>
<q-card flat bordered class="mp-ku-map-placeholder">
<div class="row items-center justify-center column">
<q-icon name="fa-solid fa-map-location-dot" size="64px" color="grey-5" />
<div class="text-body2 q-mt-md text-grey-7">Yandex Maps (виджет)</div>
<div class="text-caption text-grey-6">Выбран: {{ selected || '—' }}</div>
</div>
</q-card>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const selected = ref<string>('')
const pvzList = [
{ coreBraname: 'pvz-molodezhnaya', addressFull: 'г. Восход, ул. Молодёжная, 12', geocodeStatus: 'OK' },
{ coreBraname: 'pvz-gagarina', addressFull: 'г. Восход, ул. Гагарина, 5', geocodeStatus: 'OK' },
{ coreBraname: 'pvz-pobedy', addressFull: 'г. Восход, ул. Победы, 8', geocodeStatus: 'PENDING' },
{ coreBraname: 'pvz-bibliotechnaya', addressFull: 'г. Восход, ул. Библиотечная, 1', geocodeStatus: 'FAILED' },
]
</script>
<style scoped lang="scss">
.mp-ku-map-placeholder {
height: 360px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, .03);
}
</style>
@@ -0,0 +1,58 @@
<template>
<div>
<div class="text-h5 q-mb-md">MultiChannelStatus · Story 10.2.12 · UX-DR18</div>
<div class="text-body2 text-grey-7 q-mb-lg">
Статус доставки нотификации пайщику по каналам push / email / SMS с tooltip по
каждому каналу. 6 status (sent / delivered / read / failed / pending / disabled).
</div>
<div class="column q-gutter-md">
<MultiChannelStatus
label="Заказ MP-1234 — все каналы доставлены"
:channels="case1"
/>
<MultiChannelStatus
label="Заказ MP-1235 — SMS не отправлен"
:channels="case2"
/>
<MultiChannelStatus
label="Заказ MP-1236 — все в очереди"
:channels="case3"
/>
<MultiChannelStatus
label="Пользователь отключил email"
:channels="case4"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { MultiChannelStatus, type ChannelStatusEntry } from 'src/widgets/Marketplace/MultiChannelStatus'
const now = new Date()
const case1: ChannelStatusEntry[] = [
{ kind: 'push', status: 'read', at: now },
{ kind: 'email', status: 'delivered', at: now },
{ kind: 'sms', status: 'delivered', at: now },
]
const case2: ChannelStatusEntry[] = [
{ kind: 'push', status: 'delivered', at: now },
{ kind: 'email', status: 'sent', at: now },
{ kind: 'sms', status: 'failed', error: 'Номер не подтверждён' },
]
const case3: ChannelStatusEntry[] = [
{ kind: 'push', status: 'pending' },
{ kind: 'email', status: 'pending' },
{ kind: 'sms', status: 'pending' },
]
const case4: ChannelStatusEntry[] = [
{ kind: 'push', status: 'delivered', at: now },
{ kind: 'email', status: 'disabled' },
{ kind: 'sms', status: 'delivered', at: now },
]
</script>
@@ -0,0 +1,71 @@
<template>
<div>
<div class="text-h5 q-mb-md">OnboardingCPPGate · Story 10.2.11 · UX-DR17</div>
<div class="text-body2 text-grey-7 q-mb-lg">
L3-gate подключения к Столу Заказов: одна оферта присоединения к ЦПП per-роль
(заказчик / поставщик). Согласие на обработку данных и базовые правила платформы
приняты на L2 при регистрации пайщика и здесь не дублируются.
</div>
<div class="row q-col-gutter-lg">
<div class="col-12 col-md-6">
<div class="text-subtitle1 q-mb-sm">Стол заказчика</div>
<OnboardingCPPGate
title="Подключение к Столу Заказов"
subtitle="Стол заказчика (orderer)"
lead-text="Подтвердите согласие с условиями участия в ЦПП «Стол Заказов»."
:documents="ordererDocs"
@accept="onAccept('orderer', $event)"
@decline="onDecline('orderer')"
/>
</div>
<div class="col-12 col-md-6">
<div class="text-subtitle1 q-mb-sm">Стол поставщика</div>
<OnboardingCPPGate
title="Подключение поставщика"
subtitle="Стол поставщика (offerer)"
lead-text="Подтвердите согласие с условиями участия в ЦПП «Стол Заказов» в роли поставщика."
:documents="offererDocs"
confirm-label="Принять и стать поставщиком"
@accept="onAccept('offerer', $event)"
@decline="onDecline('offerer')"
/>
</div>
</div>
<q-banner v-if="event" class="mp-event-banner q-mt-lg" rounded>
Событие: <strong>{{ event }}</strong>
</q-banner>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { OnboardingCPPGate, type CPPDocument } from 'src/widgets/Marketplace/OnboardingCPPGate'
const event = ref('')
// Per-роль — РОВНО одна оферта (фидбэк 2026-05-15):
// «ПВЗ-логистика», «ценовая политика», «согласие на режим самозанятого/ИП»,
// «подписка на рассылку» — в архитектуре нет и сейчас не нужны.
const ordererDocs: CPPDocument[] = [
{
id: 'cpp-orderer',
title: 'Оферта присоединения к ЦПП «Стол Заказов»',
description: 'Целевая потребительская программа кооператива, роль заказчика',
required: true,
},
]
const offererDocs: CPPDocument[] = [
{
id: 'cpp-offerer',
title: 'Оферта присоединения к ЦПП «Стол Заказов» (поставщик)',
description: 'Целевая потребительская программа кооператива, роль поставщика',
required: true,
},
]
function onAccept(role: string, docs: string[]) { event.value = `accept ${role}: ${docs.join(', ')}` }
function onDecline(role: string) { event.value = `decline ${role}` }
</script>
@@ -0,0 +1,68 @@
<template>
<div class="mp-order-card-section">
<div class="text-h5 q-mb-md">OrderCard · Story 10.2.3 · UX-DR9</div>
<div class="text-body2 text-grey-7 q-mb-lg">
Карточка заказа со статусом жизненного цикла и actions, зависящими от роли стола.
На <code>orderer</code> «Получить» когда готов к выдаче; на <code>operator</code>
«Принять на ПВЗ» / «Выдать»; на <code>admin</code> «Арбитраж» при претензии.
</div>
<div class="row q-gutter-md q-mb-lg items-center">
<q-select
v-model="role"
:options="roles"
emit-value
map-options
dense
outlined
label="Симуляция: actions для роли"
style="min-width: 220px"
/>
</div>
<div class="text-h6 q-mb-sm">Все статусы (per-роль actions)</div>
<div class="row q-col-gutter-md">
<div v-for="o in orders" :key="o.id" class="col-12 col-sm-6 col-md-4">
<OrderCard :order="o" :role="role" @action="onAction" />
</div>
</div>
<q-banner v-if="lastAction" class="mp-event-banner q-mt-lg" rounded>
Action: <strong>{{ lastAction.key }}</strong> на заказе
<strong>#{{ lastAction.order.shortId ?? lastAction.order.id }}</strong>
</q-banner>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { OrderCard, type Order, type OrderRole, type OrderStatus } from 'src/widgets/Marketplace/OrderCard'
const role = ref<OrderRole>('orderer')
const roles = [
{ value: 'orderer', label: 'Заказчик' },
{ value: 'offerer', label: 'Поставщик' },
{ value: 'operator', label: 'Оператор ПВЗ' },
{ value: 'admin', label: 'Админ' },
]
const lastAction = ref<{ key: string; order: Order } | null>(null)
function onAction(payload: { key: string; order: Order }) { lastAction.value = payload }
const allStatuses: OrderStatus[] = [
'draft', 'placed', 'paid', 'in-delivery', 'arrived-at-pvz',
'ready-to-issue', 'issued', 'cancelled', 'dispute', 'returned',
]
const orders: Order[] = allStatuses.map((s, i) => ({
id: 1000 + i,
shortId: `MP-${1000 + i}`,
title: 'Картофель «Невский» — 10 кг',
units: 10,
unitLabel: 'кг',
totalCost: 3500,
status: s,
createdAt: new Date(Date.now() - i * 86400000).toISOString(),
pvz: i % 2 === 0 ? 'ПВЗ Восход (Молодёжная, 12)' : undefined,
}))
</script>
@@ -0,0 +1,59 @@
<template>
<div>
<div class="text-h5 q-mb-md">Performance baseline · Story 10.3 · NFR-P1/P2/P3</div>
<div class="text-body2 text-grey-7 q-mb-lg">
Эталонная инструкция для measure-only контроля производительности marketplace-frontend.
В MVP manual через Chrome DevTools / Lighthouse; CI-gate Phase 2.
</div>
<div class="text-h6 q-mb-sm">Эталонные страницы (P1..P4)</div>
<q-list bordered separator class="rounded-borders q-mb-lg">
<q-item v-for="p in pages" :key="p.id">
<q-item-section avatar><q-chip dense color="primary" text-color="white">{{ p.id }}</q-chip></q-item-section>
<q-item-section>
<q-item-label>{{ p.role }} · {{ p.path }}</q-item-label>
<q-item-label caption>{{ p.story }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<div class="text-h6 q-mb-sm">Целевые показатели MVP</div>
<q-markup-table flat bordered class="q-mb-lg">
<thead>
<tr><th class="text-left">Метрика</th><th>Цель MVP</th><th>Phase 2 (CI-gate)</th></tr>
</thead>
<tbody>
<tr v-for="m in metrics" :key="m.label">
<td>{{ m.label }}</td>
<td>{{ m.mvp }}</td>
<td class="text-grey-7">{{ m.phase2 }}</td>
</tr>
</tbody>
</q-markup-table>
<q-banner class="mp-event-banner" rounded>
<template #avatar>
<q-icon name="fa-solid fa-circle-info" color="primary" />
</template>
Полная инструкция и Phase 2 см.
<code>src/pages/Marketplace/DesignSystem/PERFORMANCE.md</code>.
</q-banner>
</div>
</template>
<script setup lang="ts">
const pages = [
{ id: 'P1', role: 'orderer', path: '/:coopname/market/showcase', story: 'Story 3.5 — каталог' },
{ id: 'P2', role: 'orderer', path: '/:coopname/market/my-orders', story: 'Story 4.6 — Мои заказы' },
{ id: 'P3', role: 'operator', path: '/:coopname/market/warehouse', story: 'Story 9.1 — Склад моего КУ' },
{ id: 'P4', role: 'admin', path: '/:coopname/market/warehouse', story: 'Story 9.2 — Сводный склад' },
]
const metrics = [
{ label: 'Отзывчивость на действие', mvp: '≤ 1 сек', phase2: '≤ 800 мс' },
{ label: 'FCP (First Contentful Paint)', mvp: '≤ 1.5 сек', phase2: '≤ 1.2 сек' },
{ label: 'TTI (Time to Interactive)', mvp: '≤ 3 сек', phase2: '≤ 2.5 сек' },
{ label: 'Bundle size (gzipped, per-стол)', mvp: '≤ 500 КБ', phase2: '≤ 400 КБ' },
{ label: 'CLS (Cumulative Layout Shift)', mvp: '≤ 0.1', phase2: '≤ 0.05' },
]
</script>
@@ -0,0 +1,52 @@
<template>
<div class="mp-placeholder-section">
<div class="text-h5 q-mb-md">{{ section.title }} · {{ section.story }}</div>
<q-banner class="mp-event-banner q-mb-md" rounded>
<template #avatar>
<q-icon name="fa-solid fa-hammer" color="grey-7" />
</template>
Секция в плане. Компонент будет реализован отдельным коммитом Story 10.2.N
и появится здесь со всеми состояниями (idle / loading / error / disabled / empty).
</q-banner>
<div class="text-body2 q-mb-sm">{{ section.description }}</div>
<q-list bordered separator class="rounded-borders">
<q-item>
<q-item-section>
<q-item-label caption>UX-DR</q-item-label>
<q-item-label>{{ section.uxDr }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>Used at</q-item-label>
<q-item-label>{{ section.primaryRole.join(' · ') }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label caption>Status</q-item-label>
<q-item-label>
<q-badge :color="statusColor[section.status]">
{{ statusLabel[section.status] }}
</q-badge>
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</template>
<script setup lang="ts">
import { type PropType } from 'vue'
import { type DesignSystemSection, STATUS_LABEL, STATUS_COLOR } from '../../lib/sections'
defineProps({
section: { type: Object as PropType<DesignSystemSection>, required: true },
})
const statusLabel = STATUS_LABEL
const statusColor = STATUS_COLOR
</script>
@@ -0,0 +1,31 @@
<template>
<div>
<div class="text-h5 q-mb-md">TTNPrintPreview · Story 10.2.9 · UX-DR15</div>
<div class="text-body2 text-grey-7 q-mb-lg">
Предпросмотр и печать ТТН (товарно-транспортной накладной). Формат А5,
штрих-код в шапке (через BarcodeDisplay), таблица позиций, две подписи.
@media print скрывает toolbar и box-shadow. Используется в Эпике 5 (Поставка).
</div>
<TTNPrintPreview :data="ttn" />
</div>
</template>
<script setup lang="ts">
import { TTNPrintPreview, type TTNData } from 'src/widgets/Marketplace/TTNPrintPreview'
const ttn: TTNData = {
number: 'TTN-202605-0042',
date: '2026-05-15',
supplier: 'КФХ Иваново, Ивановская обл., Кольчугино',
recipient: 'ПК «Восход» / ПВЗ Молодёжная, 12',
items: [
{ sku: 'KA-001', title: 'Картофель «Невский»', qty: 100, unit: 'кг', price: 35 },
{ sku: 'KA-002', title: 'Морковь столовая', qty: 50, unit: 'кг', price: 42 },
{ sku: 'KA-003', title: 'Капуста б/к', qty: 30, unit: 'кг', price: 28 },
{ sku: 'KA-004', title: 'Лук репчатый', qty: 20, unit: 'кг', price: 38 },
{ sku: 'KA-005', title: 'Свёкла столовая', qty: 15, unit: 'кг', price: 32.5 },
],
dispatchedBy: 'Председатель КФХ Иванов И.И.',
}
</script>
@@ -0,0 +1,103 @@
<template>
<div class="mp-takeover-dialog-section">
<div class="text-h5 q-mb-md">TakeoverDialog · Story 10.2.1 · UX-DR7</div>
<div class="text-body2 text-grey-7 q-mb-lg">
Full-screen takeover для критических действий: выдача с двойной подписью (Эпик 6),
отмена заказа (Эпик 4), открытие спора (Эпик 7), списание скоропорта (Эпик 8).
4 kind: info / success / warning / danger.
</div>
<div class="row q-gutter-md">
<q-btn unelevated color="primary" label="info" @click="openKind('info')" />
<q-btn unelevated color="positive" label="success" @click="openKind('success')" />
<q-btn unelevated color="warning" label="warning" @click="openKind('warning')" />
<q-btn unelevated color="negative" label="danger" @click="openKind('danger')" />
</div>
<TakeoverDialog
v-model="open"
:title="content.title"
:lead-text="content.lead"
:kind="content.kind"
:confirm-label="content.confirmLabel"
@confirm="onConfirm"
>
<div class="text-body2 q-mb-md">{{ content.body }}</div>
<q-banner v-if="content.kind === 'danger'" class="bg-red-1 text-red-9" rounded>
Операция необратима. Двойная подпись потребует подтверждения второй стороной.
</q-banner>
<q-input
v-if="content.input"
v-model="reason"
outlined
type="textarea"
autogrow
label="Комментарий"
class="q-mt-md"
/>
</TakeoverDialog>
<q-banner v-if="lastEvent" class="mp-event-banner q-mt-lg" rounded>
Последнее событие: <strong>{{ lastEvent }}</strong>
</q-banner>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { TakeoverDialog, type TakeoverKind } from 'src/widgets/Marketplace/TakeoverDialog'
const open = ref(false)
const reason = ref('')
const lastEvent = ref('')
const PRESETS: Record<TakeoverKind, {
title: string; lead: string; body: string; confirmLabel: string; kind: TakeoverKind; input?: boolean
}> = {
info: {
title: 'Подробности заказа MP-1234',
lead: 'Подтвердите получение информации о заказе.',
body: 'Покажите эту страницу как «открытие подробностей» — ничего необратимого здесь не происходит, кнопка confirm просто закрывает диалог.',
confirmLabel: 'Понятно',
kind: 'info',
},
success: {
title: 'Выдача заказа подтверждена',
lead: 'Двойная подпись зафиксирована, имущество передано пайщику.',
body: 'Заказ переведён в статус «Выдан». Кошелёк поставщика пополнен суммой 3 500 ₽.',
confirmLabel: 'Закрыть',
kind: 'success',
},
warning: {
title: 'Списание скоропорта по решению совета',
lead: 'Просрочен срок годности — требуется акт списания.',
body: 'Списание выполняется на основании п. 6 Положения о ЦПП и решения совета №18 от 14.05.2026.',
confirmLabel: 'Перейти к акту',
kind: 'warning',
input: true,
},
danger: {
title: 'Отмена заказа MP-1234',
lead: 'Это действие нельзя отменить.',
body: 'Кошелёк заказчика будет разблокирован, поставщик получит уведомление. Если заказ уже в доставке — операция запустит цепочку гарантийного возврата.',
confirmLabel: 'Подтвердить отмену',
kind: 'danger',
input: true,
},
}
const content = ref(PRESETS.info)
function openKind(k: TakeoverKind) {
content.value = PRESETS[k]
reason.value = ''
open.value = true
}
function onConfirm() {
lastEvent.value = `confirm: ${content.value.kind}${reason.value || '(без комментария)'}`
open.value = false
}
</script>
@@ -0,0 +1,141 @@
<template>
<div class="mp-tokens-section">
<div class="text-h5 q-mb-md">Дизайн-токены · Story 10.1</div>
<div class="text-body2 text-grey-7 q-mb-lg">
Источник истины: <code>src/app/styles/marketplace-tokens.scss</code>.
Все 13 компонентов Story 10.2 используют только эти токены.
</div>
<!-- Цвета -->
<div class="text-h6 q-mt-lg q-mb-sm">Палитра (UX-DR19) · per-coop кастомизация</div>
<div class="row q-gutter-sm">
<div v-for="c in palette" :key="c.label" class="col-3 col-sm-2">
<div :class="`bg-${c.token}`" class="mp-swatch" />
<div class="text-caption q-mt-xs">{{ c.label }}</div>
<code class="text-caption text-grey-7">${{ c.token }}</code>
</div>
</div>
<!-- Статусные чипы -->
<div class="text-h6 q-mt-xl q-mb-sm">Статусные чипы (UX-DR20)</div>
<div class="text-body2 text-grey-7 q-mb-sm">
Нейтральный фон + цветная точка + текст. Никаких залитых разноцветных бэйджей.
</div>
<div class="row q-gutter-sm">
<span v-for="s in statuses" :key="s.label" :class="`mp-status-chip mp-status-chip--${s.kind}`">
{{ s.label }}
</span>
</div>
<!-- Typography -->
<div class="text-h6 q-mt-xl q-mb-sm">Типографика (UX-DR21)</div>
<div class="text-h1 q-mb-sm">H1 заголовок секции</div>
<div class="text-h2 q-mb-sm">H2 заголовок страницы</div>
<div class="text-h3 q-mb-sm">H3 заголовок блока</div>
<div class="text-h6 q-mb-sm">H6 карточка</div>
<div style="font-size: var(--mp-font-body)">Body 16px основной текст пайщика</div>
<div style="font-size: var(--mp-font-admin-table)" class="text-grey-7">
14px admin-таблицы (плотный режим)
</div>
<!-- Spacing -->
<div class="text-h6 q-mt-xl q-mb-sm">Spacing (UX-DR22)</div>
<div class="row items-end q-gutter-sm">
<div v-for="s in spacing" :key="s.label" class="column items-center">
<div class="bg-primary" :style="`width: ${s.value}; height: ${s.value};`" />
<div class="text-caption q-mt-xs">{{ s.label }}</div>
<code class="text-caption text-grey-7">{{ s.value }}</code>
</div>
</div>
<!-- Touch-targets -->
<div class="text-h6 q-mt-xl q-mb-sm">Touch-targets (UX-DR23)</div>
<div class="row q-gutter-md items-center">
<div>
<q-btn label="44px (mobile/orderer)" color="primary" style="min-height: 44px" />
<div class="text-caption q-mt-xs">var(--mp-touch-target)</div>
</div>
<div>
<q-btn label="48px (operator POS)" color="accent" style="min-height: 48px" />
<div class="text-caption q-mt-xs">var(--mp-touch-target-pos)</div>
</div>
</div>
<!-- Per-role -->
<div class="text-h6 q-mt-xl q-mb-sm">Per-роль layout (UX-DR31)</div>
<div class="row q-col-gutter-md">
<div v-for="r in roleCards" :key="r.role" :class="`col-12 col-sm-6 col-md-3 mp-role-${r.role}`">
<div class="mp-card">
<div class="text-subtitle1">{{ r.title }}</div>
<div class="text-caption text-grey-7 q-mb-md">{{ r.hint }}</div>
<q-btn :label="r.cta" color="primary" unelevated no-caps class="full-width" />
</div>
</div>
</div>
<!-- Grid / Icons -->
<div class="text-h6 q-mt-xl q-mb-sm">Grid (UX-DR29) · Icons (UX-DR30)</div>
<div class="text-body2 q-mb-sm">
Сетка Quasar 12-column flexbox-grid. Иконки Font Awesome free.
</div>
<div class="row q-gutter-sm">
<q-icon v-for="ic in icons" :key="ic" :name="ic" size="24px" />
</div>
</div>
</template>
<script setup lang="ts">
const palette = [
{ label: 'primary', token: 'primary' },
{ label: 'secondary', token: 'secondary' },
{ label: 'accent', token: 'accent' },
{ label: 'dark', token: 'dark' },
{ label: 'positive', token: 'positive' },
{ label: 'negative', token: 'negative' },
{ label: 'warning', token: 'warning' },
{ label: 'info', token: 'info' },
]
const statuses = [
{ label: 'Info', kind: 'info' },
{ label: 'Success', kind: 'success' },
{ label: 'Warning', kind: 'warning' },
{ label: 'Error', kind: 'error' },
{ label: 'Neutral', kind: 'neutral' },
]
const spacing = [
{ label: 'xs', value: '4px' },
{ label: 'sm', value: '8px' },
{ label: 'md', value: '16px' },
{ label: 'lg', value: '24px' },
{ label: 'xl', value: '48px' },
]
const roleCards = [
{ role: 'orderer', title: 'Стол заказчика', hint: 'просторный · 16px · 44px tap', cta: 'Заказать' },
{ role: 'offerer', title: 'Стол поставщика', hint: 'просторный · 16px · 44px tap', cta: 'Опубликовать' },
{ role: 'operator', title: 'Стол ПВЗ (POS)', hint: 'POS · 16px · 48px tap', cta: 'Принять' },
{ role: 'admin', title: 'Стол админа', hint: 'плотный · 14px таблицы', cta: 'Открыть' },
]
const icons = [
'fa-solid fa-shop',
'fa-solid fa-cart-shopping',
'fa-solid fa-truck',
'fa-solid fa-warehouse',
'fa-solid fa-shield-halved',
'fa-solid fa-file-invoice',
'fa-solid fa-map-location-dot',
'fa-solid fa-boxes-stacked',
]
</script>
<style scoped lang="scss">
.mp-swatch {
width: 100%;
height: 56px;
border-radius: 6px;
border: 1px solid rgba(0, 0, 0, .06);
}
</style>
@@ -0,0 +1,60 @@
<template>
<div class="mp-wallet-timeline-section">
<div class="text-h5 q-mb-md">WalletTimeline · Story 10.2.2 · UX-DR8</div>
<div class="text-body2 text-grey-7 q-mb-lg">
Лента движений кошелька пайщика: пополнения, блокировки под заказ, списания
при выдаче, возвраты при гарантии. 6 типов операций. Используется в эпиках
4 (Заказ и блокировка), 9 (Склад и отчётность).
</div>
<div class="row q-gutter-md q-mb-lg">
<q-btn-toggle
v-model="filter"
:options="filters"
unelevated
toggle-color="primary"
dense
/>
</div>
<div class="row q-col-gutter-lg">
<div class="col-12 col-md-7">
<div class="text-subtitle1 q-mb-sm">Обычный режим</div>
<WalletTimeline :entries="filtered" layout="comfortable" />
</div>
<div class="col-12 col-md-5">
<div class="text-subtitle1 q-mb-sm">Empty state</div>
<WalletTimeline :entries="[]" layout="comfortable" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { WalletTimeline, type WalletEntry, type WalletEntryKind } from 'src/widgets/Marketplace/WalletTimeline'
const filter = ref<'all' | WalletEntryKind>('all')
const filters = [
{ label: 'Все', value: 'all' },
{ label: 'Пополнение', value: 'deposit' },
{ label: 'Блокировка', value: 'block' },
{ label: 'Списание', value: 'charge' },
{ label: 'Возврат', value: 'refund' },
{ label: 'Выплата', value: 'payout' },
]
const entries: WalletEntry[] = [
{ id: 1, kind: 'deposit', amount: 5000, title: 'Пополнение через СБП', at: '2026-05-14T10:00:00Z', note: 'СБП · Сбербанк' },
{ id: 2, kind: 'block', amount: 3500, title: 'Блокировка под заказ', at: '2026-05-14T11:23:00Z', orderId: 1234 },
{ id: 3, kind: 'charge', amount: 3500, title: 'Списание при выдаче', at: '2026-05-15T14:05:00Z', orderId: 1234, note: 'Двойная подпись подтверждена оператором ПВЗ' },
{ id: 4, kind: 'block', amount: 1800, title: 'Блокировка под заказ', at: '2026-05-15T16:40:00Z', orderId: 1235 },
{ id: 5, kind: 'unblock', amount: 1800, title: 'Разблокировка (отмена заказа)', at: '2026-05-15T17:00:00Z', orderId: 1235 },
{ id: 6, kind: 'refund', amount: 600, title: 'Гарантийный возврат', at: '2026-05-16T09:00:00Z', orderId: 1230, note: 'Compensating-forward по решению совета' },
{ id: 7, kind: 'payout', amount: 12500, title: 'Выплата поставщику', at: '2026-05-16T15:30:00Z', note: 'Партия молока 50 л × 250 ₽' },
]
const filtered = computed(() =>
filter.value === 'all' ? entries : entries.filter((e) => e.kind === filter.value)
)
</script>
@@ -0,0 +1,27 @@
<template>
<div class="mp-role-admin">
<div class="text-h5 q-mb-md">WarehouseSummaryGrid · Story 10.2.10 · UX-DR16</div>
<div class="text-body2 text-grey-7 q-mb-lg">
Сводная таблица склада КУ на admin-столе: приход / расход / остаток
(КУ работает транзитом без «минимального остатка»), фильтр по SKU/названию,
плотный layout 14px. Используется в Эпике 9 (Склад и отчётность).
</div>
<WarehouseSummaryGrid :rows="rows" />
</div>
</template>
<script setup lang="ts">
import { WarehouseSummaryGrid, type WarehouseRow } from 'src/widgets/Marketplace/WarehouseSummaryGrid'
const rows: WarehouseRow[] = [
{ sku: 'KA-001', title: 'Картофель «Невский»', unit: 'кг', incoming: 800, outgoing: 720, balance: 80, pvz: 'Молодёжная, 12' },
{ sku: 'KA-002', title: 'Морковь столовая', unit: 'кг', incoming: 400, outgoing: 397, balance: 3, pvz: 'Молодёжная, 12' },
{ sku: 'KA-003', title: 'Капуста б/к', unit: 'кг', incoming: 240, outgoing: 240, balance: 0, pvz: 'Молодёжная, 12' },
{ sku: 'KA-004', title: 'Лук репчатый', unit: 'кг', incoming: 160, outgoing: 130, balance: 30, pvz: 'Гагарина, 5' },
{ sku: 'KA-005', title: 'Свёкла столовая', unit: 'кг', incoming: 120, outgoing: 120, balance: 0, pvz: 'Гагарина, 5' },
{ sku: 'MO-001', title: 'Молоко цельное', unit: 'л', incoming: 50, outgoing: 47, balance: 3, pvz: 'Молодёжная, 12' },
{ sku: 'MO-002', title: 'Творог 9%', unit: 'кг', incoming: 30, outgoing: 18, balance: 12, pvz: 'Гагарина, 5' },
{ sku: 'ME-001', title: 'Мёд гречишный', unit: 'кг', incoming: 12, outgoing: 12, balance: 0, pvz: 'Молодёжная, 12' },
]
</script>
@@ -79,7 +79,7 @@ div.page-shell
q-card-section
.row.items-center
q-btn(flat icon="fa-solid fa-arrow-left" @click="mode = 'list'")
.text-h6.q-ml-sm Претензия #{{ selectedDispute.id }}
.text-h6.q-ml-sm Претензия {{ selectedDispute.id }}
q-separator
@@ -99,6 +99,7 @@ function editPvz(pvz: IMarketplaceKUDetails) {
}
function onSaved(_pvz: IMarketplaceKUDetails) {
void _pvz
void reload()
}
@@ -115,5 +116,5 @@ onMounted(() => {
})
// Используется для подавления "unused" на details — фактический driver UI.
const _unused = details
void details
</script>
@@ -82,7 +82,7 @@ div.page-shell
q-card
q-card-section
.row.items-center
.text-h6.col Перевозка #{{ selectedShipment?.id }}
.text-h6.col Перевозка {{ selectedShipment?.id }}
q-btn(flat icon="close" @click="showTimeline = false")
q-separator
q-card-section
@@ -108,7 +108,6 @@ div.page-shell
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Notify } from 'quasar'
import { client } from 'src/shared/api/client'
const router = useRouter()
const loading = ref(false)
@@ -177,7 +176,8 @@ function onReoffer(request: any) {
showReoffer.value = true
}
function onMarkDelivered(request: any) {
function onMarkDelivered(_request: any) {
void _request
Notify.create({ type: 'info', message: 'Функция доставки в разработке' })
}
@@ -0,0 +1,96 @@
<template>
<div class="mp-barcode-display" :class="`mp-barcode-display--${size}`">
<svg
class="mp-barcode-display__svg"
:viewBox="`0 0 ${totalWidth} ${barHeight}`"
:width="totalWidth"
:height="barHeight"
:aria-label="`Штрих-код ${code}`"
role="img"
>
<rect
v-for="(bar, i) in bars"
:key="i"
:x="bar.x"
y="0"
:width="bar.w"
:height="barHeight"
fill="#111"
/>
</svg>
<div class="mp-barcode-display__code">{{ display }}</div>
</div>
</template>
<script setup lang="ts">
import { computed, type PropType } from 'vue'
/**
* Простой визуальный рендер штрих-кода (mock-Code128).
* Не предназначен для считывания сканером — для печатной формы используется
* jsbarcode на стадии функциональной реализации Эпика 6 (Story 6.x).
* Здесь — единственно визуальная согласованность UI.
*/
const props = defineProps({
code: { type: String, required: true },
size: { type: String as PropType<'sm' | 'md' | 'lg'>, default: 'md' },
showText: { type: Boolean, default: true },
})
const barHeight = computed(() => ({ sm: 36, md: 64, lg: 96 })[props.size])
/**
* Псевдо-генерация ширин из символов кода — стабильна для одного code,
* визуально похожа на реальный штрих-код. Чёрные полосы + промежутки.
*/
const bars = computed(() => {
const widths: Array<{ x: number; w: number }> = []
let x = 4
for (let i = 0; i < props.code.length; i++) {
const ch = props.code.charCodeAt(i)
const blackW = ((ch * 7) % 4) + 1
const gapW = ((ch * 11) % 3) + 1
widths.push({ x, w: blackW })
x += blackW + gapW
// Иногда сразу пара полос (имитация плотности)
if (i % 2 === 0) {
widths.push({ x, w: 1 })
x += 2
}
}
return widths
})
const totalWidth = computed(() => {
const last = bars.value[bars.value.length - 1]
return (last ? last.x + last.w : 100) + 8
})
const display = computed(() => (props.showText ? props.code : ''))
</script>
<style scoped lang="scss">
.mp-barcode-display {
display: inline-flex;
flex-direction: column;
align-items: center;
background: #fff;
padding: var(--mp-space-sm);
border-radius: 4px;
&--sm { font-size: 12px; }
&--md { font-size: 14px; }
&--lg { font-size: 16px; }
&__svg {
display: block;
}
&__code {
margin-top: 4px;
font-family: 'Courier New', monospace;
letter-spacing: 2px;
color: #111;
}
}
</style>
@@ -0,0 +1 @@
export { default as BarcodeDisplay } from './BarcodeDisplay.vue'
@@ -0,0 +1,196 @@
<template>
<div class="mp-barcode-scanner" :class="{ 'mp-barcode-scanner--active': state === 'scanning' }">
<div class="mp-barcode-scanner__viewport">
<div v-if="state === 'idle'" class="mp-barcode-scanner__overlay">
<q-icon name="fa-solid fa-barcode" size="64px" class="mp-barcode-scanner__muted" />
<div class="mp-barcode-scanner__caption">Сканер готов</div>
<q-btn unelevated no-caps color="primary" :label="startLabel" class="mp-barcode-scanner__btn" @click="start" />
</div>
<div v-else-if="state === 'requesting'" class="mp-barcode-scanner__overlay">
<q-spinner color="primary" size="48px" />
<div class="mp-barcode-scanner__caption">Запрос доступа к камере</div>
</div>
<div v-else-if="state === 'scanning'" class="mp-barcode-scanner__viewfinder">
<div class="mp-barcode-scanner__corners" />
<div class="mp-barcode-scanner__laser" />
<div class="mp-barcode-scanner__hint">Поместите штрих-код в рамку</div>
</div>
<div v-else-if="state === 'success'" class="mp-barcode-scanner__overlay mp-barcode-scanner__flash">
<q-icon name="fa-solid fa-circle-check" size="64px" color="positive" />
<div class="mp-barcode-scanner__code">{{ lastCode }}</div>
<div class="mp-barcode-scanner__caption mp-barcode-scanner__caption--small">
Visual feedback вместо звука «пик» (UX-DR26)
</div>
<q-btn unelevated no-caps color="primary" label="Сканировать ещё" class="mp-barcode-scanner__btn" @click="start" />
</div>
<div v-else-if="state === 'error'" class="mp-barcode-scanner__overlay">
<q-icon name="fa-solid fa-triangle-exclamation" size="64px" color="negative" />
<div class="mp-barcode-scanner__caption mp-barcode-scanner__caption--error">{{ errorMessage }}</div>
<q-btn flat no-caps color="primary" label="Повторить" class="mp-barcode-scanner__btn" @click="start" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, type PropType } from 'vue'
export type ScannerState = 'idle' | 'requesting' | 'scanning' | 'success' | 'error'
const props = defineProps({
startLabel: { type: String, default: 'Начать сканирование' },
// mock-режим — в реальной реализации тут будет camera-API / USB-сканер.
mockCodes: { type: Array as PropType<string[]>, default: () => ['1234567890128', '4012345678901', '5901234123457'] },
// для демо-симуляции ошибок
forceError: { type: String, default: '' },
})
const emit = defineEmits<{
(e: 'scanned', code: string): void
(e: 'error', message: string): void
}>()
const state = ref<ScannerState>('idle')
const lastCode = ref('')
const errorMessage = ref('')
let timer: ReturnType<typeof setTimeout> | null = null
function start() {
if (timer) clearTimeout(timer)
errorMessage.value = ''
state.value = 'requesting'
timer = setTimeout(() => {
if (props.forceError) {
errorMessage.value = props.forceError
state.value = 'error'
emit('error', props.forceError)
return
}
state.value = 'scanning'
timer = setTimeout(() => {
const code = props.mockCodes[Math.floor(Math.random() * props.mockCodes.length)] ?? '0000000000000'
lastCode.value = code
state.value = 'success'
emit('scanned', code)
}, 1500)
}, 600)
}
defineExpose({ start, state, lastCode })
</script>
<style scoped lang="scss">
.mp-barcode-scanner {
width: 100%;
max-width: 420px;
aspect-ratio: 4 / 3;
background: #000;
border-radius: var(--mp-radius-md);
overflow: hidden;
position: relative;
&__viewport {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
&__overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, .35); // явный полупрозрачный bg — работает и на dark и на light
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #fff;
text-align: center;
padding: var(--mp-space-lg);
gap: var(--mp-space-sm);
}
&__muted { color: rgba(255, 255, 255, .55); }
&__caption {
color: rgba(255, 255, 255, .85);
font-size: 14px;
&--small { font-size: 12px; color: rgba(255, 255, 255, .65); }
&--error { color: var(--q-negative); }
}
&__code {
font-size: 22px;
font-weight: 600;
letter-spacing: .02em;
color: var(--q-positive);
}
&__btn {
border-radius: var(--mp-radius-sm);
margin-top: var(--mp-space-sm);
box-shadow: none !important;
}
&__flash {
animation: mp-flash .4s ease-out;
}
&__viewfinder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
&__corners {
width: 70%;
height: 50%;
border: 2px solid rgba(255, 255, 255, .85);
border-radius: 6px;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, .35);
}
&__laser {
position: absolute;
top: 50%;
left: 16%;
right: 16%;
height: 2px;
background: #ff3344;
box-shadow: 0 0 12px #ff3344;
animation: mp-laser 1.6s ease-in-out infinite;
}
&__hint {
position: absolute;
bottom: var(--mp-space-md);
width: 100%;
text-align: center;
font-size: 14px;
color: rgba(255, 255, 255, .85);
}
}
@keyframes mp-laser {
0%, 100% { transform: translateY(-60px); }
50% { transform: translateY( 60px); }
}
@keyframes mp-flash {
0% { background: rgba(76, 175, 80, .55); }
100% { background: rgba(0, 0, 0, .35); }
}
</style>
@@ -0,0 +1,2 @@
export { default as BarcodeScanner } from './BarcodeScanner.vue'
export type { ScannerState } from './BarcodeScanner.vue'
@@ -0,0 +1,227 @@
<template>
<q-card flat class="mp-catalog-offer-card mp-card" :class="cardClasses" @click="onClick">
<div class="mp-catalog-offer-card__media">
<img v-if="src" :src="src" :alt="offer.title" />
<div v-else class="mp-catalog-offer-card__placeholder">
<q-icon name="fa-solid fa-image" size="48px" color="grey-5" />
</div>
<span
v-if="status"
class="mp-status-chip mp-catalog-offer-card__status"
:class="`mp-status-chip--${statusKind}`"
>
{{ statusLabel }}
</span>
</div>
<q-card-section class="mp-catalog-offer-card__body">
<div class="mp-catalog-offer-card__title">{{ offer.title }}</div>
<div class="mp-catalog-offer-card__meta">
<span class="mp-catalog-offer-card__price" v-if="offer.unitCost != null">
{{ formatPrice(offer.unitCost) }}
<span class="mp-catalog-offer-card__unit">/ {{ unitLabel }}</span>
</span>
<span class="mp-catalog-offer-card__stock" :class="{ 'mp-catalog-offer-card__stock--empty': isEmpty }">
{{ stockLabel }}
</span>
</div>
<div v-if="offer.description" class="mp-catalog-offer-card__desc">
{{ offer.description }}
</div>
</q-card-section>
<q-card-actions v-if="$slots.actions" align="right" class="mp-catalog-offer-card__actions">
<slot name="actions" :offer="offer" />
</q-card-actions>
</q-card>
</template>
<script setup lang="ts">
import { computed, type PropType } from 'vue'
export type CatalogOfferStatus = 'draft' | 'published' | 'paused' | 'sold-out' | 'completed' | 'moderation'
export interface CatalogOffer {
id?: string | number
title: string
description?: string
preview?: string // URL изображения (если есть)
remainUnits?: number
unitCost?: number | string
unitLabel?: string // ед., шт., кг и т.д.
status?: CatalogOfferStatus
}
const props = defineProps({
offer: { type: Object as PropType<CatalogOffer>, required: true },
})
const emit = defineEmits<{
(e: 'click', offer: CatalogOffer): void
}>()
const src = computed(() => props.offer.preview || '')
const unitLabel = computed(() => props.offer.unitLabel ?? 'ед.')
const status = computed(() => props.offer.status)
type StatusKind = 'info' | 'success' | 'warning' | 'error' | 'neutral'
const STATUS_MAP: Record<CatalogOfferStatus, { label: string; kind: StatusKind }> = {
draft: { label: 'Черновик', kind: 'neutral' },
moderation: { label: 'На модерации', kind: 'warning' },
published: { label: 'Опубликовано', kind: 'success' },
paused: { label: 'Приостановлено', kind: 'warning' },
'sold-out': { label: 'Закончилось', kind: 'neutral' },
completed: { label: 'Завершено', kind: 'neutral' },
}
const statusLabel = computed(() => (status.value ? STATUS_MAP[status.value].label : ''))
const statusKind = computed<StatusKind>(() => (status.value ? STATUS_MAP[status.value].kind : 'neutral'))
const isEmpty = computed(() => (props.offer.remainUnits ?? 0) <= 0)
const stockLabel = computed(() => isEmpty.value
? 'Нет в наличии'
: `${props.offer.remainUnits} ${unitLabel.value}`)
const cardClasses = computed(() => ({
'mp-card--interactive': true,
[`mp-catalog-offer-card--${status.value}`]: !!status.value,
}))
function formatPrice(v: number | string) {
const n = typeof v === 'number' ? v : Number(v)
if (Number.isNaN(n)) return String(v)
return new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(n) + ' ₽'
}
function onClick() {
emit('click', props.offer)
}
</script>
<style scoped lang="scss">
.mp-catalog-offer-card {
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
&__media {
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
background: var(--mp-surface-1);
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform .4s ease;
}
}
&:hover &__media 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);
left: var(--mp-space-sm);
// Не хардкодим white — surface-0 переключается между light/dark автоматически.
background: var(--mp-surface-0);
backdrop-filter: blur(6px);
}
&__body {
padding: var(--mp-space-md) var(--mp-space-md) var(--mp-space-sm);
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
&__title {
font-size: 15px;
font-weight: 500;
line-height: 1.35;
color: var(--mp-on-surface);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
&__meta {
display: flex;
align-items: baseline;
gap: var(--mp-space-md);
margin-top: 2px;
}
&__price {
font-size: 17px;
font-weight: 600;
letter-spacing: -.01em;
color: var(--mp-on-surface);
}
&__unit {
font-size: 12px;
font-weight: 400;
color: var(--mp-on-surface-muted);
margin-left: 2px;
}
&__stock {
font-size: 12px;
color: var(--mp-on-surface-muted);
margin-left: auto;
&--empty {
color: var(--q-negative);
}
}
&__desc {
font-size: 13px;
color: var(--mp-on-surface-muted);
line-height: 1.45;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
&__actions {
padding: 0 var(--mp-space-md) var(--mp-space-md);
gap: var(--mp-space-sm);
}
}
// На очень узких экранах — заголовок и цена уменьшаются деликатно
@media (max-width: 480px) {
.mp-catalog-offer-card {
&__title { font-size: 14px; }
&__price { font-size: 16px; }
&__media { aspect-ratio: 16 / 10; }
}
}
// Per-role: на operator-POS — крупнее touch / шрифт
.mp-role-operator .mp-catalog-offer-card {
&__title { font-size: 17px; }
&__price { font-size: 20px; }
}
</style>
@@ -0,0 +1,2 @@
export { default as CatalogOfferCard } from './CatalogOfferCard.vue'
export type { CatalogOffer, CatalogOfferStatus } from './CatalogOfferCard.vue'
@@ -0,0 +1,255 @@
<template>
<div class="mp-correction-table">
<!-- Desktop / planshet обычная таблица -->
<q-table
v-if="!compact"
class="mp-correction-table__grid"
:rows="enrichedRows"
:columns="columns"
row-key="sku"
flat
bordered
hide-bottom
:pagination="{ rowsPerPage: 0 }"
:rows-per-page-options="[0]"
>
<template #body-cell-delta="props">
<q-td :props="props" :class="deltaClass(props.row)">
<strong>{{ props.row.delta > 0 ? '+' : '' }}{{ props.row.delta }}</strong>
</q-td>
</template>
<template #body-cell-fact="props">
<q-td :props="props">
<q-input
v-model.number="props.row.fact"
type="number"
dense
outlined
input-class="text-right mp-correction-table__fact-input"
@update:model-value="emit('change', { sku: props.row.sku, fact: props.row.fact })"
/>
</q-td>
</template>
<template #body-cell-status="props">
<q-td :props="props">
<span class="mp-status-chip" :class="`mp-status-chip--${statusKind(props.row)}`">
{{ statusLabel(props.row) }}
</span>
</q-td>
</template>
</q-table>
<!-- Mobile (xs) карточный вид, факт это крупное touch-friendly поле -->
<div v-else class="mp-correction-table__cards">
<div
v-for="r in enrichedRows"
:key="r.sku"
class="mp-card mp-correction-table__card"
>
<div class="mp-correction-table__card-head">
<div>
<div class="mp-correction-table__card-title">{{ r.title }}</div>
<div class="mp-correction-table__card-sku">SKU · {{ r.sku }}</div>
</div>
<span class="mp-status-chip" :class="`mp-status-chip--${statusKind(r)}`">
{{ statusLabel(r) }}
</span>
</div>
<div class="mp-correction-table__card-grid">
<div>
<div class="mp-correction-table__card-label">План</div>
<div class="mp-correction-table__card-value">{{ r.expected }} {{ r.unit }}</div>
</div>
<div>
<div class="mp-correction-table__card-label">Факт</div>
<q-input
v-model.number="r.fact"
type="number"
outlined
dense
:suffix="r.unit"
class="mp-correction-table__fact-input--mobile"
@update:model-value="emit('change', { sku: r.sku, fact: r.fact })"
/>
</div>
<div>
<div class="mp-correction-table__card-label">Δ</div>
<div :class="['mp-correction-table__card-value', deltaClass(r)]">
{{ r.delta > 0 ? '+' : '' }}{{ r.delta }}
</div>
</div>
</div>
</div>
</div>
<!-- Footer summary (одинаковый и для таблицы, и для карточек) -->
<div class="mp-correction-table__summary">
<div class="mp-correction-table__summary-count">
Итого позиций: {{ enrichedRows.length }}
</div>
<div class="mp-correction-table__summary-chips">
<span class="mp-status-chip mp-status-chip--success">Совпадает · {{ matchCount }}</span>
<span class="mp-status-chip mp-status-chip--warning">Недостача · {{ shortCount }}</span>
<span class="mp-status-chip mp-status-chip--info">Избыток · {{ overCount }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, type PropType } from 'vue'
import { useQuasar, type QTableProps } from 'quasar'
export interface CorrectionRow {
sku: string
title: string
unit: string
expected: number // план (заказ)
fact: number // факт (поступление)
}
const props = defineProps({
rows: { type: Array as PropType<CorrectionRow[]>, required: true },
})
const emit = defineEmits<{
(e: 'change', payload: { sku: string; fact: number }): void
}>()
const $q = useQuasar()
const compact = computed(() => $q.screen.lt.sm)
const enrichedRows = computed(() =>
props.rows.map((r) => ({ ...r, delta: r.fact - r.expected }))
)
const columns: QTableProps['columns'] = [
{ name: 'sku', label: 'SKU', field: 'sku', align: 'left' },
{ name: 'title', label: 'Позиция', field: 'title', align: 'left' },
{ name: 'expected', label: 'План', field: 'expected', align: 'right' },
{ name: 'fact', label: 'Факт', field: 'fact', align: 'right' },
{ name: 'delta', label: 'Δ', field: 'delta', align: 'right' },
{ name: 'unit', label: 'Ед.', field: 'unit', align: 'left' },
{ name: 'status', label: 'Статус', field: 'sku', align: 'center' },
]
type StatusKind = 'success' | 'warning' | 'info'
function statusLabel(r: CorrectionRow & { delta: number }): string {
if (r.delta === 0) return 'Совпадает'
if (r.delta < 0) return 'Недостача'
return 'Избыток'
}
function statusKind(r: CorrectionRow & { delta: number }): StatusKind {
if (r.delta === 0) return 'success'
if (r.delta < 0) return 'warning'
return 'info'
}
function deltaClass(r: CorrectionRow & { delta: number }): string {
if (r.delta === 0) return 'text-positive'
if (r.delta < 0) return 'text-warning'
return 'text-info'
}
const matchCount = computed(() => enrichedRows.value.filter((r) => r.delta === 0).length)
const shortCount = computed(() => enrichedRows.value.filter((r) => r.delta < 0).length)
const overCount = computed(() => enrichedRows.value.filter((r) => r.delta > 0).length)
</script>
<style scoped lang="scss">
.mp-correction-table {
display: flex;
flex-direction: column;
gap: var(--mp-space-md);
&__grid {
background: var(--mp-surface-0);
border-radius: var(--mp-radius-md);
:deep(td), :deep(th) {
font-size: 14px;
}
// Поле «факт» не должно быть микроскопическим — даём минимум на ввод
:deep(.mp-correction-table__fact-input) {
min-width: 64px;
}
}
&__cards {
display: flex;
flex-direction: column;
gap: var(--mp-space-md);
}
&__card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--mp-space-sm);
margin-bottom: var(--mp-space-sm);
}
&__card-title {
font-weight: 500;
color: var(--mp-on-surface);
}
&__card-sku {
font-size: 12px;
color: var(--mp-on-surface-muted);
margin-top: 2px;
}
&__card-grid {
display: grid;
grid-template-columns: 1fr 1.4fr 1fr;
gap: var(--mp-space-md);
align-items: end;
}
&__card-label {
font-size: 11px;
color: var(--mp-on-surface-muted);
text-transform: uppercase;
letter-spacing: .04em;
margin-bottom: 4px;
}
&__card-value {
font-size: 17px;
font-weight: 600;
color: var(--mp-on-surface);
}
&__fact-input--mobile :deep(.q-field__control) {
min-height: var(--mp-touch-target);
}
&__summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--mp-space-md);
flex-wrap: wrap;
padding-top: var(--mp-space-sm);
border-top: 1px solid var(--mp-border-subtle);
}
&__summary-count {
font-size: 13px;
color: var(--mp-on-surface-muted);
}
&__summary-chips {
display: flex;
gap: var(--mp-space-sm);
flex-wrap: wrap;
}
}
</style>
@@ -0,0 +1,2 @@
export { default as CorrectionTable } from './CorrectionTable.vue'
export type { CorrectionRow } from './CorrectionTable.vue'
@@ -0,0 +1,231 @@
<template>
<div class="mp-egb">
<div class="mp-egb__hint">
Перетаскивайте заявки между колонками. Для оптом-переноса используйте «Переместить всё».
</div>
<div class="row q-col-gutter-md no-wrap mp-egb__board">
<div
v-for="col in columns"
:key="col.id"
class="mp-egb__column"
:class="{ 'mp-egb__column--hover': hoverColId === col.id }"
@dragover.prevent="hoverColId = col.id"
@dragleave="hoverColId === col.id && (hoverColId = null)"
@drop="onDrop(col.id, $event)"
>
<div class="mp-egb__col-header">
<div class="mp-egb__col-title">{{ col.title }}</div>
<span class="mp-status-chip mp-status-chip--neutral">
{{ col.items.length }}
</span>
</div>
<div v-if="col.meta" class="mp-egb__col-meta">{{ col.meta }}</div>
<div class="mp-egb__items mp-gap-sm">
<div
v-for="item in col.items"
:key="item.id"
class="mp-egb__item mp-card"
draggable="true"
@dragstart="onDragStart($event, item.id, col.id)"
>
<div class="mp-egb__item-id"> {{ item.shortId }}</div>
<div class="mp-egb__item-title">{{ item.title }}</div>
<div class="mp-egb__item-meta">
{{ item.units }} {{ item.unitLabel ?? 'ед.' }} · {{ item.pvz }}
</div>
</div>
<div v-if="!col.items.length" class="mp-egb__empty">
<q-icon name="fa-solid fa-inbox" size="28px" class="mp-egb__empty-icon" />
<div class="mp-egb__empty-text">Перетащите сюда</div>
</div>
</div>
<!-- Bulk move: переместить все элементы из других колонок (если в столбце мало или пусто
и есть много единиц того же заказа в соседней колонке оператору не нужно тащить по одной). -->
<div v-if="bulkSources(col).length" class="mp-egb__bulk">
<q-btn
v-for="src in bulkSources(col)"
:key="src.id"
flat
dense
no-caps
class="mp-egb__bulk-btn"
:label="`Переместить всё (${src.items.length}) из «${src.title}»`"
@click="moveAll(src.id, col.id)"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, type PropType } from 'vue'
export interface GroupingItem {
id: string | number
shortId: string
title: string
units: number
unitLabel?: string
pvz: string
}
export interface GroupingColumn {
id: string
title: string
meta?: string
items: GroupingItem[]
}
const props = defineProps({
columns: { type: Array as PropType<GroupingColumn[]>, required: true },
})
const emit = defineEmits<{
(e: 'move', payload: { itemId: string | number; fromColumnId: string; toColumnId: string }): void
(e: 'move-all', payload: { fromColumnId: string; toColumnId: string }): void
}>()
const hoverColId = ref<string | null>(null)
function onDragStart(ev: DragEvent, itemId: string | number, fromColumnId: string) {
ev.dataTransfer?.setData('application/x-mp-item', JSON.stringify({ itemId, fromColumnId }))
if (ev.dataTransfer) ev.dataTransfer.effectAllowed = 'move'
}
function onDrop(toColumnId: string, ev: DragEvent) {
hoverColId.value = null
const raw = ev.dataTransfer?.getData('application/x-mp-item')
if (!raw) return
try {
const { itemId, fromColumnId } = JSON.parse(raw)
if (fromColumnId === toColumnId) return
emit('move', { itemId, fromColumnId, toColumnId })
} catch { /* noop */ }
}
// Подбор источников для bulk-move: для колонки `target`
// показываем все колонки, в которых ≥2 элементов, которые могут поехать сюда оптом.
// Решение «куда» оставляем за оператором — он жмёт явную кнопку.
function bulkSources(target: GroupingColumn) {
return props.columns.filter((c) => c.id !== target.id && c.items.length >= 2)
}
function moveAll(fromColumnId: string, toColumnId: string) {
emit('move-all', { fromColumnId, toColumnId })
}
</script>
<style scoped lang="scss">
.mp-egb {
&__hint {
font-size: 13px;
color: var(--mp-on-surface-muted);
margin-bottom: var(--mp-space-md);
}
&__board {
overflow-x: auto;
padding-bottom: var(--mp-space-sm);
}
&__column {
min-width: 280px;
flex: 1;
background: var(--mp-surface-1);
border: 1px solid var(--mp-border-subtle);
border-radius: var(--mp-radius-md);
padding: var(--mp-space-md);
display: flex;
flex-direction: column;
transition: border-color .15s ease, background-color .15s ease;
&--hover {
border-color: var(--q-primary);
background: var(--mp-surface-2);
}
}
&__col-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--mp-space-xs);
}
&__col-title {
font-weight: 600;
color: var(--mp-on-surface);
}
&__col-meta {
font-size: 12px;
color: var(--mp-on-surface-muted);
margin-bottom: var(--mp-space-sm);
}
&__items {
display: flex;
flex-direction: column;
gap: var(--mp-space-sm);
flex: 1;
}
&__item {
padding: var(--mp-space-sm) var(--mp-space-md);
cursor: grab;
&:active { cursor: grabbing; }
}
&__item-id {
font-size: 12px;
color: var(--mp-on-surface-muted);
}
&__item-title {
font-size: 14px;
font-weight: 500;
color: var(--mp-on-surface);
margin-top: 2px;
}
&__item-meta {
font-size: 12px;
color: var(--mp-on-surface-muted);
margin-top: 4px;
}
&__empty {
border: 1px dashed var(--mp-border-strong);
border-radius: var(--mp-radius-sm);
padding: var(--mp-space-lg);
display: flex;
flex-direction: column;
align-items: center;
color: var(--mp-on-surface-muted);
}
&__empty-icon { color: var(--mp-on-surface-muted); opacity: .6; }
&__empty-text { font-size: 12px; margin-top: var(--mp-space-xs); }
&__bulk {
margin-top: var(--mp-space-sm);
display: flex;
flex-direction: column;
gap: 4px;
padding-top: var(--mp-space-sm);
border-top: 1px dashed var(--mp-border-subtle);
}
&__bulk-btn {
border-radius: var(--mp-radius-sm);
justify-content: flex-start;
color: var(--q-primary);
}
}
</style>
@@ -0,0 +1,2 @@
export { default as ExpeditorGroupingBoard } from './ExpeditorGroupingBoard.vue'
export type { GroupingItem, GroupingColumn } from './ExpeditorGroupingBoard.vue'
@@ -0,0 +1,138 @@
<template>
<div class="mp-mcs">
<div class="mp-mcs__label">{{ label }}</div>
<div class="mp-mcs__list">
<div
v-for="ch in channels"
:key="ch.kind"
class="mp-mcs__chip"
:class="`mp-mcs__chip--${statusKind(ch.status)}`"
>
<q-icon :name="iconOf(ch.kind)" class="mp-mcs__chip-icon" />
<span class="mp-mcs__chip-label">{{ kindLabel[ch.kind] }}</span>
<span class="mp-mcs__chip-status">{{ statusLabel[ch.status] }}</span>
<q-tooltip>
<div><strong>{{ kindLabel[ch.kind] }}</strong></div>
<div>Статус: {{ statusLabel[ch.status] }}</div>
<div v-if="ch.at">Время: {{ formatTime(ch.at) }}</div>
<div v-if="ch.error">Ошибка: {{ ch.error }}</div>
</q-tooltip>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { type PropType } from 'vue'
export type ChannelKind = 'push' | 'email' | 'sms'
export type ChannelStatus = 'sent' | 'delivered' | 'read' | 'failed' | 'pending' | 'disabled'
export interface ChannelStatusEntry {
kind: ChannelKind
status: ChannelStatus
at?: string | Date
error?: string
}
defineProps({
label: { type: String, default: 'Доставка уведомления' },
channels: { type: Array as PropType<ChannelStatusEntry[]>, required: true },
})
const kindLabel: Record<ChannelKind, string> = {
push: 'Push',
email: 'E-mail',
sms: 'SMS',
}
const statusLabel: Record<ChannelStatus, string> = {
sent: 'Отправлено',
delivered: 'Доставлено',
read: 'Прочитано',
failed: 'Ошибка',
pending: 'В очереди',
disabled: 'Отключено',
}
type ChipKind = 'ok' | 'warn' | 'fail' | 'idle'
function statusKind(s: ChannelStatus): ChipKind {
if (s === 'delivered' || s === 'read') return 'ok'
if (s === 'sent' || s === 'pending') return 'warn'
if (s === 'failed') return 'fail'
return 'idle'
}
function iconOf(k: ChannelKind): string {
return ({
push: 'fa-solid fa-bell',
email: 'fa-solid fa-envelope',
sms: 'fa-solid fa-message',
} as const)[k]
}
function formatTime(v: string | Date) {
const d = typeof v === 'string' ? new Date(v) : v
return d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
</script>
<style scoped lang="scss">
.mp-mcs {
&__label {
font-size: 12px;
color: var(--mp-on-surface-muted);
margin-bottom: var(--mp-space-sm);
}
&__list {
display: flex;
flex-wrap: wrap;
gap: var(--mp-space-md);
}
&__chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 999px;
background: var(--mp-surface-1);
border: 1px solid var(--mp-border-subtle);
font-size: 13px;
color: var(--mp-on-surface);
cursor: default;
position: relative;
// Цветовая точка-индикатор статуса (как у mp-status-chip)
&::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--mp-on-surface-muted);
flex-shrink: 0;
}
&--ok::before { background: var(--q-positive); }
&--warn::before { background: var(--q-warning); }
&--fail::before { background: var(--q-negative); }
&--idle::before { background: var(--mp-on-surface-muted); }
}
&__chip-icon {
color: var(--mp-on-surface-muted);
font-size: 14px;
}
&__chip-label {
font-weight: 500;
}
&__chip-status {
color: var(--mp-on-surface-muted);
font-size: 12px;
}
}
</style>
@@ -0,0 +1,2 @@
export { default as MultiChannelStatus } from './MultiChannelStatus.vue'
export type { ChannelKind, ChannelStatus, ChannelStatusEntry } from './MultiChannelStatus.vue'
@@ -0,0 +1,112 @@
<template>
<q-card flat bordered class="mp-onboarding-gate">
<q-card-section class="mp-onboarding-gate__header">
<q-icon name="fa-solid fa-handshake" size="32px" color="primary" />
<div>
<div class="text-h6">{{ title }}</div>
<div class="text-caption text-grey-7">{{ subtitle }}</div>
</div>
</q-card-section>
<q-separator />
<q-card-section>
<div class="text-body2 q-mb-md">{{ leadText }}</div>
<q-list>
<q-item v-for="d in documents" :key="d.id" tag="label" v-ripple>
<q-item-section avatar>
<q-checkbox v-model="accepted[d.id]" :disable="d.required && d.locked" />
</q-item-section>
<q-item-section>
<q-item-label>
<span v-if="d.required" class="mp-status-chip mp-status-chip--warning mp-onboarding-gate__required">
Обязательно
</span>
{{ d.title }}
</q-item-label>
<q-item-label caption v-if="d.description">{{ d.description }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-btn flat dense color="primary" icon="fa-solid fa-eye" @click.prevent="openDoc(d)" />
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-separator />
<q-card-actions align="right" class="q-pa-md">
<q-btn flat label="Отказаться" color="grey-7" @click="$emit('decline')" />
<q-btn
unelevated
color="primary"
:label="confirmLabel"
:disable="!allRequiredAccepted"
@click="$emit('accept', acceptedDocs)"
/>
</q-card-actions>
</q-card>
</template>
<script setup lang="ts">
import { computed, reactive, type PropType } from 'vue'
export interface CPPDocument {
id: string
title: string
description?: string
url?: string
required?: boolean
locked?: boolean
}
const props = defineProps({
title: { type: String, required: true },
subtitle: { type: String, default: '' },
leadText: { type: String, default: 'Ознакомьтесь с пакетом документов и подтвердите согласие.' },
documents: { type: Array as PropType<CPPDocument[]>, required: true },
confirmLabel: { type: String, default: 'Принять и продолжить' },
})
defineEmits<{
(e: 'accept', accepted: string[]): void
(e: 'decline'): void
}>()
const accepted = reactive<Record<string, boolean>>(
Object.fromEntries(props.documents.map((d) => [d.id, !!d.locked]))
)
const allRequiredAccepted = computed(() =>
props.documents.filter((d) => d.required).every((d) => accepted[d.id])
)
const acceptedDocs = computed(() =>
Object.entries(accepted).filter(([, v]) => v).map(([k]) => k)
)
function openDoc(d: CPPDocument) {
if (d.url) window.open(d.url, '_blank', 'noopener')
}
</script>
<style scoped lang="scss">
.mp-onboarding-gate {
max-width: 720px;
border-radius: var(--mp-radius-md);
border: 1px solid var(--mp-border-subtle);
box-shadow: none;
&__header {
display: flex;
gap: var(--mp-space-md);
align-items: center;
}
&__required {
margin-right: 6px;
vertical-align: 1px;
}
}
</style>
@@ -0,0 +1,2 @@
export { default as OnboardingCPPGate } from './OnboardingCPPGate.vue'
export type { CPPDocument } from './OnboardingCPPGate.vue'
@@ -0,0 +1,247 @@
<template>
<q-card flat bordered class="mp-order-card" :class="cardClasses">
<q-card-section class="mp-order-card__header">
<div class="row items-center q-gutter-sm">
<div class="text-subtitle1 mp-order-card__id"> {{ order.shortId ?? order.id }}</div>
<q-space />
<span class="mp-status-chip mp-order-card__status" :class="`mp-status-chip--${statusKind}`">
{{ statusLabel }}
</span>
</div>
<div class="mp-order-card__date">{{ formatDate(order.createdAt) }}</div>
</q-card-section>
<q-separator />
<q-card-section>
<div class="text-body1 q-mb-sm">{{ order.title }}</div>
<div class="mp-order-card__meta">
<div class="mp-order-card__meta-item">
<div class="mp-order-card__meta-label">Кол-во</div>
<div class="mp-order-card__meta-value">{{ order.units }} {{ order.unitLabel ?? 'ед.' }}</div>
</div>
<div class="mp-order-card__meta-item">
<div class="mp-order-card__meta-label">Сумма</div>
<div class="mp-order-card__meta-value">{{ formatPrice(order.totalCost) }}</div>
</div>
<div v-if="order.pvz" class="mp-order-card__meta-item mp-order-card__meta-item--wide">
<div class="mp-order-card__meta-label">ПВЗ</div>
<div class="mp-order-card__meta-value mp-order-card__meta-value--muted">{{ order.pvz }}</div>
</div>
</div>
</q-card-section>
<q-card-actions v-if="actionsForRole.length || $slots.actions" align="right" class="mp-order-card__actions">
<slot name="actions" :order="order" :role="role">
<q-btn
v-for="a in actionsForRole"
:key="a.key"
:flat="!isAccent(a)"
:unelevated="isAccent(a)"
:color="a.kind === 'danger' ? 'negative' : (isAccent(a) ? 'primary' : undefined)"
:label="a.label"
dense
no-caps
class="mp-order-card__btn"
@click="emit('action', { key: a.key, order })"
/>
</slot>
</q-card-actions>
</q-card>
</template>
<script setup lang="ts">
import { computed, type PropType } from 'vue'
export type OrderStatus =
| 'draft'
| 'placed'
| 'paid'
| 'in-delivery'
| 'arrived-at-pvz'
| 'ready-to-issue'
| 'issued'
| 'cancelled'
| 'dispute'
| 'returned'
export type OrderRole = 'orderer' | 'offerer' | 'operator' | 'admin'
export interface Order {
id: string | number
shortId?: string
title: string
units: number
unitLabel?: string
totalCost: number
status: OrderStatus
createdAt: string | Date
pvz?: string
}
interface OrderAction {
key: string
label: string
kind?: 'primary' | 'flat' | 'danger'
}
const props = defineProps({
order: { type: Object as PropType<Order>, required: true },
role: { type: String as PropType<OrderRole>, default: 'orderer' },
})
const emit = defineEmits<{
(e: 'action', payload: { key: string; order: Order }): void
}>()
type StatusKind = 'info' | 'success' | 'warning' | 'error' | 'neutral'
const STATUS_MAP: Record<OrderStatus, { label: string; kind: StatusKind }> = {
draft: { label: 'Черновик', kind: 'neutral' },
placed: { label: 'Размещён', kind: 'info' },
paid: { label: 'Оплачен', kind: 'success' },
'in-delivery': { label: 'В доставке', kind: 'warning' },
'arrived-at-pvz': { label: 'Прибыл в ПВЗ', kind: 'info' },
'ready-to-issue': { label: 'Готов к выдаче', kind: 'info' },
issued: { label: 'Выдан', kind: 'success' },
cancelled: { label: 'Отменён', kind: 'error' },
dispute: { label: 'Претензия', kind: 'error' },
returned: { label: 'Возвращён', kind: 'neutral' },
}
const statusLabel = computed(() => STATUS_MAP[props.order.status].label)
const statusKind = computed<StatusKind>(() => STATUS_MAP[props.order.status].kind)
const cardClasses = computed(() => `mp-order-card--${props.order.status}`)
// Per-role набор действий по умолчанию (slot actions перебивает).
const ACTIONS_PER_ROLE: Record<OrderRole, Record<OrderStatus, OrderAction[]>> = {
orderer: {
draft: [{ key: 'open', label: 'Открыть' }, { key: 'cancel', label: 'Удалить', kind: 'flat' }],
placed: [{ key: 'open', label: 'Открыть' }],
paid: [{ key: 'open', label: 'Открыть' }],
'in-delivery': [{ key: 'open', label: 'Открыть' }],
'arrived-at-pvz': [{ key: 'open', label: 'Подробнее', kind: 'primary' }],
'ready-to-issue': [{ key: 'open', label: 'Получить', kind: 'primary' }],
issued: [{ key: 'open', label: 'Открыть' }],
cancelled: [{ key: 'open', label: 'Открыть' }],
dispute: [{ key: 'open', label: 'Открыть' }],
returned: [{ key: 'open', label: 'Открыть' }],
},
offerer: {
draft: [], placed: [{ key: 'accept', label: 'Принять', kind: 'primary' }],
paid: [{ key: 'ship', label: 'Отгрузить', kind: 'primary' }],
'in-delivery': [], 'arrived-at-pvz': [], 'ready-to-issue': [],
issued: [], cancelled: [], dispute: [{ key: 'reply', label: 'Ответить', kind: 'primary' }],
returned: [],
},
operator: {
draft: [], placed: [], paid: [],
'in-delivery': [{ key: 'mark-arrived', label: 'Принять на ПВЗ', kind: 'primary' }],
'arrived-at-pvz': [{ key: 'issue', label: 'Выдать', kind: 'primary' }],
'ready-to-issue': [{ key: 'issue', label: 'Выдать', kind: 'primary' }],
issued: [], cancelled: [], dispute: [], returned: [{ key: 'process-return', label: 'Принять возврат', kind: 'primary' }],
},
admin: {
draft: [{ key: 'open', label: 'Открыть' }],
placed: [{ key: 'open', label: 'Открыть' }],
paid: [{ key: 'open', label: 'Открыть' }],
'in-delivery': [{ key: 'open', label: 'Открыть' }],
'arrived-at-pvz': [{ key: 'open', label: 'Открыть' }],
'ready-to-issue': [{ key: 'open', label: 'Открыть' }],
issued: [{ key: 'open', label: 'Открыть' }],
cancelled: [{ key: 'open', label: 'Открыть' }],
dispute: [{ key: 'open', label: 'Открыть' }, { key: 'arbitrate', label: 'Арбитраж', kind: 'primary' }],
returned: [{ key: 'open', label: 'Открыть' }],
},
}
const actionsForRole = computed<OrderAction[]>(
() => ACTIONS_PER_ROLE[props.role]?.[props.order.status] ?? []
)
// Акцентная кнопка = primary или danger. Всё остальное — flat second-level.
function isAccent(a: OrderAction): boolean {
return a.kind === 'primary' || a.kind === 'danger'
}
function formatDate(v: string | Date) {
const d = typeof v === 'string' ? new Date(v) : v
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
function formatPrice(v: number) {
return new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 0 }).format(v) + ' ₽'
}
</script>
<style scoped lang="scss">
.mp-order-card {
border-radius: var(--mp-radius-md);
border: 1px solid var(--mp-border-subtle);
box-shadow: none;
background: var(--mp-surface-0);
&__header { padding-bottom: var(--mp-space-sm); }
&__id { font-weight: 600; letter-spacing: -.01em; color: var(--mp-on-surface); }
&__date {
font-size: 12px;
color: var(--mp-on-surface-muted);
margin-top: 4px;
}
&__actions {
padding: var(--mp-space-sm) var(--mp-space-md) var(--mp-space-md);
gap: var(--mp-space-sm);
border-top: 1px solid var(--mp-border-subtle);
}
&__btn {
border-radius: var(--mp-radius-sm);
box-shadow: none !important;
min-height: 36px;
&:not(.q-btn--flat) {
letter-spacing: 0;
}
}
&__meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: var(--mp-space-md);
}
&__meta-item--wide { grid-column: span 2; }
&__meta-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--mp-on-surface-muted);
margin-bottom: 2px;
}
&__meta-value {
font-size: 15px;
color: var(--mp-on-surface);
&--muted { color: var(--mp-on-surface-muted); font-size: 13px; }
}
// На очень узких — meta стэк, заголовок и статус в одну колонку
@media (max-width: 480px) {
&__meta {
grid-template-columns: 1fr 1fr;
}
&__meta-item--wide { grid-column: 1 / -1; }
}
// На operator-POS — больше отступы и крупнее текст
.mp-role-operator & {
font-size: 1.05rem;
.mp-order-card__meta-value { font-size: 17px; }
}
}
</style>
@@ -0,0 +1,2 @@
export { default as OrderCard } from './OrderCard.vue'
export type { Order, OrderStatus, OrderRole } from './OrderCard.vue'
@@ -0,0 +1,191 @@
<template>
<div class="mp-ttn">
<div class="mp-ttn__toolbar no-print">
<q-btn unelevated color="primary" icon="fa-solid fa-print" label="Печать" @click="print" />
<q-space />
<span class="text-caption text-grey-7">Формат А5 (148×210 мм)</span>
</div>
<div ref="sheetRef" class="mp-ttn__sheet">
<header class="mp-ttn__header">
<div>
<div class="text-h6">ТТН {{ data.number }}</div>
<div class="text-caption">от {{ formatDate(data.date) }}</div>
</div>
<BarcodeDisplay :code="data.number" size="sm" />
</header>
<section class="mp-ttn__parties">
<div>
<div class="text-caption text-grey-7">Поставщик</div>
<div>{{ data.supplier }}</div>
</div>
<div>
<div class="text-caption text-grey-7">Получатель / ПВЗ</div>
<div>{{ data.recipient }}</div>
</div>
</section>
<table class="mp-ttn__items">
<thead>
<tr>
<th></th>
<th>SKU</th>
<th>Наименование</th>
<th>Кол-во</th>
<th>Ед.</th>
<th>Цена</th>
<th>Сумма</th>
</tr>
</thead>
<tbody>
<tr v-for="(i, idx) in data.items" :key="i.sku">
<td>{{ idx + 1 }}</td>
<td>{{ i.sku }}</td>
<td>{{ i.title }}</td>
<td class="text-right">{{ i.qty }}</td>
<td>{{ i.unit }}</td>
<td class="text-right">{{ formatPrice(i.price) }}</td>
<td class="text-right">{{ formatPrice(i.qty * i.price) }}</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="6" class="text-right">Итого:</td>
<td class="text-right"><strong>{{ formatPrice(total) }}</strong></td>
</tr>
</tfoot>
</table>
<footer class="mp-ttn__signatures">
<div>
<div class="text-caption text-grey-7">Отгрузил</div>
<div class="mp-ttn__sign-line" />
<div class="text-caption">{{ data.dispatchedBy }}</div>
</div>
<div>
<div class="text-caption text-grey-7">Принял</div>
<div class="mp-ttn__sign-line" />
<div class="text-caption">{{ data.acceptedBy ?? '____________________' }}</div>
</div>
</footer>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, type PropType } from 'vue'
import { BarcodeDisplay } from 'src/widgets/Marketplace/BarcodeDisplay'
export interface TTNItem {
sku: string
title: string
qty: number
unit: string
price: number
}
export interface TTNData {
number: string
date: string | Date
supplier: string
recipient: string
items: TTNItem[]
dispatchedBy: string
acceptedBy?: string
}
const props = defineProps({
data: { type: Object as PropType<TTNData>, required: true },
})
const sheetRef = ref<HTMLElement | null>(null)
const total = computed(() => props.data.items.reduce((acc, i) => acc + i.qty * i.price, 0))
function formatDate(v: string | Date) {
const d = typeof v === 'string' ? new Date(v) : v
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
function formatPrice(v: number) {
return new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(v) + ' ₽'
}
function print() {
window.print()
}
</script>
<style scoped lang="scss">
.mp-ttn {
display: flex;
flex-direction: column;
gap: var(--mp-space-md);
&__toolbar {
display: flex;
align-items: center;
}
&__sheet {
background: white;
color: #111;
width: 148mm;
min-height: 210mm;
padding: 10mm;
box-shadow: 0 1px 6px rgba(0, 0, 0, .12);
margin: 0 auto;
font-size: 11pt;
font-family: 'Times New Roman', serif;
}
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1px solid #111;
padding-bottom: 4mm;
margin-bottom: 4mm;
}
&__parties {
display: flex;
justify-content: space-between;
margin-bottom: 6mm;
}
&__items {
width: 100%;
border-collapse: collapse;
font-size: 10pt;
margin-bottom: 6mm;
th, td {
border: 1px solid #111;
padding: 2mm;
}
th { background: #f0f0f0; }
}
&__signatures {
display: flex;
justify-content: space-between;
margin-top: 10mm;
gap: var(--mp-space-xl);
> div { flex: 1; }
}
&__sign-line {
margin: 8mm 0 2mm;
border-bottom: 1px solid #111;
height: 0;
}
}
@media print {
.no-print { display: none !important; }
.mp-ttn__sheet { box-shadow: none; margin: 0; }
}
</style>
@@ -0,0 +1,2 @@
export { default as TTNPrintPreview } from './TTNPrintPreview.vue'
export type { TTNData, TTNItem } from './TTNPrintPreview.vue'
@@ -0,0 +1,175 @@
<template>
<q-dialog
v-model="open"
persistent
maximized
transition-show="slide-up"
transition-hide="slide-down"
>
<q-card class="mp-takeover" :class="`mp-takeover--${kind}`">
<div class="mp-takeover__bar">
<q-btn
flat
dense
round
icon="fa-solid fa-xmark"
class="mp-takeover__close"
aria-label="Закрыть"
@click="onCancel"
/>
<q-icon v-if="kindIcon" :name="kindIcon" :class="`mp-takeover__icon mp-takeover__icon--${kind}`" />
<div class="mp-takeover__title">{{ title }}</div>
<q-space />
</div>
<q-card-section class="mp-takeover__body">
<div v-if="leadText" class="mp-takeover__lead">{{ leadText }}</div>
<slot />
</q-card-section>
<q-separator />
<q-card-actions class="mp-takeover__actions" align="right">
<slot name="actions" :cancel="onCancel" :confirm="onConfirm">
<q-btn flat no-caps :label="cancelLabel" @click="onCancel" />
<q-btn
unelevated
no-caps
:color="confirmColor"
:label="confirmLabel"
:loading="loading"
:disable="disableConfirm"
class="mp-takeover__confirm"
@click="onConfirm"
/>
</slot>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { computed, type PropType } from 'vue'
export type TakeoverKind = 'info' | 'success' | 'warning' | 'danger'
const props = defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, required: true },
leadText: { type: String, default: '' },
kind: { type: String as PropType<TakeoverKind>, default: 'info' },
cancelLabel: { type: String, default: 'Отмена' },
confirmLabel: { type: String, default: 'Подтвердить' },
loading: { type: Boolean, default: false },
disableConfirm: { type: Boolean, default: false },
})
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'cancel'): void
(e: 'confirm'): void
}>()
const open = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v),
})
const KIND_MAP: Record<TakeoverKind, { icon: string; confirmColor: string }> = {
info: { icon: 'fa-solid fa-circle-info', confirmColor: 'primary' },
success: { icon: 'fa-solid fa-circle-check', confirmColor: 'positive' },
warning: { icon: 'fa-solid fa-triangle-exclamation', confirmColor: 'warning' },
danger: { icon: 'fa-solid fa-circle-exclamation', confirmColor: 'negative' },
}
const kindIcon = computed(() => KIND_MAP[props.kind].icon)
const confirmColor = computed(() => KIND_MAP[props.kind].confirmColor)
function onCancel() {
emit('cancel')
open.value = false
}
function onConfirm() {
emit('confirm')
}
</script>
<style scoped lang="scss">
.mp-takeover {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--mp-surface-0);
color: var(--mp-on-surface);
// Тонкая 4px-полоса акцента слева — единственный источник цвета в шапке
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 4px;
background: var(--q-primary);
}
&--success::before { background: var(--q-positive); }
&--warning::before { background: var(--q-warning); }
&--danger::before { background: var(--q-negative); }
&__bar {
display: flex;
align-items: center;
gap: var(--mp-space-md);
height: 64px;
padding: 0 var(--mp-space-lg);
border-bottom: 1px solid var(--mp-border-subtle);
}
&__close {
color: var(--mp-on-surface-muted);
}
&__icon {
font-size: 22px;
color: var(--q-primary);
&--success { color: var(--q-positive); }
&--warning { color: var(--q-warning); }
&--danger { color: var(--q-negative); }
}
&__title {
font-size: 17px;
font-weight: 600;
letter-spacing: -.01em;
color: var(--mp-on-surface);
}
&__body {
flex: 1;
overflow: auto;
padding: var(--mp-space-xl) var(--mp-space-lg);
max-width: 720px;
margin: 0 auto;
width: 100%;
}
&__lead {
font-size: 15px;
color: var(--mp-on-surface-muted);
margin-bottom: var(--mp-space-md);
}
&__actions {
padding: var(--mp-space-md) var(--mp-space-lg);
gap: var(--mp-space-sm);
}
&__confirm {
border-radius: var(--mp-radius-sm);
box-shadow: none !important;
}
}
</style>
@@ -0,0 +1,2 @@
export { default as TakeoverDialog } from './TakeoverDialog.vue'
export type { TakeoverKind } from './TakeoverDialog.vue'
@@ -0,0 +1,137 @@
<template>
<q-timeline class="mp-wallet-timeline" :layout="layout" color="primary">
<q-timeline-entry
v-for="e in entries"
:key="e.id"
:title="e.title"
:subtitle="formatDate(e.at)"
:icon="iconOf(e.kind)"
color="primary"
>
<div class="mp-wallet-timeline__row">
<div :class="['mp-wallet-timeline__amount', amountClass(e)]">
{{ amountSign(e) }}{{ formatAmount(e.amount) }}
</div>
<span class="mp-status-chip" :class="`mp-status-chip--${chipKindOf(e.kind)}`">
{{ kindLabel[e.kind] }}
</span>
<span v-if="e.orderId" class="mp-status-chip mp-status-chip--neutral">
{{ e.orderId }}
</span>
</div>
<div v-if="e.note" class="mp-wallet-timeline__note">{{ e.note }}</div>
</q-timeline-entry>
<q-timeline-entry v-if="!entries.length" subtitle="История пуста" title="Операций пока нет">
<div class="mp-wallet-timeline__empty">
Когда вы заказываете товар или получаете выплату записи появятся здесь.
</div>
</q-timeline-entry>
</q-timeline>
</template>
<script setup lang="ts">
import { type PropType } from 'vue'
export type WalletEntryKind = 'deposit' | 'block' | 'unblock' | 'charge' | 'refund' | 'payout'
export interface WalletEntry {
id: string | number
at: string | Date
kind: WalletEntryKind
amount: number
title: string
note?: string
orderId?: string | number
}
defineProps({
entries: { type: Array as PropType<WalletEntry[]>, required: true },
layout: { type: String as PropType<'comfortable' | 'dense' | 'loose'>, default: 'comfortable' },
})
const kindLabel: Record<WalletEntryKind, string> = {
deposit: 'Пополнение',
block: 'Блокировка',
unblock: 'Разблокировка',
charge: 'Списание',
refund: 'Возврат',
payout: 'Выплата',
}
type ChipKind = 'info' | 'success' | 'warning' | 'error' | 'neutral'
function chipKindOf(k: WalletEntryKind): ChipKind {
return ({
deposit: 'success',
block: 'warning',
unblock: 'neutral',
charge: 'neutral',
refund: 'success',
payout: 'info',
} as const)[k]
}
function iconOf(k: WalletEntryKind): string {
return ({
deposit: 'fa-solid fa-circle-down',
block: 'fa-solid fa-lock',
unblock: 'fa-solid fa-lock-open',
charge: 'fa-solid fa-circle-up',
refund: 'fa-solid fa-rotate-left',
payout: 'fa-solid fa-money-bill-transfer',
} as const)[k]
}
function amountSign(e: WalletEntry): string {
if (e.kind === 'deposit' || e.kind === 'refund' || e.kind === 'payout' || e.kind === 'unblock') return '+'
if (e.kind === 'charge' || e.kind === 'block') return ''
return ''
}
function amountClass(e: WalletEntry): string {
if (e.kind === 'deposit' || e.kind === 'refund' || e.kind === 'payout') return 'mp-wallet-timeline__amount--positive'
if (e.kind === 'charge') return 'mp-wallet-timeline__amount--negative'
return ''
}
function formatAmount(v: number): string {
return new Intl.NumberFormat('ru-RU').format(Math.abs(v))
}
function formatDate(v: string | Date): string {
const d = typeof v === 'string' ? new Date(v) : v
return d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
</script>
<style scoped lang="scss">
.mp-wallet-timeline {
&__row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--mp-space-sm);
}
&__amount {
font-size: 20px;
font-weight: 600;
letter-spacing: -.02em;
color: var(--mp-on-surface);
&--positive { color: var(--q-positive); }
&--negative { color: var(--q-negative); }
}
&__note {
font-size: 13px;
color: var(--mp-on-surface-muted);
margin-top: 4px;
}
&__empty {
color: var(--mp-on-surface-muted);
}
}
</style>
@@ -0,0 +1,2 @@
export { default as WalletTimeline } from './WalletTimeline.vue'
export type { WalletEntry, WalletEntryKind } from './WalletTimeline.vue'
@@ -0,0 +1,87 @@
<template>
<q-table
class="mp-warehouse-grid mp-admin-dense"
:rows="filteredRows"
:columns="columns"
row-key="sku"
dense
flat
bordered
:pagination="{ rowsPerPage: 50 }"
:rows-per-page-options="[25, 50, 100, 0]"
binary-state-sort
>
<template #top>
<div class="row q-gutter-md items-center full-width">
<div class="text-subtitle1">Сводный склад кооператива</div>
<q-space />
<q-input
v-model="filter"
outlined
dense
debounce="200"
placeholder="Поиск по SKU / названию"
style="min-width: 280px"
>
<template #append><q-icon name="search" /></template>
</q-input>
</div>
</template>
<template #body-cell-balance="props">
<q-td :props="props" :class="balanceClass(props.row.balance)">
<strong>{{ props.row.balance }}</strong>
</q-td>
</template>
</q-table>
</template>
<script setup lang="ts">
import { ref, computed, type PropType } from 'vue'
import type { QTableProps } from 'quasar'
export interface WarehouseRow {
sku: string
title: string
unit: string
incoming: number // приход на КУ
outgoing: number // расход (выдано / отгружено)
balance: number // остаток на КУ (in - out)
pvz?: string
}
const props = defineProps({
rows: { type: Array as PropType<WarehouseRow[]>, required: true },
})
const filter = ref('')
// КУ работает транзитом: пришло → ушло. Колонка «статус достаточности»
// удалена — это не торговая точка с минимальными остатками; transient-balance
// и так виден в колонке «Остаток».
const columns: QTableProps['columns'] = [
{ name: 'sku', label: 'SKU', field: 'sku', align: 'left', sortable: true },
{ name: 'title', label: 'Позиция', field: 'title', align: 'left', sortable: true },
{ name: 'pvz', label: 'ПВЗ', field: 'pvz', align: 'left' },
{ name: 'incoming', label: 'Приход', field: 'incoming', align: 'right', sortable: true },
{ name: 'outgoing', label: 'Расход', field: 'outgoing', align: 'right', sortable: true },
{ name: 'balance', label: 'Остаток', field: 'balance', align: 'right', sortable: true },
{ name: 'unit', label: 'Ед.', field: 'unit', align: 'left' },
]
// Подсветка остатка остаётся (визуально: 0 — приглушённо, >0 — нейтрально),
// но это не «статус достаточности».
function balanceClass(b: number) {
return b > 0 ? '' : 'text-grey-6'
}
const filteredRows = computed(() => {
const f = filter.value.trim().toLowerCase()
if (!f) return props.rows
return props.rows.filter((r) =>
r.sku.toLowerCase().includes(f) || r.title.toLowerCase().includes(f)
)
})
defineExpose({ filteredRows })
</script>
@@ -0,0 +1,2 @@
export { default as WarehouseSummaryGrid } from './WarehouseSummaryGrid.vue'
export type { WarehouseRow } from './WarehouseSummaryGrid.vue'