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:
Дмитрий
2026-05-09 20:28:25 +03:00
parent cd13e6c8bb
commit 6c2f0ce682
11 changed files with 1249 additions and 898 deletions
@@ -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">Готовится асинхронно. При &gt; 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');
}
+38 -340
View File
@@ -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>
+27 -358
View File
@@ -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">Готовится асинхронно. При &gt; 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>