feat(marketplace): admin-стол «Пункты выдачи заказов» — подключение КУ как ПВЗ

Новая страница market-admin: соединяет список КУ (core getBranches) с их
ПВЗ-детализациями (marketplaceListKUDetails), председатель подключает участок
как пункт выдачи (адрес/контакты/режим работы → marketplaceDetailKU) и
управляет статусом/геокодингом. Диалог детализации переведён на канон
(BaseDialog/BaseButton + raw q-form с :rules), предзаполнение из карточки КУ,
правила времени/email вынесены в script (pug съедает backslash в inline-regex).
Статус-бейдж без dot (dot скрывал подпись), тултип с ошибкой геокода.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ant
2026-05-29 19:23:40 +00:00
parent d117d87a96
commit 0980c11db5
5 changed files with 592 additions and 73 deletions
@@ -14,6 +14,7 @@ import { OffererSupplyPreparationPage } from 'src/pages/Marketplace/OffererSuppl
import { OffererPaymentHistoryPage } from 'src/pages/Marketplace/OffererPaymentHistory'
import { AdminWriteoffsPage } from 'src/pages/Marketplace/AdminWriteoffs'
import { ChairmanModerationPage } from 'src/pages/Marketplace/ChairmanModeration'
import { AdminIssuancePointsPage } from 'src/pages/Marketplace/AdminIssuancePoints'
import { OperatorOwnWarehousePage } from 'src/pages/Marketplace/OperatorOwnWarehouse'
import { OperatorIncomingShipmentsPage } from 'src/pages/Marketplace/OperatorIncomingShipments'
import { AdminWarehouseSummaryPage } from 'src/pages/Marketplace/AdminWarehouseSummary'
@@ -508,6 +509,25 @@ export default async function (): Promise<IWorkspaceConfig[]> {
},
children: [],
},
{
// Эпик 2: admin-стол «Пункты выдачи заказов». Председатель
// подключает кооперативные участки (core) как ПВЗ Стола заказов
// (адрес/контакты/режим работы + геокодинг) и управляет их
// статусом. Видна совету и председателю (`Order:read:all`);
// управляющие действия внутри — только председателю (isChairman),
// что совпадает с бэкенд-авторизацией мутаций (chairman-only).
path: 'issuance-points',
name: 'marketplace-issuance-points',
component: markRaw(AdminIssuancePointsPage),
meta: {
title: 'Пункты выдачи заказов',
icon: 'pin_drop',
requires: 'Order:read:all',
requiresAuth: true,
agreements: agreementsBase,
},
children: [],
},
{
// Эпик 8 / Story 8.7: admin-стол списания скоропорта.
path: 'writeoffs',
@@ -1,90 +1,125 @@
<template lang="pug">
q-dialog(:model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" persistent)
q-card(style="min-width: 480px; max-width: 720px;")
q-card-section.row.items-center
.text-h6 Детализация ПВЗ {{ coreBraname }}
q-space
q-btn(flat round icon="close" @click="cancel")
q-card-section
q-form(@submit.prevent="submit" greedy)
q-input(
v-model="form.addressFull"
label="Полный адрес ПВЗ"
required
autogrow
:rules="[v => !!v || 'Адрес обязателен']"
aria-label="Полный адрес пункта выдачи"
)
BaseDialog(
:model-value="modelValue"
:title="`Детализация ПВЗ — ${coreBraname}`"
size="lg"
@update:model-value="emit('update:modelValue', $event)"
)
.detail-ku
.banner.banner--info
q-icon.banner__icon(name="info", size="18px")
.banner__body
| Укажите фактический адрес, контакты и режим работы пункта выдачи
| он может отличаться от юридического адреса участка. После сохранения
| адрес автоматически геокодируется для отображения на карте.
q-form.detail-ku__form(ref="formRef", greedy)
q-input(
v-model="form.addressFull"
outlined
dense
label="Фактический адрес ПВЗ"
autogrow
:rules="[v => !!(v && v.trim()) || 'Адрес обязателен']"
aria-label="Фактический адрес пункта выдачи"
)
.detail-ku__row
q-input(
v-model="form.contactPhone"
outlined
dense
label="Контактный телефон"
mask="+# (###) ###-##-##"
required
:rules="[v => !!v || 'Телефон обязателен']"
aria-label="Контактный телефон пункта выдачи"
)
q-input(
v-model="form.contactEmail"
outlined
dense
label="Контактный email"
type="email"
required
:rules="[v => /.+@.+\..+/.test(v) || 'Введите валидный email']"
lazy-rules
:rules="[emailRule]"
aria-label="Контактный email пункта выдачи"
)
q-input(
v-model="form.description"
label="Описание (опционально)"
type="textarea"
autogrow
aria-label="Описание пункта выдачи"
)
.text-subtitle2.q-mt-md Режим работы
.row.q-col-gutter-sm
.col-12.col-sm-6(v-for="day in days" :key="day.key")
q-card.q-pa-sm(flat bordered)
.row.items-center.q-mb-xs
q-toggle(
v-model="form.workingHours[day.key].enabled"
:label="day.label"
dense
)
.row.q-col-gutter-xs(v-if="form.workingHours[day.key].enabled")
.col
q-input(
v-model="form.workingHours[day.key].open"
label="Открытие"
mask="##:##"
placeholder="09:00"
dense
:rules="[v => /^\d{2}:\d{2}$/.test(v) || 'HH:mm']"
)
.col
q-input(
v-model="form.workingHours[day.key].close"
label="Закрытие"
mask="##:##"
placeholder="18:00"
dense
:rules="[v => /^\d{2}:\d{2}$/.test(v) || 'HH:mm']"
)
q-card-actions(align="right")
q-btn(flat label="Отмена" @click="cancel")
q-btn(color="primary" label="Сохранить" :loading="isSaving" @click="submit")
q-input(
v-model="form.description"
outlined
dense
label="Описание (опционально)"
type="textarea"
autogrow
aria-label="Описание пункта выдачи"
)
.detail-ku__section-title Режим работы
.detail-ku__days
.detail-ku__day(v-for="day in days", :key="day.key")
q-toggle(
v-model="form.workingHours[day.key].enabled"
:label="day.label"
dense
)
.detail-ku__day-times(v-if="form.workingHours[day.key].enabled")
q-input(
v-model="form.workingHours[day.key].open"
outlined
dense
label="Открытие"
mask="time"
placeholder="09:00"
lazy-rules
:rules="[timeRule]"
)
q-input(
v-model="form.workingHours[day.key].close"
outlined
dense
label="Закрытие"
mask="time"
placeholder="18:00"
lazy-rules
:rules="[timeRule]"
)
template(#footer)
BaseButton(variant="ghost", :disabled="isSaving", @click="cancel") Отмена
BaseButton(variant="primary", :loading="isSaving", @click="submit")
template(#icon-left)
q-icon(name="save", size="16px")
| Сохранить
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import type { QForm } from 'quasar'
import { FailAlert, SuccessAlert } from 'src/shared/api'
import { BaseButton, BaseDialog } from 'src/shared/ui/base'
import { useMarketplaceKUDetailsStore } from 'src/entities/MarketplaceKUDetails'
import type {
IMarketplaceKUDetails,
IWorkingHours,
} from 'src/entities/MarketplaceKUDetails'
import type { IBranch } from 'src/entities/Branch/model'
/**
* Детализация существующего в core кооперативного участка как ПВЗ Стола
* заказов (`marketplaceDetailKU`). Используется и для добавления нового ПВЗ
* (передаётся `branch` — поля адреса/контактов предзаполняются из карточки
* КУ стола совета), и для редактирования уже подключённого (`existing`).
*
* Поля с масками/валидацией остаются на сыром `q-form`/`q-input` — это
* одобренный канон форм проекта (см. CreateMarketplaceOfferPage); base-обёртки
* (BaseDialog/BaseButton) применяются к оболочке и действиям.
*/
const props = defineProps<{
modelValue: boolean
coopname: string
coreBraname: string
existing?: IMarketplaceKUDetails | null
branch?: IBranch | null
}>()
const emit = defineEmits<{
@@ -102,9 +137,24 @@ const days = [
{ key: 'sun' as const, label: 'Вс' },
]
type DayKey = (typeof days)[number]['key']
const isSaving = ref(false)
const formRef = ref<QForm | null>(null)
const store = useMarketplaceKUDetailsStore()
// Правила с регулярками держим в script, а не inline в pug-атрибуте:
// pug съедает обратные слэши в строковом значении :rules, и `\d`/`\.`
// деградируют в литералы `d`/`.`, из-за чего валидные значения ложно
// краснеют (канон форм — выносить такие правила в функции, ср. priceRule).
function emailRule(v: string): true | string {
return /.+@.+\..+/.test(v) || 'Введите валидный email'
}
function timeRule(v: string): true | string {
return /^([01]\d|2[0-3]):[0-5]\d$/.test(v) || 'Время в формате ЧЧ:ММ'
}
const form = reactive({
addressFull: '',
contactPhone: '',
@@ -112,24 +162,43 @@ const form = reactive({
description: '',
workingHours: Object.fromEntries(
days.map((d) => [d.key, { enabled: false, open: '09:00', close: '18:00' }])
) as Record<(typeof days)[number]['key'], { enabled: boolean; open: string; close: string }>,
) as Record<DayKey, { enabled: boolean; open: string; close: string }>,
})
watch(
() => props.existing,
(next) => {
if (next) {
form.addressFull = next.addressFull
form.contactPhone = next.contactPhone
form.contactEmail = next.contactEmail
form.description = next.description ?? ''
for (const d of days) {
const wh = next.workingHours[d.key]
form.workingHours[d.key] = wh
? { enabled: true, open: wh.open, close: wh.close }
: { enabled: false, open: '09:00', close: '18:00' }
}
function resetWorkingHours() {
for (const d of days) {
form.workingHours[d.key] = { enabled: false, open: '09:00', close: '18:00' }
}
}
// Перезаполнение при открытии: редактирование берёт значения из `existing`,
// добавление нового — из карточки КУ (фактический адрес → контакты).
function resetForm() {
const next = props.existing
if (next) {
form.addressFull = next.addressFull
form.contactPhone = next.contactPhone
form.contactEmail = next.contactEmail
form.description = next.description ?? ''
for (const d of days) {
const wh = next.workingHours[d.key]
form.workingHours[d.key] = wh
? { enabled: true, open: wh.open, close: wh.close }
: { enabled: false, open: '09:00', close: '18:00' }
}
} else {
form.addressFull = props.branch?.fact_address || props.branch?.full_address || ''
form.contactPhone = props.branch?.phone || ''
form.contactEmail = props.branch?.email || ''
form.description = ''
resetWorkingHours()
}
}
watch(
() => props.modelValue,
(open) => {
if (open) resetForm()
},
{ immediate: true }
)
@@ -148,6 +217,9 @@ function cancel() {
}
async function submit() {
const valid = await formRef.value?.validate()
if (!valid) return
isSaving.value = true
try {
const saved = await store.detailKU({
@@ -159,10 +231,67 @@ async function submit() {
description: form.description || undefined,
workingHours: buildWorkingHours(),
})
SuccessAlert('Пункт выдачи сохранён. Адрес геокодируется автоматически.')
emit('saved', saved)
emit('update:modelValue', false)
} catch (e) {
FailAlert(e, 'Не удалось сохранить пункт выдачи')
} finally {
isSaving.value = false
}
}
</script>
<style scoped lang="scss">
.detail-ku {
display: flex;
flex-direction: column;
gap: var(--p-4, 16px);
&__form {
display: flex;
flex-direction: column;
gap: var(--p-3, 12px);
}
&__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--p-3, 12px);
}
&__section-title {
margin-top: var(--p-2, 8px);
font-weight: 600;
color: var(--p-ink-2);
}
&__days {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--p-3, 12px);
}
&__day {
border: 1px solid var(--p-line);
border-radius: var(--p-r-md, 8px);
padding: var(--p-3, 12px);
display: flex;
flex-direction: column;
gap: var(--p-2, 8px);
}
&__day-times {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--p-2, 8px);
}
}
@media (max-width: 600px) {
.detail-ku__row,
.detail-ku__days {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1 @@
export * from './ui'
@@ -0,0 +1,368 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { FailAlert, SuccessAlert } from 'src/shared/api'
import { useDismissibleBanner } from 'src/shared/hooks'
import { useSessionStore } from 'src/entities/Session'
import { useBranchStore } from 'src/entities/Branch/model'
import type { IBranch } from 'src/entities/Branch/model'
import { useMarketplaceKUDetailsStore } from 'src/entities/MarketplaceKUDetails'
import type {
GeocodeStatus,
IMarketplaceKUDetails,
KuDetailsStatus,
} from 'src/entities/MarketplaceKUDetails'
import { BaseBadge, BaseButton, EmptyState, TableSkeleton } from 'src/shared/ui/base'
import type { BaseBadgeVariant, TableSkeletonColumn } from 'src/shared/ui/base'
import { MarketplaceDetailKUDialog } from 'src/features/MarketplaceDetailKU'
/**
* Эпик 2: admin-стол «Пункты выдачи заказов».
*
* Соединяет список ВСЕХ кооперативных участков кооператива (core `getBranches`,
* создаются на столе совета) с их marketplace-детализациями ПВЗ
* (`marketplaceListKUDetails`). Председатель видит, какие КУ уже подключены как
* пункты выдачи, и подключает новые: «Сделать ПВЗ» открывает диалог с
* предзаполнением адреса/контактов из карточки КУ → `marketplaceDetailKU`.
*
* Управляющие действия (подключить/изменить/геокодинг/активация) — только
* председателю; совет видит сеть ПВЗ в режиме чтения.
*/
const route = useRoute()
const session = useSessionStore()
const branchStore = useBranchStore()
const kuStore = useMarketplaceKUDetailsStore()
const coopname = computed(() => String(route.params.coopname ?? ''))
const isChairman = computed(() => session.isChairman ?? false)
const loading = ref(false)
const { dismissed: bannerDismissed, dismiss: dismissBanner } = useDismissibleBanner(
'mp:admin-pvz:banner-dismissed',
)
const dialogOpen = ref(false)
const dialogBranch = ref<IBranch | null>(null)
const dialogExisting = ref<IMarketplaceKUDetails | null>(null)
interface IssuancePointRow {
branch: IBranch
details: IMarketplaceKUDetails | null
}
// КУ + его ПВЗ-детализация (если подключён). КУ без детализации — кандидат
// на подключение; подключённые показываются со статусом и геокодом.
const rows = computed<IssuancePointRow[]>(() => {
const byBraname = new Map(kuStore.details.map((d) => [d.coreBraname, d]))
return branchStore.branches.map((branch) => ({
branch,
details: byBraname.get(branch.braname) ?? null,
}))
})
const connectedCount = computed(() => rows.value.filter((r) => r.details).length)
const STATUS_LABEL: Record<KuDetailsStatus, { label: string; variant: BaseBadgeVariant }> = {
ACTIVE: { label: 'Активен', variant: 'pos' },
INACTIVE: { label: 'Деактивирован', variant: 'neutral' },
}
const GEOCODE_LABEL: Record<GeocodeStatus, { label: string; variant: BaseBadgeVariant }> = {
OK: { label: 'Координаты есть', variant: 'pos' },
PENDING: { label: 'Определяются…', variant: 'warn' },
FAILED: { label: 'Ошибка геокода', variant: 'neg' },
}
function statusOf(row: IssuancePointRow): { label: string; variant: BaseBadgeVariant } {
if (!row.details) return { label: 'Не подключён', variant: 'neutral' }
return STATUS_LABEL[row.details.status]
}
function addressOf(row: IssuancePointRow): string {
if (row.details) return row.details.addressFull
return row.branch.fact_address || row.branch.full_address || '—'
}
const skeletonColumns: TableSkeletonColumn[] = [
{ label: 'Участок', cell: 'text' },
{ label: 'Город', cell: 'text', cellWidth: '120px' },
{ label: 'Адрес', cell: 'text' },
{ label: 'Статус', cell: 'badge' },
{ label: 'Геокод', cell: 'badge' },
{ label: 'Действия', class: 'col-action', cell: 'icon' },
]
async function load(): Promise<void> {
loading.value = true
try {
await Promise.all([
branchStore.loadBranches({ coopname: coopname.value }),
kuStore.load({ coopname: coopname.value, onlyActive: false }),
])
} catch (e) {
FailAlert(e, 'Не удалось загрузить участки и пункты выдачи')
} finally {
loading.value = false
}
}
function openAdd(branch: IBranch): void {
dialogBranch.value = branch
dialogExisting.value = null
dialogOpen.value = true
}
function openEdit(row: IssuancePointRow): void {
dialogBranch.value = row.branch
dialogExisting.value = row.details
dialogOpen.value = true
}
async function onSaved(): Promise<void> {
await load()
}
async function setStatus(row: IssuancePointRow, status: KuDetailsStatus): Promise<void> {
try {
await kuStore.setStatus({ coopname: coopname.value, coreBraname: row.branch.braname, status })
SuccessAlert(status === 'ACTIVE' ? 'Пункт выдачи активирован' : 'Пункт выдачи деактивирован')
} catch (e) {
FailAlert(e, 'Не удалось изменить статус пункта выдачи')
}
}
async function retryGeocode(row: IssuancePointRow): Promise<void> {
try {
await kuStore.retryGeocode(coopname.value, row.branch.braname)
SuccessAlert('Геокодинг адреса перезапущен')
} catch (e) {
FailAlert(e, 'Не удалось перезапустить геокодинг')
}
}
onMounted(() => {
void load()
})
</script>
<template lang="pug">
q-page.admin-pvz
.banner.banner--info(v-if='!bannerDismissed')
q-icon.banner__icon(name='info', size='18px')
.banner__body
| Пункты выдачи заказов это кооперативные участки, подключённые к Столу
| заказов. Участки создаются на столе совета; здесь председатель делает их
| пунктами выдачи, указывая фактический адрес, контакты и режим работы.
| Адрес геокодируется автоматически для карты.
BaseButton.admin-pvz__banner-close(
variant='ghost',
icon-only,
size='sm',
aria-label='Скрыть подсказку',
@click='dismissBanner'
)
template(#icon-left)
q-icon(name='close', size='16px')
.admin-pvz__toolbar
.admin-pvz__counter(v-if='!loading && rows.length')
| Подключено пунктов выдачи: {{ connectedCount }} из {{ rows.length }}
q-space
BaseButton(
variant='ghost',
icon-only,
aria-label='Обновить',
:loading='loading',
@click='load'
)
template(#icon-left)
q-icon(name='refresh', size='20px')
TableSkeleton(
v-if='loading && !rows.length',
:columns='skeletonColumns',
:rows='5',
min-width='980px'
)
.table-wrap(v-else-if='rows.length')
.table-scroll
table.table
thead
tr
th.col-ku Участок
th.col-city Город
th Адрес
th.col-status Статус
th.col-geo Геокод
th.col-action Действия
tbody
tr(v-for='row in rows', :key='row.branch.braname')
td.col-ku
.admin-pvz__ku-name {{ row.branch.short_name || row.branch.full_name || row.branch.braname }}
.admin-pvz__ku-acc {{ row.branch.braname }}
td.col-city {{ row.branch.city || '—' }}
td.admin-pvz__address {{ addressOf(row) }}
td.col-status
BaseBadge(:variant='statusOf(row).variant') {{ statusOf(row).label }}
td.col-geo
BaseBadge(
v-if='row.details',
:variant='GEOCODE_LABEL[row.details.geocodeStatus].variant'
)
| {{ GEOCODE_LABEL[row.details.geocodeStatus].label }}
q-tooltip(
v-if='row.details.geocodeStatus === "FAILED" && row.details.geocodeErrorMessage'
) {{ row.details.geocodeErrorMessage }}
span.admin-pvz__dash(v-else)
td.col-action
template(v-if='isChairman')
BaseButton(
v-if='!row.details',
variant='primary',
size='sm',
@click='openAdd(row.branch)'
)
template(#icon-left)
q-icon(name='add_location_alt', size='16px')
| Сделать ПВЗ
.admin-pvz__actions(v-else)
BaseButton(
variant='ghost',
icon-only,
size='sm',
aria-label='Изменить',
@click='openEdit(row)'
)
template(#icon-left)
q-icon(name='edit', size='18px')
BaseButton(
v-if='row.details.geocodeStatus !== "OK"',
variant='ghost',
icon-only,
size='sm',
aria-label='Перезапустить геокодинг',
@click='retryGeocode(row)'
)
template(#icon-left)
q-icon(name='my_location', size='18px')
BaseButton(
v-if='row.details.status === "ACTIVE"',
variant='ghost',
icon-only,
size='sm',
aria-label='Деактивировать',
@click='setStatus(row, "INACTIVE")'
)
template(#icon-left)
q-icon(name='block', size='18px')
BaseButton(
v-else,
variant='ghost',
icon-only,
size='sm',
aria-label='Активировать',
@click='setStatus(row, "ACTIVE")'
)
template(#icon-left)
q-icon(name='check_circle', size='18px')
span.admin-pvz__dash(v-else)
EmptyState(
v-else,
title='Кооперативных участков нет',
body='Создайте кооперативный участок на столе совета — после этого его можно будет подключить как пункт выдачи заказов.'
)
template(#icon)
q-icon(name='pin_drop', size='48px')
MarketplaceDetailKUDialog(
v-if='dialogBranch',
v-model='dialogOpen',
:coopname='coopname',
:core-braname='dialogBranch.braname',
:existing='dialogExisting',
:branch='dialogBranch',
@saved='onSaved'
)
</template>
<style scoped lang="scss">
.admin-pvz {
padding: var(--p-6, 24px);
display: flex;
flex-direction: column;
gap: var(--p-4, 16px);
&__banner-close {
flex-shrink: 0;
align-self: flex-start;
margin: -4px -4px 0 0;
}
&__toolbar {
display: flex;
align-items: center;
gap: var(--p-3, 12px);
}
&__counter {
color: var(--p-ink-3);
font-size: 0.875rem;
}
&__ku-name {
font-weight: 600;
}
&__ku-acc {
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--p-ink-3);
}
&__address {
color: var(--p-ink-2);
}
&__actions {
display: flex;
justify-content: flex-end;
gap: var(--p-1, 4px);
}
&__dash {
color: var(--p-ink-3);
}
}
.table-scroll {
overflow-x: auto;
}
.table {
table-layout: fixed;
min-width: 980px;
}
.col-ku {
width: 240px;
}
.col-city {
width: 140px;
}
.col-status {
width: 160px;
}
.col-geo {
width: 170px;
}
.col-action {
width: 200px;
text-align: right;
}
@media (max-width: 768px) {
.admin-pvz {
padding: var(--p-4, 16px);
}
}
</style>
@@ -0,0 +1 @@
export { default as AdminIssuancePointsPage } from './AdminIssuancePointsPage.vue';