feat(реклама-фронт): вкладка «Мои кампании» — карточки со статусами

CampaignList.vue: карточки кампаний Яндекс Аудитории (B2-4) — статус-чип
по словарю (running/pending_moderation/rejected/stopped_no_funds/paused/
draft), недельный бюджет, дата запуска; «Пауза»/«Отчёт» — disabled-заглушки
(нет эндпоинтов в B1); пустое состояние с кнопкой «Новая реклама».
AdvertisingYandexView.vue: вкладка campaigns теперь рендерит CampaignList,
события new/edit переключают вкладку. TDD: 3 новых теста +
advertising-yandex-view.spec.ts обновлён под мок fetchCampaigns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-25 01:14:30 +03:00
parent b468d6683f
commit 7cd689f897
4 changed files with 298 additions and 3 deletions
@@ -0,0 +1,191 @@
<script setup lang="ts">
/**
* Вкладка «Мои кампании» рекламного модуля «Яндекс Аудитория» (Часть B2, задача B2-4).
*
* Грузит список кампаний тенанта (GET /api/advertising/campaigns,
* api/advertising.ts::fetchCampaigns) и рисует карточку на каждую: имя,
* статус-чип, недельный бюджет, дата запуска. «Пауза»/«Отчёт» — заглушки,
* под них ещё нет эндпоинтов (см. Часть B1); «Изменить» пробрасывает id
* наверх — понадобится мастеру редактирования (B2-9).
*/
import { ref, onMounted } from 'vue';
import { fetchCampaigns, type Campaign, type CampaignStatus } from '../../api/advertising';
import { extractErrorMessage } from '../../api/client';
import { formatPlain } from '../../composables/billingFormatters';
const emit = defineEmits<{
new: [];
edit: [id: number];
}>();
const campaigns = ref<Campaign[]>([]);
const loading = ref(true);
const loadError = ref<string | null>(null);
interface StatusMeta {
label: string;
color: string;
icon: string;
}
const STATUS_META: Record<CampaignStatus, StatusMeta> = {
running: { label: 'Крутится', color: 'success', icon: 'mdi-play-circle' },
pending_moderation: { label: 'Проверяется Яндексом', color: 'warning', icon: 'mdi-clock-outline' },
rejected: { label: 'Отклонено', color: 'error', icon: 'mdi-close-circle' },
stopped_no_funds: { label: 'Остановлено (нет денег)', color: 'error', icon: 'mdi-alert-circle' },
paused: { label: 'На паузе', color: 'grey', icon: 'mdi-pause-circle' },
draft: { label: 'Черновик', color: 'grey', icon: 'mdi-file-outline' },
};
function statusMeta(status: CampaignStatus): StatusMeta {
return STATUS_META[status];
}
/** «5000.00» (bcmath-строка) → «5 000 ₽» — переиспользуем формат портала (BillingView). */
function formatMoney(value: string): string {
return formatPlain(Number(value));
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('ru-RU');
}
async function loadCampaigns(): Promise<void> {
loading.value = true;
loadError.value = null;
try {
campaigns.value = await fetchCampaigns();
} catch (e) {
campaigns.value = [];
loadError.value = extractErrorMessage(e, 'Не удалось загрузить список кампаний.');
} finally {
loading.value = false;
}
}
onMounted(() => {
void loadCampaigns();
});
defineExpose({ loadCampaigns });
</script>
<template>
<div class="campaign-list" data-testid="campaign-list">
<div v-if="loading" class="d-flex justify-center py-8">
<v-progress-circular indeterminate color="primary" size="28" />
</div>
<p v-else-if="loadError" class="text-body-2 text-error" data-testid="campaign-load-error">
{{ loadError }}
</p>
<div v-else-if="campaigns.length === 0" class="text-center py-12" data-testid="campaign-empty">
<v-icon icon="mdi-bullhorn-outline" size="48" color="grey" class="mb-3" />
<p class="text-body-1 text-medium-emphasis mb-4">Пока нет кампаний</p>
<v-btn
color="primary"
variant="flat"
prepend-icon="mdi-plus"
data-testid="campaign-new-btn"
@click="emit('new')"
>
Новая реклама
</v-btn>
</div>
<template v-else>
<div class="d-flex justify-end mb-4">
<v-btn
color="primary"
variant="flat"
prepend-icon="mdi-plus"
data-testid="campaign-new-btn"
@click="emit('new')"
>
Новая реклама
</v-btn>
</div>
<v-row dense>
<v-col v-for="campaign in campaigns" :key="campaign.id" cols="12" sm="6" lg="4">
<v-card variant="outlined" class="pa-4 h-100" data-testid="campaign-card">
<div class="d-flex align-center justify-space-between ga-2 mb-2">
<span class="text-subtitle-1 font-weight-medium">{{ campaign.name }}</span>
</div>
<v-chip
size="small"
variant="tonal"
:color="statusMeta(campaign.status).color"
:prepend-icon="statusMeta(campaign.status).icon"
class="mb-3"
data-testid="campaign-status-chip"
>
{{ statusMeta(campaign.status).label }}
</v-chip>
<div class="campaign-stat mb-1">
<span class="text-caption text-medium-emphasis">Бюджет в неделю</span>
<span class="num text-body-2 font-weight-medium">{{
formatMoney(campaign.weekly_budget_rub)
}}</span>
</div>
<div v-if="campaign.launched_at" class="campaign-stat mb-3">
<span class="text-caption text-medium-emphasis">Запущена</span>
<span class="text-body-2">{{ formatDate(campaign.launched_at) }}</span>
</div>
<v-divider class="mb-3" />
<div class="d-flex ga-2 flex-wrap">
<v-btn
size="small"
variant="outlined"
color="primary"
data-testid="campaign-edit-btn"
@click="emit('edit', campaign.id)"
>
{{ campaign.status === 'rejected' ? 'Исправить' : 'Изменить' }}
</v-btn>
<v-tooltip text="Скоро">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps">
<v-btn size="small" variant="text" disabled data-testid="campaign-pause-btn">
Пауза
</v-btn>
</span>
</template>
</v-tooltip>
<v-tooltip text="Скоро">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps">
<v-btn size="small" variant="text" disabled data-testid="campaign-report-btn">
Отчёт
</v-btn>
</span>
</template>
</v-tooltip>
</div>
</v-card>
</v-col>
</v-row>
</template>
</div>
</template>
<style scoped>
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
}
.campaign-stat {
display: flex;
flex-direction: column;
gap: 2px;
}
</style>
@@ -1,14 +1,23 @@
<script setup lang="ts">
/**
* «Яндекс Аудитория» — экран рекламного канала (Часть B2, задача B2-2).
* Пока скелет: две вкладки-плейсхолдера. Наполнение (список кампаний,
* форма создания) — следующие задачи B2-3/B2-4; api-слой уже готов
* Вкладка «Мои кампании» (B2-4) — CampaignList.vue. Вкладка «Новая реклама» —
* пока плейсхолдер (мастер создания — следующая задача); api-слой готов
* в resources/js/api/advertising.ts.
*/
import { ref } from 'vue';
import AdWalletHeader from '../../components/advertising/AdWalletHeader.vue';
import CampaignList from '../../components/advertising/CampaignList.vue';
const tab = ref('campaigns');
// id редактируемой кампании — пробрасывается из CampaignList (событие edit),
// понадобится мастеру редактирования в B2-9; пока только храним и переключаем вкладку.
const editCampaignId = ref<number | null>(null);
function onEdit(id: number): void {
editCampaignId.value = id;
tab.value = 'new';
}
</script>
<template>
@@ -30,7 +39,7 @@ const tab = ref('campaigns');
<v-tabs-window v-model="tab" class="mt-4">
<v-tabs-window-item value="campaigns">
<p class="text-body-2 text-medium-emphasis" data-testid="tab-campaigns-placeholder"> скоро </p>
<CampaignList @new="tab = 'new'" @edit="onEdit" />
</v-tabs-window-item>
<v-tabs-window-item value="new">
<p class="text-body-2 text-medium-emphasis" data-testid="tab-new-placeholder"> скоро </p>
@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
const { fetchCampaignsMock } = vi.hoisted(() => ({
fetchCampaignsMock: vi.fn(),
}));
vi.mock('../../resources/js/api/advertising', () => ({
fetchCampaigns: fetchCampaignsMock,
}));
import CampaignList from '../../resources/js/components/advertising/CampaignList.vue';
const vuetify = createVuetify();
async function flush(): Promise<void> {
await new Promise((r) => setTimeout(r, 0));
}
describe('CampaignList.vue (B2-4 — вкладка «Мои кампании»)', () => {
beforeEach(() => {
fetchCampaignsMock.mockReset();
});
it('рендерит карточки кампаний со статус-чипами', async () => {
fetchCampaignsMock.mockResolvedValue([
{
id: 1,
name: 'Директора СПб',
status: 'running',
weekly_budget_rub: '5000.00',
audience_days: 30,
launched_at: '2026-07-20T10:00:00Z',
},
{
id: 2,
name: 'Директора Москвы',
status: 'rejected',
weekly_budget_rub: '3000.00',
audience_days: 14,
launched_at: null,
},
]);
const w = mount(CampaignList, { global: { plugins: [vuetify] } });
await flush();
await w.vm.$nextTick();
const cards = w.findAll('[data-testid="campaign-card"]');
expect(cards).toHaveLength(2);
expect(cards[0].text()).toContain('Директора СПб');
expect(cards[1].text()).toContain('Директора Москвы');
const chips = w.findAll('[data-testid="campaign-status-chip"]');
expect(chips[0].text()).toContain('Крутится');
expect(chips[1].text()).toContain('Отклонено');
});
it('пустой список показывает пустое состояние с кнопкой «Новая реклама», которая эмитит new', async () => {
fetchCampaignsMock.mockResolvedValue([]);
const w = mount(CampaignList, { global: { plugins: [vuetify] } });
await flush();
await w.vm.$nextTick();
const empty = w.find('[data-testid="campaign-empty"]');
expect(empty.exists()).toBe(true);
await w.find('[data-testid="campaign-new-btn"]').trigger('click');
expect(w.emitted('new')).toBeTruthy();
});
it('клик «Изменить» на карточке эмитит edit с id кампании', async () => {
fetchCampaignsMock.mockResolvedValue([
{
id: 42,
name: 'Тестовая кампания',
status: 'draft',
weekly_budget_rub: '1000.00',
audience_days: 7,
launched_at: null,
},
]);
const w = mount(CampaignList, { global: { plugins: [vuetify] } });
await flush();
await w.vm.$nextTick();
await w.find('[data-testid="campaign-edit-btn"]').trigger('click');
expect(w.emitted('edit')).toBeTruthy();
expect(w.emitted('edit')?.[0]).toEqual([42]);
});
});
@@ -12,6 +12,7 @@ vi.mock('../../resources/js/api/advertising', () => ({
fetchWallet: vi.fn(() =>
Promise.resolve({ solvent: true, balance_rub: '0.00', frozen_rub: '0.00', free_rub: '0.00' }),
),
fetchCampaigns: vi.fn(() => Promise.resolve([])),
}));
import AdvertisingYandexView from '../../resources/js/views/advertising/AdvertisingYandexView.vue';