refactor(frontend): Sprint 3 Phase C — Top-3 Vue components split (audit O-refactor-04)
Sprint 3 Phase C. Закрытие audit O-refactor-04 (частичное — Top-3 из 12): - DealsView.vue: 852 → 560 строк. Выделены: DealsFilters (123), DealsBulkBar (150), DealsTable (165). CSV-utilities (csvEscape/triggerCsvDownload/triggerBlobDownload/ buildCsvString) вынесены в composables/useCsvDownload.ts. - ReportsView.vue: 592 → 261 строк. Выделены: ReportRequestForm (тип отчёта, даты, фильтры, формат, submit/reset), ReportJobsList (список заданий со статусами и actions retry/cancel/delete). - DealDetailDrawer.vue: 580 → 386 строк. Выделены: DealDetailHero (header + phone-link + status-chip), DealDetailTimeline (activity log с MOCK_EVENTS). Comment- и Reminders-секции оставлены inline — связаны с API и defineExpose. DealsView и DealDetailDrawer остались выше 350-целевого уровня: bulk-action функции (applyBulkStatus/applyBulkDelete/applyBulkExport/undoBulkDelete/ applyBulkRestoreFromTrash) и comment/reminders fetch — экспонируются через defineExpose в Vitest-тестах напрямую, дальнейшая декомпозиция требует изменения тест-контракта (отдельным flow). Layout-структуры (AppLayout 466, AuthLayout, AppShell) НЕ ТРОНУТЫ — R0.6 hard-стоп. Остальные 9 components >300 строк (AdminTenantDetailView, BillingView, AdminTenantsView, SecurityTab, RemindersView, ErrorView, DashboardView, ImpersonationDialog, далее) — вне scope Sprint 3, отдельным flow по запросу. vue-tsc: 0 errors. ESLint: 0. Vitest: 416/416 PASS. Build: success (1.15s). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,12 +20,14 @@
|
||||
*/
|
||||
import { computed, defineAsyncComponent, ref, watch } from 'vue';
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import { type DealEvent, MOCK_EVENTS, eventTypeIcon, eventTypeLabel } from '../../composables/mockDealEvents';
|
||||
import { type DealEvent, MOCK_EVENTS } from '../../composables/mockDealEvents';
|
||||
import { mapApiDealEvent } from '../../composables/dealsApiMapper';
|
||||
import * as dealsApi from '../../api/deals';
|
||||
import * as remindersApi from '../../api/reminders';
|
||||
import type { ApiReminder } from '../../api/reminders';
|
||||
import { useLeadStatusesStore } from '../../stores/leadStatuses';
|
||||
import DealDetailHero from './DealDetailHero.vue';
|
||||
import DealDetailTimeline from './DealDetailTimeline.vue';
|
||||
// Sprint 2 Phase B / O-perf-06: ReminderDialog гейтится через v-model — chunk-split.
|
||||
const ReminderDialog = defineAsyncComponent(() => import('../reminders/ReminderDialog.vue'));
|
||||
|
||||
@@ -53,12 +55,6 @@ function formatCost(cost: number): string {
|
||||
return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
|
||||
}
|
||||
|
||||
function formatRelative(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes} мин назад`;
|
||||
if (minutes < 60 * 24) return `${Math.floor(minutes / 60)} ч назад`;
|
||||
return `${Math.floor(minutes / (60 * 24))} д назад`;
|
||||
}
|
||||
|
||||
// Activity timeline: при наличии tenant_id делаем GET /api/deals/{id} и
|
||||
// показываем реальные events. На fail / без tenant_id — fallback на MOCK_EVENTS.
|
||||
const events = ref<DealEvent[]>([...MOCK_EVENTS]);
|
||||
@@ -190,38 +186,7 @@ defineExpose({
|
||||
<template>
|
||||
<v-navigation-drawer v-model="drawerOpen" location="right" temporary :width="480" class="deal-drawer">
|
||||
<div v-if="deal" class="drawer-content">
|
||||
<header class="hero pa-5">
|
||||
<div class="hero-eyebrow text-caption text-medium-emphasis">Сделка #{{ deal.id }}</div>
|
||||
<div class="hero-row mt-1">
|
||||
<h2 class="hero-name text-h5">{{ deal.name }}</h2>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
size="small"
|
||||
aria-label="Закрыть панель"
|
||||
@click="drawerOpen = false"
|
||||
/>
|
||||
</div>
|
||||
<div class="hero-meta mt-2">
|
||||
<a :href="`tel:${deal.phone.replace(/[^+\d]/g, '')}`" class="phone-link">{{ deal.phone }}</a>
|
||||
<span class="sep">·</span>
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
<v-icon size="14" class="mr-1">mdi-clock-outline</v-icon>
|
||||
{{ formatRelative(28) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="status" class="status-row mt-3">
|
||||
<v-chip
|
||||
size="small"
|
||||
variant="tonal"
|
||||
:style="{ color: status.colorHex, borderColor: status.colorHex }"
|
||||
>
|
||||
<span class="status-dot" :style="{ background: status.colorHex }" />
|
||||
{{ status.nameRu }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</header>
|
||||
<DealDetailHero :deal="deal" :status="status" @close="drawerOpen = false" />
|
||||
|
||||
<v-divider />
|
||||
|
||||
@@ -321,44 +286,7 @@ defineExpose({
|
||||
|
||||
<v-divider v-if="tenantId && deal" />
|
||||
|
||||
<section class="section pa-5">
|
||||
<h3 class="section-title text-subtitle-2 mb-3">Активность</h3>
|
||||
<v-alert
|
||||
v-if="eventsFetchError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
closable
|
||||
class="mb-3"
|
||||
data-testid="events-fetch-error-alert"
|
||||
>
|
||||
Backend недоступен — показаны mock-события.
|
||||
</v-alert>
|
||||
<ul class="timeline">
|
||||
<li v-for="event in events" :key="event.id" class="timeline-item">
|
||||
<div class="timeline-icon">
|
||||
<v-icon size="16">{{ eventTypeIcon(event.type) }}</v-icon>
|
||||
</div>
|
||||
<div class="timeline-body">
|
||||
<div class="timeline-head">
|
||||
<span class="timeline-type text-caption text-medium-emphasis">
|
||||
{{ eventTypeLabel(event.type) }}
|
||||
</span>
|
||||
<span class="timeline-time text-caption text-medium-emphasis">
|
||||
{{ formatRelative(event.minutesAgo) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="timeline-detail text-body-2">{{ event.detail }}</div>
|
||||
<div v-if="event.actor" class="timeline-actor text-caption text-medium-emphasis">
|
||||
<v-avatar size="16" color="secondary" class="mr-1">
|
||||
<span class="text-caption">{{ event.actor.initials }}</span>
|
||||
</v-avatar>
|
||||
{{ event.actor.name }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<DealDetailTimeline :events="events" :events-fetch-error="eventsFetchError" />
|
||||
|
||||
<v-snackbar
|
||||
v-model="commentToastOpen"
|
||||
@@ -390,57 +318,6 @@ defineExpose({
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero-eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.hero-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hero-name {
|
||||
font-variation-settings: 'opsz' 24;
|
||||
letter-spacing: -0.018em;
|
||||
line-height: 1.2;
|
||||
color: #081319;
|
||||
}
|
||||
|
||||
.hero-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.phone-link {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
color: #0f6e56;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
.phone-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.hero-meta .sep {
|
||||
color: #92907b;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
}
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
color: #081319;
|
||||
@@ -473,78 +350,6 @@ defineExpose({
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
|
||||
.timeline {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
}
|
||||
.timeline-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 28px;
|
||||
bottom: -16px;
|
||||
width: 1px;
|
||||
background: #e8e3d6;
|
||||
}
|
||||
.timeline-item:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline-icon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #e1eeea;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #0f6e56;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.timeline-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.timeline-type {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 11px;
|
||||
}
|
||||
.timeline-time {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
.timeline-detail {
|
||||
color: #081319;
|
||||
margin-top: 2px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.timeline-actor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.reminders-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Hero-секция детального drawer'а сделки (Sprint 3 Phase C — extraction).
|
||||
*
|
||||
* Header с #id, именем, кнопкой закрытия, телефоном (tel: ссылка), относительным
|
||||
* временем и status-chip'ом.
|
||||
*/
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import type { LeadStatus } from '../../composables/leadStatuses';
|
||||
|
||||
defineProps<{
|
||||
deal: MockDeal;
|
||||
status: LeadStatus | null;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
function formatRelative(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes} мин назад`;
|
||||
if (minutes < 60 * 24) return `${Math.floor(minutes / 60)} ч назад`;
|
||||
return `${Math.floor(minutes / (60 * 24))} д назад`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="hero pa-5">
|
||||
<div class="hero-eyebrow text-caption text-medium-emphasis">Сделка #{{ deal.id }}</div>
|
||||
<div class="hero-row mt-1">
|
||||
<h2 class="hero-name text-h5">{{ deal.name }}</h2>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
size="small"
|
||||
aria-label="Закрыть панель"
|
||||
@click="$emit('close')"
|
||||
/>
|
||||
</div>
|
||||
<div class="hero-meta mt-2">
|
||||
<a :href="`tel:${deal.phone.replace(/[^+\d]/g, '')}`" class="phone-link">{{ deal.phone }}</a>
|
||||
<span class="sep">·</span>
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
<v-icon size="14" class="mr-1">mdi-clock-outline</v-icon>
|
||||
{{ formatRelative(28) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="status" class="status-row mt-3">
|
||||
<v-chip
|
||||
size="small"
|
||||
variant="tonal"
|
||||
:style="{ color: status.colorHex, borderColor: status.colorHex }"
|
||||
>
|
||||
<span class="status-dot" :style="{ background: status.colorHex }" />
|
||||
{{ status.nameRu }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero-eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.hero-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hero-name {
|
||||
font-variation-settings: 'opsz' 24;
|
||||
letter-spacing: -0.018em;
|
||||
line-height: 1.2;
|
||||
color: #081319;
|
||||
}
|
||||
|
||||
.hero-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.phone-link {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
color: #0f6e56;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
.phone-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.hero-meta .sep {
|
||||
color: #92907b;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
}
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Activity timeline секция детального drawer'а (Sprint 3 Phase C — extraction).
|
||||
*
|
||||
* Список событий сделки (call, status_change, comment, и т.д.) с иконкой,
|
||||
* типом, временем, детализацией и actor'ом. Banner при fetch-error.
|
||||
*/
|
||||
import { type DealEvent, eventTypeIcon, eventTypeLabel } from '../../composables/mockDealEvents';
|
||||
|
||||
defineProps<{
|
||||
events: DealEvent[];
|
||||
eventsFetchError: boolean;
|
||||
}>();
|
||||
|
||||
function formatRelative(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes} мин назад`;
|
||||
if (minutes < 60 * 24) return `${Math.floor(minutes / 60)} ч назад`;
|
||||
return `${Math.floor(minutes / (60 * 24))} д назад`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="section pa-5">
|
||||
<h3 class="section-title text-subtitle-2 mb-3">Активность</h3>
|
||||
<v-alert
|
||||
v-if="eventsFetchError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
closable
|
||||
class="mb-3"
|
||||
data-testid="events-fetch-error-alert"
|
||||
>
|
||||
Backend недоступен — показаны mock-события.
|
||||
</v-alert>
|
||||
<ul class="timeline">
|
||||
<li v-for="event in events" :key="event.id" class="timeline-item">
|
||||
<div class="timeline-icon">
|
||||
<v-icon size="16">{{ eventTypeIcon(event.type) }}</v-icon>
|
||||
</div>
|
||||
<div class="timeline-body">
|
||||
<div class="timeline-head">
|
||||
<span class="timeline-type text-caption text-medium-emphasis">
|
||||
{{ eventTypeLabel(event.type) }}
|
||||
</span>
|
||||
<span class="timeline-time text-caption text-medium-emphasis">
|
||||
{{ formatRelative(event.minutesAgo) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="timeline-detail text-body-2">{{ event.detail }}</div>
|
||||
<div v-if="event.actor" class="timeline-actor text-caption text-medium-emphasis">
|
||||
<v-avatar size="16" color="secondary" class="mr-1">
|
||||
<span class="text-caption">{{ event.actor.initials }}</span>
|
||||
</v-avatar>
|
||||
{{ event.actor.name }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
color: #081319;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
}
|
||||
.timeline-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 28px;
|
||||
bottom: -16px;
|
||||
width: 1px;
|
||||
background: #e8e3d6;
|
||||
}
|
||||
.timeline-item:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline-icon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #e1eeea;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #0f6e56;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.timeline-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.timeline-type {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 11px;
|
||||
}
|
||||
.timeline-time {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
.timeline-detail {
|
||||
color: #081319;
|
||||
margin-top: 2px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.timeline-actor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Sticky-bar bulk-actions для выбранных сделок (Sprint 3 Phase C).
|
||||
*
|
||||
* Показывается когда selectedCount > 0. В trash-mode — только кнопка
|
||||
* «Восстановить»; в обычном режиме — Сменить статус (menu со списком),
|
||||
* Экспорт, Удалить.
|
||||
*
|
||||
* Контракт: stateless presentation — родитель держит `selected`, `statusMenuOpen`,
|
||||
* `leadStatuses`, передаёт через props и слушает emit'ы.
|
||||
*/
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import type { LeadStatus } from '../../composables/leadStatuses';
|
||||
|
||||
defineProps<{
|
||||
selectedCount: number;
|
||||
trashMode: boolean;
|
||||
statusMenuOpen: boolean;
|
||||
leadStatuses: LeadStatus[];
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
'update:statusMenuOpen': [value: boolean];
|
||||
'apply-status': [slug: MockDeal['statusSlug']];
|
||||
'apply-export': [];
|
||||
'request-delete': [];
|
||||
'apply-restore-trash': [];
|
||||
'clear-selected': [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card
|
||||
v-if="selectedCount > 0"
|
||||
class="bulk-bar mt-3"
|
||||
color="secondary"
|
||||
theme="dark"
|
||||
variant="flat"
|
||||
data-testid="bulk-bar"
|
||||
>
|
||||
<div class="bulk-bar-inner">
|
||||
<span class="bulk-count">
|
||||
Выбрано <span class="num">{{ selectedCount }}</span>
|
||||
</span>
|
||||
<v-spacer />
|
||||
<!-- В trash-mode только Восстановить; в обычном режиме — полный набор. -->
|
||||
<v-btn
|
||||
v-if="trashMode"
|
||||
variant="tonal"
|
||||
color="success"
|
||||
size="small"
|
||||
prepend-icon="mdi-restore"
|
||||
data-testid="bulk-restore-trash-btn"
|
||||
@click="$emit('apply-restore-trash')"
|
||||
>
|
||||
Восстановить
|
||||
</v-btn>
|
||||
<template v-if="!trashMode">
|
||||
<v-menu
|
||||
:model-value="statusMenuOpen"
|
||||
:close-on-content-click="false"
|
||||
@update:model-value="(v: boolean) => $emit('update:statusMenuOpen', v)"
|
||||
>
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
prepend-icon="mdi-tag-arrow-right"
|
||||
data-testid="bulk-status-btn"
|
||||
>
|
||||
Сменить статус
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact" max-height="320" min-width="240">
|
||||
<v-list-item
|
||||
v-for="s in leadStatuses"
|
||||
:key="s.slug"
|
||||
:data-testid="`bulk-status-item-${s.slug}`"
|
||||
@click="$emit('apply-status', s.slug)"
|
||||
>
|
||||
<template #prepend>
|
||||
<span class="status-dot" :style="{ background: s.colorHex }" />
|
||||
</template>
|
||||
<v-list-item-title>{{ s.nameRu }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
size="small"
|
||||
prepend-icon="mdi-download"
|
||||
data-testid="bulk-export-btn"
|
||||
@click="$emit('apply-export')"
|
||||
>
|
||||
Экспорт
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
color="error"
|
||||
size="small"
|
||||
prepend-icon="mdi-trash-can-outline"
|
||||
data-testid="bulk-delete-btn"
|
||||
@click="$emit('request-delete')"
|
||||
>
|
||||
Удалить
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
size="small"
|
||||
data-testid="bulk-clear-btn"
|
||||
@click="$emit('clear-selected')"
|
||||
/>
|
||||
</div>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.bulk-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 4;
|
||||
}
|
||||
.bulk-bar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.bulk-count {
|
||||
color: #f6f3ec;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Filter-bar для DealsView (Sprint 3 Phase C):
|
||||
* - btn-toggle с DEALS_TABS (active/all/...) + chip-counts
|
||||
* - search input (имя/телефон/проект)
|
||||
* - multi-select Проект и Менеджер
|
||||
* - кнопка «Сбросить фильтры» (если хоть один из multi-select заполнен)
|
||||
*
|
||||
* Состояние держится в родителе через v-model:* (двунаправленные связки).
|
||||
*/
|
||||
import { DEALS_TABS } from '../../composables/mockDeals';
|
||||
|
||||
defineProps<{
|
||||
activeTab: (typeof DEALS_TABS)[number]['id'];
|
||||
searchQuery: string;
|
||||
filterProjects: string[];
|
||||
filterManagers: string[];
|
||||
availableProjects: string[];
|
||||
availableManagers: { name: string; initials: string }[];
|
||||
counts: Record<string, number>;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
'update:activeTab': [value: (typeof DEALS_TABS)[number]['id']];
|
||||
'update:searchQuery': [value: string];
|
||||
'update:filterProjects': [value: string[]];
|
||||
'update:filterManagers': [value: string[]];
|
||||
'clear-filters': [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter-bar mt-4">
|
||||
<v-btn-toggle
|
||||
:model-value="activeTab"
|
||||
mandatory
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
@update:model-value="(v: (typeof DEALS_TABS)[number]['id']) => $emit('update:activeTab', v)"
|
||||
>
|
||||
<v-btn v-for="tab in DEALS_TABS" :key="tab.id" :value="tab.id" size="small">
|
||||
{{ tab.label }}
|
||||
<v-chip size="x-small" class="ml-2 chip-count" variant="tonal">
|
||||
{{ counts[tab.id] }}
|
||||
</v-chip>
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<v-text-field
|
||||
:model-value="searchQuery"
|
||||
placeholder="Поиск: имя, телефон, проект…"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
class="search-input ml-4"
|
||||
@update:model-value="(v: string) => $emit('update:searchQuery', v ?? '')"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
:model-value="filterProjects"
|
||||
:items="availableProjects"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
clearable
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
label="Проект"
|
||||
style="min-width: 180px; max-width: 260px"
|
||||
data-testid="filter-projects"
|
||||
@update:model-value="(v: string[]) => $emit('update:filterProjects', v ?? [])"
|
||||
/>
|
||||
<v-select
|
||||
:model-value="filterManagers"
|
||||
:items="availableManagers"
|
||||
item-title="name"
|
||||
item-value="name"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
clearable
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
label="Менеджер"
|
||||
style="min-width: 180px; max-width: 260px"
|
||||
data-testid="filter-managers"
|
||||
@update:model-value="(v: string[]) => $emit('update:filterManagers', v ?? [])"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="filterProjects.length > 0 || filterManagers.length > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
prepend-icon="mdi-filter-off"
|
||||
data-testid="clear-filters-btn"
|
||||
@click="$emit('clear-filters')"
|
||||
>
|
||||
Сбросить фильтры
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1 1 320px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.chip-count {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Таблица сделок (Sprint 3 Phase C — extraction из DealsView).
|
||||
*
|
||||
* Логически замкнутый блок: v-data-table со всеми типизированными слотами
|
||||
* (Vuetify 3.12 VDataTableSlots, Sprint 2 Phase B / O-stack-05).
|
||||
*
|
||||
* Контракт:
|
||||
* props:
|
||||
* - deals: MockDeal[] — отфильтрованный список (computed в родителе).
|
||||
* - selectedIds: number[] — v-model:selected (двунаправленно).
|
||||
* - statusBySlug: Map<string, LeadStatus> — для status-chip color/label.
|
||||
* emits:
|
||||
* - update:selectedIds — sync v-model selected с родителем.
|
||||
* - row-click(deal) — раскрыть drawer.
|
||||
*/
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import type { LeadStatus } from '../../composables/leadStatuses';
|
||||
|
||||
defineProps<{
|
||||
deals: MockDeal[];
|
||||
selectedIds: number[];
|
||||
statusBySlug: Map<string, LeadStatus>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedIds': [value: number[]];
|
||||
'row-click': [deal: MockDeal];
|
||||
}>();
|
||||
|
||||
function onSelectedUpdate(value: number[]) {
|
||||
emit('update:selectedIds', value);
|
||||
}
|
||||
|
||||
function formatRelative(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes} мин назад`;
|
||||
if (minutes < 60 * 24) return `${Math.floor(minutes / 60)} ч назад`;
|
||||
return `${Math.floor(minutes / (60 * 24))} д назад`;
|
||||
}
|
||||
|
||||
function formatCost(cost: number): string {
|
||||
return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card variant="outlined" class="deals-table-card">
|
||||
<v-data-table
|
||||
:model-value="selectedIds"
|
||||
:items="deals"
|
||||
:headers="[
|
||||
{ title: 'Лид', key: 'name', sortable: true },
|
||||
{ title: 'Статус', key: 'statusSlug', sortable: false },
|
||||
{ title: 'Проект', key: 'project', sortable: false },
|
||||
{ title: 'Менеджер', key: 'manager', sortable: false },
|
||||
{ title: 'Стоимость', key: 'cost', align: 'end', sortable: true },
|
||||
{ title: 'Время', key: 'receivedMinutesAgo', align: 'end', sortable: true },
|
||||
]"
|
||||
show-select
|
||||
item-value="id"
|
||||
items-per-page="-1"
|
||||
hide-default-footer
|
||||
hover
|
||||
density="comfortable"
|
||||
@update:model-value="onSelectedUpdate"
|
||||
@click:row="(_e: Event, { item }: { item: MockDeal }) => emit('row-click', item)"
|
||||
>
|
||||
<!--
|
||||
Vuetify 3.12 типизированные слоты VDataTable (Sprint 2 Phase B / O-stack-05).
|
||||
`:items="deals"` (MockDeal[]) → Vuetify через VDataTableSlots<ItemType<T>>
|
||||
выводит `item` как `MockDeal` автоматически. Дополнительная inline-аннотация
|
||||
`{ item }: { item: MockDeal }` фиксирует этот контракт явно — IDE и vue-tsc
|
||||
проверяют доступ к полям статически.
|
||||
-->
|
||||
<template #[`item.name`]="{ item }: { item: MockDeal }">
|
||||
<div class="cell-deal">
|
||||
<v-avatar size="32" color="primary" class="mr-3">
|
||||
<span class="text-caption font-weight-medium">{{
|
||||
item.name
|
||||
.split(' ')
|
||||
.map((p: string) => p[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
}}</span>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="deal-name">{{ item.name }}</div>
|
||||
<div class="deal-phone text-caption text-medium-emphasis">{{ item.phone }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #[`item.statusSlug`]="{ item }: { item: MockDeal }">
|
||||
<v-chip
|
||||
size="small"
|
||||
variant="tonal"
|
||||
:style="{
|
||||
color: statusBySlug.get(item.statusSlug)?.colorHex,
|
||||
borderColor: statusBySlug.get(item.statusSlug)?.colorHex,
|
||||
}"
|
||||
>
|
||||
<span class="status-dot" :style="{ background: statusBySlug.get(item.statusSlug)?.colorHex }" />
|
||||
{{ statusBySlug.get(item.statusSlug)?.nameRu }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #[`item.manager`]="{ item }: { item: MockDeal }">
|
||||
<div class="cell-manager">
|
||||
<v-avatar size="22" color="secondary" class="mr-2">
|
||||
<span class="text-caption">{{ item.manager.initials }}</span>
|
||||
</v-avatar>
|
||||
{{ item.manager.name }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #[`item.cost`]="{ item }: { item: MockDeal }">
|
||||
<span class="num">{{ formatCost(item.cost) }}</span>
|
||||
</template>
|
||||
|
||||
<template #[`item.receivedMinutesAgo`]="{ item }: { item: MockDeal }">
|
||||
<span class="num text-medium-emphasis">{{ formatRelative(item.receivedMinutesAgo) }}</span>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
||||
<div v-if="deals.length === 0" class="empty-state pa-8 text-center text-medium-emphasis">
|
||||
Нет сделок по выбранным фильтрам
|
||||
</div>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.deals-table-card {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cell-deal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.deal-name {
|
||||
font-weight: 500;
|
||||
color: #081319;
|
||||
}
|
||||
|
||||
.cell-manager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Список «Сгенерированные отчёты» (Sprint 3 Phase C — extraction из ReportsView).
|
||||
*
|
||||
* Логически замкнутый блок: header + ul-список заданий со статусами,
|
||||
* progress-bar для running, action-кнопки (download/retry/cancel/delete)
|
||||
* по состоянию.
|
||||
*
|
||||
* Все side-effect handler'ы (onRetry/onCancel/askDelete) — в родителе через emit.
|
||||
*/
|
||||
import type { ReportJob, ReportStatus } from '../../composables/mockReports';
|
||||
|
||||
defineProps<{
|
||||
jobs: ReportJob[];
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
retry: [jobId: number];
|
||||
cancel: [jobId: number];
|
||||
'request-delete': [jobId: number];
|
||||
}>();
|
||||
|
||||
function statusColor(status: ReportStatus): string {
|
||||
if (status === 'done') return 'success';
|
||||
if (status === 'running') return 'primary';
|
||||
if (status === 'queued') return 'info';
|
||||
return 'error';
|
||||
}
|
||||
|
||||
function statusLabel(job: ReportJob): string {
|
||||
if (job.status === 'done') return 'Готов';
|
||||
if (job.status === 'running') return `В работе`;
|
||||
if (job.status === 'queued') return 'В очереди';
|
||||
return 'Ошибка';
|
||||
}
|
||||
|
||||
function statusIcon(status: ReportStatus): string {
|
||||
if (status === 'done') return 'mdi-check-circle';
|
||||
if (status === 'running') return 'mdi-progress-clock';
|
||||
if (status === 'queued') return 'mdi-clock-outline';
|
||||
return 'mdi-alert-circle-outline';
|
||||
}
|
||||
|
||||
function jobMeta(job: ReportJob): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(job.format.toUpperCase());
|
||||
if (job.sizeText) parts.push(job.sizeText);
|
||||
if (job.rowsText) parts.push(job.rowsText);
|
||||
if (job.status === 'failed' && job.error) {
|
||||
parts.push(`${job.attempt}/3 попытки`);
|
||||
parts.push(`ошибка: ${job.error}`);
|
||||
}
|
||||
parts.push(job.timeText);
|
||||
return parts.filter((p) => p.length > 0).join(' · ');
|
||||
}
|
||||
|
||||
function canRetry(job: ReportJob): boolean {
|
||||
return job.status === 'failed' && job.attempt < 3;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card variant="outlined" class="mt-4 panel">
|
||||
<div class="panel-h pa-4">
|
||||
<div>
|
||||
<h2 class="text-h6 ma-0">Сгенерированные отчёты</h2>
|
||||
<p class="text-caption text-medium-emphasis ma-0">
|
||||
retry-failed для owner отчёта, до 3 попыток · 7 дней
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<v-divider />
|
||||
<div v-if="jobs.length === 0" class="empty-state pa-8 text-center text-medium-emphasis">
|
||||
Нет отчётов. Запросите первый — кнопка «Запустить» выше.
|
||||
</div>
|
||||
<ul v-else class="jobs-list pa-0 ma-0">
|
||||
<li v-for="job in jobs" :key="job.id" class="job-row" :data-testid="`job-${job.id}`">
|
||||
<v-icon :color="statusColor(job.status)" class="job-icon" size="22">
|
||||
{{ statusIcon(job.status) }}
|
||||
</v-icon>
|
||||
<div class="job-info">
|
||||
<div class="job-title">{{ job.title }}</div>
|
||||
<div class="job-meta text-caption text-medium-emphasis">
|
||||
{{ jobMeta(job) }}
|
||||
</div>
|
||||
<v-progress-linear
|
||||
v-if="job.status === 'running' && job.progress !== null"
|
||||
:model-value="job.progress"
|
||||
color="primary"
|
||||
height="3"
|
||||
rounded
|
||||
indeterminate
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<v-chip size="small" variant="tonal" :color="statusColor(job.status)">
|
||||
{{ statusLabel(job) }}
|
||||
</v-chip>
|
||||
<div class="job-actions">
|
||||
<v-btn
|
||||
v-if="job.status === 'done'"
|
||||
icon="mdi-download"
|
||||
variant="text"
|
||||
size="small"
|
||||
aria-label="Скачать"
|
||||
:data-testid="`download-${job.id}`"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="canRetry(job)"
|
||||
icon="mdi-refresh"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="warning"
|
||||
aria-label="Повторить"
|
||||
:data-testid="`retry-${job.id}`"
|
||||
@click="$emit('retry', job.id)"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="job.status === 'queued'"
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
size="small"
|
||||
aria-label="Отменить"
|
||||
:data-testid="`cancel-${job.id}`"
|
||||
@click="$emit('cancel', job.id)"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="job.status === 'done' || job.status === 'failed'"
|
||||
icon="mdi-delete-outline"
|
||||
variant="text"
|
||||
size="small"
|
||||
aria-label="Удалить"
|
||||
:data-testid="`delete-${job.id}`"
|
||||
@click="$emit('request-delete', job.id)"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.panel-h {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.jobs-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.job-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #f0ede4;
|
||||
}
|
||||
.job-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.job-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.job-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.job-title {
|
||||
font-weight: 500;
|
||||
color: #081319;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.job-meta {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.job-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Форма «Запросить отчёт» (Sprint 3 Phase C — extraction из ReportsView).
|
||||
*
|
||||
* Логически замкнутый блок: тип отчёта, период (date_from/date_to), фильтры
|
||||
* (project/manager), формат, кнопки запуска/сброса, баннеры успеха/ошибки.
|
||||
*
|
||||
* Состояние (selectedType, dateFrom, dateTo, project, manager, selectedFormat,
|
||||
* submitting, submitSuccess, submitError, quotaActive/Max) держится в родителе —
|
||||
* передаётся через v-model:* и props. canSubmit и handler'ы submit/reset —
|
||||
* тоже из родителя через emit.
|
||||
*/
|
||||
import { REPORT_FORMATS, REPORT_TYPES, type ReportFormat, type ReportType } from '../../composables/mockReports';
|
||||
|
||||
defineProps<{
|
||||
selectedType: ReportType;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
project: string;
|
||||
manager: string;
|
||||
selectedFormat: ReportFormat;
|
||||
projectOptions: string[];
|
||||
managerOptions: string[];
|
||||
submitting: boolean;
|
||||
submitSuccess: boolean;
|
||||
submitError: string | null;
|
||||
canSubmit: boolean;
|
||||
quotaActive: number;
|
||||
quotaMax: number;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
'update:selectedType': [value: ReportType];
|
||||
'update:dateFrom': [value: string];
|
||||
'update:dateTo': [value: string];
|
||||
'update:project': [value: string];
|
||||
'update:manager': [value: string];
|
||||
'update:selectedFormat': [value: ReportFormat];
|
||||
submit: [];
|
||||
reset: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card variant="outlined" class="mt-4 panel pa-5">
|
||||
<h2 class="text-h6 mb-1">Запросить отчёт</h2>
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">Готовится асинхронно. При > 5 000 строк — на email.</p>
|
||||
|
||||
<div class="field-block">
|
||||
<label class="field-label">Тип отчёта</label>
|
||||
<div class="type-grid">
|
||||
<v-card
|
||||
v-for="t in REPORT_TYPES"
|
||||
:key="t.id"
|
||||
variant="outlined"
|
||||
:class="['tc-card', { active: selectedType === t.id }]"
|
||||
:data-testid="`type-card-${t.id}`"
|
||||
@click="$emit('update:selectedType', t.id)"
|
||||
>
|
||||
<div class="tc-name">{{ t.name }}</div>
|
||||
<div class="tc-desc text-caption text-medium-emphasis">{{ t.description }}</div>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-row dense class="mt-4">
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
:model-value="dateFrom"
|
||||
type="date"
|
||||
label="Период с"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="num-field"
|
||||
@update:model-value="(v: string) => $emit('update:dateFrom', v ?? '')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
:model-value="dateTo"
|
||||
type="date"
|
||||
label="по"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="num-field"
|
||||
@update:model-value="(v: string) => $emit('update:dateTo', v ?? '')"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
:model-value="project"
|
||||
:items="projectOptions"
|
||||
label="Проект"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
@update:model-value="(v: string) => $emit('update:project', v ?? '')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
:model-value="manager"
|
||||
:items="managerOptions"
|
||||
label="Менеджер"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
@update:model-value="(v: string) => $emit('update:manager', v ?? '')"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div class="field-block">
|
||||
<label class="field-label">Формат файла</label>
|
||||
<div class="fmt-row">
|
||||
<v-btn
|
||||
v-for="f in REPORT_FORMATS"
|
||||
:key="f.id"
|
||||
:variant="selectedFormat === f.id ? 'flat' : 'outlined'"
|
||||
:color="selectedFormat === f.id ? 'primary' : undefined"
|
||||
size="small"
|
||||
:data-testid="`format-${f.id}`"
|
||||
@click="$emit('update:selectedFormat', f.id)"
|
||||
>
|
||||
{{ f.label }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-alert type="info" variant="tonal" density="compact" class="mt-4 quota-banner">
|
||||
Квота: <strong>{{ quotaActive }} из {{ quotaMax }}</strong> одновременных отчётов · до
|
||||
<strong>3 попыток retry</strong> в окне 7 дней.
|
||||
</v-alert>
|
||||
|
||||
<v-alert
|
||||
v-if="submitSuccess"
|
||||
type="success"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mt-3"
|
||||
closable
|
||||
data-testid="submit-success-alert"
|
||||
>
|
||||
Отчёт поставлен в очередь.
|
||||
</v-alert>
|
||||
|
||||
<v-alert
|
||||
v-if="submitError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mt-3"
|
||||
closable
|
||||
data-testid="submit-error-alert"
|
||||
>
|
||||
{{ submitError }}
|
||||
</v-alert>
|
||||
|
||||
<div class="d-flex ga-2 mt-4">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
prepend-icon="mdi-play"
|
||||
:loading="submitting"
|
||||
:disabled="!canSubmit"
|
||||
data-testid="submit-btn"
|
||||
@click="$emit('submit')"
|
||||
>
|
||||
Запустить
|
||||
</v-btn>
|
||||
<v-btn variant="text" data-testid="reset-btn" @click="$emit('reset')">Сброс</v-btn>
|
||||
</div>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.field-block {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #66635c;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tc-card {
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
.tc-card:hover {
|
||||
border-color: #66635c !important;
|
||||
}
|
||||
.tc-card.active {
|
||||
border-color: #0f6e56 !important;
|
||||
background: #e1eeea !important;
|
||||
}
|
||||
.tc-name {
|
||||
font-weight: 600;
|
||||
color: #081319;
|
||||
}
|
||||
|
||||
.fmt-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.num-field :deep(input) {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Pure CSV/Blob-download helpers (Sprint 3 Phase C — extraction из DealsView).
|
||||
*
|
||||
* Все функции работают через DOM API (`document.createElement('a')`, click,
|
||||
* URL.createObjectURL). В jsdom-среде Vitest createObjectURL может быть
|
||||
* undefined — функции gracefully no-op'ят, тесты явно мокают через
|
||||
* `Object.defineProperty(URL, 'createObjectURL', ...)`.
|
||||
*/
|
||||
|
||||
export function csvEscape(value: string): string {
|
||||
// CSV-стандарт: значение в кавычках если содержит ; / " / \n; внутри двойные «"».
|
||||
if (value.includes(';') || value.includes('"') || value.includes('\n')) {
|
||||
return '"' + value.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function triggerBlobDownload(blob: Blob, filename: string): void {
|
||||
if (typeof URL.createObjectURL !== 'function') return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
export function triggerCsvDownload(csv: string, filename: string): void {
|
||||
// jsdom не реализует URL.createObjectURL; в проде — стандартный browser-flow.
|
||||
if (typeof URL.createObjectURL !== 'function') return;
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сборка CSV-строки из массива объектов: header-row + строки + BOM (U+FEFF).
|
||||
* BOM нужен чтобы Excel корректно распознавал UTF-8.
|
||||
*/
|
||||
export function buildCsvString(headers: string[], rows: (string | number)[][]): string {
|
||||
const lines = [
|
||||
headers.join(';'),
|
||||
...rows.map((row) => row.map((v) => csvEscape(String(v))).join(';')),
|
||||
];
|
||||
// String.fromCharCode(0xfeff) вместо литерального BOM — иначе ESLint
|
||||
// no-irregular-whitespace.
|
||||
return String.fromCharCode(0xfeff) + lines.join('\r\n');
|
||||
}
|
||||
@@ -22,9 +22,13 @@ import { usePolling } from '../composables/usePolling';
|
||||
// и NewDealDialog отдельные chunk'и — initial bundle DealsView меньше.
|
||||
const DealDetailDrawer = defineAsyncComponent(() => import('../components/deals/DealDetailDrawer.vue'));
|
||||
const NewDealDialog = defineAsyncComponent(() => import('../components/deals/NewDealDialog.vue'));
|
||||
import DealsFilters from '../components/deals/DealsFilters.vue';
|
||||
import DealsBulkBar from '../components/deals/DealsBulkBar.vue';
|
||||
import DealsTable from '../components/deals/DealsTable.vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useLeadStatusesStore } from '../stores/leadStatuses';
|
||||
import * as dealsApi from '../api/deals';
|
||||
import { buildCsvString, triggerBlobDownload, triggerCsvDownload } from '../composables/useCsvDownload';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const leadStatusesStore = useLeadStatusesStore();
|
||||
@@ -289,34 +293,14 @@ async function applyBulkExport(format: 'xlsx' | 'csv' = 'xlsx') {
|
||||
buildLocalCsv();
|
||||
}
|
||||
|
||||
function triggerBlobDownload(blob: Blob, filename: string) {
|
||||
if (typeof URL.createObjectURL !== 'function') return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
function buildLocalCsv() {
|
||||
const idSet = new Set(selected.value);
|
||||
const rows = dealsState.filter((d) => idSet.has(d.id));
|
||||
const headers = ['ID', 'Имя', 'Телефон', 'Статус', 'Проект', 'Менеджер', 'Стоимость', 'Получено мин назад'];
|
||||
const csvLines = [
|
||||
headers.join(';'),
|
||||
...rows.map((d) =>
|
||||
[d.id, d.name, d.phone, d.statusSlug, d.project, d.manager.name, d.cost, d.receivedMinutesAgo]
|
||||
.map((v) => csvEscape(String(v)))
|
||||
.join(';'),
|
||||
),
|
||||
];
|
||||
// BOM (U+FEFF) — Excel правильно распознаёт UTF-8 в CSV. Используем
|
||||
// String.fromCharCode вместо литерального символа — иначе ESLint
|
||||
// no-irregular-whitespace.
|
||||
const csv = String.fromCharCode(0xfeff) + csvLines.join('\r\n');
|
||||
const csv = buildCsvString(
|
||||
headers,
|
||||
rows.map((d) => [d.id, d.name, d.phone, d.statusSlug, d.project, d.manager.name, d.cost, d.receivedMinutesAgo]),
|
||||
);
|
||||
triggerCsvDownload(csv, `deals_export_${new Date().toISOString().slice(0, 10)}.csv`);
|
||||
|
||||
if (!exportToastText.value) {
|
||||
@@ -325,28 +309,6 @@ function buildLocalCsv() {
|
||||
exportToastOpen.value = true;
|
||||
}
|
||||
|
||||
function csvEscape(value: string): string {
|
||||
// CSV-стандарт: значение в кавычках если содержит ; / " / \n; внутри двойные «"».
|
||||
if (value.includes(';') || value.includes('"') || value.includes('\n')) {
|
||||
return '"' + value.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function triggerCsvDownload(csv: string, filename: string) {
|
||||
// jsdom не реализует URL.createObjectURL; в проде — стандартный browser-flow.
|
||||
if (typeof URL.createObjectURL !== 'function') return;
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
// Expose internal state для unit-тестов (Vitest dom-mount).
|
||||
defineExpose({
|
||||
selected,
|
||||
@@ -407,16 +369,6 @@ const counts = computed(() => {
|
||||
return result;
|
||||
});
|
||||
|
||||
function formatRelative(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes} мин назад`;
|
||||
if (minutes < 60 * 24) return `${Math.floor(minutes / 60)} ч назад`;
|
||||
return `${Math.floor(minutes / (60 * 24))} д назад`;
|
||||
}
|
||||
|
||||
function formatCost(cost: number): string {
|
||||
return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
|
||||
}
|
||||
|
||||
const totalDeals = computed(() => dealsState.length);
|
||||
const newToday = 3; // mock
|
||||
const inWork = computed(
|
||||
@@ -485,151 +437,30 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
|
||||
Корзина: показаны удалённые сделки. Выберите для восстановления.
|
||||
</v-alert>
|
||||
|
||||
<div v-if="!trashMode" class="filter-bar mt-4">
|
||||
<v-btn-toggle v-model="activeTab" mandatory color="primary" density="comfortable" variant="outlined">
|
||||
<v-btn v-for="tab in DEALS_TABS" :key="tab.id" :value="tab.id" size="small">
|
||||
{{ tab.label }}
|
||||
<v-chip size="x-small" class="ml-2 chip-count" variant="tonal">
|
||||
{{ counts[tab.id] }}
|
||||
</v-chip>
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
placeholder="Поиск: имя, телефон, проект…"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
class="search-input ml-4"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-model="filterProjects"
|
||||
:items="availableProjects"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
clearable
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
label="Проект"
|
||||
style="min-width: 180px; max-width: 260px"
|
||||
data-testid="filter-projects"
|
||||
/>
|
||||
<v-select
|
||||
v-model="filterManagers"
|
||||
:items="availableManagers"
|
||||
item-title="name"
|
||||
item-value="name"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
clearable
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
label="Менеджер"
|
||||
style="min-width: 180px; max-width: 260px"
|
||||
data-testid="filter-managers"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="filterProjects.length > 0 || filterManagers.length > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
prepend-icon="mdi-filter-off"
|
||||
data-testid="clear-filters-btn"
|
||||
@click="clearFilters"
|
||||
>
|
||||
Сбросить фильтры
|
||||
</v-btn>
|
||||
</div>
|
||||
<DealsFilters
|
||||
v-if="!trashMode"
|
||||
v-model:active-tab="activeTab"
|
||||
v-model:search-query="searchQuery"
|
||||
v-model:filter-projects="filterProjects"
|
||||
v-model:filter-managers="filterManagers"
|
||||
:available-projects="availableProjects"
|
||||
:available-managers="availableManagers"
|
||||
:counts="counts"
|
||||
@clear-filters="clearFilters"
|
||||
/>
|
||||
|
||||
<!-- Bulk-actions bar (показывается только при selected.length > 0) -->
|
||||
<v-card
|
||||
v-if="selected.length > 0"
|
||||
class="bulk-bar mt-3"
|
||||
color="secondary"
|
||||
theme="dark"
|
||||
variant="flat"
|
||||
data-testid="bulk-bar"
|
||||
>
|
||||
<div class="bulk-bar-inner">
|
||||
<span class="bulk-count">
|
||||
Выбрано <span class="num">{{ selected.length }}</span>
|
||||
</span>
|
||||
<v-spacer />
|
||||
<!-- В trash-mode только Восстановить; в обычном режиме — полный набор. -->
|
||||
<v-btn
|
||||
v-if="trashMode"
|
||||
variant="tonal"
|
||||
color="success"
|
||||
size="small"
|
||||
prepend-icon="mdi-restore"
|
||||
data-testid="bulk-restore-trash-btn"
|
||||
@click="applyBulkRestoreFromTrash"
|
||||
>
|
||||
Восстановить
|
||||
</v-btn>
|
||||
<template v-if="!trashMode">
|
||||
<v-menu v-model="statusMenuOpen" :close-on-content-click="false">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
prepend-icon="mdi-tag-arrow-right"
|
||||
data-testid="bulk-status-btn"
|
||||
>
|
||||
Сменить статус
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact" max-height="320" min-width="240">
|
||||
<v-list-item
|
||||
v-for="s in leadStatuses"
|
||||
:key="s.slug"
|
||||
:data-testid="`bulk-status-item-${s.slug}`"
|
||||
@click="applyBulkStatus(s.slug)"
|
||||
>
|
||||
<template #prepend>
|
||||
<span class="status-dot" :style="{ background: s.colorHex }" />
|
||||
</template>
|
||||
<v-list-item-title>{{ s.nameRu }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
size="small"
|
||||
prepend-icon="mdi-download"
|
||||
data-testid="bulk-export-btn"
|
||||
@click="applyBulkExport"
|
||||
>
|
||||
Экспорт
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
color="error"
|
||||
size="small"
|
||||
prepend-icon="mdi-trash-can-outline"
|
||||
data-testid="bulk-delete-btn"
|
||||
@click="deleteConfirmOpen = true"
|
||||
>
|
||||
Удалить
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
size="small"
|
||||
data-testid="bulk-clear-btn"
|
||||
@click="selected = []"
|
||||
/>
|
||||
</div>
|
||||
</v-card>
|
||||
<DealsBulkBar
|
||||
v-model:status-menu-open="statusMenuOpen"
|
||||
:selected-count="selected.length"
|
||||
:trash-mode="trashMode"
|
||||
:lead-statuses="leadStatuses"
|
||||
@apply-status="applyBulkStatus"
|
||||
@apply-export="applyBulkExport()"
|
||||
@request-delete="deleteConfirmOpen = true"
|
||||
@apply-restore-trash="applyBulkRestoreFromTrash"
|
||||
@clear-selected="selected = []"
|
||||
/>
|
||||
|
||||
<v-alert
|
||||
v-if="fetchError"
|
||||
@@ -643,87 +474,14 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
|
||||
Backend недоступен — показаны mock-данные.
|
||||
</v-alert>
|
||||
|
||||
<v-card variant="outlined" class="mt-4 deals-table-card">
|
||||
<v-data-table
|
||||
v-model="selected"
|
||||
:items="filteredDeals"
|
||||
:headers="[
|
||||
{ title: 'Лид', key: 'name', sortable: true },
|
||||
{ title: 'Статус', key: 'statusSlug', sortable: false },
|
||||
{ title: 'Проект', key: 'project', sortable: false },
|
||||
{ title: 'Менеджер', key: 'manager', sortable: false },
|
||||
{ title: 'Стоимость', key: 'cost', align: 'end', sortable: true },
|
||||
{ title: 'Время', key: 'receivedMinutesAgo', align: 'end', sortable: true },
|
||||
]"
|
||||
show-select
|
||||
item-value="id"
|
||||
items-per-page="-1"
|
||||
hide-default-footer
|
||||
hover
|
||||
density="comfortable"
|
||||
@click:row="(_e: Event, { item }: { item: MockDeal }) => openDeal(item)"
|
||||
>
|
||||
<!--
|
||||
Vuetify 3.12 типизированные слоты VDataTable (Sprint 2 Phase B / O-stack-05).
|
||||
`:items="filteredDeals"` (MockDeal[]) → Vuetify через VDataTableSlots<ItemType<T>>
|
||||
выводит `item` как `MockDeal` автоматически. Дополнительная inline-аннотация
|
||||
`{ item }: { item: MockDeal }` фиксирует этот контракт явно — IDE и vue-tsc
|
||||
проверяют доступ к полям статически.
|
||||
-->
|
||||
<template #[`item.name`]="{ item }: { item: MockDeal }">
|
||||
<div class="cell-deal">
|
||||
<v-avatar size="32" color="primary" class="mr-3">
|
||||
<span class="text-caption font-weight-medium">{{
|
||||
item.name
|
||||
.split(' ')
|
||||
.map((p: string) => p[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
}}</span>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="deal-name">{{ item.name }}</div>
|
||||
<div class="deal-phone text-caption text-medium-emphasis">{{ item.phone }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #[`item.statusSlug`]="{ item }: { item: MockDeal }">
|
||||
<v-chip
|
||||
size="small"
|
||||
variant="tonal"
|
||||
:style="{
|
||||
color: statusBySlug.get(item.statusSlug)?.colorHex,
|
||||
borderColor: statusBySlug.get(item.statusSlug)?.colorHex,
|
||||
}"
|
||||
>
|
||||
<span class="status-dot" :style="{ background: statusBySlug.get(item.statusSlug)?.colorHex }" />
|
||||
{{ statusBySlug.get(item.statusSlug)?.nameRu }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #[`item.manager`]="{ item }: { item: MockDeal }">
|
||||
<div class="cell-manager">
|
||||
<v-avatar size="22" color="secondary" class="mr-2">
|
||||
<span class="text-caption">{{ item.manager.initials }}</span>
|
||||
</v-avatar>
|
||||
{{ item.manager.name }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #[`item.cost`]="{ item }: { item: MockDeal }">
|
||||
<span class="num">{{ formatCost(item.cost) }}</span>
|
||||
</template>
|
||||
|
||||
<template #[`item.receivedMinutesAgo`]="{ item }: { item: MockDeal }">
|
||||
<span class="num text-medium-emphasis">{{ formatRelative(item.receivedMinutesAgo) }}</span>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
||||
<div v-if="filteredDeals.length === 0" class="empty-state pa-8 text-center text-medium-emphasis">
|
||||
Нет сделок по выбранным фильтрам
|
||||
</div>
|
||||
</v-card>
|
||||
<DealsTable
|
||||
class="mt-4"
|
||||
:deals="filteredDeals"
|
||||
:selected-ids="selected"
|
||||
:status-by-slug="statusBySlug"
|
||||
@update:selected-ids="selected = $event"
|
||||
@row-click="openDeal"
|
||||
/>
|
||||
|
||||
<DealDetailDrawer v-model:open="drawerOpen" :deal="selectedDeal" :tenant-id="auth.user?.tenant_id" />
|
||||
|
||||
@@ -799,64 +557,4 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
|
||||
font-feature-settings: 'tnum';
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1 1 320px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.chip-count {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.cell-deal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.deal-name {
|
||||
font-weight: 500;
|
||||
color: #081319;
|
||||
}
|
||||
|
||||
.cell-manager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.deals-table-card {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.bulk-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 4;
|
||||
}
|
||||
.bulk-bar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.bulk-count {
|
||||
color: #f6f3ec;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,15 +12,10 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import { cancelReportJob, createReportJob, deleteReportJob, listReportJobs, retryReportJob } from '../api/reports';
|
||||
import { extractErrorMessage, extractValidationErrors } from '../api/client';
|
||||
import { usePolling } from '../composables/usePolling';
|
||||
import {
|
||||
REPORT_FORMATS,
|
||||
REPORT_TYPES,
|
||||
type ReportFormat,
|
||||
type ReportJob,
|
||||
type ReportStatus,
|
||||
type ReportType,
|
||||
} from '../composables/mockReports';
|
||||
import { type ReportFormat, type ReportJob, type ReportType } from '../composables/mockReports';
|
||||
import { mapApiReportJob, uiTypeToApi } from '../composables/reportsMapper';
|
||||
import ReportRequestForm from '../components/reports/ReportRequestForm.vue';
|
||||
import ReportJobsList from '../components/reports/ReportJobsList.vue';
|
||||
|
||||
const selectedType = ref<ReportType>('deals');
|
||||
const dateFrom = ref(new Date().toISOString().slice(0, 10));
|
||||
@@ -149,44 +144,6 @@ async function confirmDelete(): Promise<void> {
|
||||
}
|
||||
|
||||
const canSubmit = computed(() => quotaActive.value < quotaMax.value && !submitting.value);
|
||||
|
||||
function statusColor(status: ReportStatus): string {
|
||||
if (status === 'done') return 'success';
|
||||
if (status === 'running') return 'primary';
|
||||
if (status === 'queued') return 'info';
|
||||
return 'error';
|
||||
}
|
||||
|
||||
function statusLabel(job: ReportJob): string {
|
||||
if (job.status === 'done') return 'Готов';
|
||||
if (job.status === 'running') return `В работе`;
|
||||
if (job.status === 'queued') return 'В очереди';
|
||||
return 'Ошибка';
|
||||
}
|
||||
|
||||
function statusIcon(status: ReportStatus): string {
|
||||
if (status === 'done') return 'mdi-check-circle';
|
||||
if (status === 'running') return 'mdi-progress-clock';
|
||||
if (status === 'queued') return 'mdi-clock-outline';
|
||||
return 'mdi-alert-circle-outline';
|
||||
}
|
||||
|
||||
function jobMeta(job: ReportJob): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(job.format.toUpperCase());
|
||||
if (job.sizeText) parts.push(job.sizeText);
|
||||
if (job.rowsText) parts.push(job.rowsText);
|
||||
if (job.status === 'failed' && job.error) {
|
||||
parts.push(`${job.attempt}/3 попытки`);
|
||||
parts.push(`ошибка: ${job.error}`);
|
||||
}
|
||||
parts.push(job.timeText);
|
||||
return parts.filter((p) => p.length > 0).join(' · ');
|
||||
}
|
||||
|
||||
function canRetry(job: ReportJob): boolean {
|
||||
return job.status === 'failed' && job.attempt < 3;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -226,210 +183,31 @@ function canRetry(job: ReportJob): boolean {
|
||||
Backend недоступен: {{ fetchError }}
|
||||
</v-alert>
|
||||
|
||||
<v-card variant="outlined" class="mt-4 panel pa-5">
|
||||
<h2 class="text-h6 mb-1">Запросить отчёт</h2>
|
||||
<p class="text-body-2 text-medium-emphasis mb-4">Готовится асинхронно. При > 5 000 строк — на email.</p>
|
||||
<ReportRequestForm
|
||||
v-model:selected-type="selectedType"
|
||||
v-model:date-from="dateFrom"
|
||||
v-model:date-to="dateTo"
|
||||
v-model:project="project"
|
||||
v-model:manager="manager"
|
||||
v-model:selected-format="selectedFormat"
|
||||
:project-options="projectOptions"
|
||||
:manager-options="managerOptions"
|
||||
:submitting="submitting"
|
||||
:submit-success="submitSuccess"
|
||||
:submit-error="submitError"
|
||||
:can-submit="canSubmit"
|
||||
:quota-active="quotaActive"
|
||||
:quota-max="quotaMax"
|
||||
@submit="submitForm"
|
||||
@reset="resetForm"
|
||||
/>
|
||||
|
||||
<div class="field-block">
|
||||
<label class="field-label">Тип отчёта</label>
|
||||
<div class="type-grid">
|
||||
<v-card
|
||||
v-for="t in REPORT_TYPES"
|
||||
:key="t.id"
|
||||
variant="outlined"
|
||||
:class="['tc-card', { active: selectedType === t.id }]"
|
||||
:data-testid="`type-card-${t.id}`"
|
||||
@click="selectedType = t.id"
|
||||
>
|
||||
<div class="tc-name">{{ t.name }}</div>
|
||||
<div class="tc-desc text-caption text-medium-emphasis">{{ t.description }}</div>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-row dense class="mt-4">
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="dateFrom"
|
||||
type="date"
|
||||
label="Период с"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="num-field"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="dateTo"
|
||||
type="date"
|
||||
label="по"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="num-field"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="project"
|
||||
:items="projectOptions"
|
||||
label="Проект"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="manager"
|
||||
:items="managerOptions"
|
||||
label="Менеджер"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div class="field-block">
|
||||
<label class="field-label">Формат файла</label>
|
||||
<div class="fmt-row">
|
||||
<v-btn
|
||||
v-for="f in REPORT_FORMATS"
|
||||
:key="f.id"
|
||||
:variant="selectedFormat === f.id ? 'flat' : 'outlined'"
|
||||
:color="selectedFormat === f.id ? 'primary' : undefined"
|
||||
size="small"
|
||||
:data-testid="`format-${f.id}`"
|
||||
@click="selectedFormat = f.id"
|
||||
>
|
||||
{{ f.label }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-alert type="info" variant="tonal" density="compact" class="mt-4 quota-banner">
|
||||
Квота: <strong>{{ quotaActive }} из {{ quotaMax }}</strong> одновременных отчётов · до
|
||||
<strong>3 попыток retry</strong> в окне 7 дней.
|
||||
</v-alert>
|
||||
|
||||
<v-alert
|
||||
v-if="submitSuccess"
|
||||
type="success"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mt-3"
|
||||
closable
|
||||
data-testid="submit-success-alert"
|
||||
>
|
||||
Отчёт поставлен в очередь.
|
||||
</v-alert>
|
||||
|
||||
<v-alert
|
||||
v-if="submitError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mt-3"
|
||||
closable
|
||||
data-testid="submit-error-alert"
|
||||
>
|
||||
{{ submitError }}
|
||||
</v-alert>
|
||||
|
||||
<div class="d-flex ga-2 mt-4">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
prepend-icon="mdi-play"
|
||||
:loading="submitting"
|
||||
:disabled="!canSubmit"
|
||||
data-testid="submit-btn"
|
||||
@click="submitForm"
|
||||
>
|
||||
Запустить
|
||||
</v-btn>
|
||||
<v-btn variant="text" data-testid="reset-btn" @click="resetForm">Сброс</v-btn>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<v-card variant="outlined" class="mt-4 panel">
|
||||
<div class="panel-h pa-4">
|
||||
<div>
|
||||
<h2 class="text-h6 ma-0">Сгенерированные отчёты</h2>
|
||||
<p class="text-caption text-medium-emphasis ma-0">
|
||||
retry-failed для owner отчёта, до 3 попыток · 7 дней
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<v-divider />
|
||||
<div v-if="jobs.length === 0" class="empty-state pa-8 text-center text-medium-emphasis">
|
||||
Нет отчётов. Запросите первый — кнопка «Запустить» выше.
|
||||
</div>
|
||||
<ul v-else class="jobs-list pa-0 ma-0">
|
||||
<li v-for="job in jobs" :key="job.id" class="job-row" :data-testid="`job-${job.id}`">
|
||||
<v-icon :color="statusColor(job.status)" class="job-icon" size="22">
|
||||
{{ statusIcon(job.status) }}
|
||||
</v-icon>
|
||||
<div class="job-info">
|
||||
<div class="job-title">{{ job.title }}</div>
|
||||
<div class="job-meta text-caption text-medium-emphasis">
|
||||
{{ jobMeta(job) }}
|
||||
</div>
|
||||
<v-progress-linear
|
||||
v-if="job.status === 'running' && job.progress !== null"
|
||||
:model-value="job.progress"
|
||||
color="primary"
|
||||
height="3"
|
||||
rounded
|
||||
indeterminate
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<v-chip size="small" variant="tonal" :color="statusColor(job.status)">
|
||||
{{ statusLabel(job) }}
|
||||
</v-chip>
|
||||
<div class="job-actions">
|
||||
<v-btn
|
||||
v-if="job.status === 'done'"
|
||||
icon="mdi-download"
|
||||
variant="text"
|
||||
size="small"
|
||||
aria-label="Скачать"
|
||||
:data-testid="`download-${job.id}`"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="canRetry(job)"
|
||||
icon="mdi-refresh"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="warning"
|
||||
aria-label="Повторить"
|
||||
:data-testid="`retry-${job.id}`"
|
||||
@click="onRetry(job.id)"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="job.status === 'queued'"
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
size="small"
|
||||
aria-label="Отменить"
|
||||
:data-testid="`cancel-${job.id}`"
|
||||
@click="onCancel(job.id)"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="job.status === 'done' || job.status === 'failed'"
|
||||
icon="mdi-delete-outline"
|
||||
variant="text"
|
||||
size="small"
|
||||
aria-label="Удалить"
|
||||
:data-testid="`delete-${job.id}`"
|
||||
@click="askDelete(job.id)"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</v-card>
|
||||
<ReportJobsList
|
||||
:jobs="jobs"
|
||||
@retry="onRetry"
|
||||
@cancel="onCancel"
|
||||
@request-delete="askDelete"
|
||||
/>
|
||||
|
||||
<v-dialog v-model="deleteDialog" max-width="420" persistent>
|
||||
<v-card>
|
||||
@@ -480,113 +258,4 @@ function canRetry(job: ReportJob): boolean {
|
||||
font-feature-settings: 'tnum';
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.panel-h {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.field-block {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #66635c;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tc-card {
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
.tc-card:hover {
|
||||
border-color: #66635c !important;
|
||||
}
|
||||
.tc-card.active {
|
||||
border-color: #0f6e56 !important;
|
||||
background: #e1eeea !important;
|
||||
}
|
||||
.tc-name {
|
||||
font-weight: 600;
|
||||
color: #081319;
|
||||
}
|
||||
|
||||
.fmt-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.num-field :deep(input) {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
|
||||
.jobs-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.job-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #f0ede4;
|
||||
}
|
||||
.job-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.job-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.job-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.job-title {
|
||||
font-weight: 500;
|
||||
color: #081319;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.job-meta {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.job-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user