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:
@@ -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).
|
||||
+74
@@ -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'
|
||||
+38
@@ -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>
|
||||
+37
@@ -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>
|
||||
+110
@@ -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>
|
||||
+32
@@ -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>
|
||||
+70
@@ -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>
|
||||
+77
@@ -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>
|
||||
+58
@@ -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>
|
||||
+71
@@ -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>
|
||||
+68
@@ -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>
|
||||
+59
@@ -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>
|
||||
+52
@@ -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>
|
||||
+31
@@ -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>
|
||||
+103
@@ -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>
|
||||
+60
@@ -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>
|
||||
+27
@@ -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'
|
||||
+231
@@ -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'
|
||||
+87
@@ -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'
|
||||
Reference in New Issue
Block a user