6c2f0ce682
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>
141 lines
3.8 KiB
Vue
141 lines
3.8 KiB
Vue
<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>
|