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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user