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 { computed, defineAsyncComponent, ref, watch } from 'vue';
|
||||||
import type { MockDeal } from '../../composables/mockDeals';
|
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 { mapApiDealEvent } from '../../composables/dealsApiMapper';
|
||||||
import * as dealsApi from '../../api/deals';
|
import * as dealsApi from '../../api/deals';
|
||||||
import * as remindersApi from '../../api/reminders';
|
import * as remindersApi from '../../api/reminders';
|
||||||
import type { ApiReminder } from '../../api/reminders';
|
import type { ApiReminder } from '../../api/reminders';
|
||||||
import { useLeadStatusesStore } from '../../stores/leadStatuses';
|
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.
|
// Sprint 2 Phase B / O-perf-06: ReminderDialog гейтится через v-model — chunk-split.
|
||||||
const ReminderDialog = defineAsyncComponent(() => import('../reminders/ReminderDialog.vue'));
|
const ReminderDialog = defineAsyncComponent(() => import('../reminders/ReminderDialog.vue'));
|
||||||
|
|
||||||
@@ -53,12 +55,6 @@ function formatCost(cost: number): string {
|
|||||||
return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
|
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} и
|
// Activity timeline: при наличии tenant_id делаем GET /api/deals/{id} и
|
||||||
// показываем реальные events. На fail / без tenant_id — fallback на MOCK_EVENTS.
|
// показываем реальные events. На fail / без tenant_id — fallback на MOCK_EVENTS.
|
||||||
const events = ref<DealEvent[]>([...MOCK_EVENTS]);
|
const events = ref<DealEvent[]>([...MOCK_EVENTS]);
|
||||||
@@ -190,38 +186,7 @@ defineExpose({
|
|||||||
<template>
|
<template>
|
||||||
<v-navigation-drawer v-model="drawerOpen" location="right" temporary :width="480" class="deal-drawer">
|
<v-navigation-drawer v-model="drawerOpen" location="right" temporary :width="480" class="deal-drawer">
|
||||||
<div v-if="deal" class="drawer-content">
|
<div v-if="deal" class="drawer-content">
|
||||||
<header class="hero pa-5">
|
<DealDetailHero :deal="deal" :status="status" @close="drawerOpen = false" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<v-divider />
|
<v-divider />
|
||||||
|
|
||||||
@@ -321,44 +286,7 @@ defineExpose({
|
|||||||
|
|
||||||
<v-divider v-if="tenantId && deal" />
|
<v-divider v-if="tenantId && deal" />
|
||||||
|
|
||||||
<section class="section pa-5">
|
<DealDetailTimeline :events="events" :events-fetch-error="eventsFetchError" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<v-snackbar
|
<v-snackbar
|
||||||
v-model="commentToastOpen"
|
v-model="commentToastOpen"
|
||||||
@@ -390,57 +318,6 @@ defineExpose({
|
|||||||
flex-direction: column;
|
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 {
|
.section-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #081319;
|
color: #081319;
|
||||||
@@ -473,78 +350,6 @@ defineExpose({
|
|||||||
font-feature-settings: 'tnum';
|
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 {
|
.reminders-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
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 меньше.
|
// и NewDealDialog отдельные chunk'и — initial bundle DealsView меньше.
|
||||||
const DealDetailDrawer = defineAsyncComponent(() => import('../components/deals/DealDetailDrawer.vue'));
|
const DealDetailDrawer = defineAsyncComponent(() => import('../components/deals/DealDetailDrawer.vue'));
|
||||||
const NewDealDialog = defineAsyncComponent(() => import('../components/deals/NewDealDialog.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 { useAuthStore } from '../stores/auth';
|
||||||
import { useLeadStatusesStore } from '../stores/leadStatuses';
|
import { useLeadStatusesStore } from '../stores/leadStatuses';
|
||||||
import * as dealsApi from '../api/deals';
|
import * as dealsApi from '../api/deals';
|
||||||
|
import { buildCsvString, triggerBlobDownload, triggerCsvDownload } from '../composables/useCsvDownload';
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const leadStatusesStore = useLeadStatusesStore();
|
const leadStatusesStore = useLeadStatusesStore();
|
||||||
@@ -289,34 +293,14 @@ async function applyBulkExport(format: 'xlsx' | 'csv' = 'xlsx') {
|
|||||||
buildLocalCsv();
|
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() {
|
function buildLocalCsv() {
|
||||||
const idSet = new Set(selected.value);
|
const idSet = new Set(selected.value);
|
||||||
const rows = dealsState.filter((d) => idSet.has(d.id));
|
const rows = dealsState.filter((d) => idSet.has(d.id));
|
||||||
const headers = ['ID', 'Имя', 'Телефон', 'Статус', 'Проект', 'Менеджер', 'Стоимость', 'Получено мин назад'];
|
const headers = ['ID', 'Имя', 'Телефон', 'Статус', 'Проект', 'Менеджер', 'Стоимость', 'Получено мин назад'];
|
||||||
const csvLines = [
|
const csv = buildCsvString(
|
||||||
headers.join(';'),
|
headers,
|
||||||
...rows.map((d) =>
|
rows.map((d) => [d.id, d.name, d.phone, d.statusSlug, d.project, d.manager.name, d.cost, d.receivedMinutesAgo]),
|
||||||
[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');
|
|
||||||
triggerCsvDownload(csv, `deals_export_${new Date().toISOString().slice(0, 10)}.csv`);
|
triggerCsvDownload(csv, `deals_export_${new Date().toISOString().slice(0, 10)}.csv`);
|
||||||
|
|
||||||
if (!exportToastText.value) {
|
if (!exportToastText.value) {
|
||||||
@@ -325,28 +309,6 @@ function buildLocalCsv() {
|
|||||||
exportToastOpen.value = true;
|
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).
|
// Expose internal state для unit-тестов (Vitest dom-mount).
|
||||||
defineExpose({
|
defineExpose({
|
||||||
selected,
|
selected,
|
||||||
@@ -407,16 +369,6 @@ const counts = computed(() => {
|
|||||||
return result;
|
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 totalDeals = computed(() => dealsState.length);
|
||||||
const newToday = 3; // mock
|
const newToday = 3; // mock
|
||||||
const inWork = computed(
|
const inWork = computed(
|
||||||
@@ -485,151 +437,30 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
|
|||||||
Корзина: показаны удалённые сделки. Выберите для восстановления.
|
Корзина: показаны удалённые сделки. Выберите для восстановления.
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
|
||||||
<div v-if="!trashMode" class="filter-bar mt-4">
|
<DealsFilters
|
||||||
<v-btn-toggle v-model="activeTab" mandatory color="primary" density="comfortable" variant="outlined">
|
v-if="!trashMode"
|
||||||
<v-btn v-for="tab in DEALS_TABS" :key="tab.id" :value="tab.id" size="small">
|
v-model:active-tab="activeTab"
|
||||||
{{ tab.label }}
|
v-model:search-query="searchQuery"
|
||||||
<v-chip size="x-small" class="ml-2 chip-count" variant="tonal">
|
v-model:filter-projects="filterProjects"
|
||||||
{{ counts[tab.id] }}
|
v-model:filter-managers="filterManagers"
|
||||||
</v-chip>
|
:available-projects="availableProjects"
|
||||||
</v-btn>
|
:available-managers="availableManagers"
|
||||||
</v-btn-toggle>
|
:counts="counts"
|
||||||
|
@clear-filters="clearFilters"
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Bulk-actions bar (показывается только при selected.length > 0) -->
|
<!-- Bulk-actions bar (показывается только при selected.length > 0) -->
|
||||||
<v-card
|
<DealsBulkBar
|
||||||
v-if="selected.length > 0"
|
v-model:status-menu-open="statusMenuOpen"
|
||||||
class="bulk-bar mt-3"
|
:selected-count="selected.length"
|
||||||
color="secondary"
|
:trash-mode="trashMode"
|
||||||
theme="dark"
|
:lead-statuses="leadStatuses"
|
||||||
variant="flat"
|
@apply-status="applyBulkStatus"
|
||||||
data-testid="bulk-bar"
|
@apply-export="applyBulkExport()"
|
||||||
>
|
@request-delete="deleteConfirmOpen = true"
|
||||||
<div class="bulk-bar-inner">
|
@apply-restore-trash="applyBulkRestoreFromTrash"
|
||||||
<span class="bulk-count">
|
@clear-selected="selected = []"
|
||||||
Выбрано <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>
|
|
||||||
|
|
||||||
<v-alert
|
<v-alert
|
||||||
v-if="fetchError"
|
v-if="fetchError"
|
||||||
@@ -643,87 +474,14 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
|
|||||||
Backend недоступен — показаны mock-данные.
|
Backend недоступен — показаны mock-данные.
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
|
||||||
<v-card variant="outlined" class="mt-4 deals-table-card">
|
<DealsTable
|
||||||
<v-data-table
|
class="mt-4"
|
||||||
v-model="selected"
|
:deals="filteredDeals"
|
||||||
:items="filteredDeals"
|
:selected-ids="selected"
|
||||||
:headers="[
|
:status-by-slug="statusBySlug"
|
||||||
{ title: 'Лид', key: 'name', sortable: true },
|
@update:selected-ids="selected = $event"
|
||||||
{ title: 'Статус', key: 'statusSlug', sortable: false },
|
@row-click="openDeal"
|
||||||
{ 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>
|
|
||||||
|
|
||||||
<DealDetailDrawer v-model:open="drawerOpen" :deal="selectedDeal" :tenant-id="auth.user?.tenant_id" />
|
<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-feature-settings: 'tnum';
|
||||||
font-weight: 500;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -12,15 +12,10 @@ import { computed, onMounted, ref } from 'vue';
|
|||||||
import { cancelReportJob, createReportJob, deleteReportJob, listReportJobs, retryReportJob } from '../api/reports';
|
import { cancelReportJob, createReportJob, deleteReportJob, listReportJobs, retryReportJob } from '../api/reports';
|
||||||
import { extractErrorMessage, extractValidationErrors } from '../api/client';
|
import { extractErrorMessage, extractValidationErrors } from '../api/client';
|
||||||
import { usePolling } from '../composables/usePolling';
|
import { usePolling } from '../composables/usePolling';
|
||||||
import {
|
import { type ReportFormat, type ReportJob, type ReportType } from '../composables/mockReports';
|
||||||
REPORT_FORMATS,
|
|
||||||
REPORT_TYPES,
|
|
||||||
type ReportFormat,
|
|
||||||
type ReportJob,
|
|
||||||
type ReportStatus,
|
|
||||||
type ReportType,
|
|
||||||
} from '../composables/mockReports';
|
|
||||||
import { mapApiReportJob, uiTypeToApi } from '../composables/reportsMapper';
|
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 selectedType = ref<ReportType>('deals');
|
||||||
const dateFrom = ref(new Date().toISOString().slice(0, 10));
|
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);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -226,210 +183,31 @@ function canRetry(job: ReportJob): boolean {
|
|||||||
Backend недоступен: {{ fetchError }}
|
Backend недоступен: {{ fetchError }}
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
|
||||||
<v-card variant="outlined" class="mt-4 panel pa-5">
|
<ReportRequestForm
|
||||||
<h2 class="text-h6 mb-1">Запросить отчёт</h2>
|
v-model:selected-type="selectedType"
|
||||||
<p class="text-body-2 text-medium-emphasis mb-4">Готовится асинхронно. При > 5 000 строк — на email.</p>
|
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">
|
<ReportJobsList
|
||||||
<label class="field-label">Тип отчёта</label>
|
:jobs="jobs"
|
||||||
<div class="type-grid">
|
@retry="onRetry"
|
||||||
<v-card
|
@cancel="onCancel"
|
||||||
v-for="t in REPORT_TYPES"
|
@request-delete="askDelete"
|
||||||
: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>
|
|
||||||
|
|
||||||
<v-dialog v-model="deleteDialog" max-width="420" persistent>
|
<v-dialog v-model="deleteDialog" max-width="420" persistent>
|
||||||
<v-card>
|
<v-card>
|
||||||
@@ -480,113 +258,4 @@ function canRetry(job: ReportJob): boolean {
|
|||||||
font-feature-settings: 'tnum';
|
font-feature-settings: 'tnum';
|
||||||
font-weight: 500;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user