refactor(deals): extract DealDetailBody; DealDetailDrawer = overlay/inline wrapper

This commit is contained in:
Дмитрий
2026-05-17 15:40:27 +03:00
parent b1ea9208c2
commit 016e9b09ae
4 changed files with 422 additions and 457 deletions
@@ -0,0 +1,340 @@
<script setup lang="ts">
/**
* Тело панели деталей сделки (hero + параметры + комментарий + напоминания +
* timeline). Извлечено из DealDetailDrawer (редизайн 2026-05-17) — общее тело
* для overlay-дровера (Канбан) и inline-панели master-detail («Сделки»).
*
* Backend: GET /api/deals/{id}, PATCH /api/deals/{id}, GET /api/deals/{id}/events.
*/
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import type { MockDeal } from '../../composables/mockDeals';
import { type DealEvent } 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';
const ReminderDialog = defineAsyncComponent(() => import('../reminders/ReminderDialog.vue'));
const leadStatusesStore = useLeadStatusesStore();
const props = defineProps<{
deal: MockDeal | null;
tenantId?: number;
}>();
const emit = defineEmits<{ close: [] }>();
const status = computed(() => {
if (!props.deal) return null;
return leadStatusesStore.findBySlug(props.deal.statusSlug);
});
function formatCost(cost: number): string {
return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
}
const events = ref<DealEvent[]>([]);
const eventsLoading = ref(false);
const eventsFetchError = ref(false);
const commentDraft = ref<string>('');
const commentSaving = ref(false);
const commentSaveError = ref(false);
const commentToastOpen = ref(false);
const commentToastText = ref('');
const reminders = ref<ApiReminder[]>([]);
const remindersLoading = ref(false);
const reminderDialogOpen = ref(false);
async function loadReminders() {
if (!props.deal || !props.tenantId) {
reminders.value = [];
return;
}
remindersLoading.value = true;
try {
const res = await remindersApi.listReminders({ filter: 'active', dealId: props.deal.id });
reminders.value = res.items;
} catch {
reminders.value = [];
} finally {
remindersLoading.value = false;
}
}
async function completeReminderInDrawer(id: number) {
try {
await remindersApi.completeReminder(id);
reminders.value = reminders.value.filter((r) => r.id !== id);
} catch {
/* silent */
}
}
function onReminderSaved() {
void loadReminders();
}
function formatReminderTime(iso: string | null): string {
if (!iso) return '—';
const ms = new Date(iso).getTime() - Date.now();
const min = Math.round(Math.abs(ms) / 60_000);
const future = ms > 0;
if (min < 60) return future ? `через ${min} мин` : `${min} мин назад`;
const hr = Math.round(min / 60);
if (hr < 24) return future ? `через ${hr} ч` : `${hr} ч назад`;
const days = Math.round(hr / 24);
return future ? `через ${days} д` : `${days} д назад`;
}
async function loadEvents() {
if (!props.deal || !props.tenantId) {
events.value = [];
commentDraft.value = '';
return;
}
eventsLoading.value = true;
eventsFetchError.value = false;
try {
const res = await dealsApi.getDeal(props.deal.id, props.tenantId);
events.value = res.events.map((e) => mapApiDealEvent(e));
commentDraft.value = res.deal.comment ?? '';
} catch {
eventsFetchError.value = true;
events.value = [];
commentDraft.value = '';
} finally {
eventsLoading.value = false;
}
}
async function saveComment() {
if (!props.deal || !props.tenantId) return;
commentSaving.value = true;
commentSaveError.value = false;
try {
await dealsApi.updateDeal(props.deal.id, {
tenant_id: props.tenantId,
comment: commentDraft.value || null,
});
commentToastText.value = 'Комментарий сохранён.';
commentToastOpen.value = true;
await loadEvents();
} catch {
commentSaveError.value = true;
commentToastText.value = 'Не удалось сохранить — попробуйте позже.';
commentToastOpen.value = true;
} finally {
commentSaving.value = false;
}
}
// Загрузка при появлении/смене сделки (панель монтируется только при deal != null).
watch(
() => [props.deal?.id, props.tenantId] as const,
() => {
if (props.deal) {
loadEvents();
void loadReminders();
}
},
{ immediate: true },
);
defineExpose({
events, eventsLoading, eventsFetchError, loadEvents,
commentDraft, commentSaving, commentSaveError, commentToastOpen, commentToastText, saveComment,
});
</script>
<template>
<div v-if="deal" class="drawer-content">
<DealDetailHero :deal="deal" :status="status" @close="emit('close')" />
<v-divider />
<section class="section pa-5">
<h3 class="section-title text-subtitle-2 mb-3">Параметры</h3>
<dl class="params">
<div class="param">
<dt class="text-caption text-medium-emphasis">Проект</dt>
<dd class="text-body-2">{{ deal.project }}</dd>
</div>
<div class="param">
<dt class="text-caption text-medium-emphasis">Стоимость лида</dt>
<dd class="text-body-2 num">{{ formatCost(deal.cost) }}</dd>
</div>
<div class="param">
<dt class="text-caption text-medium-emphasis">Менеджер</dt>
<dd class="text-body-2">
<v-avatar size="20" color="secondary" class="mr-1">
<span class="text-caption">{{ deal.manager.initials }}</span>
</v-avatar>
{{ deal.manager.name }}
</dd>
</div>
<div class="param">
<dt class="text-caption text-medium-emphasis">Источник</dt>
<dd class="text-body-2 link">Я.Директ landing-1</dd>
</div>
</dl>
</section>
<v-divider />
<section v-if="tenantId" class="section pa-5" data-testid="comment-section">
<h3 class="section-title text-subtitle-2 mb-3">Комментарий</h3>
<v-textarea
v-model="commentDraft"
placeholder="Заметка менеджера…"
variant="outlined"
density="comfortable"
auto-grow
rows="3"
hide-details
counter="5000"
data-testid="comment-textarea"
/>
<div class="d-flex ga-2 mt-2 justify-end">
<v-btn
:loading="commentSaving"
color="primary"
size="small"
prepend-icon="mdi-content-save-outline"
data-testid="save-comment-btn"
@click="saveComment"
>
Сохранить
</v-btn>
</div>
</section>
<v-divider v-if="tenantId" />
<section v-if="tenantId && deal" class="section pa-5" data-testid="reminders-section">
<div class="d-flex justify-space-between align-center mb-3">
<h3 class="section-title text-subtitle-2 mb-0">Напоминания</h3>
<v-btn
size="x-small"
variant="text"
prepend-icon="mdi-plus"
data-testid="add-reminder-btn"
@click="reminderDialogOpen = true"
>
Создать
</v-btn>
</div>
<div v-if="reminders.length === 0 && !remindersLoading" class="text-caption text-medium-emphasis">
Нет активных напоминаний.
</div>
<ul v-else class="reminders-list">
<li v-for="r in reminders" :key="r.id" class="reminder-row" data-testid="drawer-reminder-item">
<v-btn
icon="mdi-check-circle-outline"
size="x-small"
variant="text"
density="comfortable"
:data-testid="`drawer-complete-${r.id}`"
@click="completeReminderInDrawer(r.id)"
/>
<div class="reminder-body">
<div class="reminder-text">{{ r.text || 'Без описания' }}</div>
<div class="reminder-meta text-caption text-medium-emphasis">
{{ formatReminderTime(r.remind_at) }}
</div>
</div>
</li>
</ul>
</section>
<v-divider v-if="tenantId && deal" />
<DealDetailTimeline :events="events" :events-fetch-error="eventsFetchError" />
<v-snackbar
v-model="commentToastOpen"
:timeout="3000"
:color="commentSaveError ? 'warning' : undefined"
data-testid="comment-toast"
location="bottom right"
>
{{ commentToastText }}
</v-snackbar>
<ReminderDialog
v-if="tenantId && deal"
v-model="reminderDialogOpen"
:deal-id="deal.id"
@saved="onReminderSaved"
/>
</div>
</template>
<style scoped>
.drawer-content {
display: flex;
flex-direction: column;
}
.section-title {
font-weight: 600;
color: #081319;
}
.params {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px 12px;
margin: 0;
}
.param dt {
font-size: 11px;
margin-bottom: 2px;
}
.param dd {
margin: 0;
color: #081319;
}
.param .link {
color: #0f6e56;
cursor: pointer;
}
.param .link:hover {
text-decoration: underline;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
}
.reminders-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.reminder-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 1px solid #e8e3d6;
border-radius: 6px;
background: #fdfaf3;
}
.reminder-body {
flex: 1;
min-width: 0;
}
.reminder-text {
font-size: 13px;
line-height: 1.4;
color: #081319;
}
.reminder-meta {
margin-top: 2px;
}
</style>
@@ -1,43 +1,23 @@
<script setup lang="ts">
/**
* Правая панель с деталями сделки. Открывается при click на строку в DealsView
* или на карточку в KanbanView.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_deal_card.html.
* MVP: hero (имя + телефон + статус-chip + close), параметры (Проект/Стоимость/
* Источник/Email), Activity timeline (5-7 событий).
*
* Не входит в этот коммит:
* - Редактирование параметров (input-fields + save).
* - Смена статуса через dropdown (на Канбане — через DnD).
* - Tag management, manager assignment, reminders, comment/templates —
* отдельные секции, отдельные коммиты.
*
* Backend:
* - GET /api/deals/{id} — full detail with events.
* - PATCH /api/deals/{id} — частичное обновление полей.
* - GET /api/deals/{id}/events — `activity_log` фильтр по deal_id.
* Обёртка панели деталей сделки. `inline=false` (по умолчанию) — overlay
* v-navigation-drawer (Канбан). `inline=true` — боковая панель master-detail
* для страницы «Сделки» (список сжимается, панель встаёт рядом, не перекрывает).
* Тело — общий DealDetailBody.vue.
*/
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import { computed } from 'vue';
import type { MockDeal } from '../../composables/mockDeals';
import { type DealEvent } 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'));
import DealDetailBody from './DealDetailBody.vue';
const leadStatusesStore = useLeadStatusesStore();
const props = defineProps<{
open: boolean;
deal: MockDeal | null;
tenantId?: number;
}>();
const props = withDefaults(
defineProps<{
open: boolean;
deal: MockDeal | null;
tenantId?: number;
inline?: boolean;
}>(),
{ inline: false },
);
const emit = defineEmits<{ 'update:open': [value: boolean] }>();
@@ -46,265 +26,24 @@ const drawerOpen = computed({
set: (v) => emit('update:open', v),
});
const status = computed(() => {
if (!props.deal) return null;
return leadStatusesStore.findBySlug(props.deal.statusSlug);
});
function formatCost(cost: number): string {
return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
function close() {
emit('update:open', false);
}
// Activity timeline: при наличии tenant_id делаем GET /api/deals/{id} и
// показываем реальные events. На fail / без tenant_id — events пуст + eventsFetchError.
const events = ref<DealEvent[]>([]);
const eventsLoading = ref(false);
const eventsFetchError = ref(false);
// Comment editor — редактирование текущего комментария сделки.
const commentDraft = ref<string>('');
const commentSaving = ref(false);
const commentSaveError = ref(false);
const commentToastOpen = ref(false);
const commentToastText = ref('');
// Reminders на сделку — отдельная секция с inline-create + список.
const reminders = ref<ApiReminder[]>([]);
const remindersLoading = ref(false);
const reminderDialogOpen = ref(false);
async function loadReminders() {
if (!props.deal || !props.tenantId) {
reminders.value = [];
return;
}
remindersLoading.value = true;
try {
const res = await remindersApi.listReminders({ filter: 'active', dealId: props.deal.id });
reminders.value = res.items;
} catch {
reminders.value = [];
} finally {
remindersLoading.value = false;
}
}
async function completeReminderInDrawer(id: number) {
try {
await remindersApi.completeReminder(id);
reminders.value = reminders.value.filter((r) => r.id !== id);
} catch {
/* silent */
}
}
function onReminderSaved() {
void loadReminders();
}
function formatReminderTime(iso: string | null): string {
if (!iso) return '—';
const ms = new Date(iso).getTime() - Date.now();
const min = Math.round(Math.abs(ms) / 60_000);
const future = ms > 0;
if (min < 60) return future ? `через ${min} мин` : `${min} мин назад`;
const hr = Math.round(min / 60);
if (hr < 24) return future ? `через ${hr} ч` : `${hr} ч назад`;
const days = Math.round(hr / 24);
return future ? `через ${days} д` : `${days} д назад`;
}
async function loadEvents() {
if (!props.deal || !props.tenantId) {
events.value = [];
commentDraft.value = '';
return;
}
eventsLoading.value = true;
eventsFetchError.value = false;
try {
const res = await dealsApi.getDeal(props.deal.id, props.tenantId);
events.value = res.events.map((e) => mapApiDealEvent(e));
commentDraft.value = res.deal.comment ?? '';
} catch {
eventsFetchError.value = true;
events.value = [];
commentDraft.value = '';
} finally {
eventsLoading.value = false;
}
}
async function saveComment() {
if (!props.deal || !props.tenantId) return;
commentSaving.value = true;
commentSaveError.value = false;
try {
await dealsApi.updateDeal(props.deal.id, {
tenant_id: props.tenantId,
comment: commentDraft.value || null,
});
commentToastText.value = 'Комментарий сохранён.';
commentToastOpen.value = true;
// Reload events чтобы показать новый deal.commented в timeline.
await loadEvents();
} catch {
commentSaveError.value = true;
commentToastText.value = 'Не удалось сохранить — попробуйте позже.';
commentToastOpen.value = true;
} finally {
commentSaving.value = false;
}
}
// Fetch при открытии drawer'а или смене сделки.
watch(
() => [props.open, props.deal?.id, props.tenantId] as const,
([open]) => {
if (open) {
loadEvents();
void loadReminders();
}
},
{ immediate: true },
);
defineExpose({
events,
eventsLoading,
eventsFetchError,
loadEvents,
commentDraft,
commentSaving,
commentSaveError,
commentToastOpen,
commentToastText,
saveComment,
});
</script>
<template>
<v-navigation-drawer v-model="drawerOpen" location="right" temporary :width="480" class="deal-drawer">
<div v-if="deal" class="drawer-content">
<DealDetailHero :deal="deal" :status="status" @close="drawerOpen = false" />
<v-divider />
<section class="section pa-5">
<h3 class="section-title text-subtitle-2 mb-3">Параметры</h3>
<dl class="params">
<div class="param">
<dt class="text-caption text-medium-emphasis">Проект</dt>
<dd class="text-body-2">{{ deal.project }}</dd>
</div>
<div class="param">
<dt class="text-caption text-medium-emphasis">Стоимость лида</dt>
<dd class="text-body-2 num">{{ formatCost(deal.cost) }}</dd>
</div>
<div class="param">
<dt class="text-caption text-medium-emphasis">Менеджер</dt>
<dd class="text-body-2">
<v-avatar size="20" color="secondary" class="mr-1">
<span class="text-caption">{{ deal.manager.initials }}</span>
</v-avatar>
{{ deal.manager.name }}
</dd>
</div>
<div class="param">
<dt class="text-caption text-medium-emphasis">Источник</dt>
<dd class="text-body-2 link">Я.Директ landing-1</dd>
</div>
</dl>
</section>
<v-divider />
<section v-if="tenantId" class="section pa-5" data-testid="comment-section">
<h3 class="section-title text-subtitle-2 mb-3">Комментарий</h3>
<v-textarea
v-model="commentDraft"
placeholder="Заметка менеджера…"
variant="outlined"
density="comfortable"
auto-grow
rows="3"
hide-details
counter="5000"
data-testid="comment-textarea"
/>
<div class="d-flex ga-2 mt-2 justify-end">
<v-btn
:loading="commentSaving"
color="primary"
size="small"
prepend-icon="mdi-content-save-outline"
data-testid="save-comment-btn"
@click="saveComment"
>
Сохранить
</v-btn>
</div>
</section>
<v-divider v-if="tenantId" />
<section v-if="tenantId && deal" class="section pa-5" data-testid="reminders-section">
<div class="d-flex justify-space-between align-center mb-3">
<h3 class="section-title text-subtitle-2 mb-0">Напоминания</h3>
<v-btn
size="x-small"
variant="text"
prepend-icon="mdi-plus"
data-testid="add-reminder-btn"
@click="reminderDialogOpen = true"
>
Создать
</v-btn>
</div>
<div v-if="reminders.length === 0 && !remindersLoading" class="text-caption text-medium-emphasis">
Нет активных напоминаний.
</div>
<ul v-else class="reminders-list">
<li v-for="r in reminders" :key="r.id" class="reminder-row" data-testid="drawer-reminder-item">
<v-btn
icon="mdi-check-circle-outline"
size="x-small"
variant="text"
density="comfortable"
:data-testid="`drawer-complete-${r.id}`"
@click="completeReminderInDrawer(r.id)"
/>
<div class="reminder-body">
<div class="reminder-text">{{ r.text || 'Без описания' }}</div>
<div class="reminder-meta text-caption text-medium-emphasis">
{{ formatReminderTime(r.remind_at) }}
</div>
</div>
</li>
</ul>
</section>
<v-divider v-if="tenantId && deal" />
<DealDetailTimeline :events="events" :events-fetch-error="eventsFetchError" />
<v-snackbar
v-model="commentToastOpen"
:timeout="3000"
:color="commentSaveError ? 'warning' : undefined"
data-testid="comment-toast"
location="bottom right"
>
{{ commentToastText }}
</v-snackbar>
<ReminderDialog
v-if="tenantId && deal"
v-model="reminderDialogOpen"
:deal-id="deal.id"
@saved="onReminderSaved"
/>
</div>
<aside v-if="inline" v-show="open" class="deal-detail-inline" data-testid="deal-detail-panel">
<DealDetailBody :deal="deal" :tenant-id="tenantId" @close="close" />
</aside>
<v-navigation-drawer
v-else
v-model="drawerOpen"
location="right"
temporary
:width="480"
class="deal-drawer"
>
<DealDetailBody :deal="deal" :tenant-id="tenantId" @close="close" />
</v-navigation-drawer>
</template>
@@ -312,75 +51,16 @@ defineExpose({
.deal-drawer {
background: #fff;
}
.drawer-content {
display: flex;
flex-direction: column;
}
.section-title {
font-weight: 600;
color: #081319;
}
.params {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px 12px;
margin: 0;
}
.param dt {
font-size: 11px;
margin-bottom: 2px;
}
.param dd {
margin: 0;
color: #081319;
}
.param .link {
color: #0f6e56;
cursor: pointer;
}
.param .link:hover {
text-decoration: underline;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
}
.reminders-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.reminder-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
.deal-detail-inline {
flex: 0 0 400px;
width: 400px;
background: #fff;
border: 1px solid #e8e3d6;
border-radius: 6px;
background: #fdfaf3;
}
.reminder-body {
flex: 1;
min-width: 0;
}
.reminder-text {
font-size: 13px;
line-height: 1.4;
color: #081319;
}
.reminder-meta {
margin-top: 2px;
border-radius: 8px;
overflow-y: auto;
align-self: flex-start;
max-height: calc(100vh - 160px);
position: sticky;
top: 16px;
}
</style>
+31 -80
View File
@@ -1,95 +1,46 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createPinia, setActivePinia } from 'pinia';
import DealDetailDrawer from '../../resources/js/components/deals/DealDetailDrawer.vue';
import { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
import type { MockDeal } from '../../resources/js/composables/mockDeals';
beforeEach(() => {
const vuetify = createVuetify();
const deal: MockDeal = {
id: 1, name: '+7 999', phone: '+7 999', statusSlug: 'new', project: 'Окна',
manager: { initials: 'AD', name: 'Admin' }, cost: 0, receivedMinutesAgo: 5,
};
function mountDrawer(props: Record<string, unknown>) {
setActivePinia(createPinia());
});
// DealDetailDrawer использует v-navigation-drawer, который требует layout-
// контекст от v-app/v-layout. В Vitest auto-import недоступен — stub'им
// v-navigation-drawer как passthrough div чтобы slot-content рендерился
// и был доступен для assertion.
describe('DealDetailDrawer.vue', () => {
const factory = (props: { open: boolean; deal: (typeof MOCK_DEALS)[number] | null }) =>
mount(DealDetailDrawer, {
props,
global: {
plugins: [createVuetify()],
stubs: {
VNavigationDrawer: {
template: '<div class="drawer-stub" v-if="modelValue"><slot /></div>',
props: ['modelValue'],
},
return mount(DealDetailDrawer, {
props: { open: true, deal, ...props },
global: {
plugins: [vuetify, createPinia()],
stubs: {
DealDetailBody: true,
VNavigationDrawer: {
template: '<div class="drawer-stub" v-if="modelValue"><slot /></div>',
props: ['modelValue'],
},
},
});
},
});
}
const sampleDeal = MOCK_DEALS[0]; // Анна Соколова
it('не рендерит контент когда open=false', () => {
const wrapper = factory({ open: false, deal: sampleDeal });
expect(wrapper.find('.drawer-stub').exists()).toBe(false);
describe('DealDetailDrawer wrapper', () => {
it('inline=true рендерит <aside> (master-detail панель)', () => {
const w = mountDrawer({ inline: true });
expect(w.find('aside.deal-detail-inline').exists()).toBe(true);
});
it('не рендерит контент когда deal=null (даже при open=true)', () => {
const wrapper = factory({ open: true, deal: null });
// Drawer открыт, но deal нет — content внутри v-if не рендерится.
const stub = wrapper.find('.drawer-stub');
if (stub.exists()) {
// Нет hero/section элементов внутри.
expect(wrapper.find('.hero').exists()).toBe(false);
}
it('inline=false (по умолчанию) рендерит overlay v-navigation-drawer', () => {
const w = mountDrawer({});
expect(w.find('aside.deal-detail-inline').exists()).toBe(false);
});
it('рендерит hero с именем сделки и id', () => {
const wrapper = factory({ open: true, deal: sampleDeal });
const text = wrapper.text();
expect(text).toContain(sampleDeal.name);
expect(text).toContain(`#${sampleDeal.id}`);
});
it('рендерит phone как кликабельную ссылку tel:', () => {
const wrapper = factory({ open: true, deal: sampleDeal });
const phoneLink = wrapper.find('.phone-link');
expect(phoneLink.exists()).toBe(true);
expect(phoneLink.attributes('href')).toMatch(/^tel:\+/);
expect(phoneLink.text()).toBe(sampleDeal.phone);
});
it('рендерит status-chip с nameRu статуса сделки', () => {
const wrapper = factory({ open: true, deal: sampleDeal });
// sampleDeal.statusSlug='new' → 'Новые'.
expect(wrapper.text()).toContain('Новые');
});
it('рендерит секцию параметров с проектом, стоимостью, менеджером', () => {
const wrapper = factory({ open: true, deal: sampleDeal });
const text = wrapper.text();
expect(text).toContain('Параметры');
expect(text).toContain(sampleDeal.project);
expect(text).toContain(sampleDeal.manager.name);
expect(text).toMatch(/1\s+850\s*₽/); // sampleDeal.cost = 1850
});
it('рендерит timeline без событий (без tenantId events пуст — I3)', () => {
const wrapper = factory({ open: true, deal: sampleDeal });
const items = wrapper.findAll('.timeline-item');
expect(items).toHaveLength(0);
});
it('emit-ит update:open=false при close-кнопке', async () => {
const wrapper = factory({ open: true, deal: sampleDeal });
// Vuetify v-btn рендерит как button. close-btn — единственный с aria-label.
const closeBtn = wrapper.find('button[aria-label="Закрыть панель"]');
if (closeBtn.exists()) {
await closeBtn.trigger('click');
expect(wrapper.emitted('update:open')).toBeTruthy();
expect(wrapper.emitted('update:open')?.[0]).toEqual([false]);
}
it('inline-панель содержит DealDetailBody', () => {
const w = mountDrawer({ inline: true });
expect(w.findComponent({ name: 'DealDetailBody' }).exists()).toBe(true);
});
});
+12 -18
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createPinia, setActivePinia } from 'pinia';
import DealDetailDrawer from '../../resources/js/components/deals/DealDetailDrawer.vue';
import DealDetailBody from '../../resources/js/components/deals/DealDetailBody.vue';
import { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
import type { GetDealResponse, ApiDealEvent } from '../../resources/js/api/deals';
@@ -22,17 +22,11 @@ beforeEach(() => {
setActivePinia(createPinia());
});
const factory = (props: { open: boolean; tenantId?: number }) =>
mount(DealDetailDrawer, {
const factory = (props: { tenantId?: number }) =>
mount(DealDetailBody, {
props: { deal: MOCK_DEALS[0], ...props },
global: {
plugins: [createVuetify()],
stubs: {
VNavigationDrawer: {
template: '<div class="drawer-stub" v-if="modelValue"><slot /></div>',
props: ['modelValue'],
},
},
},
});
@@ -47,9 +41,9 @@ function makeApiEvent(overrides: Partial<ApiDealEvent> = {}): ApiDealEvent {
};
}
describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
describe('DealDetailBody ↔ GET /api/deals/{id} integration', () => {
it('БЕЗ tenantId — getDeal не вызывается, events пуст (I3)', async () => {
const wrapper = factory({ open: true });
const wrapper = factory({});
await flushPromises();
expect(dealsApi.getDeal).not.toHaveBeenCalled();
@@ -89,7 +83,7 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
};
vi.mocked(dealsApi.getDeal).mockResolvedValueOnce(apiResponse);
const wrapper = factory({ open: true, tenantId: 1 });
const wrapper = factory({ tenantId: 1 });
await flushPromises();
expect(dealsApi.getDeal).toHaveBeenCalledWith(MOCK_DEALS[0].id, 1);
@@ -102,7 +96,7 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
it('getDeal reject → eventsFetchError=true, alert виден, events пуст (I3)', async () => {
vi.mocked(dealsApi.getDeal).mockRejectedValueOnce(new Error('500'));
const wrapper = factory({ open: true, tenantId: 1 });
const wrapper = factory({ tenantId: 1 });
await flushPromises();
const vm = wrapper.vm as unknown as { eventsFetchError: boolean };
@@ -113,8 +107,8 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
expect(items).toHaveLength(0);
});
it('open=false → getDeal не вызывается', async () => {
factory({ open: false, tenantId: 1 });
it('без tenantId → getDeal не вызывается (body загружается по deal/tenantId)', async () => {
factory({});
await flushPromises();
expect(dealsApi.getDeal).not.toHaveBeenCalled();
});
@@ -147,7 +141,7 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
comment: 'новая заметка',
});
const wrapper = factory({ open: true, tenantId: 1 });
const wrapper = factory({ tenantId: 1 });
await flushPromises();
const vm = wrapper.vm as unknown as {
@@ -197,7 +191,7 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
});
vi.mocked(dealsApi.updateDeal).mockRejectedValueOnce(new Error('500'));
const wrapper = factory({ open: true, tenantId: 1 });
const wrapper = factory({ tenantId: 1 });
await flushPromises();
const vm = wrapper.vm as unknown as {
@@ -215,7 +209,7 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
});
it('comment-section не показывается без tenantId (read-only mode)', async () => {
const wrapper = factory({ open: true });
const wrapper = factory({});
await flushPromises();
expect(wrapper.find('[data-testid="comment-section"]').exists()).toBe(false);
});